fix(desktop): a fresh profile follows the OS, and plugins stay behind the SDK

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.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 02:16:25 -05:00
committed by brooklyn!
parent 50e2f970c8
commit bd853747bb
5 changed files with 71 additions and 14 deletions
+16 -7
View File
@@ -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<Oklch>(() => hexToOklch(painted) ?? { l: 0.5, c: 0.15, h: 260 })
const [text, setText] = useState(painted)
+1 -3
View File
@@ -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'
+24
View File
@@ -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. */
+11 -3
View File
@@ -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<ThemeMode>(() =>
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
+19 -1
View File
@@ -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')
})
})