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:
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user