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:
committed by
brooklyn!
parent
50e2f970c8
commit
bd853747bb
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user