diff --git a/docs/superpowers/plans/2026-07-31-dark-palette-rework.md b/docs/superpowers/plans/2026-07-31-dark-palette-rework.md index d85a9fb..26964cc 100644 --- a/docs/superpowers/plans/2026-07-31-dark-palette-rework.md +++ b/docs/superpowers/plans/2026-07-31-dark-palette-rework.md @@ -82,7 +82,10 @@ describe('dark note colours', () => { - [ ] **Step 2: Run the tests to verify they fail** Run: `pnpm test:unit colors` -Expected: FAIL. The muted-text test fails on several colours (the old `#4a2522` family sits too close to `#b3a695`), and the lightness-band test fails because the old values wander. +Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA +tests are forward guards, not regressions of the old palette — the old palette actually clears +both AA thresholds. The distinguishability test is the one that would fail on the old palette +(its closest pair, `fog`/`storm`, sits at RGB distance 7.5). - [ ] **Step 3: Replace the dark palette** diff --git a/docs/superpowers/specs/2026-07-31-dark-palette-design.md b/docs/superpowers/specs/2026-07-31-dark-palette-design.md index 2d3141f..39974ed 100644 --- a/docs/superpowers/specs/2026-07-31-dark-palette-design.md +++ b/docs/superpowers/specs/2026-07-31-dark-palette-design.md @@ -91,10 +91,13 @@ html, body { accent-color: var(--accent-check); } ## 2. Note card colours (`src/lib/utils/colors.ts`) -Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, chroma roughly doubles -against today's values, and lightness is held near-constant (~18%) so the twelve read as siblings — -with blues and purples lifted ~3.5 points because they read darker than warm hues at equal HSL -lightness. +Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness +near-constant against today's values (measured average change: +4.1 saturation points, −0.1 +lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink +and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge. +The visible improvement in dark mode comes from the base surfaces, the picker chips, the +softened border, and the lifted gold — not from the card surfaces, which are close in chroma to +the previous dark values. ```ts export const NOTE_COLORS_DARK: Record = { diff --git a/src/lib/utils/colors.test.ts b/src/lib/utils/colors.test.ts index 7977c30..9624d63 100644 --- a/src/lib/utils/colors.test.ts +++ b/src/lib/utils/colors.test.ts @@ -24,10 +24,14 @@ describe('getNoteColor', () => { }); }); +function toRgb(hex: string): [number, number, number] { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + /** WCAG relative luminance. */ function relativeLuminance(hex: string): number { - const n = parseInt(hex.slice(1), 16); - const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => { + const channels = toRgb(hex).map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); @@ -39,6 +43,12 @@ function contrastRatio(a: string, b: string): number { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } +function rgbDistance(a: string, b: string): number { + const [ra, ga, ba] = toRgb(a); + const [rb, gb, bb] = toRgb(b); + return Math.hypot(ra - rb, ga - gb, ba - bb); +} + describe('dark note colours', () => { // Must track [data-theme="dark"] in src/app.css. const DARK_TEXT = '#ece3d3'; @@ -51,15 +61,25 @@ describe('dark note colours', () => { }); it('keeps muted text above the AA threshold on every dark card', () => { - // This is the invariant the old palette broke: muted/struck-through text - // became unreadable on the lighter surfaces. + // The shipped dark theme failed this at 3.77:1 with its previous muted + // colour (#9a8e7e). This guard exists so a future palette edit cannot + // reintroduce it — it does not discriminate the old surfaces, which were + // darker and so scored higher against the lifted muted colour. for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) { expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5); } }); - it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => { - const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg)); - expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03); + it('keeps every pair of dark surfaces distinguishable', () => { + // The old palette failed this: fog and storm sat 7.5 apart, so the twelve + // colours read as one dark smudge. Current closest pair is coral/peach at 12. + const entries = Object.entries(NOTE_COLORS_DARK); + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const [[nameA, a], [nameB, b]] = [entries[i], entries[j]]; + const distance = rgbDistance(a.bg, b.bg); + expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(10); + } + } }); });