chore: remove accidental files from feature branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-04-08 22:36:19 +02:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 4c13d345fc
commit 49c28a33ff
4 changed files with 745 additions and 1 deletions
@@ -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 `<html>`, 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 `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
```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>
```
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 `<meta name="theme-color">`. 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 -->
<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 ..."
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
```
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 `<meta name="theme-color">` 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)
@@ -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<string, string>` | 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<IP, { count: number, windowStart: number }>
// 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
@@ -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.