Files
crumbs-original/CLAUDE.md
T
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

14 KiB

CLAUDE.md

Project overview

Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.

Tech stack

  • Framework: SvelteKit with Svelte 5 (runes: $state, $derived, $props, $effect)
  • Adapter: @sveltejs/adapter-node (builds to build/, runs via node build)
  • Styling: Tailwind CSS v4 (via @tailwindcss/vite)
  • Database: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at src/lib/server/db/schema.ts)
  • Auth: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
  • PWA: @vite-pwa/sveltekit for service worker and offline caching
  • Package manager: pnpm

Design system: Retro Parchment

The UI follows a parchment + 8-bit retro aesthetic. All design decisions must preserve this character.

Color palette

All colors are CSS variables in src/app.css. Never use hardcoded Tailwind colors (e.g. text-gray-500, bg-red-600) — always reference CSS variables:

Variable Value Usage
--bg-base #f0e6d3 Page background (warm parchment)
--bg-surface #faf5eb Cards, panels, inputs
--primary #C8860A Brand gold — links, active states, accents
--primary-hover #E8A020 Gold hover state
--text #1a1a2e Primary text (near-black)
--text-muted #6b6272 Secondary text, placeholders
--border #1a1a2e Strong borders (editor, login card)
--border-subtle #d4cabb Default card borders, dividers
--destructive #a63d2f Delete actions, error icons
--error-bg/border/text warm reds Error states (themed, not clinical red)
--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
  • Brand / empty states: Press Start 2P (pixel font) — the 8-bit accent
  • These two fonts are intentional. Do not replace them or add others.

Visual rules

  • Shadows: Always hard-offset (2px 2px 0px) with zero blur. Never use shadow-sm, shadow-md etc. — they look too modern.
  • Corners: rounded-sm everywhere. Sharp, not rounded. Exception: color picker circles use rounded-full.
  • 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(--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.

Layout groups

  • (auth) — Login/setup pages: minimal centered layout, no app chrome
  • (app) — Main app: header, sidebar, footer, toast notifications

Project structure

src/
  routes/
    +layout.svelte     # Root layout (imports CSS only)
    (auth)/            # Layout group: login/setup (minimal, no app chrome)
      +layout.svelte   # Centered parchment background
      login/           # Login page
      setup/           # First-time password setup
    (app)/             # Layout group: main app (header, sidebar, footer)
      +layout.svelte   # App shell with Header, Sidebar, Toast
      +page.svelte     # Main notes view
      archive/         # Archived notes view
      trash/           # Trashed notes view
      tag/[name]/      # Notes filtered by tag
      settings/        # Settings with tabbed layout (profile, MCP, users)
    api/auth/          # Login, logout, setup, OAuth endpoints
    api/admin/users/   # Admin user management (CRUD, role, sessions)
    api/notes/         # CRUD + attachments
    api/search/        # Full-text search
    api/sync/          # Offline sync
    api/tags/          # Tag management
  lib/
    components/        # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
    stores/            # Svelte stores (notes.ts, theme.ts)
    server/db/         # Drizzle schema + connection (schema.ts, index.ts)
    server/            # Auth logic, attachment handling
    sync/              # Client sync (idb.ts), CRDT merge (crdt.ts), server sync
    types/             # TypeScript interfaces (Note, Attachment, Tag, etc.)
    utils/             # Colors, markdown, tags, debounce
  hooks.server.ts      # Auth middleware (redirects unauthenticated users)
tests/
  unit/                # Vitest unit tests
  e2e/                 # Playwright e2e tests (Gherkin-style)
    helpers/fixtures.ts  # Shared authenticatedPage fixture
website/               # Static landing page (crumbs.bretzel.app)
  index.html           # Landing page with feature cards, screenshots, deploy section
  styles.css           # Landing page styles (retro parchment theme)
  assets/              # Favicon, screenshots

Commands

A Makefile wraps all common tasks for tool-agnostic usage. Run make help to list targets.

Make target pnpm equivalent Description
make dev pnpm dev Start dev server
make build pnpm build Production build
make preview pnpm preview Preview production build
make check pnpm check Svelte type checking
make test pnpm test Run all tests (unit + e2e)
make test-unit pnpm test:unit Run unit tests only
make test-e2e pnpm test:e2e Run e2e tests only
make lint pnpm lint Run linter
make db-push pnpm db:push Push schema to DB
make db-generate pnpm db:generate Generate migrations
make db-migrate pnpm db:migrate Run migrations
make db-studio pnpm db:studio Open Drizzle Studio
make docs-api pnpm docs:api Regenerate API docs from OpenAPI spec
make install pnpm install Install dependencies
make docker-build — Build Docker image
make docker-up — Start containers
make docker-down — Stop containers
make docker-logs — Tail container logs
make clean — Remove build artifacts + test DBs
make release — Auto-bump version from commits, tag, and push
make release-patch — Bump patch version, tag, and push
make release-minor — Bump minor version, tag, and push
make release-major — Bump major version, tag, and push

Git conventions

  • Conventional commits: Use feat:, fix:, chore:, docs:, refactor:, test: prefixes
  • Commit regularly: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
  • Main branch: main — use as base for all PRs and feature branches
  • Feature branches: Use feat/<name> branches with worktrees for isolation
  • Rebase before PR: Always git rebase origin/main (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR

CI/CD

GitHub Actions with two workflows:

CI (.github/workflows/ci.yml) — runs on push to main and PRs to main (branch work is validated once via the PR event, avoiding duplicate push+PR runs):

  1. Lint & Type Check — pnpm check
  2. Unit Tests — pnpm test:unit
  3. Build — pnpm build (depends on steps 1+2)
  4. E2E Tests — Playwright with Chromium (depends on step 3, uploads report on failure)
  5. Docker Build — validates the Docker image builds (depends on step 3)

Bump & Release (.github/workflows/bump.yml) — workflow_dispatch (primary release method):

  • Runs scripts/release.sh to bump version in package.json + docker-compose.yml, tag, and push
  • Runs full CI pipeline, then builds + pushes Docker image to ghcr.io
  • Creates a GitHub Release with auto-generated notes

Release (.github/workflows/release.yml) — runs on v* tags (fallback for manual/local tag pushes):

  • Same as above: CI pipeline, Docker build/push, GitHub Release

Docker: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to /data volume.

Database schema changes

When adding or modifying tables in src/lib/server/db/schema.ts:

  • If the table has a foreign key to users: update deleteUser() in src/lib/server/auth.ts to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
  • If the table has a foreign key to notes: either add onDelete: 'cascade' in the schema definition, or add explicit cleanup in the note deletion logic.

Testing philosophy: BDD/TDD

Write tests first or alongside features. Tests serve as living documentation of expected behavior.

Unit tests (Vitest)

  • Location: tests/unit/*.test.ts
  • Config: vitest.config.ts (node environment)
  • Cover pure logic: markdown parsing, tag extraction, auth, search, sync

E2E tests (Playwright)

  • Location: tests/e2e/*.spec.ts
  • Config: playwright.config.ts (Chromium only, builds + previews app on port 4173)
  • Test database: ./data/test-crumbs.db (cleaned via global-setup.ts)
  • Auth fixture: tests/e2e/helpers/fixtures.ts provides authenticatedPage (handles setup/login race conditions across parallel workers)
  • Auth tests use test.describe.serial because they depend on sequential database state
  • Offline/online tests: After setOffline(false), always manually dispatch the online event via page.evaluate(() => window.dispatchEvent(new Event('online'))) — Playwright doesn't reliably emit it in headless/CI environments, causing flaky waitForResponse timeouts

Gherkin-style Given/When/Then

E2E tests follow Cucumber BDD conventions via comments. Follow the Better Gherkin guidelines:

Declarative, not imperative — describe what the system does, not how the user interacts with the UI:

// Good: "When the user trashes the note"
// Bad:  "When the user hovers over the note card and clicks the trash button"

Given describes state, not navigation:

// Good: "Given a note titled 'Delete Me' exists"
// Bad:  "Given the user clicks the new note button and fills in the title"

Collapse multi-step UI actions into a single intent:

// Good: "When the user creates a note titled 'My Note' with content 'Hello'"
// Bad:  "When they click new note / And fill in the title / And fill in the content / And close the editor"

Scenario names describe outcomes/behavior, not actions:

// Good: "Scenario: Trashed note disappears from the main view"
// Bad:  "Scenario: User moves a note to trash"

Resilience test — ask: "Will this comment need to change if the UI implementation changes?" If yes, rewrite it to remove implementation details. The Playwright code underneath handles the how; the comments describe the what.

Documentation

Keep docs in docs/ up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:

Doc Covers
docs/FEATURES.md User-facing feature list
docs/ARCHITECTURE.md System design, data flow, schema, tech stack
docs/AUTH.md Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.)
docs/DEPLOYMENT.md Docker, env vars, reverse proxy, backups
docs/openapi.yaml API spec (run pnpm docs:api to regenerate docs/API.md)