Compare commits

..
13 Commits
Author SHA1 Message Date
github-actions[bot] 3c1fb4a79d chore: bump version to 0.31.0 2026-07-31 10:05:34 +00:00
Frederic Cabassut a46387172c fix(theme): restore hover-wash contrast and correct doc claims from dark palette review
Separates --hover-wash from --border so the softened dark-mode border grey
no longer collapses hover/active tint washes to near-invisibility (measured
5% wash on --bg-surface: 4.7 -> 17.0 RGB distance). Light mode is unaffected
since --hover-wash falls back to var(--border) in :root.

Also fixes stale/incorrect claims surfaced by the final review: the
checkbox rule and missing CSS variables in CLAUDE.md, the "warm parchment"
description of dark mode in FEATURES.md, the chip contrast claim in the
palette design doc (chips are 3:1 UI, not 4:1 text), and adds a measured
lightness-floor test guarding against chips regressing to card lightness.
2026-07-31 12:03:39 +02:00
Frederic Cabassut 2095c32347 docs: document the dark palette and the card-vs-chip distinction 2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 e8bef26b4f fix(theme): sync browser theme-colour with the new dark base
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic Cabassut 3041de4b2f feat(theme): replace dark surfaces, borders and accents with the warm ink palette 2026-07-31 12:03:39 +02:00
Frederic Cabassut 4d77f7f819 feat(theme): give the dark colour picker vivid identifiable chips 2026-07-31 12:03:39 +02:00
Frederic Cabassut a2463ac316 test(theme): widen distinguishability threshold headroom
The distinguishability threshold (10) sat only 2 units below the palette's
measured closest pair (coral/peach at 12.0), making it brittle to a minor
future nudge. Lower to 8: still fails the old palette's closest pair
(fog/storm at 7.55) while giving the current palette 4 units of headroom.
Also corrects the spec's lightness-change figure from -0.1 (computed from
rounded HSL) to the precise -0.18.
2026-07-31 12:03:39 +02:00
Frederic Cabassut ffc49f2676 test(theme): replace lightness-band test with pairwise distinguishability
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.
2026-07-31 12:03:39 +02:00
Frederic Cabassut 9600c62288 feat(theme): rebuild dark note colours as a consistent family 2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 365f941240 docs: add dark palette rework implementation plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 edee5f7cd4 docs: add dark theme palette rework design spec
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 b5109e7522 fix(sharing): make public note colors follow the visitor's theme
The public share page picked the note background from the light palette
unconditionally, while app.html applies data-theme="dark" on every page.
A visitor in dark mode therefore got a light pastel card rendered with the
dark theme's light text colour — washed out and barely readable.

Emit both palettes as custom properties and let [data-theme] select one, so
the correct colour lands on the first paint of this SSR page instead of
flashing after hydration.

Reported by a user over email.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
github-actions[bot] 4e3b6dfd1c chore: pin docker-compose to v0.30.1 [skip ci] 2026-07-15 09:06:34 +00:00
24 changed files with 1124 additions and 84 deletions
+33 -1
View File
@@ -37,9 +37,41 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
| `--success-bg/text` | warm greens | Success states |
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
### 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 low 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 (4.5:1) against both `--text` and `--text-muted` on every dark surface, no two dark card
surfaces closer than an RGB distance of 8, no two dark chips closer than an RGB distance of 25, and
`getNoteChip()` returning the card colour in light mode but a distinct chip in dark mode.
`--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.
### Typography
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
@@ -53,7 +85,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Checkboxes**: Use `accent-color: var(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.29.0
image: ghcr.io/bretzel-app/crumbs:0.30.1
container_name: crumbs
ports:
- "3000:3000"
+4 -1
View File
@@ -159,7 +159,10 @@ Theme switching uses CSS variable overrides on the `<html>` element:
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
- **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.
## File Organization
+1 -1
View File
@@ -71,7 +71,7 @@
### Dark Mode
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — retro aesthetic preserved in both themes
- Respects system `prefers-color-scheme` preference by default
- FOUC-free: theme applied before first paint via a blocking inline script
@@ -0,0 +1,557 @@
# 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 `:root` colour values other than the new `--accent-check` is out of scope.
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
- Run `pnpm check` before every commit — not just tests. (Project rule.)
- Run `pnpm build` before `pnpm test:e2e` locally, 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 `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
---
### Task 1: New dark note-card colours
**Files:**
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
- Test: `src/lib/utils/colors.test.ts`
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being 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.
```ts
/** 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:
```ts
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:
```ts
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)`:
```ts
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:
```ts
// 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**
```bash
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` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
- 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_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(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`:
```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:
```ts
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`:
```ts
/**
* 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:
```svelte
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
```
```svelte
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`:
```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**
```bash
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-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
**Interfaces:**
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
- Produces: `--accent-check` custom property, available in both themes.
- [ ] **Step 1: Add `--accent-check` to `:root`**
In the `:root` block in `src/app.css`, after `--grid-dot`:
```css
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
```
- [ ] **Step 2: Replace the dark block**
Replace the entire `[data-theme="dark"]` block with:
```css
[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-color` at the new variable**
In the `html, body` rule:
```css
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**
```bash
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` `#141312` from 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`:
```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:
```ts
const DARK_THEME_COLOR = '#141312';
```
`src/app.html` line 21, inside the FOUC script:
```js
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.
```bash
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:
```markdown
### 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:
```markdown
- **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**
```bash
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 errors
- [ ] `pnpm test:unit` — all pass
- [ ] `pnpm 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
@@ -0,0 +1,205 @@
# Dark Theme Palette Rework — Design Spec
**Date:** 2026-07-31
**Status:** Approved
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
## Summary
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
with a systematically generated one, and splits the picker swatch from the card surface — they are
different jobs and should not share a value.
Light mode is unchanged.
## Problems being fixed
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
no consistent relationship, so they don't read as a family.
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
percent of `--bg-base`, so cards lose their edges.
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
twelve swatches become twelve grey dots.
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
with gold and cream on top, the UI reads dim rather than crisp.
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
inverted border reads as a glowing rectangle, not as structure.
## Decisions
| # | Decision | Choice |
|---|---|---|
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
Only dark mode needs the split.
## 1. CSS variables (`src/app.css`)
Replace the `[data-theme="dark"]` block:
```css
[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;
--accent-check: #c8a44a;
}
```
Three of these are notable changes rather than tweaks:
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
hard offset shadow still carries the retro structure.
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
### Checkbox accent
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
the checkbox can be dimmed independently of links and the brand:
```css
:root { --accent-check: var(--primary); } /* light: unchanged */
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, holds lightness
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
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<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' }
};
```
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
different hue in each theme. This is an accepted trade: distinguishable beats identical.
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
All clear WCAG AA.
## 3. Picker chips (`src/lib/utils/colors.ts`)
New map, used only by the picker, only in dark mode:
```ts
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'
};
/** Picker swatch colour: a vivid label, not the card surface. */
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
```
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
apart at 28px.
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
the editor and the public share page keep calling `getNoteColor`.
## 4. Theme-colour meta tag
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
Both become `#141312`.
## Files to modify
| File | Change |
|---|---|
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
through `getNoteColor`, so they inherit the new palette.
## Testing
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
- unknown colour falls back to `default` in both maps
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
before, so it gets asserted rather than eyeballed
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
picker swatch is *not* the card background, which is the bug in user-visible terms.
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
can't judge those.
## Out of scope
- Light mode, including its picker
- The 12-colour set itself (no colours added, removed, or renamed)
- Per-note custom colours
- `prefers-contrast` / high-contrast variants
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.30.1",
"version": "0.31.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
+24 -14
View File
@@ -21,29 +21,39 @@
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
/* Tint source for hover/active washes. Light mode reuses the ink border, so
light styling is byte-identical; dark mode keeps the cream that --border
used to supply, because a mid-grey wash is invisible on a dark surface. */
--hover-wash: var(--border);
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--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: #e8dcc8;
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
}
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
+1 -1
View File
@@ -18,7 +18,7 @@
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
if (m) m.content = '#141312';
}
} catch(e) {}
})();
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
@@ -16,7 +16,7 @@
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+6 -6
View File
@@ -121,15 +121,15 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -442,7 +442,7 @@
<button
onmousedown={preventToolbarMouseFocus}
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
@@ -454,7 +454,7 @@
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
@@ -465,7 +465,7 @@
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
title="Remove link"
data-testid="format-link-remove"
>
+2 -2
View File
@@ -21,7 +21,7 @@
{:else}
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
aria-label="Toggle sidebar"
>
<Menu class="h-6 w-6 text-[var(--text)]" />
@@ -40,7 +40,7 @@
<!-- Mobile search icon -->
<button
onclick={() => (mobileSearchOpen = true)}
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
aria-label="Search"
>
<Search class="h-5 w-5 text-[var(--text)]" />
+8 -8
View File
@@ -118,7 +118,7 @@
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
@@ -164,7 +164,7 @@
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
@@ -172,7 +172,7 @@
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
@@ -182,7 +182,7 @@
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
@@ -192,7 +192,7 @@
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
@@ -201,7 +201,7 @@
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
@@ -211,7 +211,7 @@
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
@@ -220,7 +220,7 @@
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
+17 -17
View File
@@ -544,7 +544,7 @@
<!-- Mobile Back Button -->
<button
onclick={saveAndClose}
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--border)]/10 flex items-center shrink-0"
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 flex items-center shrink-0"
aria-label="Back"
>
<ArrowLeft class="h-6 w-6" />
@@ -569,7 +569,7 @@
<button
bind:this={mobileOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
class="md:hidden rounded-sm p-2 hover:bg-[var(--border)]/10 text-[var(--text)] shrink-0"
class="md:hidden rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 text-[var(--text)] shrink-0"
data-testid="mobile-overflow-menu-btn"
>
<EllipsisVertical class="h-6 w-6" />
@@ -633,7 +633,7 @@
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
@@ -649,7 +649,7 @@
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
@@ -660,7 +660,7 @@
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Share"}
data-testid="share-toggle"
>
@@ -682,7 +682,7 @@
{#if !currentlyNew}
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
@@ -694,7 +694,7 @@
<button
bind:this={desktopOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
@@ -704,7 +704,7 @@
<button
onclick={saveAndClose}
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="close-editor-btn"
>
Close
@@ -733,7 +733,7 @@
<!-- Attachments -->
<button
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
Attachments
@@ -743,7 +743,7 @@
{#if isOwner}
<button
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
{#if hasPublicLink}
<Globe class="h-4 w-4 text-[var(--primary)]" />
@@ -765,7 +765,7 @@
{#if !currentlyNew}
<button
onclick={handleArchive}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<Archive class="h-4 w-4" />
Archive
@@ -777,7 +777,7 @@
<!-- Checklist mode toggle -->
<button
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="checklist-toggle"
>
{#if checklistMode}
@@ -793,7 +793,7 @@
{#if !checklistMode}
<button
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
@@ -810,7 +810,7 @@
{#if !currentlyNew}
<button
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="history-toggle"
>
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
@@ -823,7 +823,7 @@
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
<button
onclick={handleTrash}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--hover-wash)]/10"
data-testid="trash-note-btn"
>
<Trash2 class="h-4 w-4" />
@@ -837,7 +837,7 @@
{#if hasDoneItems}
<button
onclick={deleteCheckedItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="delete-checked-btn"
>
<Trash2 class="h-4 w-4" />
@@ -846,7 +846,7 @@
{/if}
<button
onclick={uncheckAllItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
+3 -3
View File
@@ -116,7 +116,7 @@
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
title="Close history"
data-testid="close-history-btn"
>
@@ -140,7 +140,7 @@
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
@@ -195,7 +195,7 @@
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
Cancel
</button>
+2 -2
View File
@@ -168,7 +168,7 @@
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
title="Close"
>
<X class="h-4 w-4" />
@@ -277,7 +277,7 @@
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
+123 -4
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
@@ -8,13 +15,13 @@ describe('getNoteColor', () => {
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
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('#2a2520');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
it('has matching keys in light and dark color maps', () => {
@@ -23,3 +30,115 @@ describe('getNoteColor', () => {
expect(lightKeys).toEqual(darkKeys);
});
});
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 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);
});
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);
}
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';
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', () => {
// 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('keeps every pair of dark surfaces distinguishable', () => {
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
// colours read as one dark smudge. Threshold 8 clears that while leaving
// headroom above the current closest pair, coral/peach at 12.0.
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(8);
}
}
});
});
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 distance = rgbDistance(a, b);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
// A chip set to card-lightness values could still pass the pairwise
// distance test above (25 apart) while being just as dark as the cards,
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
// side of the 0.1 threshold below.
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
const maxCardLuminance = Math.max(...cardLuminances);
expect(maxCardLuminance).toBeLessThan(0.1);
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
}
});
});
+38 -12
View File
@@ -16,18 +16,18 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
};
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#2a2520', label: 'Default' },
coral: { bg: '#4a2522', label: 'Coral' },
peach: { bg: '#4a3020', label: 'Peach' },
sand: { bg: '#3a3520', label: 'Sand' },
mint: { bg: '#2a3a22', label: 'Mint' },
sage: { bg: '#223a32', label: 'Sage' },
fog: { bg: '#222e3a', label: 'Fog' },
storm: { bg: '#1e2a35', label: 'Storm' },
dusk: { bg: '#352540', label: 'Dusk' },
blossom: { bg: '#3a2830', label: 'Blossom' },
clay: { bg: '#302e28', label: 'Clay' },
chalk: { bg: '#2e2e30', label: 'Chalk' }
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' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
@@ -39,3 +39,29 @@ export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]
value: value as NoteColor,
label
}));
/**
* 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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
const DARK_THEME_COLOR = '#141312';
let darkMode = $state(false);
+1 -1
View File
@@ -37,7 +37,7 @@ describe('applyTheme', () => {
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
expect(meta?.getAttribute('content')).toBe('#141312');
});
it('updates meta theme-color for light mode', () => {
+18 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { linkifyText } from '$lib/utils/checklist.js';
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
@@ -12,8 +12,12 @@
const { data } = $props();
// Both palettes are emitted as custom properties so the card follows the visitor's theme on the
// first paint — the CSS variables in app.css switch on [data-theme], which app.html sets before
// hydration, so a JS-resolved single colour would flash the wrong palette on this SSR page.
const noteColor = data.color as NoteColor;
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const bgLight = getNoteColor(noteColor, false);
const bgDark = getNoteColor(noteColor, true);
const renderedContent = renderMarkdown(data.content);
@@ -55,8 +59,8 @@
<div class="mx-4 w-full max-w-2xl py-10">
<article
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="background-color: {bgColor}"
class="note-surface rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="--note-bg: {bgLight}; --note-bg-dark: {bgDark}"
data-testid="shared-note"
>
<div class="px-6 py-5">
@@ -128,3 +132,13 @@
</a>
</div>
</div>
<style>
.note-surface {
background-color: var(--note-bg);
}
:global([data-theme='dark']) .note-surface {
background-color: var(--note-bg-dark);
}
</style>
+27 -1
View File
@@ -81,7 +81,33 @@ test.describe.serial('Dark mode', () => {
const noteCard = page.getByTestId('note-card').first();
await expect(noteCard).toBeVisible();
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
// Dark default color is #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
expect(bg).not.toBe('rgb(250, 245, 235)');
});
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();
});
});
+48
View File
@@ -121,6 +121,54 @@ test.describe('Public Note Sharing', () => {
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
});
test('Scenario: Shared note follows a dark-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Dark Visitor Note', 'Readable in dark mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers dark mode opens the share URL
const context = await browser.newContext({ colorScheme: 'dark' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the dark "fog" surface, so its text stays legible
await expect(publicPage.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
await context.close();
});
test('Scenario: Shared note follows a light-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Light Visitor Note', 'Readable in light mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers light mode opens the share URL
const context = await browser.newContext({ colorScheme: 'light' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the light "fog" surface
await expect(publicPage.locator('html')).not.toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(212, 228, 237)'
);
await context.close();
});
test('Scenario: Copy link button copies to clipboard', async ({
authenticatedPage: page
}) => {
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.30.1</span>
<span class="footer-version">v0.31.0</span>
</div>
</footer>