diff --git a/docs/superpowers/specs/2026-03-20-dark-mode-design.md b/docs/superpowers/specs/2026-03-20-dark-mode-design.md
new file mode 100644
index 0000000..6b2b453
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-20-dark-mode-design.md
@@ -0,0 +1,190 @@
+# Dark Mode / Theme Toggle — Design Spec
+
+**Issue:** #26
+**Date:** 2026-03-20
+**Status:** Draft
+
+## Summary
+
+Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
+
+## Decisions
+
+- **Toggle location:** Settings > Preferences only (no header icon)
+- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
+- **Implementation:** CSS `[data-theme="dark"]` attribute on ``, variable overrides in `app.css`
+- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
+- **Default:** `system` (follows `prefers-color-scheme`)
+- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
+
+## 1. CSS Variable Overrides
+
+Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
+
+```css
+:root {
+ /* ...existing vars... */
+ --grid-dot: #1a1a2e; /* new — extracted from body::before */
+}
+
+[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;
+}
+```
+
+`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
+
+`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
+
+Update `body::before` to use the new variable:
+```css
+body::before {
+ background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
+}
+```
+
+## 2. Theme Preference Storage
+
+Extend the existing `UserPreferences` system:
+
+- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
+- **Default:** `'system'`
+- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
+- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
+
+Files to modify:
+- `src/lib/types/preferences.ts` — add `theme` field and default
+
+The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
+
+## 3. FOUC Prevention
+
+Add a blocking `
+```
+
+This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
+
+## 4. Reactive Theme Application
+
+Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
+
+- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates ` `. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
+- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
+
+Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
+
+## 5. Note Card Dark Colors
+
+Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
+
+| Color | Light | Dark |
+|-------|-------|------|
+| default | `#faf5eb` | `#2a2520` |
+| coral | `#faafa8` | `#4a2522` |
+| peach | `#f39f76` | `#4a3020` |
+| sand | `#fff8b8` | `#3a3520` |
+| mint | `#e2f6d3` | `#2a3a22` |
+| sage | `#b4ddd3` | `#223a32` |
+| fog | `#d4e4ed` | `#222e3a` |
+| storm | `#aeccdc` | `#1e2a35` |
+| dusk | `#d3bfdb` | `#352540` |
+| blossom | `#f6e2dd` | `#3a2830` |
+| clay | `#e9e3d4` | `#302e28` |
+| chalk | `#efeff1` | `#2e2e30` |
+
+Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
+
+Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
+
+## 6. Settings UI
+
+Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
+
+```svelte
+
+
+
Theme
+
+ {#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
+ updatePreference('theme', value)}
+ class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
+ data-testid="pref-theme-{value}"
+ >
+ {label}
+
+ {/each}
+
+
+```
+
+Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
+
+## 7. Landing Page
+
+Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
+
+## 8. Prose / Typography Overrides
+
+The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
+
+The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
+
+## 9. Color Picker in Dark Mode
+
+The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
+
+## 10. Testing
+
+### Unit tests
+- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
+- `colors.ts` — test `getNoteColor` returns correct color for light/dark
+
+### E2E tests
+- Theme toggle in preferences persists and applies (use `data-testid` selectors)
+- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
+- No FOUC on page reload in dark mode
+- Note cards render with correct dark colors
+
+## 11. PWA Manifest
+
+The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The ` ` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
+
+## Out of Scope
+
+- Header quick-toggle icon
+- Per-note theme override
+- Custom color theme builder
+- Dark mode for public shared note pages (can be added later)
+- Dark mode for landing page (separate task)
diff --git a/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md b/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md
new file mode 100644
index 0000000..119e469
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md
@@ -0,0 +1,232 @@
+# Public Note Sharing — Design Spec
+
+**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
+**Date**: 2026-03-20
+
+## Overview
+
+Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
+
+## Decisions
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
+| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
+| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
+| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
+| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
+
+## Schema
+
+New `sharedNotes` table in `src/lib/server/db/schema.ts`:
+
+```typescript
+export const sharedNotes = sqliteTable('shared_notes', {
+ noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
+ token: text('token').notNull().unique(),
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
+ expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
+});
+```
+
+- `noteId` as primary key — one share per note, no separate `id` needed
+- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
+- `createdAt` set in service layer (`new Date()`), not via SQL default
+- CASCADE delete — deleting the note removes the share
+- Token: `nanoid(21)` — ~128 bits of entropy
+- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
+
+## Types
+
+Add to `src/lib/types/index.ts`:
+
+```typescript
+export interface NoteShare {
+ noteId: string;
+ token: string;
+ createdAt: Date;
+ expiresAt: Date | null;
+}
+```
+
+Extend the `Note` interface:
+
+```typescript
+export interface Note {
+ // ...existing fields
+ shareToken?: string; // populated during hydration if a public share exists
+}
+```
+
+## Service Layer
+
+New file: `src/lib/server/shares-service.ts`
+
+| Function | Params | Returns | Description |
+|---|---|---|---|
+| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
+| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
+| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
+| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
+| `fetchSharesForNotes` | `db, noteIds` | `Map` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
+
+`SharedNoteData` type (returned to public viewers):
+
+```typescript
+interface SharedNoteData {
+ title: string;
+ content: string;
+ checklistMode: boolean;
+ color: NoteColor;
+ attachments: Attachment[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+```
+
+## API Endpoints
+
+### `POST /api/notes/[id]/share`
+
+- **Auth**: Required (owner only)
+- **Body**: None
+- **Response**: `{ token: string, url: string }`
+- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
+
+### `DELETE /api/notes/[id]/share`
+
+- **Auth**: Required (owner only)
+- **Response**: `204 No Content`
+- **Behavior**: Deletes share row. Link immediately returns 404.
+
+### `GET /api/shared/[token]`
+
+- **Auth**: None (public)
+- **Rate limit**: 60 requests/min per IP (in-memory)
+- **Response**: `SharedNoteData` JSON
+- **Error cases**:
+ - Invalid/unknown token → 404
+ - Note is trashed → 404
+ - Rate limited → 429
+
+### `GET /api/shared/[token]/attachment/[attachmentId]`
+
+- **Auth**: None (public)
+- **Rate limit**: Same as above
+- **Response**: Image binary (proxied from attachment storage)
+- **Behavior**: Verify attachment belongs to the shared note, then serve
+
+## Routes
+
+```
+src/routes/
+ (share)/
+ +layout.svelte # Minimal layout: parchment bg, centered, no app chrome
+ s/[token]/
+ +page.server.ts # Load shared note by token via shares-service
+ +page.svelte # Read-only note renderer
+```
+
+### Auth Middleware
+
+Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
+
+### Public Page Design
+
+- Parchment background (`--bg-base`)
+- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
+- Note title in JetBrains Mono
+- Rendered markdown content or checklist (read-only)
+- Inline images from attachments
+- No tags, no edit controls, no sidebar, no navigation
+- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
+
+## UI Components
+
+### ShareDialog Changes
+
+Add a "Public link" section **above** the existing collaborators section:
+
+**Inactive state:**
+- Globe icon + "Public link" label + toggle switch (off)
+- Subtitle: "Anyone with the link can view this note (read-only)"
+
+**Active state:**
+- Globe icon + "Public link" label + toggle switch (on, gold)
+- URL input (readonly) + "Copy" button
+- Subtitle: "Toggle off to revoke access. The link will return a 404."
+- Border highlight in `--primary` gold
+
+Toggle on → calls `POST /api/notes/[id]/share`, displays URL
+Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
+Copy button → clipboard API, brief "Copied!" feedback
+
+### NoteCard Share Indicator
+
+Single icon slot in top-right of NoteCard (replaces showing both icons):
+
+| State | Icon | Color |
+|---|---|---|
+| Public link active | `Globe` (Lucide) | `--primary` gold |
+| Collaborators only | `Users` (Lucide) | `--text-muted` |
+| Both | `Globe` (Lucide) | `--primary` gold |
+| Neither | No icon | — |
+
+**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
+- "Shared publicly via link" (if public share exists)
+- "Shared with N collaborator(s)" (if collaborators exist)
+- Both lines if both exist
+
+### NoteEditor Share Button
+
+Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
+
+## Rate Limiting
+
+New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
+
+```typescript
+// In-memory store: Map
+// 60 requests per minute per IP
+// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
+```
+
+Resets on server restart. No database table needed.
+
+## Hydration
+
+`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
+
+## Security
+
+- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
+- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
+- Trashed notes return 404 even if share token is valid
+- Only note owners can create/revoke shares — collaborators cannot
+- No user information exposed on public page (no username, no email, no tags)
+- Rate limiting prevents brute-force enumeration
+- Attachments served through share-specific route (validates attachment belongs to shared note)
+
+## Out of Scope (v1)
+
+- Expiry dates / auto-expire
+- Password-protected shares
+- Edit access through share links
+- Share analytics / view counts
+- Email notifications for share events
+- E2EE interaction (no E2EE exists)
+
+## Acceptance Criteria
+
+- [ ] Share toggle in ShareDialog creates/revokes public link
+- [ ] Unique nanoid(21) token generated per shared note
+- [ ] Public `/s/[token]` route renders note without auth
+- [ ] Copy link to clipboard with feedback
+- [ ] Revoke share → link returns 404
+- [ ] Globe indicator on NoteCard for publicly shared notes
+- [ ] Combined tooltip showing all sharing state
+- [ ] Trashed notes return 404 even if shared
+- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
+- [ ] In-memory rate limiting on public endpoints
+- [ ] Attachments accessible on public page
diff --git a/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md b/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md
new file mode 100644
index 0000000..6344988
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md
@@ -0,0 +1,322 @@
+# Screenshot Automation for Landing Page
+
+## Overview
+
+A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
+
+## Architecture
+
+```
+scripts/
+ screenshots.ts # Main orchestrator: seed + capture
+ fixtures/
+ bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
+```
+
+Single script (`scripts/screenshots.ts`) that:
+1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
+2. Deletes any existing screenshot DB to start fresh
+3. Seeds data via API calls (setup admin, login, create notes/users/keys)
+4. Navigates to each view and captures screenshots at specific viewports
+5. Saves PNGs directly to `website/assets/`
+6. Kills the preview server in `finally` block
+
+## Seed Data — Bretzel Universe
+
+### Users
+
+| User | Email | Role | Purpose |
+|------|-------|------|---------|
+| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
+| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
+
+### Tags
+
+Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
+
+### Notes
+
+| # | Title | Color | Mode | Tags | Pinned | Special |
+|---|-------|-------|------|------|--------|---------|
+| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
+| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
+| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
+| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
+| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
+| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
+| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
+
+### Note Content Details
+
+**Bretzel Ingredients** (checklist):
+- [x] 500g bread flour
+- [x] 300ml warm water
+- [x] 10g salt
+- [ ] 7g dry yeast
+- [ ] 30g butter (softened)
+- [ ] Baking soda for the bath
+- [ ] Coarse salt for topping
+
+**Self-Hosting Stack** (rich text — used for editor screenshot):
+```
+Current homelab setup:
+
+**Crumbs** — notes (obviously)
+**42** — holiday budget tracking
+**Dokploy** — deployment platform
+**Outline** — team wiki
+**Navidrome** — music streaming
+**Vaultwarden** — passwords
+**Nextcloud** — files & calendar
+**Plausible** — analytics
+
+Everything below daily reverse proxy.
+```
+
+**The Best Software Is the Software You Own** (rich text):
+```
+Self-host everything. Trust no cloud.
+
+The best software is the software you own.
+Your data. Your server. Your rules.
+
+If it can't run on your hardware, it's not really yours.
+```
+
+**Back to Retro Gaming** (rich text):
+```
+Games to replay this summer:
+
+Chrono Trigger (SNES) — best RPG ever made
+The Legend of Zelda: Link's Awakening (GB)
+Castlevania: Symphony of the Night (PS1)
+Advance Wars (GBA)
+Final Fantasy Tactics (PS1)
+
+The pixel art era was peak game design.
+```
+
+**Weekend Plans** (rich text):
+```
+Saturday:
+- Farmer's market (get sourdough starter)
+- Fix the Dokploy SSL cert renewal
+- Bake a batch of bretzels
+
+Sunday:
+- Retro gaming marathon
+- Update the self-hosting stack
+- Plan next trip on 42
+```
+
+**Ancient Flute Harmonica Discovered** (rich text):
+```
+Archaeologists found a 40,000-year-old flute carved from vulture bone.
+
+The oldest known musical instrument.
+Imagine the first song ever played.
+```
+
+**Baking code for the...** (checklist):
+```
+- [x] Set up CI/CD pipeline
+- [x] Write the CRDT sync logic
+- [ ] Bake the docker image
+- [ ] Deploy to the homelab
+- [ ] Celebrate with a bretzel
+```
+
+### Image Attachments
+
+**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
+- Generated programmatically as a PNG using raw pixel data (no external dependencies)
+- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
+- Attached as featured image to "Bretzel Ingredients" note
+- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
+
+### Version History
+
+Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
+
+### API Keys
+
+Create via API after seeding:
+| Name | Purpose |
+|------|---------|
+| Claude Code | For MCP integration screenshot |
+| N8N | For automation screenshot |
+
+## Screenshot Capture Plan
+
+### Desktop (1280x800)
+
+| # | Filename | View | Capture Steps |
+|---|----------|------|---------------|
+| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
+| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
+| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
+| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
+| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
+| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
+| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
+
+### Mobile (390x844)
+
+| # | Filename | View | Capture Steps |
+|---|----------|------|---------------|
+| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
+| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
+| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
+
+### Capture Settings
+- Format: PNG
+- Full page: false (viewport only)
+- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
+- Wait for `networkidle` after each navigation before capturing
+
+## GitHub Actions Workflow
+
+**File**: `.github/workflows/screenshots.yml`
+
+```yaml
+name: Screenshots
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ screenshots:
+ name: Capture Landing Page Screenshots
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: latest
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright browsers and system deps
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Build app
+ run: pnpm build
+ env:
+ DATABASE_URL: ./data/screenshots.db
+
+ - name: Capture screenshots
+ run: pnpm exec tsx scripts/screenshots.ts
+ env:
+ DATABASE_URL: ./data/screenshots.db
+
+ - name: Commit screenshots
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add website/assets/screenshot-*.png
+ git diff --cached --quiet && echo "No screenshot changes" && exit 0
+ git commit -m "chore: update landing page screenshots [skip ci]"
+ git push origin main
+```
+
+## Script Structure
+
+```typescript
+// scripts/screenshots.ts
+
+import { chromium, type Page } from 'playwright';
+import { spawn, type ChildProcess } from 'child_process';
+import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
+
+const BASE = 'http://localhost:4173';
+const DESKTOP = { width: 1280, height: 800 };
+const MOBILE = { width: 390, height: 844 };
+const OUTPUT = 'website/assets';
+
+async function main() {
+ // 1. Start preview server (spawn child process, wait for port 4173)
+ const server = await startPreviewServer();
+
+ // 2. Launch browser
+ const browser = await chromium.launch();
+
+ try {
+ // 3. Seed data (desktop context — API calls work at any viewport)
+ const seedContext = await browser.newContext({ viewport: DESKTOP });
+ const seedPage = await seedContext.newPage();
+ await seed(seedPage);
+
+ // 4. Desktop screenshots
+ await captureDesktop(seedPage);
+ await seedContext.close();
+
+ // 5. Mobile screenshots
+ const mobileContext = await browser.newContext({ viewport: MOBILE });
+ const mobilePage = await mobileContext.newPage();
+ await login(mobilePage);
+ await captureMobile(mobilePage);
+ await mobileContext.close();
+ } finally {
+ await browser.close();
+ server.close();
+ }
+}
+
+async function seed(page: Page) {
+ // Setup admin via POST /api/auth/setup
+ // Login via POST /api/auth/login (sets session cookie on page context)
+ // Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
+ // → capture note IDs from responses for later use
+ // Create collaborator user via POST /api/admin/users
+ // → capture alice.id (numeric) from response
+ // Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
+ // Create version history: PATCH /api/notes/:id × 4-5 with varying content
+ // Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
+ // Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
+}
+
+async function captureDesktop(page: Page) {
+ // D1: Grid view
+ // D2: Editor (Self-Hosting Stack)
+ // D3: Checklist (Bretzel Ingredients)
+ // D4: Sharing dialog
+ // D5: Version history
+ // D6: Attachments
+ // D7: API/MCP settings
+}
+
+async function captureMobile(page: Page) {
+ // M1: Grid view
+ // M2: Editor
+ // M3: Checklist
+}
+```
+
+## Landing Page Updates
+
+After the screenshots are generated, `website/index.html` needs updating:
+- Add new screenshot entries for: checklist, sharing, history, attachments, API
+- Update alt text and labels
+- Hero image stays as `screenshot-grid.png`
+- Mobile section adds checklist screenshot, replaces sidebar screenshot
+
+## Dependencies
+
+No new dependencies. Uses:
+- `playwright` (already in devDependencies for e2e tests)
+- `tsx` (already in devDependencies for other scripts)
+- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
+- SvelteKit's preview server (spawned as child process via `pnpm preview`)
+
+## Open Questions
+
+None — all decisions resolved during brainstorming.
diff --git a/static/api/openapi.json b/static/api/openapi.json
index 988f438..55bf71a 100644
--- a/static/api/openapi.json
+++ b/static/api/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
- "version": "0.26.0",
+ "version": "0.24.0",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"