refactor(themes): web dashboard presets derive from the desktop palette table

The desktop and the web dashboard each carried a private copy of the
cyberpunk / ember / midnight / mono palettes and they had drifted: the
dashboard's cyberpunk canvas was #040608 with a mint #9bffcf accent
while the desktop's was #000a00 with #00ff41, ember and midnight
disagreed on both canvas and accent, mono agreed only by luck.

Move the raw palette table for every built-in preset into
@hermes/shared (`THEME_PRESET_PALETTES`, apps/shared/src/theme-presets.ts)
and make it the single source of truth:

- apps/desktop/src/themes/presets.ts spreads its `colors` / `darkColors`
  from the shared table; the OKLCH synthesis, terminal palettes and
  typography stay in the desktop. Serialised BUILTIN_THEMES are
  byte-identical to before, so the existing `--dt-primary-solid`
  parity pins stay green untouched.
- web/src/themes/presets.ts projects each shared preset onto its
  3-slot model through one pure function, `webPresetFromShared`
  (background <- background, midground <- primary, warmGlow <- the
  midground/ring accent), so cyberpunk / ember / midnight / mono now
  render the desktop's palette. Web-only presets (default,
  default-large, nous-blue, rose) are untouched.
- Invariant test (web): for every preset shared by both surfaces the
  dashboard canvas equals the shared background and the projected text
  colour keeps >= 3:1 contrast against it. Red on the previous hexes,
  green now.

Why: one edit in one place should recolour a preset on every surface;
two hand-maintained tables guarantee the drift the audit found.
This commit is contained in:
teknium1
2026-09-13 09:42:56 -07:00
committed by Teknium
parent 7d2bb463c4
commit e7657792df
6 changed files with 637 additions and 489 deletions
+29
View File
@@ -0,0 +1,29 @@
import { contrastRatio, THEME_PRESET_PALETTES } from "@hermes/shared";
import { describe, expect, it } from "vitest";
import { BUILTIN_THEMES, webPresetFromShared } from "./presets";
// Every preset the dashboard shares with the desktop must render the shared
// table's palette, not a private copy — that is the whole point of the table.
// The second assertion keeps the projection honest: whatever slot the mapping
// picks as the dashboard's text/primary colour has to stay legible on the
// canvas it picks, so a future re-mapping cannot silently ship grey-on-grey.
describe("dashboard presets derive from the shared palette table", () => {
const shared = Object.keys(BUILTIN_THEMES).filter(
(name): name is keyof typeof THEME_PRESET_PALETTES => name in THEME_PRESET_PALETTES,
);
it("covers the presets both surfaces ship", () => {
expect(shared).toEqual(expect.arrayContaining(["cyberpunk", "ember", "midnight", "mono"]));
});
it.each(shared)("%s: canvas equals the shared background and the accent reads on it", (name) => {
const preset = THEME_PRESET_PALETTES[name];
const derived = webPresetFromShared(preset);
const palette = BUILTIN_THEMES[name].palette;
expect(palette.background.hex).toBe((preset.darkColors ?? preset.colors).background);
expect(palette.midground.hex).toBe(derived.midground.hex);
expect(contrastRatio(palette.midground.hex, palette.background.hex)).toBeGreaterThanOrEqual(3);
});
});
+32 -17
View File
@@ -1,4 +1,5 @@
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
import { parseColor, THEME_PRESET_PALETTES, type ThemePresetPalette } from "@hermes/shared";
import type { DashboardTheme, ThemePalette, ThemeTypography, ThemeLayout } from "./types";
/**
* Built-in dashboard themes.
@@ -9,6 +10,10 @@ import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
*
* Theme names must stay in sync with the backend's
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
*
* Presets that also ship on the desktop (midnight, ember, mono, cyberpunk)
* take their colours from `@hermes/shared` `THEME_PRESET_PALETTES` so both
* surfaces render one palette; only typography/layout/overrides live here.
*/
// ---------------------------------------------------------------------------
@@ -34,6 +39,28 @@ const DEFAULT_LAYOUT: ThemeLayout = {
density: "comfortable",
};
/**
* Project a shared (desktop-shaped) preset palette onto the dashboard's
* 3-slot model. The dashboard's `midground` is its text + primary-fill
* colour, which is the desktop's `primary`; its `warmGlow` is the brand
* accent stroke, which is the desktop's `midground` (falling back to `ring`).
* `foreground` stays the dashboard's invisible white overlay. Dark palettes
* are the dashboard's home turf, so a preset shipping `darkColors` is read
* from that side.
*/
export function webPresetFromShared(
preset: ThemePresetPalette,
): Omit<ThemePalette, "noiseOpacity"> {
const colors = preset.darkColors ?? preset.colors;
const [r, g, b] = parseColor(colors.midground ?? colors.ring) ?? [255, 255, 255];
return {
background: { hex: colors.background, alpha: 1 },
midground: { hex: colors.primary, alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: `rgba(${r}, ${g}, ${b}, 0.3)`,
};
}
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
@@ -59,10 +86,7 @@ export const midnightTheme: DashboardTheme = {
label: "Midnight",
description: "Deep blue-violet with cool accents",
palette: {
background: { hex: "#0a0a1f", alpha: 1 },
midground: { hex: "#d4c8ff", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(167, 139, 250, 0.32)",
...webPresetFromShared(THEME_PRESET_PALETTES.midnight),
noiseOpacity: 0.8,
},
typography: {
@@ -84,10 +108,7 @@ export const emberTheme: DashboardTheme = {
label: "Ember",
description: "Warm crimson and bronze — forge vibes",
palette: {
background: { hex: "#1a0a06", alpha: 1 },
midground: { hex: "#ffd8b0", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(249, 115, 22, 0.38)",
...webPresetFromShared(THEME_PRESET_PALETTES.ember),
noiseOpacity: 1,
},
typography: {
@@ -112,10 +133,7 @@ export const monoTheme: DashboardTheme = {
label: "Mono",
description: "Clean grayscale — minimal and focused",
palette: {
background: { hex: "#0e0e0e", alpha: 1 },
midground: { hex: "#eaeaea", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(255, 255, 255, 0.1)",
...webPresetFromShared(THEME_PRESET_PALETTES.mono),
noiseOpacity: 0.6,
},
typography: {
@@ -136,10 +154,7 @@ export const cyberpunkTheme: DashboardTheme = {
label: "Cyberpunk",
description: "Neon green on black — matrix terminal",
palette: {
background: { hex: "#040608", alpha: 1 },
midground: { hex: "#9bffcf", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(0, 255, 136, 0.22)",
...webPresetFromShared(THEME_PRESET_PALETTES.cyberpunk),
noiseOpacity: 1.2,
},
typography: {