Closes #90. Code blocks and inline code inherited the typography plugin's defaults, which shrank them to ~86% of an already-monospace body and painted them --bg-surface — the same colour as the default note card — so a snippet was only recognisable by being smaller. Long lines also grew a horizontal scrollbar inside card previews. - New --code-bg surface (one step below the page in light mode, page-level ink in dark) with a subtle border, and code keeps the surrounding text size. Decorative backtick pseudo-elements dropped now that the chip carries the signal. - Card previews clip wide code instead of scrolling; editor and share page keep horizontal scroll. - Syntax highlighting via one shared lowlight instance feeding both the TipTap editor (extension-code-block-lowlight) and the markdown-it renderer, so a fence looks identical in the editor, on cards and on the share page. Only highlight.js "common" grammars are bundled. - No auto-detection: highlight.js misreads note-sized snippets too often (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the language comes from the fence info string or a toolbar picker shown while the cursor is in a code block. - Six --hl-* token colours per theme; a unit test parses app.css and asserts AA contrast against --code-bg and mutual distinguishability. - Sanitizer now allows only language-* / hljs-* classes on code and span. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
15 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 tobuild/, runs vianode 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/sveltekitfor 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 |
--code-bg |
#dfd5c1 (light) / #141312 (dark) |
Code block and inline code surface — one step below the page so it stands out on every note colour, including the default card which is --bg-surface itself |
--hl-keyword … --hl-tag |
six token colours per theme | Syntax highlighting roles (keyword, string, number, comment, title, tag). Must keep AA contrast against --code-bg; asserted in src/lib/utils/highlight.test.ts |
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:
--primarydiffers per theme.#C8860Aon parchment,#e0a030on ink. The light gold reads mustard on a dark background.--borderis 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 useshadow-sm,shadow-mdetc. — they look too modern. - Corners:
rounded-smeverywhere. Sharp, not rounded. Exception: color picker circles userounded-full. - Borders: Default card borders use
--border-subtle.--borderis 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--primaryin 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:blockfor 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 - Always work in a worktree: Never edit files in the main checkout. Before touching any file, create a dedicated worktree (
EnterWorktreeorgit worktree add .worktrees/<name> -b <branch>) on a fresh branch fromorigin/main, do all work, commits, and PR creation from there. This keeps the main checkout clean and lets several tasks run side by side without clobbering each other's uncommitted changes. - Feature branches: Use
feat/<name>(orfix/<name>) branches, one per worktree - 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):
- Lint & Type Check —
pnpm check - Unit Tests —
pnpm test:unit - Build —
pnpm build(depends on steps 1+2) - E2E Tests — Playwright with Chromium (depends on step 3, uploads report on failure)
- 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.shto bump version inpackage.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: updatedeleteUser()insrc/lib/server/auth.tsto 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 addonDelete: '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 viaglobal-setup.ts) - Auth fixture:
tests/e2e/helpers/fixtures.tsprovidesauthenticatedPage(handles setup/login race conditions across parallel workers) - Shared login state:
tests/e2e/auth.setup.ts(projectlogin-state) logs in once and saves the admin session cookie todata/test-storage-state.json; theappproject loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out withtest.use({ storageState: { cookies: [], origins: [] } })(seepwa.spec.ts,mcp.spec.ts). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers - Auth tests use
test.describe.serialbecause they depend on sequential database state - Offline/online tests: After
setOffline(false), always manually dispatch theonlineevent viapage.evaluate(() => window.dispatchEvent(new Event('online')))— Playwright doesn't reliably emit it in headless/CI environments, causing flakywaitForResponsetimeouts
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) |