The lightness-band guard was looser than the palette it was meant to catch
(old band 0.0175 vs new 0.0246) and the muted-AA test never failed against
the old palette (5.08:1), so both docs' stated expectations and one spec
claim ("chroma roughly doubles") were measured falsehoods. Replace the band
test with an RGB-distance pairwise check, which does discriminate (old
closest pair fog/storm = 7.5, new closest pair coral/peach = 12), fix the
misleading test comment, and correct the spec/plan docs to match reality.
20 KiB
Dark Theme Palette Rework Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
Architecture: Purely a colour-data and CSS-variable change. NOTE_COLORS_DARK in src/lib/utils/colors.ts gets new values; a new NOTE_CHIPS_DARK map plus getNoteChip() serves the picker only. The [data-theme="dark"] block in src/app.css is rewritten. Consumers (NoteCard, NoteEditor, the public share page) already resolve colour through getNoteColor() and need no changes — only ColorPicker.svelte switches functions.
Tech Stack: SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
Spec: docs/superpowers/specs/2026-07-31-dark-palette-design.md
Global Constraints
- Light mode must not change. Any diff touching
NOTE_COLORS(the light map) or:rootcolour values other than the new--accent-checkis out of scope. - Never hardcode Tailwind colours (
text-gray-500,bg-red-600). Always reference CSS variables. (Project rule,CLAUDE.md.) - Run
pnpm checkbefore every commit — not just tests. (Project rule.) - Run
pnpm buildbeforepnpm test:e2elocally, or e2e tests run against a stale bundle. - Conventional commit prefixes:
feat:,fix:,chore:,docs:,refactor:,test:. - Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against
--textand--text-mutedon all twelve surfaces, and hand-tweaking breaks that.
Task 1: New dark note-card colours
Files:
- Modify:
src/lib/utils/colors.ts:18-31(theNOTE_COLORS_DARKmap) - Test:
src/lib/utils/colors.test.ts - Modify:
tests/e2e/public-sharing.spec.ts(asserted darkfogvalue) - Modify:
tests/e2e/dark-mode.spec.ts:84(stale comment)
Interfaces:
-
Consumes: nothing from earlier tasks.
-
Produces:
NOTE_COLORS_DARKwith the values below.getNoteColor(color: NoteColor, isDark: boolean): stringkeeps its existing signature. Task 2 relies ongetNoteColorbeing unchanged; Task 2's chip values must differ from every value here. -
Step 1: Write the failing invariant tests
Add to src/lib/utils/colors.test.ts. These assert the properties that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
/** 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 s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
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.
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);
});
});
- Step 2: Run the tests to verify they fail
Run: pnpm test:unit colors
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
In src/lib/utils/colors.ts, replace the whole NOTE_COLORS_DARK map with these values exactly:
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
Note for the implementer: blossom and storm intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with coral/peach and fog. This is deliberate — see the spec.
- Step 4: Update the two hex assertions that pin the old values
In src/lib/utils/colors.test.ts, the existing getNoteColor tests assert old hexes. Change them:
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
- Step 5: Run the unit tests to verify they pass
Run: pnpm test:unit colors
Expected: PASS, all tests in the file.
- Step 6: Update the e2e test that asserts a dark note colour
tests/e2e/public-sharing.spec.ts — the "Shared note follows a dark-mode visitor theme" scenario asserts dark fog. #253946 is rgb(37, 57, 70):
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
In tests/e2e/dark-mode.spec.ts:84, the assertion itself is a negative one (not.toBe light default) and needs no change, but its comment names the old hex. Update it:
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
- Step 7: Verify types, then the full e2e suites that touch colour
Run: pnpm check
Expected: 0 errors.
Run: pnpm build && pnpm test:e2e public-sharing dark-mode organization
Expected: PASS. (organization.spec.ts exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
- Step 8: Commit
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
Task 2: Decouple picker swatches from card surfaces
Files:
- Modify:
src/lib/utils/colors.ts(addNOTE_CHIPS_DARKandgetNoteChip) - Modify:
src/lib/components/ColorPicker.svelte:2,19 - Test:
src/lib/utils/colors.test.ts - Test:
tests/e2e/dark-mode.spec.ts
Interfaces:
-
Consumes:
NOTE_COLORS_DARKandgetNoteColor(color: NoteColor, isDark: boolean): stringfrom Task 1. -
Produces:
NOTE_CHIPS_DARK: Record<NoteColor, string>andgetNoteChip(color: NoteColor, isDark: boolean): string. No later task depends on these. -
Step 1: Write the failing tests
Add to src/lib/utils/colors.test.ts:
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_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 toRgb = (hex: string) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
});
Extend the import at the top of the file:
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
- Step 2: Run the tests to verify they fail
Run: pnpm test:unit colors
Expected: FAIL — getNoteChip is not a function / NOTE_CHIPS_DARK undefined.
- Step 3: Add the chip map and accessor
Append to src/lib/utils/colors.ts, after getNoteColor:
/**
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
* reads as a grey dot — small patches lose apparent saturation — so the dark
* picker uses vivid mid-lightness chips that share the card's hue but not its
* lightness. Light mode needs no equivalent: there the pastels are the cards.
*/
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c',
coral: '#d04f43',
peach: '#cd7c42',
sand: '#c7b138',
mint: '#7bbd4c',
sage: '#47b89d',
fog: '#609abe',
storm: '#547bc0',
dusk: '#a965bd',
blossom: '#c05d78',
clay: '#b5954a',
chalk: '#c3c3c8'
};
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false);
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
- Step 4: Run the tests to verify they pass
Run: pnpm test:unit colors
Expected: PASS.
- Step 5: Point the picker at the chips
In src/lib/components/ColorPicker.svelte, change the import on line 2 and the style on line 19:
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
Leave everything else alone — the 2px --border-subtle ring stays, and it's what gives each swatch its boundary regardless of fill.
- Step 6: Add the e2e scenario
Append inside the test.describe.serial('Dark mode', ...) block in tests/e2e/dark-mode.spec.ts:
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user opens the colour picker while editing a note
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Swatch Test');
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
// Then a swatch shows its vivid chip rather than the muted card surface
await expect(page.getByTestId('color-coral')).toHaveCSS(
'background-color',
'rgb(208, 79, 67)'
);
await page.getByTestId('close-editor-btn').click();
});
- Step 7: Verify
Run: pnpm check
Expected: 0 errors.
Run: pnpm build && pnpm test:e2e dark-mode organization
Expected: PASS.
- Step 8: Commit
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
Task 3: New dark CSS variables and checkbox accent
Files:
- Modify:
src/app.css:6-24(:root— add--accent-checkonly),src/app.css:26-42(the[data-theme="dark"]block),src/app.css:44-48(accent-color)
Interfaces:
-
Consumes: nothing. The
--text/--text-mutedvalues here must match the constants asserted in Task 1's tests (#ece3d3,#b3a695). -
Produces:
--accent-checkcustom property, available in both themes. -
Step 1: Add
--accent-checkto:root
In the :root block in src/app.css, after --grid-dot:
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
- Step 2: Replace the dark block
Replace the entire [data-theme="dark"] block with:
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
}
Two of these reverse earlier decisions on purpose: --primary is no longer shared with light mode (#C8860A reads mustard on ink), and --border is a warm mid-grey rather than the mirrored cream #e8dcc8 (a bright 1px line on a dark background reads as glare, not structure).
- Step 3: Point
accent-colorat the new variable
In the html, body rule:
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
- Step 4: Verify light mode is untouched and dark mode renders
Run: pnpm check
Expected: 0 errors.
Run: pnpm build && pnpm test:e2e dark-mode public-sharing
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
Then look at it: pnpm preview, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
- Step 5: Commit
git add src/app.css
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
Task 4: Sync the browser theme-colour with the new base
Files:
- Modify:
src/lib/utils/theme.svelte.ts:4 - Modify:
src/app.html:21 - Test:
src/lib/utils/theme.test.ts:40
Interfaces:
-
Consumes:
--bg-base#141312from Task 3. These two hardcoded copies must equal it. -
Produces: nothing.
-
Step 1: Update the failing test first
In src/lib/utils/theme.test.ts:
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#141312');
});
- Step 2: Run it to verify it fails
Run: pnpm test:unit theme
Expected: FAIL — received #1a1715.
- Step 3: Update both hardcoded copies
src/lib/utils/theme.svelte.ts line 4:
const DARK_THEME_COLOR = '#141312';
src/app.html line 21, inside the FOUC script:
if (m) m.content = '#141312';
- Step 4: Run the test to verify it passes
Run: pnpm test:unit theme
Expected: PASS.
- Step 5: Verify and commit
Run: pnpm check && pnpm test:unit
Expected: 0 errors, all unit tests pass.
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
Task 5: Document the palette
Files:
- Modify:
CLAUDE.md(the "Design system: Retro Parchment" section) - Modify:
docs/ARCHITECTURE.md:155-165(the dark mode section)
Interfaces:
-
Consumes: everything from Tasks 1-4.
-
Produces: nothing.
-
Step 1: Document the dark palette in
CLAUDE.md
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about src/lib/utils/colors.ts, add:
### Dark theme
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
mechanical inversion of light mode — two differences are deliberate:
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
mustard on a dark background.
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
Note colours have two representations, and they are not interchangeable:
| Map | Function | Used by | Role |
|---|---|---|---|
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
A dark card sits at ~18% lightness, where a 28px picker circle would read as a grey dot — small
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
AA contrast against both `--text` and `--text-muted` on every surface, a narrow lightness band across
the twelve, and no two chips closer than an RGB distance of 25.
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
together.
- Step 2: Update
docs/ARCHITECTURE.md
In the dark mode section, the line describing note colours currently mentions only getNoteColor(). Replace it with:
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
draws from a separate vivid `NOTE_CHIPS_DARK` map.
- Step 3: Verify the docs match the code
Re-read both edited sections against src/lib/utils/colors.ts and src/app.css. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
- Step 4: Commit
git add CLAUDE.md docs/ARCHITECTURE.md
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
Final verification
pnpm check— 0 errorspnpm test:unit— all passpnpm build && pnpm test:e2e— full suite passes- Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged