From bd853747bbafeafe71b2cfd26791127794201418 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 02:16:25 -0500 Subject: [PATCH] fix(desktop): a fresh profile follows the OS, and plugins stay behind the SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a genuinely fresh instance surfaced that no existing profile could. The renderer's mode fell back to `light` when nothing was stored, so a dark-mode desktop opened a white window on first launch. Main already defaulted its own themeSource to `system`, so the two disagreed at boot — and once translucency became per-appearance it also handed those users light's much heavier tint, tuned for a bright desktop they don't have. Both the normalizer and the SSR fallback now say `system`; an explicit choice still wins. The accent plugin reached straight into `@/components` and `@/themes`, which the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk` and nothing else, so the app can move its internals without breaking them. The fix is to widen the SDK rather than exempt the plugin — it now exports the OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any plugin can derive a palette instead of hardcoding one. --- apps/desktop/src/plugins/accent/picker.tsx | 23 ++++++++++++------ apps/desktop/src/plugins/accent/plugin.tsx | 4 +--- apps/desktop/src/sdk/index.ts | 24 +++++++++++++++++++ apps/desktop/src/themes/context.tsx | 14 ++++++++--- apps/desktop/src/themes/profile-theme.test.ts | 20 +++++++++++++++- 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/plugins/accent/picker.tsx b/apps/desktop/src/plugins/accent/picker.tsx index 2ab39ab343..144b0706e1 100644 --- a/apps/desktop/src/plugins/accent/picker.tsx +++ b/apps/desktop/src/plugins/accent/picker.tsx @@ -19,14 +19,23 @@ // Sizing note: the statusbar is `h-5` (20px). The TRIGGER must live inside that // band or it clips to a sliver; the panel is a popover and can be any size. -import { useStore } from '@nanostores/react' +import { + $accentOverride, + contrastRatio, + hexToOklch, + maxChroma, + type Oklch, + oklchToHex, + oklchToSrgb255, + Popover, + PopoverContent, + PopoverTrigger, + setAccentOverride, + useTheme, + useValue +} from '@hermes/plugin-sdk' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { $accentOverride, setAccentOverride } from '@/themes/accent-override' -import { contrastRatio, hexToOklch, maxChroma, type Oklch, oklchToHex, oklchToSrgb255 } from '@/themes/color' -import { useTheme } from '@/themes/context' - // Named reference points, with their OKLCH hue — the ones worth comparing while // judging an accent. The hue spread across the blues is the whole reason this // control exists: they look far apart and are 6° apart. @@ -183,7 +192,7 @@ function HueRail({ lch, onPick }: { lch: Oklch; onPick: (h: number) => void }) { function AccentPicker() { const { theme, renderedMode } = useTheme() - const override = useStore($accentOverride) + const override = useValue($accentOverride) const painted = theme.colors.primary const [lch, setLch] = useState(() => hexToOklch(painted) ?? { l: 0.5, c: 0.15, h: 260 }) const [text, setText] = useState(painted) diff --git a/apps/desktop/src/plugins/accent/plugin.tsx b/apps/desktop/src/plugins/accent/plugin.tsx index 368b854a72..2c45c1a8ab 100644 --- a/apps/desktop/src/plugins/accent/plugin.tsx +++ b/apps/desktop/src/plugins/accent/plugin.tsx @@ -14,9 +14,7 @@ */ import type { HermesPlugin, PaletteContribution } from '@hermes/plugin-sdk' -import { PALETTE_AREA, STATUSBAR_AREAS } from '@hermes/plugin-sdk' - -import { $accentOverride, setAccentOverride } from '@/themes/accent-override' +import { $accentOverride, PALETTE_AREA, setAccentOverride, STATUSBAR_AREAS } from '@hermes/plugin-sdk' import { AccentPickerTrigger } from './picker' diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 3bf5ab0079..6b83836be6 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -1069,6 +1069,30 @@ export { type TranscriptDirectiveProps } from '@/lib/transcript-directives' export { cn } from '@/lib/utils' +/** Live accent override — set a hex and the ACTIVE theme repaints with its + * accent family re-seeded from it (see `retintTheme`); `null` restores the + * authored palette. Deliberately not persisted: it is an authoring knob, not + * a setting, so a plugin that sets it must clear it on dispose. */ +export { $accentOverride, setAccentOverride } from '@/themes/accent-override' +/** OKLCH colour maths, for anything deriving a palette rather than hardcoding + * one: perceptual conversion, the sRGB gamut boundary, WCAG contrast, and + * hue-stable blending. */ +export { + contrastRatio, + hexToOklch, + hueDelta, + maxChroma, + mixOklab, + normalizeHex, + type Oklch, + oklchToHex, + oklchToSrgb255, + readableOn +} from '@/themes/color' +/** The painted theme, its name, and the appearance it resolved to. */ +export { useTheme } from '@/themes/context' +export { retintTheme, themeHue } from '@/themes/retint' +export type { DesktopTheme, DesktopThemeColors } from '@/themes/types' export { THEMES_AREA } from '@/themes/user-themes' export type { RpcEvent, StatusResponse } from '@/types/hermes' /** Subscribe a component to a `host.state` atom. */ diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index b527fcd8cb..6409315d06 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -18,8 +18,8 @@ import { persistString, persistStringRecord, storedString, storedStringRecord } import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { setAppearance } from '@/store/translucency' -import { $backendThemes, $pendingSkinApply } from './backend-sync' import { $accentOverride } from './accent-override' +import { $backendThemes, $pendingSkinApply } from './backend-sync' import { harmonize, hexToRgb, mix, readableOn } from './color' import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' import { retintTheme } from './retint' @@ -52,8 +52,16 @@ const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color- const normalizeSkin = (name: string | null): string => name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME +/** + * A stored mode, or `system` when there isn't one. + * + * A fresh profile follows the OS. Defaulting to `light` meant someone whose + * desktop is dark got a white window on first launch and had to go find the + * setting — and with per-appearance translucency it also handed them light's + * much heavier tint, tuned for a bright desktop they don't have. + */ const normalizeMode = (value: string | null): ThemeMode => - value === 'light' || value === 'dark' || value === 'system' ? value : 'light' + value === 'light' || value === 'dark' || value === 'system' ? value : 'system' // ─── Per-profile appearance persistence ───────────────────────────────────── // Skin and mode are each stored per profile. "default" isn't a real profile — @@ -368,7 +376,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { ) const [mode, setModeState] = useState(() => - typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey()) + typeof window === 'undefined' ? 'system' : modePref.resolve(readBootProfileKey()) ) // Follow profile switches: paint the profile's assigned skin + mode and diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts index 34ec517aa9..3567a8a677 100644 --- a/apps/desktop/src/themes/profile-theme.test.ts +++ b/apps/desktop/src/themes/profile-theme.test.ts @@ -18,7 +18,7 @@ const cases = [ b: 'catppuccin', junk: 'nope' }, - { name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' } + { name: 'mode', pref: modePref as unknown as Pref, fallback: 'system', a: 'dark', b: 'light', junk: 'dusk' } ] describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => { @@ -46,3 +46,21 @@ describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => { expect(pref.resolve('work')).toBe(fallback) }) }) + +// A fresh profile follows the OS. This defaulted to `light`, so a dark-mode +// desktop got a white window on first launch — and, once translucency became +// per-appearance, light's much heavier tint along with it. Main already +// defaulted its own themeSource to 'system', so the two disagreed at boot. +describe('a profile that has never chosen a mode', () => { + beforeEach(() => window.localStorage.clear()) + + it('follows the OS rather than forcing light', () => { + expect(modePref.resolve('default')).toBe('system') + expect(modePref.resolve('work')).toBe('system') + }) + + it('still honours an explicit choice', () => { + modePref.assign('default', 'light') + expect(modePref.resolve('default')).toBe('light') + }) +})