From 98bac5feb49612ca31ddf7ac5984c31c7cbb5d71 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 01:16:14 -0500 Subject: [PATCH] feat(desktop): an accent picker plugin, off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding a colour by hex is guesswork; finding one by eye needs a picker that does not lie about where you will land. HSV crushes the whole blue family into a narrow band of its hue rail, so dragging "to blue" puts you on pure sRGB blue, which reads violet — every blue that actually looks blue lives in a few degrees you cannot reliably hit there. This one is OKLCH. The hue rail is perceptually even and previews the current colour at every hue rather than showing a generic rainbow, and the field is a canvas drawn per-pixel through the real conversion, so its curved edge is the true sRGB gamut boundary — every pixel is a colour the display can show. Dragging repaints the whole app against the real derivation. It ships off (`defaultEnabled: false`) and holds no persisted state: the override clears on dispose, so turning the plugin off returns every surface to the authored theme rather than stranding a colour with no control to clear it. The retint itself stays in core, where Appearance settings and the command palette can reach it. --- apps/desktop/src/plugins/accent/picker.tsx | 308 +++++++++++++++++++++ apps/desktop/src/plugins/accent/plugin.tsx | 72 +++++ apps/desktop/src/store/backdrop.ts | 2 +- 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/plugins/accent/picker.tsx create mode 100644 apps/desktop/src/plugins/accent/plugin.tsx diff --git a/apps/desktop/src/plugins/accent/picker.tsx b/apps/desktop/src/plugins/accent/picker.tsx new file mode 100644 index 0000000000..2ab39ab343 --- /dev/null +++ b/apps/desktop/src/plugins/accent/picker.tsx @@ -0,0 +1,308 @@ +// Dev-only: an OKLCH accent picker in the statusbar. +// +// Rebuilt from an HSV picker, which was the wrong instrument for the job: HSV +// crushes the entire blue family into a narrow band of its hue rail, so +// dragging "to blue" lands you on ~266° — pure sRGB blue, which reads violet to +// the eye. Every blue that actually looks blue (GitHub 257°, Tailwind 260°, +// Nous 263°) sits in a few degrees you cannot reliably hit in HSV. In OKLCH the +// rail is perceptually even, so those degrees get their fair share of track. +// +// The field is a CANVAS, not a CSS gradient, because a hue slice of OKLCH is a +// curved wedge in sRGB rather than a rectangle. Drawing it per-pixel through +// the real conversion means every pixel is a color the display can actually +// show, and the wedge's edge IS the gamut boundary — no silent clamping. +// +// It drives `$accentOverride`, which the theme context feeds through +// `retintTheme`, so the whole app repaints against the REAL derivation on every +// pointer move. Nothing persists: reload and you're back on the authored theme. +// +// 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 { 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. +const SWATCHES: ReadonlyArray<{ hex: string; name: string }> = [ + { hex: '#0053FD', name: 'Nous blue · 263° (light seed)' }, + { hex: '#4a84fe', name: 'Nous blue · 263° (dark seed)' }, + { hex: '#1540B1', name: 'Psyche blue · 264°' }, + { hex: '#0969da', name: 'GitHub blue · 257°' }, + { hex: '#196d31', name: 'GitHub green · 148°' }, + { hex: '#8250df', name: 'GitHub purple · 303°' }, + { hex: '#bf3989', name: 'GitHub pink · 354°' }, + { hex: '#bc4c00', name: 'GitHub orange · 52°' } +] + +const FIELD_W = 224 +const FIELD_H = 130 +const RAIL_H = 12 + +// Statusbar label width, in mono characters. The chip's text CHANGES as you +// drag, and the bar lays out right-to-left from the right edge — so an unpinned +// width slides the picker out from under the pointer mid-drag. +const HEX_CH = 7 // '#rrggbb' + +/** Chroma axis top — past this is out of gamut at every hue, so it'd be dead space. */ +const CHROMA_MAX = 0.33 + +const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) + +/** Pointer drag over a box, reported as 0..1 on each axis. */ +function useDrag(onMove: (x: number, y: number) => void) { + const ref = useRef(null) + const moveRef = useRef(onMove) + moveRef.current = onMove + + const onPointerDown = useCallback((event: React.PointerEvent) => { + const box = ref.current + + if (!box) { + return + } + + const emit = (clientX: number, clientY: number) => { + const r = box.getBoundingClientRect() + moveRef.current(clamp01((clientX - r.left) / r.width), clamp01((clientY - r.top) / r.height)) + } + + emit(event.clientX, event.clientY) + + const onPointerMove = (e: PointerEvent) => emit(e.clientX, e.clientY) + + const stop = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', stop) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', stop) + }, []) + + return { onPointerDown, ref } +} + +/** Lightness (y, 1→0) × chroma (x, 0→CHROMA_MAX) at one hue. */ +function ChromaLightnessField({ hue, lch, onPick }: { hue: number; lch: Oklch; onPick: (l: number, c: number) => void }) { + const canvas = useRef(null) + const drag = useDrag((x, y) => onPick(1 - y, x * CHROMA_MAX)) + + useEffect(() => { + const el = canvas.current + + if (!el) { + return + } + + const ctx = el.getContext('2d') + + if (!ctx) { + return + } + + const img = ctx.createImageData(FIELD_W, FIELD_H) + + for (let y = 0; y < FIELD_H; y += 1) { + const l = 1 - y / (FIELD_H - 1) + + for (let x = 0; x < FIELD_W; x += 1) { + const rgb = oklchToSrgb255({ l, c: (x / (FIELD_W - 1)) * CHROMA_MAX, h: hue }) + const i = (y * FIELD_W + x) * 4 + + if (rgb) { + img.data[i] = rgb[0] + img.data[i + 1] = rgb[1] + img.data[i + 2] = rgb[2] + img.data[i + 3] = 255 + } else { + // Outside sRGB: leave it transparent so the wedge's real edge shows. + img.data[i + 3] = 0 + } + } + } + + ctx.clearRect(0, 0, FIELD_W, FIELD_H) + ctx.putImageData(img, 0, 0) + }, [hue]) + + return ( +
} + style={{ height: FIELD_H, width: FIELD_W }} + > + +
+
+ ) +} + +/** Hue rail, sampled through the real conversion at the current L and C. */ +function HueRail({ lch, onPick }: { lch: Oklch; onPick: (h: number) => void }) { + const drag = useDrag(x => onPick(x * 360)) + + // Hold the current lightness/chroma so the rail previews THIS color at every + // hue, rather than a generic rainbow that lies about where you'll land. + const gradient = useMemo(() => { + const stops: string[] = [] + + for (let i = 0; i <= 36; i += 1) { + const h = (i / 36) * 360 + const c = Math.min(lch.c, maxChroma(lch.l, h)) + stops.push(`${oklchToHex({ l: lch.l, c, h })} ${(i / 36) * 100}%`) + } + + return `linear-gradient(to right, ${stops.join(', ')})` + }, [lch.l, lch.c]) + + return ( +
} + style={{ background: gradient, height: RAIL_H, width: FIELD_W }} + > +
+
+ ) +} + +function AccentPicker() { + const { theme, renderedMode } = useTheme() + const override = useStore($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) + + // Follow the painted accent when it changes from outside this control — but + // never mid-drag, which would fight the user's own pointer. + const dragging = useRef(false) + + useEffect(() => { + if (!dragging.current) { + setLch(hexToOklch(painted) ?? { l: 0.5, c: 0.15, h: 260 }) + setText(painted) + } + }, [painted]) + + const commit = (next: Oklch) => { + dragging.current = true + setLch(next) + const hex = oklchToHex(next) + setText(hex) + setAccentOverride(hex) + requestAnimationFrame(() => { + dragging.current = false + }) + } + + const surface = (renderedMode === 'dark' ? theme.darkColors : theme.colors) ?? theme.colors + const ratio = contrastRatio(painted, surface.sidebarBackground ?? surface.background) + + return ( +
+ commit({ ...lch, c, l })} /> + commit({ ...lch, h })} /> + +
+ + { + setText(event.target.value) + setAccentOverride(event.target.value) + }} + spellCheck={false} + value={text} + /> + +
+ +
+ {SWATCHES.map(swatch => ( +
+ + {/* OKLCH readout + what the theme RESOLVED to for the painted mode, which + is usually not the hex you clicked (dark adapts for contrast). Digits + are tabular and the hue/L/C fields are fixed-width: an unpinned + readout re-wraps as you drag past 100°, which resizes the popover + under the pointer. */} +
+ + H{Math.round(lch.h)} L{lch.l.toFixed(2)} C + {lch.c.toFixed(3)} + + = 4.5 ? '' : 'text-(--dt-destructive)'}> + {renderedMode} {ratio.toFixed(1)}:1 + +
+ {/* Always occupies its row, even when empty — this line appears exactly + when a pick needs adapting, which is mid-drag, and a mounting

+ would grow the panel while the pointer is inside it. */} +

+ {override && override.toLowerCase() !== painted.toLowerCase() + ? `picked ${override} → ${painted} for contrast` + : '\u00a0'} +

+
+ ) +} + +export function AccentPickerTrigger() { + const { theme } = useTheme() + + return ( + + + + + + + + + ) +} diff --git a/apps/desktop/src/plugins/accent/plugin.tsx b/apps/desktop/src/plugins/accent/plugin.tsx new file mode 100644 index 0000000000..368b854a72 --- /dev/null +++ b/apps/desktop/src/plugins/accent/plugin.tsx @@ -0,0 +1,72 @@ +/** + * Accent — pick the theme's accent color and watch the whole app retint live. + * + * The COLOR MATH is not here. `themes/retint.ts` owns re-seeding a palette from + * one color, and it works on any theme with any accent; this plugin is only the + * control surface for it. That split is deliberate: the retint is core behavior + * that Appearance settings and ⌘K should be able to drive too, while the + * statusbar picker is an authoring tool most users never need. + * + * Ships OFF (`defaultEnabled: false`): it inventories in Settings ▸ Plugins and + * registers nothing until the switch is flipped. With it off, `$accentOverride` + * stays null and `retintTheme` is never called — themes paint exactly as + * authored. + */ + +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 { AccentPickerTrigger } from './picker' + +const plugin: HermesPlugin = { + id: 'accent', + name: 'Accent Picker', + description: + 'Pick the theme accent from an OKLCH color picker in the status bar; the palette re-derives live. Authoring tool — the color is not persisted.', + defaultEnabled: false, + register(ctx) { + // The override is a scratch value, not a setting. Dropping it on unregister + // means disabling the plugin (or reloading) returns every surface to the + // authored theme instead of stranding a color with no control to clear it. + ctx.onDispose(() => setAccentOverride(null)) + + ctx.registerMany([ + { + id: 'picker', + area: STATUSBAR_AREAS.right, + order: 90, + render: () => + }, + { + id: 'reset', + area: PALETTE_AREA, + data: { + id: 'accent.reset', + label: 'Accent: reset to the theme default', + keywords: ['accent', 'color', 'theme', 'reset', 'default'], + run: () => setAccentOverride(null) + } satisfies PaletteContribution + }, + { + id: 'copy', + area: PALETTE_AREA, + data: { + id: 'accent.copy', + label: 'Accent: copy the current color', + keywords: ['accent', 'color', 'hex', 'copy', 'clipboard'], + run: () => { + const hex = $accentOverride.get() + + if (hex) { + void navigator.clipboard?.writeText(hex) + } + } + } satisfies PaletteContribution + } + ]) + } +} + +export default plugin diff --git a/apps/desktop/src/store/backdrop.ts b/apps/desktop/src/store/backdrop.ts index cecc718026..340c5b5cba 100644 --- a/apps/desktop/src/store/backdrop.ts +++ b/apps/desktop/src/store/backdrop.ts @@ -5,7 +5,7 @@ import { persistBoolean, storedBoolean } from '@/lib/storage' const KEY = 'hermes.desktop.backdrop.v1' /** Whether the faint statue image renders behind the chat transcript. */ -export const $backdrop = atom(storedBoolean(KEY, true)) +export const $backdrop = atom(storedBoolean(KEY, false)) $backdrop.subscribe(on => persistBoolean(KEY, on))