forked from mirror/crumbs-original
10-task plan covering CSS variables, preferences, FOUC prevention, reactive theme application, component updates, and testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
782 lines
24 KiB
Markdown
782 lines
24 KiB
Markdown
# Dark Mode 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:** Add a dark parchment theme variant with system/light/dark toggle in Settings > Preferences.
|
|
|
|
**Architecture:** CSS `[data-theme="dark"]` attribute on `<html>` overrides CSS variables in `app.css`. A blocking inline script in `app.html` prevents FOUC. Theme preference uses the existing `UserPreferences` key-value system. A reactive `isDarkMode` rune in `theme.svelte.ts` drives note card color switching.
|
|
|
|
**Tech Stack:** SvelteKit, Svelte 5 runes, CSS custom properties, Tailwind CSS v4, Vitest, Playwright
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-20-dark-mode-design.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | File | Responsibility |
|
|
|--------|------|----------------|
|
|
| Modify | `src/app.css` | Add `--grid-dot` to `:root`, add `[data-theme="dark"]` block, update `body::before` |
|
|
| Modify | `src/app.html` | Add FOUC-prevention inline script |
|
|
| Modify | `src/lib/types/preferences.ts` | Add `theme` field to `UserPreferences` |
|
|
| Create | `src/lib/utils/theme.svelte.ts` | `applyTheme()`, reactive `isDarkMode` rune |
|
|
| Modify | `src/lib/utils/colors.ts` | Add `NOTE_COLORS_DARK`, export `getNoteColor()` |
|
|
| Modify | `src/routes/+layout.svelte` | Add theme `$effect` watcher |
|
|
| Modify | `src/routes/(app)/settings/preferences/+page.svelte` | Add theme segmented toggle |
|
|
| Modify | `src/lib/components/NoteCard.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
|
| Modify | `src/lib/components/NoteEditor.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
|
| Modify | `src/lib/components/ColorPicker.svelte` | Use dark color swatches when `isDarkMode` |
|
|
| Skip | `src/routes/(share)/s/[token]/+page.svelte` | Out of scope per spec (public share pages) |
|
|
| Modify | `.gitignore` | Add `.superpowers/` |
|
|
| Create | `tests/unit/theme.test.ts` | Unit tests for `applyTheme` and `isDarkMode` |
|
|
| Create | `tests/unit/colors.test.ts` | Unit tests for `getNoteColor` |
|
|
| Create | `tests/e2e/dark-mode.spec.ts` | E2E tests for theme toggle, persistence, system detection |
|
|
|
|
---
|
|
|
|
### Task 1: CSS Dark Theme Variables
|
|
|
|
**Files:**
|
|
- Modify: `src/app.css:6-23` (`:root` block) and `src/app.css:36` (`body::before`)
|
|
|
|
- [ ] **Step 1: Add `--grid-dot` to `:root` and add `[data-theme="dark"]` block**
|
|
|
|
In `src/app.css`, add `--grid-dot: #1a1a2e;` to the end of the `:root` block, then add the dark theme block after `:root`:
|
|
|
|
```css
|
|
:root {
|
|
--bg-base: #f0e6d3;
|
|
--bg-surface: #faf5eb;
|
|
--primary: #C8860A;
|
|
--primary-hover: #E8A020;
|
|
--text: #1a1a2e;
|
|
--text-muted: #6b6272;
|
|
--border: #1a1a2e;
|
|
--border-subtle: #d4cabb;
|
|
--destructive: #a63d2f;
|
|
--error-bg: #f5e0db;
|
|
--error-border: #d4a69e;
|
|
--error-text: #8b2e1f;
|
|
--success-bg: #dce8d4;
|
|
--success-text: #3a5a2e;
|
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
|
--grid-dot: #1a1a2e;
|
|
}
|
|
|
|
[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;
|
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
|
--grid-dot: #e8dcc8;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `body::before` to use `--grid-dot` variable**
|
|
|
|
Replace the hardcoded color in the `body::before` rule:
|
|
|
|
```css
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
opacity: 0.03;
|
|
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
|
background-size: 4px 4px;
|
|
z-index: 0;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify by temporarily adding `data-theme="dark"` to `app.html`**
|
|
|
|
In `src/app.html`, temporarily change `<html lang="en">` to `<html lang="en" data-theme="dark">`. Run `make dev` and visually confirm dark background and light text. Then revert the change.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/app.css
|
|
git commit -m "feat: add dark theme CSS variables and grid-dot extraction"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Theme Preference Type
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/types/preferences.ts`
|
|
|
|
- [ ] **Step 1: Add `theme` field to `UserPreferences`**
|
|
|
|
```typescript
|
|
export interface UserPreferences {
|
|
theme: 'system' | 'light' | 'dark';
|
|
defaultNoteMode: 'richtext' | 'markdown';
|
|
defaultNoteColor: NoteColor;
|
|
hideFooter: boolean;
|
|
sidebarDefaultState: 'open' | 'collapsed';
|
|
notifyOnShare: boolean;
|
|
notifyOnCollabRemoved: boolean;
|
|
notifyOnNoteDeleted: boolean;
|
|
}
|
|
|
|
export const DEFAULT_PREFERENCES: UserPreferences = {
|
|
theme: 'system',
|
|
defaultNoteMode: 'richtext',
|
|
defaultNoteColor: 'default',
|
|
hideFooter: false,
|
|
sidebarDefaultState: 'open',
|
|
notifyOnShare: true,
|
|
notifyOnCollabRemoved: true,
|
|
notifyOnNoteDeleted: true
|
|
};
|
|
```
|
|
|
|
Do NOT add `theme` to `BOOLEAN_PREF_KEYS`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/types/preferences.ts
|
|
git commit -m "feat: add theme preference type (system/light/dark)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Theme Utility — `applyTheme` and `isDarkMode`
|
|
|
|
**Files:**
|
|
- Create: `src/lib/utils/theme.svelte.ts`
|
|
- Test: `tests/unit/theme.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests for theme utility**
|
|
|
|
Note: `theme.svelte.ts` uses module-scope `$state` which requires the Svelte compiler. The Vitest node environment does not process `.svelte.ts` runes correctly, so we test only the DOM side-effects of `applyTheme` (attribute and meta tag changes). The reactive `isDarkMode` / `getIsDarkMode()` behavior is covered by E2E tests instead.
|
|
|
|
Create `tests/unit/theme.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Mock $app/environment
|
|
vi.mock('$app/environment', () => ({ browser: true }));
|
|
|
|
describe('applyTheme', () => {
|
|
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
|
|
|
|
beforeEach(async () => {
|
|
// Reset DOM
|
|
document.documentElement.removeAttribute('data-theme');
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
if (meta) meta.remove();
|
|
const metaEl = document.createElement('meta');
|
|
metaEl.setAttribute('name', 'theme-color');
|
|
metaEl.setAttribute('content', '#f0e6d3');
|
|
document.head.appendChild(metaEl);
|
|
|
|
// Reset module state
|
|
vi.resetModules();
|
|
const mod = await import('$lib/utils/theme.svelte.js');
|
|
applyTheme = mod.applyTheme;
|
|
});
|
|
|
|
it('sets data-theme="dark" for dark mode', () => {
|
|
applyTheme('dark');
|
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
|
});
|
|
|
|
it('removes data-theme for light mode', () => {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
applyTheme('light');
|
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
|
});
|
|
|
|
it('updates meta theme-color for dark mode', () => {
|
|
applyTheme('dark');
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
expect(meta?.getAttribute('content')).toBe('#1a1715');
|
|
});
|
|
|
|
it('updates meta theme-color for light mode', () => {
|
|
applyTheme('dark');
|
|
applyTheme('light');
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
|
|
});
|
|
|
|
it('falls back to system for unknown values', () => {
|
|
// matchMedia returns false by default in jsdom → resolves to light
|
|
applyTheme('invalid' as 'system' | 'light' | 'dark');
|
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
|
});
|
|
|
|
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: query === '(prefers-color-scheme: dark)',
|
|
media: query,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn()
|
|
}))
|
|
});
|
|
applyTheme('system');
|
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
|
Expected: FAIL — module not found
|
|
|
|
- [ ] **Step 3: Implement `theme.svelte.ts`**
|
|
|
|
Create `src/lib/utils/theme.svelte.ts`:
|
|
|
|
```typescript
|
|
import { browser } from '$app/environment';
|
|
|
|
const LIGHT_THEME_COLOR = '#f0e6d3';
|
|
const DARK_THEME_COLOR = '#1a1715';
|
|
|
|
let darkMode = $state(false);
|
|
|
|
export function getIsDarkMode(): boolean {
|
|
return darkMode;
|
|
}
|
|
|
|
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
|
|
if (!browser) return;
|
|
|
|
// Validate input
|
|
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
|
|
theme = 'system';
|
|
}
|
|
|
|
// Resolve system preference
|
|
let resolved = theme;
|
|
if (theme === 'system') {
|
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
|
|
const isDark = resolved === 'dark';
|
|
darkMode = isDark;
|
|
|
|
if (isDark) {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
if (meta) {
|
|
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
|
Expected: All PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/utils/theme.svelte.ts tests/unit/theme.test.ts
|
|
git commit -m "feat: add theme utility with applyTheme and isDarkMode"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Note Card Dark Colors
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/utils/colors.ts`
|
|
- Test: `tests/unit/colors.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests for `getNoteColor`**
|
|
|
|
Create `tests/unit/colors.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
|
|
|
|
describe('getNoteColor', () => {
|
|
it('returns light color when isDark is false', () => {
|
|
expect(getNoteColor('default', false)).toBe('#faf5eb');
|
|
expect(getNoteColor('coral', false)).toBe('#faafa8');
|
|
});
|
|
|
|
it('returns dark color when isDark is true', () => {
|
|
expect(getNoteColor('default', true)).toBe('#2a2520');
|
|
expect(getNoteColor('coral', true)).toBe('#4a2522');
|
|
});
|
|
|
|
it('returns default color for unknown color name', () => {
|
|
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
|
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
|
|
});
|
|
|
|
it('has matching keys in light and dark color maps', () => {
|
|
const lightKeys = Object.keys(NOTE_COLORS).sort();
|
|
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
|
|
expect(lightKeys).toEqual(darkKeys);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
|
Expected: FAIL — `getNoteColor` and `NOTE_COLORS_DARK` not found
|
|
|
|
- [ ] **Step 3: Add dark colors and helper to `colors.ts`**
|
|
|
|
Update `src/lib/utils/colors.ts`:
|
|
|
|
```typescript
|
|
import type { NoteColor } from '$lib/types/index.js';
|
|
|
|
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
|
default: { bg: '#faf5eb', label: 'Default' },
|
|
coral: { bg: '#faafa8', label: 'Coral' },
|
|
peach: { bg: '#f39f76', label: 'Peach' },
|
|
sand: { bg: '#fff8b8', label: 'Sand' },
|
|
mint: { bg: '#e2f6d3', label: 'Mint' },
|
|
sage: { bg: '#b4ddd3', label: 'Sage' },
|
|
fog: { bg: '#d4e4ed', label: 'Fog' },
|
|
storm: { bg: '#aeccdc', label: 'Storm' },
|
|
dusk: { bg: '#d3bfdb', label: 'Dusk' },
|
|
blossom: { bg: '#f6e2dd', label: 'Blossom' },
|
|
clay: { bg: '#e9e3d4', label: 'Clay' },
|
|
chalk: { bg: '#efeff1', label: 'Chalk' }
|
|
};
|
|
|
|
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' }
|
|
};
|
|
|
|
export function getNoteColor(color: NoteColor, isDark: boolean): string {
|
|
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
|
|
return (map[color] ?? map.default).bg;
|
|
}
|
|
|
|
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
|
|
value: value as NoteColor,
|
|
label
|
|
}));
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
|
Expected: All PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/utils/colors.ts tests/unit/colors.test.ts
|
|
git commit -m "feat: add dark note card colors and getNoteColor helper"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: FOUC Prevention Script
|
|
|
|
**Files:**
|
|
- Modify: `src/app.html`
|
|
|
|
- [ ] **Step 1: Add inline script to `app.html`**
|
|
|
|
Insert after the `<meta name="theme-color">` tag (line 6) and before `%sveltekit.head%` (line 15):
|
|
|
|
```html
|
|
<script>
|
|
(function() {
|
|
try {
|
|
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
|
var t = p.theme || 'system';
|
|
if (t !== 'light' && t !== 'dark') t = 'system';
|
|
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
if (t === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
var m = document.querySelector('meta[name="theme-color"]');
|
|
if (m) m.content = '#1a1715';
|
|
}
|
|
} catch(e) {}
|
|
})();
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/app.html
|
|
git commit -m "feat: add FOUC-prevention script for dark mode"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Reactive Theme in Root Layout
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/+layout.svelte`
|
|
|
|
- [ ] **Step 1: Add theme `$effect` to root layout**
|
|
|
|
The root layout currently imports CSS and registers the PWA service worker. Add the theme `$effect` here so both `(app)` and `(auth)` layouts get the theme applied. **Important:** Keep `initPreferences()` in `(app)/+layout.svelte` — do NOT move it here. The `(app)` layout reads `sidebarDefaultState` immediately after init, and moving init to the root layout would create a race condition. The root layout only watches the preference reactively via `$effect`, which fires after preferences are loaded.
|
|
|
|
On auth pages (login/setup), `initPreferences()` hasn't been called, so `prefs.theme` will be `'system'` (the default). The FOUC script already handles the initial theme from localStorage, and the `$effect` will apply the correct theme once it runs.
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import '../app.css';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { pwaInfo } from 'virtual:pwa-info';
|
|
import { browser } from '$app/environment';
|
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
|
import { applyTheme } from '$lib/utils/theme.svelte.js';
|
|
|
|
let { children } = $props();
|
|
let mediaQuery: MediaQueryList | undefined;
|
|
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
|
|
|
|
const prefs = $derived(getPreferences());
|
|
|
|
onMount(async () => {
|
|
if (pwaInfo) {
|
|
const { registerSW } = await import('virtual:pwa-register');
|
|
registerSW({
|
|
immediate: true,
|
|
onRegistered(r: ServiceWorkerRegistration | undefined) {
|
|
console.log('SW Registered:', r);
|
|
},
|
|
onRegisterError(error: Error) {
|
|
console.log('SW registration error', error);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const theme = prefs.theme ?? 'system';
|
|
applyTheme(theme);
|
|
|
|
// Listen for system preference changes when in system mode
|
|
if (systemListener && mediaQuery) {
|
|
mediaQuery.removeEventListener('change', systemListener);
|
|
}
|
|
if (theme === 'system') {
|
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
systemListener = () => applyTheme('system');
|
|
mediaQuery.addEventListener('change', systemListener);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (systemListener && mediaQuery) {
|
|
mediaQuery.removeEventListener('change', systemListener);
|
|
}
|
|
});
|
|
|
|
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
|
|
</script>
|
|
|
|
<svelte:head>
|
|
{@html webManifest}
|
|
</svelte:head>
|
|
|
|
{@render children()}
|
|
```
|
|
|
|
**Note:** `initPreferences()` stays in `(app)/+layout.svelte`. The root layout only adds the theme `$effect` and does not call `initPreferences()`.
|
|
|
|
- [ ] **Step 2: Verify dev server works**
|
|
|
|
Run `make dev`, open the app, go to Settings > Preferences. The theme toggle doesn't exist yet, but the app should render without errors. Check browser console for any issues.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/routes/+layout.svelte src/routes/\(app\)/+layout.svelte
|
|
git commit -m "feat: add reactive theme watcher in root layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Theme Toggle in Settings
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/(app)/settings/preferences/+page.svelte`
|
|
|
|
- [ ] **Step 1: Add theme segmented toggle**
|
|
|
|
Add as the first preference section (before "Default note mode"):
|
|
|
|
```svelte
|
|
<!-- Theme -->
|
|
<div class="space-y-2">
|
|
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
|
<div class="flex gap-1" role="group" aria-label="Theme">
|
|
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
|
<button
|
|
onclick={() => updatePreference('theme', value)}
|
|
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
|
data-testid="pref-theme-{value}"
|
|
>
|
|
{label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Manually test**
|
|
|
|
Run `make dev`. Go to Settings > Preferences. Click System / Light / Dark and confirm:
|
|
- Clicking "Dark" immediately applies dark theme
|
|
- Clicking "Light" reverts to light theme
|
|
- Clicking "System" follows OS preference
|
|
- Refreshing the page persists the choice (no FOUC)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/routes/\(app\)/settings/preferences/+page.svelte
|
|
git commit -m "feat: add theme toggle to preferences settings"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Update Components to Use Dark Note Colors
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/components/NoteCard.svelte:2,29-32`
|
|
- Modify: `src/lib/components/NoteEditor.svelte:12,194-197`
|
|
- Modify: `src/lib/components/ColorPicker.svelte`
|
|
|
|
**Important:** `getIsDarkMode()` must be called **inside** `$effect` or `$derived` blocks for reactivity. Calling it as a top-level `const` would capture the value once and never update when the theme changes. Each component already has an `$effect` block where it computes card/bg styles — add `getIsDarkMode()` inside those existing blocks.
|
|
|
|
Public share page (`src/routes/(share)/s/[token]/+page.svelte`) is out of scope per the spec.
|
|
|
|
- [ ] **Step 1: Update `NoteCard.svelte`**
|
|
|
|
Replace `NOTE_COLORS` import with `getNoteColor` and `getIsDarkMode`:
|
|
|
|
```svelte
|
|
<!-- Change import at top of script -->
|
|
import { getNoteColor } from '$lib/utils/colors.js';
|
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
|
|
|
<!-- Inside the existing $effect block where cardStyle is computed (around lines 29-32): -->
|
|
$effect(() => {
|
|
// ... existing logic ...
|
|
const bg = getNoteColor(note.color, getIsDarkMode());
|
|
cardStyle = `background-color: ${bg}`;
|
|
});
|
|
```
|
|
|
|
Remove the `NOTE_COLORS` import. `getIsDarkMode()` is called inside the `$effect` so it re-runs when the theme changes.
|
|
|
|
- [ ] **Step 2: Update `NoteEditor.svelte`**
|
|
|
|
Same pattern — inside the existing `$effect` where `bgStyle` is computed:
|
|
|
|
```svelte
|
|
import { getNoteColor } from '$lib/utils/colors.js';
|
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
|
|
|
<!-- Inside the existing $effect (around lines 194-197): -->
|
|
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
|
```
|
|
|
|
Remove the `NOTE_COLORS` import.
|
|
|
|
- [ ] **Step 3: Update `ColorPicker.svelte`**
|
|
|
|
The ColorPicker shows circular swatches. Use `$derived` for the dark mode check so swatches update reactively:
|
|
|
|
```svelte
|
|
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
|
|
|
<!-- Use getIsDarkMode() in the template directly (it's reactive in Svelte 5 template expressions): -->
|
|
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
|
|
```
|
|
|
|
Remove `NOTE_COLORS` import if no longer needed.
|
|
|
|
- [ ] **Step 4: Verify all components render correctly in both themes**
|
|
|
|
Run `make dev`. Toggle between light and dark in Settings > Preferences. Check:
|
|
- Note cards show correct dark/light background colors
|
|
- Color picker shows dark/light swatches
|
|
- Note editor uses correct background color
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/components/NoteCard.svelte src/lib/components/NoteEditor.svelte src/lib/components/ColorPicker.svelte
|
|
git commit -m "feat: use dark note card colors in all components"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: E2E Tests
|
|
|
|
**Files:**
|
|
- Create: `tests/e2e/dark-mode.spec.ts`
|
|
|
|
- [ ] **Step 1: Write E2E tests**
|
|
|
|
Create `tests/e2e/dark-mode.spec.ts`:
|
|
|
|
```typescript
|
|
import { test, expect } from './helpers/fixtures.js';
|
|
|
|
test.describe('Dark mode', () => {
|
|
test('Scenario: Theme toggle persists selection across page reload', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given the user is on the preferences page
|
|
await page.goto('/settings/preferences');
|
|
|
|
// When the user selects dark theme
|
|
await page.getByTestId('pref-theme-dark').click();
|
|
|
|
// Then the page applies dark theme
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
|
|
// When the page is reloaded
|
|
await page.reload();
|
|
|
|
// Then the dark theme persists without flash
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
|
|
});
|
|
|
|
test('Scenario: Light mode removes dark theme attribute', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given dark theme is active
|
|
await page.goto('/settings/preferences');
|
|
await page.getByTestId('pref-theme-dark').click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
|
|
|
// When the user selects light theme
|
|
await page.getByTestId('pref-theme-light').click();
|
|
|
|
// Then the dark theme attribute is removed
|
|
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
|
|
});
|
|
|
|
test('Scenario: System mode follows OS dark preference', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given the OS prefers dark mode
|
|
await page.emulateMedia({ colorScheme: 'dark' });
|
|
|
|
// When the user selects system theme
|
|
await page.goto('/settings/preferences');
|
|
await page.getByTestId('pref-theme-system').click();
|
|
|
|
// Then the page applies dark theme from system preference
|
|
// Use toHaveAttribute with timeout to handle async $effect execution
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark', { timeout: 5000 });
|
|
});
|
|
|
|
test('Scenario: Note cards use dark colors in dark mode', async ({
|
|
authenticatedPage: page
|
|
}) => {
|
|
// Given dark theme is active
|
|
await page.goto('/settings/preferences');
|
|
await page.getByTestId('pref-theme-dark').click();
|
|
|
|
// When viewing the notes page
|
|
await page.goto('/');
|
|
|
|
// Then note cards should have dark background colors
|
|
const noteCard = page.getByTestId('note-card').first();
|
|
if (await noteCard.isVisible()) {
|
|
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
|
|
// Dark default color is #2a2520 = rgb(42, 37, 32)
|
|
expect(bg).not.toBe('rgb(250, 245, 235)'); // Not the light default
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run E2E tests**
|
|
|
|
Run: `pnpm test:e2e tests/e2e/dark-mode.spec.ts`
|
|
Expected: All PASS
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/e2e/dark-mode.spec.ts
|
|
git commit -m "test: add E2E tests for dark mode toggle and persistence"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Housekeeping and Type Check
|
|
|
|
**Files:**
|
|
- Modify: `.gitignore`
|
|
|
|
- [ ] **Step 1: Add `.superpowers/` to `.gitignore`**
|
|
|
|
Append to `.gitignore`:
|
|
|
|
```
|
|
# Superpowers brainstorm artifacts
|
|
.superpowers/
|
|
```
|
|
|
|
- [ ] **Step 2: Run type check**
|
|
|
|
Run: `pnpm check`
|
|
Expected: No errors. If there are type errors, fix them.
|
|
|
|
- [ ] **Step 3: Run full test suite**
|
|
|
|
Run: `make test`
|
|
Expected: All unit and E2E tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add .gitignore
|
|
git commit -m "chore: add .superpowers/ to gitignore"
|
|
```
|