Merge ticket 056: add Light variants for remaining colour schemes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-06 09:43:57 +01:00
co-authored by Claude Sonnet 5
7 changed files with 327 additions and 77 deletions
+32
View File
@@ -40,6 +40,21 @@ pub enum ColorScheme {
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
#[serde(rename = "light_green")]
LightGreen,
/// Brighter, light-surfaced counterpart to `Red` (ticket 056).
#[serde(rename = "light_red")]
LightRed,
/// Brighter, light-surfaced counterpart to `Blue` (ticket 056).
#[serde(rename = "light_blue")]
LightBlue,
/// Brighter, light-surfaced counterpart to `Yellow` (ticket 056).
#[serde(rename = "light_yellow")]
LightYellow,
/// Brighter, light-surfaced counterpart to `Pink` (ticket 056).
#[serde(rename = "light_pink")]
LightPink,
/// Brighter, light-surfaced counterpart to `Orange` (ticket 056).
#[serde(rename = "light_orange")]
LightOrange,
#[default]
System,
}
@@ -425,6 +440,23 @@ mod tests {
assert_eq!(reloaded, settings);
}
#[test]
fn light_variant_color_schemes_round_trip_through_serde() {
for (scheme, rename) in [
(ColorScheme::LightGreen, "light_green"),
(ColorScheme::LightRed, "light_red"),
(ColorScheme::LightBlue, "light_blue"),
(ColorScheme::LightYellow, "light_yellow"),
(ColorScheme::LightPink, "light_pink"),
(ColorScheme::LightOrange, "light_orange"),
] {
let json = serde_json::to_string(&scheme).unwrap();
assert_eq!(json, format!("\"{rename}\""));
let deserialized: ColorScheme = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, scheme);
}
}
#[test]
fn legacy_grey_color_scheme_deserializes_as_dark() {
let settings: Settings = serde_json::from_str(
@@ -1,77 +0,0 @@
# 056 — Add `Light *` variants for the remaining colour schemes
## Summary
Ticket 048 already added one light-surfaced variant, `light_green`
(`webapp/src/lib/themes.ts:231-254`, commit `5371d3e`: "Adds a new
'Light Green' scheme (light-surfaced, same green/magenta hue family as
'Green') selectable alongside the existing built-ins"). The wishlist
comment that prompted 048 — "we need a `Light Green` alongside `Green`"
— is now clarified as applying to *every* colour scheme, not just green:
the current built-in set is `dark`, `light`, `red`, `green`, `blue`,
`yellow`, `pink`, `orange`, `light_green`
(`webapp/src/lib/themes.ts:38-255`), so `red`, `blue`, `yellow`, `pink`,
and `orange` still have no light-surfaced counterpart.
Adding a scheme is not fully data-driven from `themes.ts` alone — six
files must be kept in sync per scheme, per the no-build-step-links-them
comment at `webapp/src/index.css:1-7`:
1. `webapp/src/index.css:264-300` — the `html.light_green` CSS block is
the reference recipe: same hue + same accent hues as the dark
counterpart, but with `light`'s inverted (high-lightness)
surface/border/text scale, and `light`'s hue-neutral danger colors
(comment at `index.css:264-268` documents this explicitly).
2. `webapp/src/lib/themes.ts:231-254` — the matching `BUILTIN_THEMES`
entry (used for CSS export/download and validation).
3. `webapp/src/lib/api.ts:83-93` — the `ColorScheme` TS union type.
4. `src/settings.rs:30-42` — the matching Rust `ColorScheme` enum variant,
with `#[serde(rename = "light_<name>")]`.
5. `webapp/src/lib/useSettingsPanel.ts:24-34` — `THEME_CLASSES`, used to
apply/strip the `html.*` class.
6. `webapp/src/components/NavMenu.tsx:20-31` — `COLOR_SCHEMES`, which
drives the picker UI; `schemeLabel()` (`NavMenu.tsx:34-35`) already
auto-titles `light_red` → "Light Red" by replacing underscores, so no
extra label wiring is needed once a name is added to this array.
`PanelChart.tsx`'s multi-accent `color-mix()` series-color logic (touched
by ticket 048) is generic per-theme rather than per-scheme-name, so it
likely needs no changes — worth a quick check while implementing, not
expected to require edits.
Tickets 047/048's original files were deleted on implementation (git
history only shows their removal commits, `5cb2dfd`/`7c331e7`; bodies
aren't recoverable), but commit `5371d3e`'s message and the resulting
`light_green` block are sufficient as the template.
## Scope
- Add `light_red`, `light_blue`, `light_yellow`, `light_pink`, and
`light_orange` following the exact `light_green` recipe: same base
hue and accent-hue choices as the corresponding dark-surfaced scheme,
`light`'s inverted surface/border/text lightness scale, `light`'s
neutral danger colors.
- Update all six files listed above for each new scheme:
`webapp/src/index.css`, `webapp/src/lib/themes.ts`,
`webapp/src/lib/api.ts`, `src/settings.rs`,
`webapp/src/lib/useSettingsPanel.ts`, `webapp/src/components/NavMenu.tsx`.
- Verify `schemeLabel()` produces correct titles ("Light Red", "Light
Blue", etc.) without additional label-mapping code.
- Spot-check `PanelChart.tsx`'s series-color logic against one new light
scheme to confirm it needs no changes (or fix it if it does).
## Acceptance criteria
- All five new schemes are selectable in the settings picker
(`NavMenu.tsx`), persist correctly, and are downloadable as CSS via
the existing "download built-in theme" path.
- Each new scheme's CSS block round-trips through `Settings`
serialization/deserialization (`src/settings.rs`) without errors,
verified with a test analogous to any existing `ColorScheme`
serialization test.
- `make build` and `make lint` pass with the new schemes added.
## Depends on
048 (implemented; this ticket extends its established pattern to the
remaining base hues).
+5
View File
@@ -28,6 +28,11 @@ const COLOR_SCHEMES: ColorScheme[] = [
'pink',
'orange',
'light_green',
'light_red',
'light_blue',
'light_yellow',
'light_pink',
'light_orange',
]
/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
+160
View File
@@ -296,6 +296,166 @@ html.light_green {
--c-accent-3: hsl(50 70% 38%);
}
/* Brighter, light-surfaced counterpart to `red` (ticket 056): same base hue
(0) and accent hues as `red`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_red {
--c-surface-0: hsl(0 40% 96%);
--c-surface-1: hsl(0 35% 92%);
--c-surface-2: hsl(0 30% 85%);
--c-border: hsl(0 25% 80%);
--c-border-soft: hsl(0 22% 70%);
--c-border-faint: hsl(0 35% 92%);
--c-border-strong: hsl(0 20% 40%);
--c-text: hsl(0 30% 12%);
--c-text-secondary: hsl(0 20% 25%);
--c-text-muted: hsl(0 15% 40%);
--c-text-subtle: hsl(0 10% 50%);
--c-text-faint: hsl(0 10% 65%);
--c-text-faintest: hsl(0 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(180 70% 40%);
--c-accent-soft: hsl(150 60% 85%);
--c-accent-text: hsl(180 75% 28%);
--c-accent-2: hsl(90 70% 45%);
--c-accent-3: hsl(270 70% 38%);
}
/* Brighter, light-surfaced counterpart to `blue` (ticket 056): same base hue
(210) and accent hues as `blue`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_blue {
--c-surface-0: hsl(210 40% 96%);
--c-surface-1: hsl(210 35% 92%);
--c-surface-2: hsl(210 30% 85%);
--c-border: hsl(210 25% 80%);
--c-border-soft: hsl(210 22% 70%);
--c-border-faint: hsl(210 35% 92%);
--c-border-strong: hsl(210 20% 40%);
--c-text: hsl(210 30% 12%);
--c-text-secondary: hsl(210 20% 25%);
--c-text-muted: hsl(210 15% 40%);
--c-text-subtle: hsl(210 10% 50%);
--c-text-faint: hsl(210 10% 65%);
--c-text-faintest: hsl(210 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(30 70% 40%);
--c-accent-soft: hsl(0 60% 85%);
--c-accent-text: hsl(30 75% 28%);
--c-accent-2: hsl(300 70% 45%);
--c-accent-3: hsl(120 70% 38%);
}
/* Brighter, light-surfaced counterpart to `yellow` (ticket 056): same base
hue (50) and accent hues as `yellow`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_yellow {
--c-surface-0: hsl(50 40% 96%);
--c-surface-1: hsl(50 35% 92%);
--c-surface-2: hsl(50 30% 85%);
--c-border: hsl(50 25% 80%);
--c-border-soft: hsl(50 22% 70%);
--c-border-faint: hsl(50 35% 92%);
--c-border-strong: hsl(50 20% 40%);
--c-text: hsl(50 30% 12%);
--c-text-secondary: hsl(50 20% 25%);
--c-text-muted: hsl(50 15% 40%);
--c-text-subtle: hsl(50 10% 50%);
--c-text-faint: hsl(50 10% 65%);
--c-text-faintest: hsl(50 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(230 70% 40%);
--c-accent-soft: hsl(200 60% 85%);
--c-accent-text: hsl(230 75% 28%);
--c-accent-2: hsl(140 70% 45%);
--c-accent-3: hsl(320 70% 38%);
}
/* Brighter, light-surfaced counterpart to `pink` (ticket 056): same base hue
(330) and accent hues as `pink`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_pink {
--c-surface-0: hsl(330 40% 96%);
--c-surface-1: hsl(330 35% 92%);
--c-surface-2: hsl(330 30% 85%);
--c-border: hsl(330 25% 80%);
--c-border-soft: hsl(330 22% 70%);
--c-border-faint: hsl(330 35% 92%);
--c-border-strong: hsl(330 20% 40%);
--c-text: hsl(330 30% 12%);
--c-text-secondary: hsl(330 20% 25%);
--c-text-muted: hsl(330 15% 40%);
--c-text-subtle: hsl(330 10% 50%);
--c-text-faint: hsl(330 10% 65%);
--c-text-faintest: hsl(330 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(150 70% 40%);
--c-accent-soft: hsl(120 60% 85%);
--c-accent-text: hsl(150 75% 28%);
--c-accent-2: hsl(60 70% 45%);
--c-accent-3: hsl(240 70% 38%);
}
/* Brighter, light-surfaced counterpart to `orange` (ticket 056): same base
hue (30) and accent hues as `orange`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_orange {
--c-surface-0: hsl(30 40% 96%);
--c-surface-1: hsl(30 35% 92%);
--c-surface-2: hsl(30 30% 85%);
--c-border: hsl(30 25% 80%);
--c-border-soft: hsl(30 22% 70%);
--c-border-faint: hsl(30 35% 92%);
--c-border-strong: hsl(30 20% 40%);
--c-text: hsl(30 30% 12%);
--c-text-secondary: hsl(30 20% 25%);
--c-text-muted: hsl(30 15% 40%);
--c-text-subtle: hsl(30 10% 50%);
--c-text-faint: hsl(30 10% 65%);
--c-text-faintest: hsl(30 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(210 70% 40%);
--c-accent-soft: hsl(180 60% 85%);
--c-accent-text: hsl(210 75% 28%);
--c-accent-2: hsl(120 70% 45%);
--c-accent-3: hsl(300 70% 38%);
}
@theme inline {
--color-surface-0: var(--c-surface-0);
--color-surface-1: var(--c-surface-1);
+5
View File
@@ -91,6 +91,11 @@ export type ColorScheme =
| 'pink'
| 'orange'
| 'light_green'
| 'light_red'
| 'light_blue'
| 'light_yellow'
| 'light_pink'
| 'light_orange'
export interface Settings {
color_scheme: ColorScheme
+120
View File
@@ -252,6 +252,126 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
'--c-accent-2': 'hsl(230 70% 45%)',
'--c-accent-3': 'hsl(50 70% 38%)',
},
light_red: {
'--c-surface-0': 'hsl(0 40% 96%)',
'--c-surface-1': 'hsl(0 35% 92%)',
'--c-surface-2': 'hsl(0 30% 85%)',
'--c-border': 'hsl(0 25% 80%)',
'--c-border-soft': 'hsl(0 22% 70%)',
'--c-border-faint': 'hsl(0 35% 92%)',
'--c-border-strong': 'hsl(0 20% 40%)',
'--c-text': 'hsl(0 30% 12%)',
'--c-text-secondary': 'hsl(0 20% 25%)',
'--c-text-muted': 'hsl(0 15% 40%)',
'--c-text-subtle': 'hsl(0 10% 50%)',
'--c-text-faint': 'hsl(0 10% 65%)',
'--c-text-faintest': 'hsl(0 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(180 70% 40%)',
'--c-accent-soft': 'hsl(150 60% 85%)',
'--c-accent-text': 'hsl(180 75% 28%)',
'--c-accent-2': 'hsl(90 70% 45%)',
'--c-accent-3': 'hsl(270 70% 38%)',
},
light_blue: {
'--c-surface-0': 'hsl(210 40% 96%)',
'--c-surface-1': 'hsl(210 35% 92%)',
'--c-surface-2': 'hsl(210 30% 85%)',
'--c-border': 'hsl(210 25% 80%)',
'--c-border-soft': 'hsl(210 22% 70%)',
'--c-border-faint': 'hsl(210 35% 92%)',
'--c-border-strong': 'hsl(210 20% 40%)',
'--c-text': 'hsl(210 30% 12%)',
'--c-text-secondary': 'hsl(210 20% 25%)',
'--c-text-muted': 'hsl(210 15% 40%)',
'--c-text-subtle': 'hsl(210 10% 50%)',
'--c-text-faint': 'hsl(210 10% 65%)',
'--c-text-faintest': 'hsl(210 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(30 70% 40%)',
'--c-accent-soft': 'hsl(0 60% 85%)',
'--c-accent-text': 'hsl(30 75% 28%)',
'--c-accent-2': 'hsl(300 70% 45%)',
'--c-accent-3': 'hsl(120 70% 38%)',
},
light_yellow: {
'--c-surface-0': 'hsl(50 40% 96%)',
'--c-surface-1': 'hsl(50 35% 92%)',
'--c-surface-2': 'hsl(50 30% 85%)',
'--c-border': 'hsl(50 25% 80%)',
'--c-border-soft': 'hsl(50 22% 70%)',
'--c-border-faint': 'hsl(50 35% 92%)',
'--c-border-strong': 'hsl(50 20% 40%)',
'--c-text': 'hsl(50 30% 12%)',
'--c-text-secondary': 'hsl(50 20% 25%)',
'--c-text-muted': 'hsl(50 15% 40%)',
'--c-text-subtle': 'hsl(50 10% 50%)',
'--c-text-faint': 'hsl(50 10% 65%)',
'--c-text-faintest': 'hsl(50 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(230 70% 40%)',
'--c-accent-soft': 'hsl(200 60% 85%)',
'--c-accent-text': 'hsl(230 75% 28%)',
'--c-accent-2': 'hsl(140 70% 45%)',
'--c-accent-3': 'hsl(320 70% 38%)',
},
light_pink: {
'--c-surface-0': 'hsl(330 40% 96%)',
'--c-surface-1': 'hsl(330 35% 92%)',
'--c-surface-2': 'hsl(330 30% 85%)',
'--c-border': 'hsl(330 25% 80%)',
'--c-border-soft': 'hsl(330 22% 70%)',
'--c-border-faint': 'hsl(330 35% 92%)',
'--c-border-strong': 'hsl(330 20% 40%)',
'--c-text': 'hsl(330 30% 12%)',
'--c-text-secondary': 'hsl(330 20% 25%)',
'--c-text-muted': 'hsl(330 15% 40%)',
'--c-text-subtle': 'hsl(330 10% 50%)',
'--c-text-faint': 'hsl(330 10% 65%)',
'--c-text-faintest': 'hsl(330 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(150 70% 40%)',
'--c-accent-soft': 'hsl(120 60% 85%)',
'--c-accent-text': 'hsl(150 75% 28%)',
'--c-accent-2': 'hsl(60 70% 45%)',
'--c-accent-3': 'hsl(240 70% 38%)',
},
light_orange: {
'--c-surface-0': 'hsl(30 40% 96%)',
'--c-surface-1': 'hsl(30 35% 92%)',
'--c-surface-2': 'hsl(30 30% 85%)',
'--c-border': 'hsl(30 25% 80%)',
'--c-border-soft': 'hsl(30 22% 70%)',
'--c-border-faint': 'hsl(30 35% 92%)',
'--c-border-strong': 'hsl(30 20% 40%)',
'--c-text': 'hsl(30 30% 12%)',
'--c-text-secondary': 'hsl(30 20% 25%)',
'--c-text-muted': 'hsl(30 15% 40%)',
'--c-text-subtle': 'hsl(30 10% 50%)',
'--c-text-faint': 'hsl(30 10% 65%)',
'--c-text-faintest': 'hsl(30 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(210 70% 40%)',
'--c-accent-soft': 'hsl(180 60% 85%)',
'--c-accent-text': 'hsl(210 75% 28%)',
'--c-accent-2': 'hsl(120 70% 45%)',
'--c-accent-3': 'hsl(300 70% 38%)',
},
}
export function generateThemeCss(vars: ThemeVars): string {
+5
View File
@@ -31,6 +31,11 @@ const THEME_CLASSES = [
'pink',
'orange',
'light_green',
'light_red',
'light_blue',
'light_yellow',
'light_pink',
'light_orange',
] as const
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.