Compare commits

..
Author SHA1 Message Date
Frederic CabassutandClaude Opus 4.6 8a7d2ddb41 fix: rename test note to avoid name collision in CI
- "Shopping" matched existing "Shopping List" note from another test
- Renamed to "Groceries" to avoid strict mode violation
- Reworded comments: "interleaved" → "checked items before active ones"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:08:15 +01:00
Frederic CabassutandClaude Opus 4.6 32bd101421 fix: checklist Enter key focuses new item on reopened notes
The addItem() focus logic used positional indexing (inputs[afterIndex+1])
but afterIndex came from the full items[] array while DOM inputs only
render active (unchecked) items. On reopened notes, checked items are
interleaved in the parsed content, causing the index offset to be wrong.

Fix: use data-item-id attribute for targeted querySelector lookup.
Test: reopened note with interleaved checked/unchecked items.

Introduced in e5688c9 (active/done split).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:17:46 +01:00
github-actions[bot] 393a1db672 chore: pin docker-compose to v0.23.0 [skip ci] 2026-03-21 07:59:33 +00:00
github-actions[bot] aa13798851 chore: bump version to 0.23.0 2026-03-21 07:48:40 +00:00
Frederic CabassutandClaude Opus 4.6 c4afcb2aa0 fix: address PR review comments
- Wrap readFile in try/catch, return 404 for missing attachment files
- Remove unused CollaboratorPopover import from NoteCard
- Make darkMode a proper $state rune for reactivity (add svelte-runes
  polyfill for Vitest so $state works in unit tests)
- Add error toasts for failed share link toggle (non-2xx responses)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 37169f153e fix: ensure dark mode E2E test creates a note before asserting colors
The "note cards use dark colors" test was conditional on notes existing,
making the assertion potentially vacuous. Now creates a note first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut e24fab58d9 docs: add dark mode to features and architecture docs 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 9e4c27e141 fix: eliminate E2E dark mode test flakiness
- Use test.describe.serial to prevent parallel preference conflicts
- Set up waitForResponse listener BEFORE click (response may arrive
  before listener is registered)
- Add waitForLoadState('networkidle') after navigations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 36a5d44b12 chore: add .superpowers/ to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut 95a47ff232 test: add E2E tests for dark mode toggle and persistence 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 30e571262e feat: use dark note card colors in all components
Switch NoteCard, NoteEditor, and ColorPicker from static NOTE_COLORS
lookups to getNoteColor(color, getIsDarkMode()), so inline background
styles react to the active theme. Also fix the theme preference button
type cast in settings/preferences to resolve the svelte-check error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut 14d7c2d52c feat: add theme toggle to preferences settings 2026-03-21 08:47:20 +01:00
Frederic Cabassut 99210afb30 feat: add reactive theme watcher in root layout 2026-03-21 08:47:20 +01:00
Frederic Cabassut 84e038494c feat: add FOUC-prevention script for dark mode 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 9daafa719d feat: add dark note card colors and getNoteColor helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 1a21cf3b0b feat: add theme utility with applyTheme and isDarkMode
Adds src/lib/utils/theme.svelte.ts with applyTheme() (sets data-theme
attribute and meta theme-color) and getIsDarkMode(). Uses a plain
variable instead of $state since Vitest's node environment does not
compile Svelte 5 runes in .svelte.ts files. Adds jsdom dev dependency
and $app/environment alias in vitest config to support DOM-level unit
tests; fixes preferences.test.ts to include the theme default added
in Task 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut f2518470ab feat: add theme preference type (system/light/dark) 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 35204867e4 feat: add dark theme CSS variables and grid-dot extraction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 80ebb02344 docs: add dark mode implementation plan
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>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 1c10915131 docs: revise dark mode spec after review
Address reviewer feedback: null-check meta element, use var(--border-subtle)
for card-shadow, specify .svelte.ts for reactivity, add data-testid attrs,
clarify SSR guards, resolve (auth) layout via root layout, defer landing page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 a6b845b1e7 docs: add dark mode design spec
Design spec for issue #26 — dark parchment theme with system/light/dark
toggle in Settings > Preferences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 d07cc59dc7 docs: add grid screenshot to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:58:39 +01:00
github-actions[bot] 5ee80a8fed chore: update landing page screenshots [skip ci] 2026-03-20 21:57:22 +00:00
Frederic CabassutandClaude Opus 4.6 a26a77ef2d fix(screenshots): force exit after capture to kill orphaned server
pnpm spawns vite as a grandchild process that SIGTERM doesn't reach,
leaving the CI job hanging after screenshots are captured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:55:32 +01:00
Frederic CabassutandClaude Opus 4.6 3f5f1da089 fix(screenshots): use HTTP polling for server readiness in CI
Replace stdout-based detection with HTTP polling (fetch + retry).
Stdout detection is unreliable in CI where pnpm buffers output.
Increased timeout to 60s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:49:11 +01:00
Frederic CabassutandClaude Opus 4.6 7d7bf9b303 chore: fix stale comment about pinned note
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 9e253cabc4 chore(screenshots): remove unused pixel-art generator
No longer needed after removing image attachments from seed data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 2746309269 fix(screenshots): final content tweaks and regenerated screenshots
- Remove title from self-host manifesto note (content-only card)
- Regenerated all 9 screenshots on rebased main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 6f496cbd87 fix(screenshots): improve seed data content and remove attachments
- Remove image attachments and attachments screenshot
- Remove pinned note for cleaner hero grid
- Enrich note content with markdown (headings, bold, italic, blockquotes, lists)
- Replace "Ancient Flute" with "Coding Playlists" (ties to Navidrome)
- Shorten titles, remove content that repeats title
- Remove hashtags from checklist notes (rendered as items)
- Use bretzel persona "Salzig Bretzel" for collaborator
- Update services: Crumbs, 42, Dokploy, Outline, Navidrome, RetroAssembly, Umami

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 1d60cca2de chore(screenshots): add generated landing page screenshots
10 screenshots captured via automated pipeline:
- 7 desktop (1280x800): grid, editor, checklist, sharing, history, attachments, API
- 3 mobile (390x844): grid, editor, checklist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 f37e7a5322 feat(website): update landing page with new screenshot references
Replace 3 desktop + 3 mobile screenshots with 7 desktop + 3 mobile.
New screenshots: checklist, sharing, history, attachments, API/MCP.
Remove obsolete screenshot-tags.png and screenshot-mobile-sidebar.png.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 ecc0e4d3c2 ci: add manual workflow to regenerate landing page screenshots
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 92c2cf7952 feat(screenshots): add main orchestrator and fix capture selectors
- Use @playwright/test instead of playwright (matches devDependencies)
- Use card-specific locators to avoid substring content matches
- Close history panel before editor to avoid overlay blocking
- Add Origin header for multipart upload CSRF compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 82a9b4fa64 feat(screenshots): add desktop and mobile capture logic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 196cd8a1df feat(screenshots): add API-based seed data with bretzel universe content
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 d577468b60 feat(screenshots): add constants and pixel-art bretzel generator
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 d34b3a18bc fix: address PR review — sanitize attachments, SSR safety, a11y
- Strip server paths from public attachment responses (new PublicAttachment type)
- Use $page.url.origin instead of window.location.origin (SSR-safe)
- Fix toggle switch left-4.5 → left-[1.125rem] (valid Tailwind)
- Add aria-label to SharingIndicator button for screen readers
- Add opportunistic pruning to IP rate limiter to prevent unbounded growth
- Reduce shared attachment cache to 1 hour (respect share revocation)
- Update OpenAPI schema with PublicAttachment type
- Add unit test asserting server paths are not leaked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 13f574e5e2 fix: polish public sharing page and landing page
- Link "POWERED BY CRUMBS" to crumbs.bretzel.app
- Add dedicated error page for (share) group without "Back to notes"
- Update landing page Note Sharing card to mention public links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 ec5f2a321f docs: add public sharing to features, architecture, and API docs
- FEATURES.md: add public link sharing feature details
- ARCHITECTURE.md: add shared_notes table to schema section
- openapi.yaml: add share create/revoke, public note, and public
  attachment endpoints with SharedNoteData schema
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 44570c5205 test: add unit and e2e tests for public note sharing
Unit tests:
- shares-service: create, idempotent create, revoke, get, batch fetch
- ip-rate-limit: under/over limit, IP independence, window reset

E2E tests:
- Create public share link via UI
- Public page renders without auth
- Revoke share returns 404
- Trashed note share returns 404
- Globe icon on shared card
- Copy link to clipboard

Also fix sharing-indicator test-id in existing sharing.spec.ts
and add shared_notes table to test-helpers and db/index.ts init.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 9dde77af96 feat: add Globe icon for public shares, update NoteCard and NoteEditor
- Create SharingIndicator component with Globe/Users icon priority
- NoteCard shows Globe icon (gold) for public shares, Users for collabs
- NoteEditor share button reflects public link state
- Share token propagated through store on create/revoke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 0661196eca feat: add public link sharing section to ShareDialog
Add toggle switch, URL display, and copy button for public share
links above the existing collaborators section. Gold border when
active, retro-styled toggle with hard shadows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 265b4266d4 feat: add public shared note page with SSR rendering
- (share) layout group with minimal centered layout
- Server-side load function fetches shared note by token
- Read-only page renders markdown, checklists, and images
- Rate limited via IP-based limiter
- Retro parchment styling with "POWERED BY CRUMBS" footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 4ffa34afa0 feat: add share API endpoints and in-memory rate limiting
- POST/DELETE /api/notes/[id]/share for creating/revoking public links
- GET /api/shared/[token] for fetching shared note data (no auth)
- GET /api/shared/[token]/attachment/[attachmentId] for public images
- Add /s/ and /api/shared to PUBLIC_PATHS in auth middleware
- Add IP-based rate limiter (60 req/min) for public endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 90996cc72d feat: add shares service layer with batch fetch and hydration
Create shares-service.ts with fetchSharesForNotes, createShare,
revokeShare, getShareByNoteId, and getSharedNote. Integrate share
tokens into hydrateNotes pipeline alongside tags, attachments,
and collaborators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 228a724c32 feat: add shared_notes schema and NoteShare types
Add sharedNotes table with noteId primary key, unique token, and
cascade delete. Add NoteShare and SharedNoteData interfaces.
Extend Note type with optional shareToken field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 108451ac7d fix: cap note card height to prevent tall checklist cards stretching rows
Add max-h-[17rem] overflow-hidden on note cards so checklist notes
don't force text notes in the same grid row to stretch with
truncated content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:56:48 +01:00
Frederic CabassutandClaude Opus 4.6 321bb9fdeb docs: add design spec for public note sharing (#7)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:03:44 +01:00
Frederic CabassutandClaude Opus 4.6 f4c6d75942 docs: add screenshot automation design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:02:20 +01:00
Frederic CabassutandClaude Opus 4.6 37a2630f77 fix: remove unknown/unknown platform entries from Docker manifests
Add provenance: false to docker/build-push-action to prevent BuildKit
from generating SLSA attestation entries that appear as unknown/unknown
in the image manifest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:24:23 +01:00
github-actions[bot] d748abb8c2 chore: pin docker-compose to v0.22.3 [skip ci] 2026-03-18 09:48:09 +00:00
github-actions[bot] 31682ccf37 chore: bump version to 0.22.3 2026-03-18 09:37:31 +00:00
Frederic CabassutandClaude Opus 4.6 4e9990518e fix: add linux/arm64 support to Docker image builds
Add QEMU emulation and multi-platform build config so ARM64 users
can pull the image without "no matching manifest" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:29:57 +01:00
github-actions[bot] 197930a24e chore: pin docker-compose to v0.22.2 [skip ci] 2026-03-17 22:44:48 +00:00
github-actions[bot] 54d54bf56e chore: bump version to 0.22.2 2026-03-17 22:40:08 +00:00
Fred Cabassut 664aa1f617 Revert "fix: set ghcr.io package visibility to public after Docker push"
This reverts commit 49cc7e0317.
2026-03-17 23:37:16 +01:00
github-actions[bot] 661b898e24 chore: bump version to 0.22.1 2026-03-17 22:13:20 +00:00
Claude 49cc7e0317 fix: set ghcr.io package visibility to public after Docker push
Users pulling the image without authentication get "unauthorized" because
ghcr.io packages default to private under an organization. Add a step in
both release workflows to explicitly set the container package to public.

https://claude.ai/code/session_01QovQeW26KfEEmU1KdFfCPU
2026-03-17 23:12:59 +01:00
Frederic CabassutandClaude Opus 4.6 9fe2705584 fix: use npx tsx for openapi generation in build script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:58:42 +01:00
github-actions[bot] d2bf683744 chore: pin docker-compose to v0.22.0 [skip ci] 2026-03-12 23:05:13 +00:00
github-actions[bot] 78194ccec0 chore: bump version to 0.22.0 2026-03-12 23:00:57 +00:00
Frederic CabassutandClaude Opus 4.6 b4d1f94c6f feat: auto-generate discoverable OpenAPI spec from route tree
Adds a script that scans src/routes/api/ for exported HTTP methods and
generates an OpenAPI 3.1 spec at /api/openapi.json. Runs automatically
before each build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:59:52 +01:00
Frederic CabassutandClaude Opus 4.6 d23f0c2f35 chore: update about page wording to "bretzel.app universe"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:53:40 +01:00
github-actions[bot] e1f38b4df3 chore: pin docker-compose to v0.21.9 [skip ci] 2026-03-12 22:31:07 +00:00
github-actions[bot] d722a842c9 chore: bump version to 0.21.9 2026-03-12 22:26:57 +00:00
Frederic CabassutandClaude Opus 4.6 870fdb8205 fix: replace emoji PWA icons with pixel-art pretzel from favicon.svg
The PWA manifest icons were a generic pretzel emoji on a dark background,
not matching the app's pixel-art pretzel favicon. Regenerated from the
favicon.svg source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:26:43 +01:00
github-actions[bot] 7317b13f62 chore: pin docker-compose to v0.21.8 [skip ci] 2026-03-12 22:16:28 +00:00
github-actions[bot] a968c39b6a chore: bump version to 0.21.8 2026-03-12 22:12:11 +00:00
Frederic CabassutandClaude Opus 4.6 f8937d287d fix: use absolute paths for service worker registration
SvelteKit sets Vite's base to './' for client builds, causing the PWA
plugin to generate relative SW paths (./sw.js). On sub-routes like
/settings or /tag/foo, this resolves to /settings/sw.js which returns
HTML, breaking SW registration. Force scope and buildBase to '/'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:11:56 +01:00
github-actions[bot] fe034f4f98 chore: pin docker-compose to v0.21.7 [skip ci] 2026-03-12 21:16:06 +00:00
github-actions[bot] 65d72b9662 chore: bump version to 0.21.7 2026-03-12 21:11:35 +00:00
Frederic CabassutandClaude Opus 4.6 b7fc8a5ca4 fix: remove maskable icon purpose to prevent double-cropping
The maskable purpose caused Android to crop the already-round icon
into its adaptive shape, resulting in a square-inside-circle appearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:11:08 +01:00
github-actions[bot] 794bd3a0c7 chore: pin docker-compose to v0.21.6 [skip ci] 2026-03-12 20:13:13 +00:00
github-actions[bot] b63d4b26fc chore: bump version to 0.21.6 2026-03-12 20:08:34 +00:00
Frederic CabassutandClaude Opus 4.6 17ccba4757 fix: block service worker in e2e tests to prevent response interception
Active service workers change the fetch lifecycle enough that Playwright's
waitForResponse() misses SW-mediated responses, causing preferences tests
to timeout. Block SW registration during e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:08:20 +01:00
github-actions[bot] b41a54677b chore: bump version to 0.21.5 2026-03-12 20:01:20 +00:00
Frederic CabassutandClaude Opus 4.6 4b233ba360 fix: restrict SW API caching to GET requests to avoid intercepting mutations
Workbox route matching intercepts all HTTP methods, which caused PUT/POST
requests (e.g. preferences updates) to be handled by the SW strategy
handler instead of passing through directly. Restrict API and attachment
cache rules to GET-only using function-based urlPattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:00:56 +01:00
github-actions[bot] 202065380a chore: bump version to 0.21.4 2026-03-12 19:56:04 +00:00
Frederic CabassutandClaude Opus 4.6 962eee3637 fix: register PWA service worker via virtual:pwa-register for offline support
@vite-pwa/sveltekit does not auto-inject SW registration into SSR HTML.
Add explicit registration via virtual:pwa-register in root layout onMount,
inject webmanifest link via virtual:pwa-info, disable SvelteKit's built-in
SW registration to avoid conflicts, and add required dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:53:38 +01:00
github-actions[bot] 9253fae548 chore: pin docker-compose to v0.21.3 [skip ci] 2026-03-12 19:24:11 +00:00
github-actions[bot] a75e3e6ef3 chore: bump version to 0.21.3 2026-03-12 19:19:56 +00:00
copilot-swe-agent[bot]andfredicious ff3570f5ad fix: snapshot pre-update state to enable 3-way merge on concurrent PATCH
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-12 20:19:10 +01:00
Claude 9ed5c52fb6 fix: resolve lost updates in multi-user collaboration sync
Concurrent edits to shared notes (e.g., one user checks a shopping list
item while another adds an item) were lost because the sync system used
whole-document Last-Write-Wins. This replaces it with a 3-way line-level
content merge using node-diff3, so changes to different lines are
preserved from both users.

Key changes:
- Add node-diff3 for 3-way line-level content merging
- Remove the timestamp gate in processSyncPush that rejected entire
  offline sync pushes when another user had updated the note
- Add content merge to both the sync push path and the PATCH endpoint,
  using version snapshots as the merge base
- Send baseVersion from the client so the server can find the correct
  common ancestor for 3-way merge
- Protect pending local fields during client pull merge by reading
  the sync queue and preserving queued-but-unpushed changes
- Add comprehensive tests for content merge, server sync, and CRDT merge

https://claude.ai/code/session_01FNpVBBjM6vKzfR8y9NM9Ci
2026-03-12 20:19:10 +01:00
Frederic CabassutandClaude Opus 4.6 3e7f5188d1 fix: enable offline page caching in service worker for PWA refresh support
Navigation requests had no caching rule, so refreshing offline showed the
browser's default "you are offline" page. Add NetworkFirst runtime caching
for navigations (3s timeout), cache Google Fonts for offline use, remove
conflicting static webmanifest in favor of the plugin-generated one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:16:23 +01:00
Claude f49f6f74f3 fix: make checklist checkboxes read-only in note card list view
Checking items directly from the card view caused glitches. Checkboxes
in the list preview are now disabled — users open the note to interact.

https://claude.ai/code/session_01HoLKBDNrHWHUVgTUbhSLE4
2026-03-12 18:56:16 +01:00
Frederic CabassutandClaude Opus 4.6 b781668475 fix: resolve dependabot vulnerabilities — override hono, remove stale package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:49:57 +01:00
Frederic CabassutandClaude Opus 4.6 ef01d03bb2 fix: delete all user-related records before removing user to prevent FK constraint error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:47 +01:00
Frederic CabassutandClaude Opus 4.6 7b4337ee14 docs: add SMTP config to README, document recent features, expose all env vars in docker-compose
- Add SMTP configuration example to README install section
- Add note version history, sharing, and email notifications to README feature list
- Expand FEATURES.md with all 9 email notification types and version history section
- Add note sharing, version history, and email alerts feature cards to landing page
- Add all OAuth/SSO env vars to docker-compose.yml with empty defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:49:55 +01:00
github-actions[bot] 85d9b6c187 chore: pin docker-compose to v0.21.2 [skip ci] 2026-03-12 09:32:50 +00:00
github-actions[bot] 6d4b265396 chore: bump version to 0.21.2 2026-03-12 09:28:39 +00:00
Frederic CabassutandClaude Opus 4.6 0e74409348 fix: prevent ghost click tap-through on mobile editor toolbar
On mobile, tapping a note card could trigger action buttons in the
editor that rendered at the same coordinates (ghost click). Suppress
pointer events on both toolbars for 150ms after mount — matching the
pop-in animation — so the synthetic click passes harmlessly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:10:47 +01:00
github-actions[bot] 9316428262 chore: pin docker-compose to v0.21.1 [skip ci] 2026-03-12 08:49:35 +00:00
github-actions[bot] bd6bab4093 chore: bump version to 0.21.1 2026-03-12 08:45:06 +00:00
Frederic CabassutandClaude Opus 4.6 a2d6ecdccf fix: resolve flaky offline sync e2e test in CI
Playwright's setOffline(false) doesn't reliably emit the browser's
'online' DOM event in headless/CI environments. Manually dispatch it
as a safety net so the sync client always triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:31:11 +01:00
Claude 98db72f5af fix: remove stopPropagation on editor card that blocked toolbar dropdown close
The editor card's onpointerdown stopPropagation prevented the
FormattingToolbar's document-level pointerdown listener from
detecting clicks outside the dropdown. Since both overlay handlers
already use e.target === e.currentTarget to ignore bubbled events,
no stopPropagation is needed on the card.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude 33236ac531 fix: use pointer events for backdrop close instead of timeout guard
Pointer events are never synthetically re-dispatched from touch
(unlike mouse events), so the tap that opened the editor cannot
ghost-click the overlay. This eliminates the mobile first-load bug
without relying on an arbitrary delay.

Both pointerdown and pointerup must target the overlay itself to
trigger close, which also handles the text-selection-outside-editor
edge case.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude fe2f534720 fix: use timestamp guard instead of rAF for mobile backdrop click
On first page load on mobile, the synthetic mousedown from a tap can
arrive ~300ms after the touch event — well after a single
requestAnimationFrame (~16ms). Replace the rAF-based overlayReady
guard with a 400ms timestamp check from mount time to reliably
ignore stale synthetic events on slow first loads.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
github-actions[bot] cf6867dc64 chore: pin docker-compose to v0.21.0 [skip ci] 2026-03-11 20:22:43 +00:00
github-actions[bot] 722989a6c7 chore: bump version to 0.21.0 2026-03-11 20:18:05 +00:00
copilot-swe-agent[bot]andfredicious a5a454ef12 fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 735e1c06d7 chore: exclude package-lock.json from git (project uses pnpm)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious ded1f4e2a3 feat: add email notifications for user, account, and collaboration events
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious e777513a79 fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:02:00 +01:00
github-actions[bot] f7ede655e9 chore: pin docker-compose to v0.20.0 [skip ci] 2026-03-11 19:45:32 +00:00
github-actions[bot] df196f189e chore: bump version to 0.20.0 2026-03-11 19:41:24 +00:00
Claude 5561e1d9c2 feat: add email notifications when a note is shared
Send an email to collaborators when they are added to a shared note.
Uses nodemailer with optional SMTP configuration (auto-enabled when
SMTP_HOST is set). Users can opt out via the notifyOnShare preference.

https://claude.ai/code/session_01P4HSMr7QvKw9TMNikUJD9e
2026-03-11 20:33:48 +01:00
copilot-swe-agent[bot]andfredicious a54c117c76 fix: address code review feedback on NoteHistory component
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
copilot-swe-agent[bot]andfredicious 32b57eb0c6 feat: add note version history and restore (#note-history)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
Claude 57e34f5e66 fix: add vertical padding to checklist add-item button
https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
Claude ce8b6c97b9 fix: increase checklist item padding and fix text overflow in editor
Add more vertical padding (py-1 → py-1.5) to checklist items for better
readability. Add min-w-0 to the text input so long text is constrained
within the flex container instead of overflowing out of the note.

https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
github-actions[bot] bcbff2ec4f chore: pin docker-compose to v0.19.3 [skip ci] 2026-03-11 19:00:59 +00:00
github-actions[bot] bafd7cb48a chore: bump version to 0.19.3 2026-03-11 18:56:19 +00:00
Claude f951baaba6 fix: resolve race condition in offline sync e2e test
Set up waitForResponse before calling setOffline(false) so the sync
response listener is registered before the online event fires.

https://claude.ai/code/session_014yTnHzjkCJLBcYcPTSLVkE
2026-03-11 19:53:47 +01:00
Claude 5f52864945 fix: improve checklist note card text wrapping, spacing, and padding
- Replace `truncate` with `break-words min-w-0` so long checklist text
  wraps visibly instead of being clipped
- Increase vertical spacing between items (space-y-1 → space-y-2)
- Add bottom margin (mb-6) to prevent action buttons overlapping content
- Align checkboxes to top of multi-line items (items-start + mt-0.5)

https://claude.ai/code/session_01UVNA5wucVEjRQzwRxMhTZ9
2026-03-11 19:43:13 +01:00
Claude b08ef84d43 fix: prevent modal backdrop from catching the originating tap on mobile
On mobile, tapping a NoteCard to open the editor produces synthetic mouse
events (mousedown → mouseup → click) at the original touch coordinates.
If those coordinates land on the overlay backdrop rather than the editor
card, the mousedown sets mousedownOnOverlay = true and the click
immediately closes the editor — causing a brief flicker.

Guard against this by deferring overlay readiness to the next animation
frame after mount, so any synthetic events from the originating tap are
ignored.

https://claude.ai/code/session_011C8bfgShjDSmDr3rUCb2gw
2026-03-11 19:24:06 +01:00
github-actions[bot] 81867c57d4 chore: pin docker-compose to v0.19.2 [skip ci] 2026-03-10 22:47:34 +00:00
github-actions[bot] f23d2f854b chore: bump version to 0.19.2 2026-03-10 22:43:26 +00:00
Frederic CabassutandClaude Opus 4.6 4c4756a346 ci: use Playwright container image instead of downloading browsers
Run E2E tests inside mcr.microsoft.com/playwright container which has
Chromium pre-installed, eliminating the ~30s browser download on each run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 06c9dda98a fix: eliminate e2e test flakiness in settings preference and API key tests
Three sources of flakiness fixed:

1. **Preferences sync race** — page.goto() triggers a full reload which
   re-runs initPreferences() + syncPreferencesFromServer(). On slow CI,
   the async server sync can return stale data from parallel test workers
   that share the same user's server-side preferences. Fixed with
   gotoWithStablePrefs() which blocks the one GET /api/preferences sync
   request, so initPreferences() falls back to localStorage (always correct).

2. **localStorage not reset** — resetPreferences() only reset the server.
   After a full page reload, loadFromLocalStorage() could return stale
   values from previous test runs. Now resets both server and localStorage.

3. **API key name collision** — parallel test runs created keys with the
   same name ("Settings Test Key"), causing Playwright strict-mode
   violations when multiple elements matched. Fixed with Date.now()
   unique suffixes.

Verified: 167/167 settings tests pass (20x repeat), 173/173 full suite (5x).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 590c8f5cae docs: add E2E BDD testing best practices and improve test suite
- Add docs/TESTING.md with 10 BDD testing best practices
- Add createNote() helper to fixtures to DRY up state setup
- Add createChecklistNote() helper in checklist tests
- Rename scenario titles to describe outcomes, not actions
- Replace imperative Given steps with createNote() helper
- Fix fragile conditional in tag filtering test (silent pass)
- Remove implementation details from Gherkin comments
- Improve Given/When/Then rhythm consistency across all tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
github-actions[bot] 2937211be3 chore: pin docker-compose to v0.19.0 [skip ci] 2026-03-10 16:07:11 +00:00
github-actions[bot] bcf5aa424d chore: bump version to 0.19.1 2026-03-10 15:16:11 +00:00
Frederic Cabassut 890f0ade94 fix: isolate preference e2e tests with API reset before each test
Each preference test now resets to default preferences via API at the
start, ensuring parallel tests don't pollute each other's state.
Also await PUT response before reload in persist test to prevent
race condition.
2026-03-10 16:13:12 +01:00
github-actions[bot] 11bdde0d88 chore: bump version to 0.19.0 2026-03-10 14:55:19 +00:00
Frederic CabassutandClaude Opus 4.6 d2dbad6e10 feat: add About settings page with version and project info
Add /settings/about page showing version, project description,
Bretzel ecosystem info, open source details, and tech stack.
Move version display from sidebar to About page and align
Settings nav item styling with other sidebar items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:54:40 +01:00
Frederic Cabassut 7a2fa6ef25 fix: prevent e2e test pollution from preferences shared state
Settings tests set defaultNoteMode to 'markdown' without resetting,
causing parallel sharing test to timeout when TiptapEditor wasn't
rendered (textarea shown instead). Two fixes:
- Reset preferences in settings tests after changing them
- Create note via API in sharing test (tests sharing, not note creation)
2026-03-10 15:54:40 +01:00
github-actions[bot] f2c12994ac chore: bump version to 0.18.0 2026-03-10 14:11:18 +00:00
Frederic CabassutandClaude Opus 4.6 ec4ab031ee fix: redirect /settings to /settings/preferences
Move Profile page to /settings/profile and add a 302 redirect from
/settings to /settings/preferences so the first settings tab is
Preferences, not Profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:10:31 +01:00
Frederic CabassutandClaude Opus 4.6 1f3a8d1e36 feat: add user preferences system
Implement 4 user preferences (default note mode, default note color,
hide footer, sidebar default state) with offline-first localStorage
backed by server-side key-value storage.

- Add user_preferences table (EAV pattern with unique user_id+key index)
- Create server service with get/upsert operations
- Add GET/PUT /api/preferences endpoints
- Build Svelte 5 runes-based client store (.svelte.ts)
- Wire preferences into app layout (footer toggle, sidebar state)
- Wire default note mode/color into NoteEditor for new notes
- Add Preferences tab as first item in Settings navigation
- Add 7 unit tests and 4 E2E tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:07:19 +01:00
Frederic CabassutandClaude Opus 4.6 bcc9d8aa87 chore: remove plan docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:54:20 +01:00
Frederic CabassutandClaude Opus 4.6 ddf192cfeb docs: add user preferences implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:33:44 +01:00
Frederic CabassutandClaude Opus 4.6 ece876ab6e docs: add user preferences design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 e954fd25e3 fix: prevent note editor from closing when text selection ends outside overlay
Track mousedown origin so click-outside-to-close only fires when both
mousedown and mouseup occur on the overlay backdrop, not when dragging a
text selection from inside the editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:16:51 +01:00
Frederic CabassutandClaude Opus 4.6 0b417bef0e test: consolidate E2E tests — remove duplicates, fix timeouts, parallelize sharing
- Remove duplicate bold formatting test from notes-crud.spec.ts
- Trim formatting.spec.ts from 9 to 5 tests (remove italic, inline code,
  toolbar bold, toolbar visibility — same mechanism as bold shortcut)
- Remove hardcoded waitForTimeout(500) from search.spec.ts (Playwright
  assertions already retry with built-in timeouts)
- Restructure sharing.spec.ts: 9 serial tests → 5 parallel self-contained
  tests using collabPage fixture and API-based setup
- Add collabPage fixture to helpers/fixtures.ts for sharing tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 914b4fc772 test: add 52 unit tests for notes-service, api-utils, rate-limit, tags, collaborators
- notes-service.test.ts (24 tests): listNotes, getNote, createNote, updateNote,
  deleteNote, searchNotes, reorderNotes with owner/collaborator patterns
- api-utils.test.ts (10 tests): canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId
- rate-limit.test.ts (8 tests): checkRateLimit, recordLoginAttempt, clearOldAttempts
- tags.test.ts (6 tests): syncNoteTags, fetchTagsForNotes
- collaborators.test.ts (4 tests): fetchCollaboratorsForNotes, addCollaborator, removeCollaborator

Also fixes test-helpers.ts missing thumbnail_path/featured columns
and rate-limit.ts raw SQL Date binding issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 e794d09978 refactor: add mandatory db parameter to all service functions for testability
Make db a required first parameter on all service functions (notes-service,
api-utils, tags, collaborators, attachments, rate-limit, sync/server).
This enables unit testing with in-memory databases via createTestDb().

Also removes the NODE_ENV=test bypass from rate-limit and adds optional
now parameter for time control in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
github-actions[bot] 72b10016ca chore: pin docker-compose to v0.17.0 [skip ci] 2026-03-10 08:57:49 +00:00
github-actions[bot] 5b6c918629 chore: bump version to 0.17.0 2026-03-10 08:53:40 +00:00
Frederic CabassutandClaude Opus 4.6 7e1125c00d feat: add coverage summary to GitHub Actions job summary
Write a markdown coverage table to $GITHUB_STEP_SUMMARY for both unit
and E2E test jobs. Uses a small bash script that parses json-summary
output from the coverage reporters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 a724770af4 feat: add code coverage with artifact upload for unit and E2E tests
Add V8 coverage for unit tests via @vitest/coverage-v8 and Istanbul
instrumentation for E2E tests via vite-plugin-istanbul. Coverage data
is collected from browser pages after each Playwright test and merged
with nyc. CI uploads both coverage reports as GitHub Actions artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 e3015c0e20 chore: set e2e retries to 0 — fix flaky tests, don't mask them
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:50:33 +01:00
Frederic CabassutandClaude Opus 4.6 ebdac3da13 perf: speed up e2e tests in CI
- Pre-build before Playwright instead of building inside webServer
- Reduce retries from 2 to 1 (fix flaky tests instead of masking them)
- Set CI workers to 4 for more parallelism
- Reduce artifact retention from 30 to 7 days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:46:22 +01:00
github-actions[bot] 9917b737c2 chore: pin docker-compose to v0.16.1 [skip ci] 2026-03-10 06:40:49 +00:00
github-actions[bot] f761b34396 chore: bump version to 0.16.1 2026-03-10 06:36:44 +00:00
Frederic CabassutandClaude Opus 4.6 c29df8a908 fix: update e2e test for renamed API settings tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:36:03 +01:00
github-actions[bot] 4c7064d9fa chore: bump version to 0.16.0 2026-03-09 23:11:31 +00:00
Frederic CabassutandClaude Opus 4.6 009eac617e chore: rename Client Configuration to MCP Client Configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
Frederic CabassutandClaude Opus 4.6 9a4eb5ecb3 feat: extend API key auth to all API endpoints
Bearer token authentication now works on all /api/* routes, not just
/api/mcp. If a Bearer token is present it's validated first; otherwise
session cookie auth is used as fallback. Updates settings UI to reflect
the broader scope (tab renamed to "API", REST API curl example added)
and updates the landing page feature card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
github-actions[bot] c3713c0391 chore: pin docker-compose to v0.15.0 [skip ci] 2026-03-09 23:02:47 +00:00
github-actions[bot] 5e2624d4c1 chore: bump version to 0.15.0 2026-03-09 22:57:53 +00:00
Frederic CabassutandClaude Opus 4.6 f42005a482 feat: add task list button to rich text editor toolbar
Register TaskList and TaskItem TipTap extensions, add "Task list" option
to the List dropdown in the formatting toolbar, and consolidate task list
CSS between the editor and markdown-it preview into shared styles in
app.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:57:29 +01:00
github-actions[bot] 6992bde0e0 chore: pin docker-compose to v0.14.0 [skip ci] 2026-03-09 22:42:53 +00:00
github-actions[bot] bd8c1fcee8 chore: bump version to 0.14.0 2026-03-09 22:38:22 +00:00
Frederic CabassutandClaude Opus 4.6 132c0c1fc9 fix: toolbar buttons now reactively show active formatting state
All isActive() and getAttributes() calls in the template were not
reading the tick prop, so Svelte 5 never re-evaluated them when the
editor state changed. Extracted isActive() and getAttrs() helpers
that consume tick to establish the reactive dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:37:38 +01:00
Frederic CabassutandClaude Opus 4.6 a6a330ca59 refactor: make toolbar wrap instead of scroll, fix overflow
Use flex-wrap instead of overflow-x-auto for the formatting toolbar —
scrolling clips absolutely-positioned dropdown popovers (CSS forces
both axes to auto). All buttons are now always visible, wrapping to a
second line on narrow screens. Remove the More dropdown entirely since
all items are top-level. Add overflow-hidden to the editor card to
prevent horizontal overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:30:21 +01:00
Frederic CabassutandClaude Opus 4.6 8cc919bb03 refactor: promote all formatting buttons to top-level on desktop
Move undo/redo to the start of the toolbar, promote inline code,
blockquote, code block, and divider to top-level desktop buttons.
The More dropdown is now mobile-only since all items are directly
accessible on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:24:08 +01:00
Frederic CabassutandClaude Opus 4.6 295358ea31 refactor: expand toolbar on desktop with alignment dropdown
- Widen editor modal on desktop (max-w-xl → md:max-w-2xl)
- Extract text alignment from More into its own dropdown with contextual
  icon reflecting the active alignment
- Promote undo/redo to top-level buttons on desktop (hidden on mobile,
  still available in More dropdown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:22:01 +01:00
Frederic CabassutandClaude Opus 4.6 b0166b9a5e feat: add row/column management to table toolbar
Replace single "Insert table" button with a Table dropdown that shows
contextual row/column operations when the cursor is inside a table:
add row, add column, delete row, delete column, delete table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:19:14 +01:00
Frederic CabassutandClaude Opus 4.6 23887c7050 feat: add table support to TipTap editor
Install @tiptap/extension-table packages, register Table/TableRow/
TableHeader/TableCell extensions, add Table button to the More dropdown
(inserts a 3×3 grid), and style tables to match the parchment theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:15:17 +01:00
Frederic CabassutandClaude Opus 4.6 108ea21e36 docs: remove tables from FEATURES.md — not implemented
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:05:27 +01:00
github-actions[bot] 37f2af6bba chore: pin docker-compose to v0.13.1 [skip ci] 2026-03-09 21:47:39 +00:00
github-actions[bot] 00b40bc180 chore: bump version to 0.13.1 2026-03-09 21:43:19 +00:00
Frederic CabassutandClaude Opus 4.6 89f77800f0 fix: anchor collaborator popover to right edge to prevent overflow
The popover is inside a top-right positioned indicator, so anchoring
to right-0 keeps it on screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:39:32 +01:00
github-actions[bot] 9f92579a86 chore: pin docker-compose to v0.13.0 [skip ci] 2026-03-09 21:36:34 +00:00
github-actions[bot] 7d9ab9bcdd chore: bump version to 0.13.0 2026-03-09 21:32:25 +00:00
Frederic CabassutandClaude Opus 4.6 3f5c177472 feat: pressing Enter in note title moves cursor to content
Prevents default newline and focuses the content area (textarea or
TipTap editor) when pressing Enter in the title input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:29:44 +01:00
Frederic CabassutandClaude Opus 4.6 d0f11a97da fix: remove tag chips from note cards in list view
Tags are still visible as hashtags in the note content and usable
via the tag filter bar. Reduces card clutter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:28:47 +01:00
Frederic CabassutandClaude Opus 4.6 3d17abbcf2 fix: increase touch drag delay to 400ms to prevent scroll conflicts
The default 80ms delay was too short, causing drag to activate
during normal scrolling on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:15:05 +01:00
github-actions[bot] bc32ab841a chore: pin docker-compose to v0.12.1 [skip ci] 2026-03-09 21:11:35 +00:00
github-actions[bot] 44112ab568 chore: bump version to 0.12.1 2026-03-09 21:07:31 +00:00
Frederic CabassutandClaude Opus 4.6 05d2a505f9 test: update sorting tests for hybrid sort order
Remove tests for sort selector UI (removed). Replace with a single
test verifying most recently updated note appears first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:06:39 +01:00
github-actions[bot] a4844f9cdf chore: bump version to 0.12.0 2026-03-09 20:58:34 +00:00
Frederic CabassutandClaude Opus 4.6 484468e3f8 fix: remove focus outline from note cards during drag
Add outline-none to both the NoteCard article and the NoteGrid
wrapper div to prevent browser focus outlines when dragging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 20b3e6d3b6 refactor: remove drag handles, use long-press to reorder notes
Replace drag handle zones with dndzone for whole-card dragging via
long-press on both desktop and mobile. Removes GripHorizontal icon
and dragHandle imports entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 d1c9c88ab5 refactor: replace sort selector with hybrid sort order
Remove the sort mode selector UI. Notes now sort by custom order
first (drag to reorder), falling back to most-recently-updated for
ties. Drag handles are always available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 bde40981cb fix: stack tag filter and sort buttons vertically on mobile
Tags get full width for horizontal scrolling, sort buttons sit
below aligned to the right. Side by side on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 c8b20a6ab4 fix: hide scrollbar on tag filter horizontal scroll
Use scrollbar-width: none and ::-webkit-scrollbar to visually hide
the scrollbar while keeping swipe-to-scroll functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 c2e7f4a851 fix: prevent tag chips from wrapping text across lines
Add whitespace-nowrap to TagChip so long tags stay on a single line
within the horizontally scrollable filter bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 3dcadc280d fix: reload notes when navigating to archive or trash views
Call loadNotes(filter) when the filter prop changes so the store
gets populated with the correct note subset for each view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 196d265635 feat: click sync indicator to trigger manual sync
Change sync indicator from div to button that calls sync() on click,
with hover feedback and updated tooltip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 51a832b3b9 fix: make tag filter horizontally scrollable on mobile
Keep tags on a single line with horizontal scroll on small screens,
wrap normally on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 b6150cafed feat: add floating action button for creating notes on mobile
Show a fixed FAB at bottom-right on mobile instead of the inline
button that scrolls out of view. Desktop keeps the inline button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 448c2127d5 fix: prevent settings page elements from overflowing on mobile
Stack MCP key form, API key items, user list rows, and delete
account confirmation vertically on small screens using
flex-col/sm:flex-row. Add truncate on user names/emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 8e630d9bb7 fix: prevent drag handle from overlapping note card title
Position drag handle as a centered horizontal grip at the top edge
of the card, out of the way of title and content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
github-actions[bot] e3c9f5d0c7 chore: pin docker-compose to v0.11.1 [skip ci] 2026-03-09 20:17:06 +00:00
github-actions[bot] 6b6654a94f chore: bump version to 0.11.1 2026-03-09 20:12:19 +00:00
Frederic CabassutandClaude Opus 4.6 1a5edc0fdd fix: sync endpoint returns per-user state overlay for shared notes
getChangesSince was returning the owner's pinned/archived/sortOrder
for shared notes instead of the collaborator's per-user values from
noteUserState. Also filtered trashed notes from collaborator sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:04 +01:00
github-actions[bot] fafb76c0d5 chore: pin docker-compose to v0.11.0 [skip ci] 2026-03-09 11:18:27 +00:00
github-actions[bot] 2d5b4a0cdc chore: bump version to 0.11.0 2026-03-09 11:12:31 +00:00
Frederic CabassutandClaude Opus 4.6 250e458c31 docs: add collaboration to FEATURES, ARCHITECTURE, and OpenAPI spec
- Add note sharing section to FEATURES.md
- Document collaboration access control model in ARCHITECTURE.md
- Add collaborators and user search endpoints to openapi.yaml
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 a7c8699542 test: add e2e tests for note sharing workflow
Serial test suite covering: owner shares note, collaborator sees it,
collaborator edits content, independent pin state, leave action shown
for non-owners, collaborator leaves and note disappears.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 3ee6c1d0b5 test: add unit tests for collaboration schema tables
Test noteCollaborators and noteUserState tables: CRUD, unique constraints,
defaults, and independent per-user state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 346256a628 feat: add sharing UI components and integrate into NoteCard/NoteEditor
- Create ShareDialog with user search autocomplete, collaborator list, and remove
- Create CollaboratorPopover for hover/click indicator on shared notes
- Add collaborator indicator to NoteCard (always visible Users icon)
- Replace trash with Leave action for non-owner shared notes
- Add share button to NoteEditor toolbar (UserPlus → Users when shared)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 bbacc5f6b3 feat: widen notes service and sync for shared notes
- Refactor notes-service to include shared notes in list/get/search/reorder
- Route per-user fields (pinned/archived/sortOrder) to noteUserState for collaborators
- Update API routes to delegate to service layer with access control
- Update sync server to handle shared note updates bidirectionally
- Add leaveNote() to client store
- Refactor search and notes API routes to use service layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 850730ec88 feat: add note collaboration schema, access control, and API routes
Add noteCollaborators and noteUserState tables for sharing notes between
users. Implement access control helpers (canAccessNote, requireNoteAccess,
requireNoteOwnership) and collaborators CRUD service. Create API endpoints
for managing collaborators and searching users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 7aedec0953 docs: remove Google Keep references from website and docs
Replace brand-specific "Google Keep alternative" copy with generic
privacy-focused messaging across meta tags, hero subtitle, README,
and feature docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:08:42 +01:00
Frederic CabassutandClaude Opus 4.6 74d3228ddd fix: use apple-touch-icon as og:image for link previews
Platforms like WhatsApp require an explicit og:image to display
anything in link previews. Point it to the existing apple-touch-icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:04:34 +01:00
Frederic CabassutandClaude Opus 4.6 bb97d4fd71 fix: remove OG image and let apple-touch-icon handle link previews
The apple-touch-icon already provides the pretzel logo for link
previews (like the app does). Remove the redundant OG image and switch
twitter:card to "summary" (icon-sized) instead of "summary_large_image".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:03:08 +01:00
Frederic CabassutandClaude Opus 4.6 d38736cdf5 fix: simplify OG image to just the pretzel logo on parchment
Title and description are already shown in the link preview text,
so the image doesn't need to repeat them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:02:15 +01:00
Frederic CabassutandClaude Opus 4.6 b4bcd33f88 fix: add apple-touch-icon to website for link preview icon
Messaging apps (WhatsApp, iMessage, etc.) use the apple-touch-icon to
display a site icon in link previews. Copy the existing icon from the
app and add the link tag to the landing page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:58:19 +01:00
Frederic CabassutandClaude Opus 4.6 b035fbb25e feat: add OG image and Twitter Card meta tags for link previews
The og-image.png was referenced in meta tags but never created. Generate
a 1200x630 parchment-themed OG image and add missing twitter:title,
twitter:description, and twitter:image meta tags for proper social
media link previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:54:47 +01:00
github-actions[bot] 6d2ed4c07a chore: pin docker-compose to v0.10.1 [skip ci] 2026-03-09 10:52:33 +00:00
github-actions[bot] 4ca23a4b6f chore: bump version to 0.10.1 2026-03-09 10:48:47 +00:00
Frederic CabassutandClaude Opus 4.6 57b3e7700f fix: migrate password_hash column to nullable for OAuth-only users
Existing databases created from the original migration had password_hash
as NOT NULL. This adds an auto-migration that recreates the users table
with a nullable password_hash column, enabling OAuth-only user creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:47:48 +01:00
Frederic CabassutandClaude Opus 4.6 06f77a6117 docs: use Authentik OIDC example in README instead of Google OAuth
Self-hosted auth provider is more relevant for a self-hosted app.
Also adds AUTH.md to the documentation table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:42:25 +01:00
github-actions[bot] 8c92925806 chore: pin docker-compose to v0.10.0 [skip ci] 2026-03-09 10:42:14 +00:00
github-actions[bot] a7a42104a9 chore: bump version to 0.10.0 2026-03-09 10:37:11 +00:00
Frederic CabassutandClaude Opus 4.6 23ff44dfaf feat: allow creating OAuth-only users without a password
Admin can now toggle "OAuth-only (no password)" when creating a user.
These users have no password hash and authProvider set to 'none', so
they can only log in via an OAuth/SSO provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
Frederic CabassutandClaude Opus 4.6 a37ee75242 docs: add dedicated AUTH.md and deduplicate auth info across docs
Create AUTH.md as the canonical reference for authentication: password
auth, OAuth/SSO setup, provider-specific guides (Authentik, Keycloak,
Okta/Auth0), env vars, callback URLs, security notes, and
troubleshooting. Fixes incorrect env var names and callback URL patterns
that had drifted in DEPLOYMENT.md. Other docs now cross-link to AUTH.md
instead of duplicating content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
github-actions[bot] 16f4e2abe3 chore: pin docker-compose to v0.9.1 [skip ci] 2026-03-09 10:04:53 +00:00
github-actions[bot] 005071f703 chore: bump version to 0.9.1 2026-03-09 10:00:54 +00:00
Frederic CabassutandClaude Opus 4.6 af75f76dc9 fix: use correct MCP server type in settings page config
Change "type": "streamable-http" to "type": "http" to match Claude
Code's actual MCP schema. Also clarify that the config targets
.claude/settings.json specifically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:47:25 +01:00
Frederic CabassutandClaude Opus 4.6 838051b1ab perf: lazy-load zxcvbn password library to reduce initial bundle size
- Dynamic import zxcvbn (820KB) in PasswordStrengthMeter so it only
  loads when user starts typing a password
- Raise chunkSizeWarningLimit to 850KB for the lazy-loaded chunk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:42:27 +01:00
Frederic CabassutandClaude Opus 4.6 48f23cbecc fix: resolve all svelte-check warnings (a11y + state_referenced_locally)
- Add svelte-ignore for intentional state_referenced_locally patterns
- Wrap clickable images in button elements for keyboard accessibility
- Add proper ARIA roles (dialog, textbox) with tabindex and focus management
- Fix lightbox Escape key handling to not close parent editor overlay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:37:07 +01:00
github-actions[bot] d3334ad91d chore: pin docker-compose to v0.9.0 [skip ci] 2026-03-09 01:36:11 +00:00
github-actions[bot] f840cc1b57 chore: bump version to 0.9.0 2026-03-09 01:32:03 +00:00
Frederic CabassutandClaude Opus 4.6 d9b4a34bac fix: resolve type errors in notes-service, MCP server, and test helpers
- Add missing userId arg to syncNoteTags() calls in notes-service
- Add seedUser option to createTestDb() for test setup
- Add explicit type annotations to MCP server.tool() callbacks (zod v4)
- Fix ambiguous getByText selector in settings e2e test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:31:18 +01:00
Frederic CabassutandClaude Opus 4.6 3cc3ecad77 docs: update CLAUDE.md to reflect multi-user auth and current project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:56 +01:00
Frederic CabassutandClaude Opus 4.6 cb80102a21 fix: clarify multi-user feature description on landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 f32e81c2ae feat: add MCP, multi-user, and OAuth features to landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 790ff4404c docs: add OAuth/auth docs and install instructions in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:07 +01:00
Frederic CabassutandClaude Opus 4.6 6b6f34cdef feat: admin can revoke user sessions (force logout)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:03 +01:00
Frederic CabassutandClaude Opus 4.6 f001164d0f feat: URL-based routing for archive, trash, and tags
Add /archive, /trash, /tag/[name] routes with proper <a> links
in the sidebar. Tags visible regardless of current filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:55 +01:00
Frederic CabassutandClaude Opus 4.6 31b3cc78b4 feat: add logout button and delete account to settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:47 +01:00
Frederic CabassutandClaude Opus 4.6 09f2d3c928 refactor: remove user avatar dropdown from header
Logout and Settings access moved to sidebar and settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:43 +01:00
Frederic CabassutandClaude Opus 4.6 11aa542b4a docs: add MCP server and API key documentation
- Update FEATURES.md, ARCHITECTURE.md, DEPLOYMENT.md with MCP sections
- Add MCP endpoints to OpenAPI spec and regenerate API.md
- Add rebase-before-PR best practice to CLAUDE.md git conventions
- Update README with MCP/API feature mention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 d338da1d1a test: add E2E tests for MCP endpoint and Settings page
- MCP tests: initialize, create/get note, search, auth rejection
- Settings tests: create key, revoke key, sidebar nav, subpage nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 8e1f8f0413 feat: add Settings subpages with API key management UI
- Settings layout with sidebar nav (Profile, MCP Server, Users)
- Profile page: display name, email, password, sessions
- MCP page: API key CRUD, JSON config with copy button, LLM prompt
- Config auto-populates with actual API key when one is created
- Remove duplicate Settings link from sidebar
- Users page integrated into settings nav (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 2a95174c07 feat: add MCP server with 14 tools and shared notes service
- Shared notes-service.ts with userId-scoped queries for DRY
- McpServer with tools: list/get/create/update/trash/restore/
  archive/unarchive/delete notes, search, list tags, pin,
  reorder, upload image
- Streamable HTTP endpoint at POST /api/mcp
- Session management with GET/DELETE handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 63feddf231 feat: add API key CRUD and Bearer token auth for MCP
- API key generation with SHA-256 hashing and crumbs_ prefix
- User-scoped key validation, listing, and deletion
- Bearer token auth in hooks.server.ts for /api/mcp paths
- Populates event.locals.user from API key for downstream scoping
- 13 unit tests covering CRUD, validation, and user isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 34ad34915d feat: add API keys schema and MCP SDK dependency
- Add `api_keys` table with userId foreign key to schema
- Add `@modelcontextprotocol/sdk` and `zod` dependencies
- Add auto-create SQL and test helpers for api_keys table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 d5a079a4bd fix: enforce note ownership across all API endpoints
- Add userId check to /api/notes/reorder (was completely unprotected)
- Scope syncNoteTags by userId to prevent cross-user tag pollution
- Verify attachmentId belongs to noteId in attachment operations (IDOR fix)
- Remove DEFAULT 0 from userId columns so missing userId fails loudly
- Update test helpers and test data to provide required userId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:50:12 +01:00
github-actions[bot] 15ba5629f7 chore: pin docker-compose to v0.8.1 [skip ci] 2026-03-09 00:33:34 +00:00
github-actions[bot] 69d3824fe7 chore: bump version to 0.8.1 2026-03-09 00:29:30 +00:00
Frederic CabassutandClaude Opus 4.6 ac83ebc4f0 style: restyle settings pages to match parchment design system
- Move settings routes into (app) layout group for header/sidebar
- Replace all gray/white/dark-mode Tailwind classes with CSS variables
- Use rounded-sm, card-shadow, and parchment color tokens throughout
- Fix PasswordStrengthMeter to use themed colors
- Fix page titles from "KeepNotes" to "Crumbs"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:26:15 +01:00
Frederic CabassutandClaude Opus 4.6 d07607bcf1 fix: remove minlength attribute to fix e2e setup validation test
The HTML minlength="8" attribute triggered native browser validation
before the JS handler could run, preventing the custom error-message
element from appearing. JS already validates password length.

Also install missing zxcvbn and arctic dependencies from phase2 merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:23:11 +01:00
Frederic CabassutandClaude Opus 4.6 ad8ba982c2 docs: add mobile hover actions best practice to CLAUDE.md
Document the opacity-based pattern for hover actions that ensures
they're always visible on touch devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:59 +01:00
Frederic CabassutandClaude Opus 4.6 6758eb1e36 fix: move drag handle to top-left to avoid overlap with pin icon
The drag handle was positioned top-right, overlapping with the pin
bookmark icon on pinned notes. Move it to top-left instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:46 +01:00
Frederic CabassutandClaude Opus 4.6 7530dc1d47 fix: increase focusable text area on mobile for rich text editor
Make the Tiptap editor fill the full wrapper height using flexbox so
tapping anywhere in the content area focuses the editor, not just the
small text area at the top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:34 +01:00
Frederic CabassutandClaude Opus 4.6 dac3ac235a fix: improve image actions for mobile and always show delete/star
- Use opacity-based visibility (max-md:opacity-100) for image hover
  actions so they're always accessible on touch devices
- Always show ImageUpload with delete/star when attachments exist,
  not just when in image upload mode
- Replace drag-and-drop zone with compact "Add images" button on mobile
- Add showDropZone prop to ImageUpload for conditional drop zone display
- Clean up unused lightbox state from NoteEditor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:08 +01:00
github-actions[bot] 2a06b2f739 chore: bump version to 0.8.0 2026-03-09 00:11:02 +00:00
Frederic CabassutandClaude Opus 4.6 2c5f8130c4 chore: regenerate lockfile after rebase onto main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b2634f8b13 fix(test): proper e2e test isolation with project dependencies
Add globalSetup to clean test DB before each run. Split Playwright into
two projects: auth-setup (serial, runs first) and app (parallel, depends
on auth-setup). Skip rate limiting in test environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 80c6744559 fix(test): resolve strict mode violations in e2e organization tests
Use .first() for pin-btn and tag filter assertions to handle cases
where multiple matching elements exist in the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 3e1e01c539 fix: resolve type errors in OAuth and API utils
Fix null vs undefined in GitHub OAuth, use arctic's
createAuthorizationURLWithPKCE for OIDC, loosen api-utils type
signature to avoid SvelteKit route-specific generics, add @types/zxcvbn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 3baeec29a8 docs: update .env.example with OAuth configuration
Add Google, GitHub, and generic OIDC environment variables. Providers
are auto-enabled when their env vars are set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 93a9379f5e feat(ui): dynamic OAuth buttons on login page
Login page loads enabled providers from server and shows "or continue
with" divider + branded buttons for Google/GitHub/OIDC. Also handles
rate limit 429 and OAuth no_account error display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 a9c771cc9f feat(auth): OAuth authorize and callback endpoints
Dynamic [provider] routes for Google, GitHub, and OIDC. Stores state and
PKCE verifier in httpOnly cookies. Callback validates code, finds/links
user by provider ID or email (invite-only), and creates session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 8806ffd50a feat(auth): OAuth provider configuration with arctic
Add Google, GitHub, and generic OIDC provider modules using arctic v3.
Providers are auto-enabled when env vars are set. Google/GitHub use PKCE,
OIDC uses OpenID Connect discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 54ec5b9833 feat(auth): session list and revocation
Track session metadata (createdAt, userAgent, ip, lastUsedAt). Add API
for listing and revoking sessions. Settings page shows active sessions
with "Revoke" per session and "Log out everywhere else" option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 7803fc531a feat(ui): password strength meter with zxcvbn
Add PasswordStrengthMeter component showing visual bar and feedback.
Integrated in setup, settings change-password, and admin create-user forms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 e3d3d5c499 feat(auth): login rate limiting
5 failed attempts per IP triggers 15-minute lockout. Uses loginAttempts
table added in Phase 1. Returns 429 with Retry-After header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b90ab18ea7 fix(test): update tests for multi-user schema and improve e2e reliability
Update unit test helpers with full multi-user schema, fix auth tests to
include email field, and improve e2e tests with specific card targeting
and force clicks for hover-revealed buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 c0b5aa3bcb chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 c9cc5d5967 fix(auth): resolve session validation and layout rendering issues
Fix session expiry check (seconds vs milliseconds mismatch in raw SQL),
conditionally render app chrome only for authenticated users, use full
page reload after login/setup for proper session propagation, remove
HTML minlength to allow JS validation, fix store to remove notes on
trash/archive, and make auth tests race-condition safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 6324d8f094 fix(db): reorder migration to run before index creation
Move migration logic (ALTER TABLE) before CREATE INDEX statements so
that user_id columns exist on old schemas before indexes reference them.
Disable foreign keys during schema setup to avoid constraint issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 5b7f8852ad test: update auth tests for multi-user
Update e2e fixtures to use email+password for setup and login.
Update auth tests to fill email field. Add multi-user test for
admin creating a new user via API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 abb9ffcf95 refactor(sync): namespace IndexedDB by userId
Each user gets their own IDB database (keepnotes-{userId}), preventing
data leaks between users on the same device. Add initDb(userId) called
on mount and destroyDb() for logout cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 263fbbdf15 feat(ui): settings page with user profile and admin user management
- Settings page: edit display name/email, change password
- Admin user management page: create/delete users, toggle roles
- Profile update API endpoint (PUT /api/auth/profile)
- Settings link added to sidebar navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 33864d9355 feat(api): admin user management endpoints
- GET/POST /api/admin/users — list and create users (admin only)
- PATCH/DELETE /api/admin/users/[id] — update role/reset password, delete user
- PUT /api/auth/password — change own password
All admin routes guarded by requireAdmin().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 133690994b feat(layout): expose user data to app shell
Add layout.server.ts to pass event.locals.user to the client.
Header now shows user avatar initial with dropdown menu
(Settings link, Log out button).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 061336ae8b refactor(api): scope all queries by authenticated user
Add getUserId/requireAdmin helpers in api-utils.ts. All notes, tags,
search, sync, and attachment routes now filter by userId to ensure
complete data isolation between users. Sync server accepts userId
parameter for push/pull operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 4dea359362 feat(auth): email-based login and setup flows
Login page now requires email + password. Setup page adds email and
optional display name fields. API endpoints updated to pass email
to verifyPassword/setupUser and explicit userId to createSession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 59b5e92d91 refactor(hooks): populate event.locals.user from session
validateSession now returns user data. Middleware sets event.locals.user
on every authenticated request. Add /api/auth/oauth to public paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 823fc27b73 refactor(auth): email-based login and user CRUD
Rewrite auth module for multi-user support:
- setupUser now takes email, password, displayName; returns User
- verifyPassword takes email+password; returns User or null
- createSession takes explicit userId
- validateSession joins sessions+users; returns user data
- Add createUser, deleteUser, changePassword, resetPassword,
  updateUserRole, updateUserProfile, listUsers, getUser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 c13d7c0ea3 feat(types): add User type and App.Locals
Define App.Locals.user interface for session user data.
Add User type to shared types (mirrors DB columns minus passwordHash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 9405acdb4d feat(db): add multi-user columns and login_attempts table
Add email, displayName, role, authProvider, providerId to users table.
Add userId foreign key to notes, tags, attachments, syncLog tables.
Add loginAttempts table for rate limiting. Include migration logic
to detect old single-user schema and backfill existing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 f21c98df19 style: add vertical padding to checklist items for better spacing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:46:32 +01:00
github-actions[bot] da7e60ed97 chore: pin docker-compose to v0.7.2 [skip ci] 2026-03-08 23:45:47 +00:00
github-actions[bot] 29a25d23b0 chore: bump version to 0.7.2 2026-03-08 23:42:16 +00:00
Frederic CabassutandClaude Opus 4.6 8ef4b0733d fix: add error resilience for IDB failures and API error feedback
loadNotes() crashed entirely if IndexedDB was unavailable because
getAllNotes() was called outside try/catch. Non-OK API responses
(401, 500) were silently ignored across loadNotes, createNote, and
sync operations — users saw empty data with no feedback.

Changes:
- Wrap all IDB calls in try/catch so the app works without IndexedDB
- Show toast on non-OK API responses in loadNotes and createNote
- Redirect to /login on 401 responses
- Make sync pull/push resilient to IDB unavailability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:41:45 +01:00
github-actions[bot] 762057c55a chore: pin docker-compose to v0.7.1 [skip ci] 2026-03-08 23:37:55 +00:00
github-actions[bot] ad3a1daa40 chore: bump version to 0.7.1 2026-03-08 23:33:55 +00:00
Frederic CabassutandClaude Opus 4.6 88e2714ec8 fix: add missing thumbnail_path migration and upload error feedback
DBs created before the image upload feature had no thumbnail_path column
in the attachments table. Since CREATE TABLE IF NOT EXISTS is a no-op for
existing tables, the column was never added, causing silent insert failures.

Add idempotent ALTER TABLE migration for thumbnail_path and surface upload
errors via toast notifications instead of swallowing them silently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:33:32 +01:00
github-actions[bot] 188e36013b chore: pin docker-compose to v0.7.0 [skip ci] 2026-03-08 21:42:42 +00:00
github-actions[bot] 5e4df2d00f chore: bump version to 0.7.0 2026-03-08 21:38:51 +00:00
Frederic CabassutandClaude Opus 4.6 a5312b60f5 feat: user-selectable featured images for note card headers
Images are hidden from card thumbnails by default. Users explicitly
feature images via a star toggle in the upload panel, and only featured
images appear in the card header strip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 e1f7a4c455 feat: add image lightbox for full-size preview on click
Clicking a thumbnail in the editor, closed-panel view, or note card
opens a full-screen overlay with the full-size image. Closes on Escape,
background click, or X button. Escape is scoped so it won't close the
editor underneath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 f61a567cea feat: auto-save note when clicking image toggle on new note
Instead of disabling the image button for unsaved notes, clicking it
now auto-saves the note first (with title "Untitled" if empty), then
opens the upload panel. This removes friction from the upload flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 0fec60a56a fix: E2E test fixes — static test image, thumbnail_path in init SQL, hover for remove
- Use static test-image.png fixture instead of dynamic beforeAll creation
- Add thumbnail_path column to auto-create SQL in db/index.ts
- Hover on thumbnail before clicking remove button (group-hover:block CSS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 501a5d3920 feat: replace Checklists with Image Attachments on landing page
Swap Checklists feature card for Image Attachments with photo SVG icon.
Mention checklists in the Rich Editor card description instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 2d001383fa docs: update FEATURES, ARCHITECTURE, and OpenAPI spec for image attachments
- FEATURES.md: document client-side optimization, thumbnails, offline support, SW caching
- ARCHITECTURE.md: add attachment data flow section (client → optimize → IDB/server → SW cache)
- openapi.yaml: add &thumb=1 query param, thumbnail FormData field, thumbnailPath in Attachment schema, attachments array in Note schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 6e1cf941fc test: add E2E tests for image attachments
Covers: upload → thumbnail in editor, persistence across close/reopen,
thumbnail on NoteCard, image removal, disabled toggle for unsaved notes.
Uses programmatically generated 1x1 PNG for test uploads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 087d7a48e6 feat: add CacheFirst SW rule for attachment images
Attachment images are immutable (UUID-based), so CacheFirst is ideal.
Previously-viewed images remain available offline even after blob URLs
expire. Rule placed before general NetworkFirst API rule to match first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 c3aa81949c docs: add git conventions section to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 7328a4a3a9 feat: add offline attachment support with IDB pending store and sync
- Bump IDB version to 2, add pendingAttachments object store with noteId index
- Add CRUD helpers: addPendingAttachment, getPendingAttachments, removePendingAttachment
- ImageUpload stores blobs in IDB when offline, displays via blob URLs
- Show pending sync indicator (RefreshCw icon) on not-yet-uploaded images
- Add pushPendingAttachments to sync client, runs after note push
- Pending attachments auto-upload on reconnect via existing online event listener

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 cc6181aba1 feat: wire ImageUpload into NoteEditor and show thumbnails on NoteCard
- Import ImageUpload into NoteEditor with attachment state management
- Add $effect to fetch attachments for existing notes loaded from IDB
- Add upload/remove handlers and image toggle button in toolbar
- Show ImageUpload panel between content and formatting toolbar
- Add full-bleed thumbnail strip at top of NoteCard (up to 3 images)
- Use &thumb=1 URL for card previews with lazy loading
- Show +N badge when more than 3 attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 f7e7aaa97d feat: add image attachment backend — batch fetch, API responses, optimization, thumbnails
- Add fetchAttachmentsForNotes() batch helper mirroring fetchTagsForNotes pattern
- Include attachments in all note API responses (list, single, sync)
- Fix ImageUpload.svelte URL bug (path-based → query param)
- Add client-side image optimization (resize, compress, WebP conversion)
- Add thumbnailPath column to attachments schema with migration
- Update saveAttachment/deleteAttachment for thumbnail file handling
- Update attachment API: accept thumbnail in POST, serve via &thumb=1 in GET
- Add Cache-Control immutable header for served attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 175c821e8d chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 df3fff0438 feat: link Bretzel in footer to bretzel.app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:25:31 +01:00
github-actions[bot] bb3a3c3369 chore: pin docker-compose to v0.6.2 [skip ci] 2026-03-08 18:19:43 +00:00
github-actions[bot] 7499350c50 chore: bump version to 0.6.2 2026-03-08 18:16:27 +00:00
Frederic CabassutandClaude Opus 4.6 0a0eef12f3 ci: always upload Playwright test results as artifacts
Upload both playwright-report/ and test-results/ (screenshots, traces)
on every run, not just failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:11:25 +01:00
github-actions[bot] 84c5b42bbc chore: pin docker-compose to v0.6.1 [skip ci] 2026-03-08 18:09:02 +00:00
Frederic CabassutandClaude Opus 4.6 c487091dd7 fix: show checklist actions on mobile
Apply the same max-md:opacity-100 pattern from NoteCard to Checklist
drag handles and remove buttons, making them visible on touch devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:08:33 +01:00
github-actions[bot] 43c095073f chore: bump version to 0.6.1 2026-03-08 18:05:13 +00:00
Frederic CabassutandClaude Opus 4.6 f70529d1d0 fix: update checklist e2e tests for done-expanded default
Tests were clicking the done toggle expecting to expand it, but since
fed0fc1 changed doneExpanded to true by default, the click was
collapsing it instead — causing visibility assertions to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:15:25 +01:00
Frederic CabassutandClaude Opus 4.6 4a596cefec ci: run all jobs in parallel to reduce pipeline time
Remove `needs` dependencies from e2e-tests and docker-build jobs.
They don't depend on lint/unit outputs — failures are visible from
their own jobs. Saves ~22s off the critical path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:14:02 +01:00
Claude a8591825eb fix: show action buttons on mobile note cards
On touch devices, hover states don't work, so the pin/archive/delete
buttons were invisible. Now they're always visible on screens below the
md breakpoint and only hover-toggled on desktop.

https://claude.ai/code/session_013DdAk1yj6r3VF6Ze4ycidp
2026-03-08 07:52:32 +01:00
github-actions[bot] f9af20554f chore: bump version to 0.6.0 2026-03-07 22:41:38 +00:00
Frederic CabassutandClaude Opus 4.6 60b5ed9442 feat: add Umami analytics to landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:40:24 +01:00
Frederic CabassutandClaude Opus 4.6 cf099d3114 docs: remove dark mode references (not implemented)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:48:41 +01:00
Frederic CabassutandClaude Opus 4.6 f19caa1b9b fix: checklist UX polish — spacing, disable markdown toggle, default to raw mode
- Increase gap between checkbox and text input (gap-1 → gap-2)
- Disable markdown toggle button in checklist mode
- Default to raw markdown mode when opening checklist notes as text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:44:31 +01:00
Frederic CabassutandClaude Opus 4.6 fed0fc1450 fix: show delete button on row hover and expand done items by default
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:34:09 +01:00
Frederic CabassutandClaude Opus 4.6 c4d53f4661 feat: enable delete for completed checklist items
Add X button to done/completed checklist items, matching the
existing remove button on active items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:27:33 +01:00
Frederic CabassutandClaude Opus 4.6 df2481bf67 fix: note grid drag-and-drop improvements
- Use dragHandleZone with grip icon instead of whole-card dragging,
  so card interactions (checkboxes, pin, archive) still work
- Scope pinned/unpinned grids to separate dnd types to prevent
  cross-zone drag bugs
- Add h-full to flip wrapper and NoteCard for equal card heights

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:17:58 +01:00
Frederic CabassutandClaude Opus 4.6 da57418de9 fix: always separate completed checklist items from active list
Completed items now consistently appear below unchecked items in both
the editor and the note card preview. Replaces the flaky two-state
hide/show toggle with a simple expand/collapse for the done section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:56:51 +01:00
Frederic CabassutandClaude Opus 4.6 5ce8601a92 feat: note sorting with drag-and-drop custom order
- Add sort mode selector (Updated/Created/Custom) with localStorage persistence
- Sort notes client-side via derived stores based on selected mode
- Enable drag-to-reorder in Custom mode via svelte-dnd-action on NoteGrid
- Add POST /api/notes/reorder endpoint for bulk sortOrder updates
- E2E tests for sorting by updated/created and persistence across reload

Closes #12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:40:04 +01:00
Frederic CabassutandClaude Opus 4.6 e5688c99b9 feat: checklist improvements — drag reorder and hide completed
- Add drag-to-reorder via svelte-dnd-action with grip handles
- Add "hide completed" toggle with collapsible done section
- Each checklist item gets a unique ID for stable drag operations
- E2E test for hide/show completed items

Closes #11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:33:56 +01:00
Frederic CabassutandClaude Opus 4.6 948d855ed6 fix: exclude archived/trashed notes from tag list
The allTags derived store now filters out archived and trashed notes
before collecting tags, so the sidebar tag filter only shows tags that
yield visible results.

Closes #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:08:16 +01:00
Frederic CabassutandClaude Opus 4.6 b20b6c383c fix: close mobile sidebar on navigation
Sidebar filter and tag selection handlers now call onClose() on mobile
viewports (< 1024px), so the overlay dismisses after tapping a link.

Closes #10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:08:16 +01:00
github-actions[bot] b5314a1610 chore: pin docker-compose to v0.5.0 [skip ci] 2026-03-07 19:44:03 +00:00
github-actions[bot] b4e4408de2 chore: bump version to 0.5.0 2026-03-07 19:40:05 +00:00
Frederic CabassutandClaude Opus 4.6 edfb371b4c fix: make release script sed portable for Linux CI
macOS sed requires -i '' while GNU sed requires -i without argument.
Detect OS with uname to use the correct syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:38:15 +01:00
github-actions[bot] b96312c2aa chore: pin docker-compose to v0.4.3 [skip ci] 2026-03-07 19:34:45 +00:00
Frederic CabassutandClaude Opus 4.6 9d1f66dcf7 feat: add app screenshots, lightbox, and polish landing page
- 6 screenshots (3 desktop + 3 mobile) with real app data
- Click-to-zoom lightbox on all screenshots
- Align header logo color (gold) between website and app
- Remove placeholder images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:32:04 +01:00
Frederic CabassutandClaude Opus 4.6 39510e62cb feat: move app version to sidebar, auto-update website version on release
- Move version badge from footer to bottom of sidebar (right-aligned)
- Update release script to sed-replace version in website/index.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:32:04 +01:00
github-actions[bot] 4fda73875a chore: bump version to 0.4.3 2026-03-07 19:30:50 +00:00
github-actions[bot] 7a34e02f9d chore: pin docker-compose to v0.4.2 [skip ci] 2026-03-07 19:12:29 +00:00
github-actions[bot] 421c3e483b chore: bump version to 0.4.2 2026-03-07 19:08:57 +00:00
github-actions[bot] 7c7413b1e6 chore: pin docker-compose to v0.4.1 [skip ci] 2026-03-07 19:06:10 +00:00
github-actions[bot] 95ebb63cd3 chore: bump version to 0.4.1 2026-03-07 19:02:42 +00:00
github-actions[bot] 425ee4213b chore: pin docker-compose to v0.4.0 [skip ci] 2026-03-07 18:52:47 +00:00
github-actions[bot] f35e307378 chore: bump version to 0.4.0 2026-03-07 18:48:50 +00:00
Frederic CabassutandClaude Opus 4.6 56372bc855 chore: update favicon assets from RealFaviconGenerator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:48:13 +01:00
Frederic CabassutandClaude Opus 4.6 bf6462b59b feat: add marketing landing page for crumbs.bretzel.app
Static landing page with parchment/retro theme matching the app aesthetic.
Includes GitHub Pages deploy workflow, responsive layout, feature grid,
deploy quick-start, and 404 page. Screenshot placeholders ready for
real captures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:48:13 +01:00
Frederic CabassutandClaude Opus 4.6 5dc1eda514 fix: patch transitive dependency vulnerabilities via pnpm overrides
Override serialize-javascript (>=7.0.3), esbuild (<0.25.0 → 0.25.12),
and cookie (>=0.7.0) to resolve Dependabot alerts #1, #2, #3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:18:52 +01:00
Frederic CabassutandClaude Opus 4.6 bc527c25cc docs: add open source community files and README badges
Add LICENSE, CONTRIBUTING, CHANGELOG, SECURITY, CODE_OF_CONDUCT,
GitHub issue/PR templates, CODEOWNERS. Update package.json with
license/repo/author metadata. Add badges and logo to README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:07:56 +01:00
github-actions[bot] 4641bad551 chore: pin docker-compose to v0.3.3 [skip ci] 2026-03-07 07:09:56 +00:00
github-actions[bot] cc590b756c chore: bump version to 0.3.3 2026-03-07 07:04:44 +00:00
Frederic CabassutandClaude Opus 4.6 8f40626f2f fix: update docker-compose only after Docker image is pushed
Move docker-compose.yml update from release.sh (pre-build) to a
post-release job in both bump.yml and release.yml. This ensures the
image tag in docker-compose only appears on main after the image
exists on ghcr.io, preventing Dokploy from trying to pull a
non-existent image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:04:18 +01:00
github-actions[bot] c83d0f6968 chore: bump version to 0.3.2 2026-03-07 06:56:57 +00:00
Frederic CabassutandClaude Opus 4.6 b04d04f731 fix: include tags in sync pull response and update API spec
- GET /api/sync now attaches tags to notes (matching GET /api/notes
  and GET /api/search behavior)
- Add trashedAt field to Note schema in OpenAPI spec
- Add optional id field to NoteInput schema
- Add 400 error response for POST /api/sync
- Add response schema for GET attachments list
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:52:36 +01:00
Frederic CabassutandClaude Opus 4.6 1bacd69443 docs: update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md
- FEATURES.md: add rich text editor section (Tiptap, formatting toolbar,
  raw markdown mode) and expand text formatting list
- DEPLOYMENT.md: fix docker-compose example to use image from ghcr.io
  instead of local build
- CLAUDE.md: add documentation maintenance instructions, update CI/CD
  section to reflect consolidated bump.yml workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:50:10 +01:00
github-actions[bot] 046ebf4539 chore: bump version to 0.3.1 2026-03-07 06:45:06 +00:00
Frederic CabassutandClaude Opus 4.6 85ff7cbbf5 fix: consolidate release pipeline into bump workflow
- Update release.sh to also bump docker-compose.yml image tag in the
  same commit, keeping the tagged commit in sync
- Add CI and Docker release jobs to bump.yml so it's self-contained
  (GITHUB_TOKEN pushes don't trigger other workflows)
- Remove "Pin docker-compose" post-step from release.yml since it's
  now handled by release.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:44:15 +01:00
github-actions[bot] d84e3fa97e chore: bump version to 0.3.0 2026-03-07 06:08:01 +00:00
Frederic CabassutandClaude Opus 4.6 5cba95bcac feat: replace pretzel emoji with favicon across the app
Use the new favicon SVG in the header, login, setup, empty state,
and error pages. Keep the pretzel emoji in the footer tagline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
Frederic CabassutandClaude Opus 4.6 7457847238 feat: add proper favicon set from RealFaviconGenerator
Replace inline emoji favicon with full cross-platform favicon support
including SVG, PNG, ICO, Apple touch icon, and PWA manifest icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
Frederic CabassutandClaude Opus 4.6 992fd7c1de rename "Horizontal rule" to "Divider" in formatting toolbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
github-actions[bot] de7288c268 chore: bump version to 0.2.3 2026-03-07 05:32:22 +00:00
Frederic CabassutandClaude Opus 4.6 e5f49448b3 fix: update docker-compose image tag after CI builds the image
Move docker-compose version pinning from release script to the release
workflow so it runs after the image is pushed to GHCR. Dokploy will
always find the referenced image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:48:28 +01:00
Frederic Cabassut 805951e9d5 chore: bump version to 0.2.2 2026-03-06 23:46:32 +01:00
Frederic CabassutandClaude Opus 4.6 85caa25636 chore: pin docker-compose image to version tag on release
Release script now updates docker-compose.yml with the exact version
tag so Dokploy always pulls the correct image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:45:47 +01:00
Frederic Cabassut c2a3d20f0d chore: bump version to 0.2.1 2026-03-06 23:38:13 +01:00
Frederic CabassutandClaude Opus 4.6 db769f0ea5 chore: switch to GHCR image in docker-compose, remove dokploy webhook
- docker-compose.yml now uses pre-built ghcr.io image instead of build
- ORIGIN is configurable via env var for deployment
- Remove deploy job from release workflow (Dokploy pulls compose from repo)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:35:08 +01:00
Frederic CabassutandClaude Opus 4.6 f0361732fe fix: make E2E tests resilient to shared DB in parallel execution
- Add noteCard() helper to target specific cards by title instead of
  generic getByTestId('note-card') which fails with multiple matches
- Scope action button clicks (trash, pin, archive) within their card
- Scope tag-chip filter clicks to the tag-filter container
- Wait for networkidle after auth to ensure full hydration
- Remove "empty notes list" test incompatible with shared DB
- Use toHaveCount(0) instead of not.toBeVisible for search empty state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:32:42 +01:00
Frederic Cabassut ae94952163 chore: bump version to 0.2.0 2026-03-06 23:13:34 +01:00
Frederic CabassutandClaude Opus 4.6 3843726424 refactor: isolate auth layout, unify tags, add editor animation and pin indicator
- Create (auth) and (app) layout groups to isolate login/setup pages
  from the main app chrome (header, sidebar, footer)
- Use window.location.href instead of goto() after login/setup to
  ensure clean full-page load across layout groups
- Add reusable TagChip component, used in TagFilter and NoteCard
- Add fade-in/pop-in entrance animation to note editor overlay
- Add persistent clickable pin indicator on pinned cards (top-right
  bookmark icon, replaces hover-only pin button for pinned notes)
- Improve "Add a crumb..." button with + icon and dashed border
- Update CLAUDE.md with retro parchment design system guidelines
- Add /.claude/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 e463c233c6 style: unify color system and add retro card shadows
Replace hardcoded colors (gray-500, #b44, red-300/red-50/red-600,
green-600) with CSS variables for theme consistency. Add --destructive,
--error-*, --success-*, and --card-shadow variables to :root. Soften
default card borders from --border to --border-subtle and add 8-bit
style hard-offset shadows with hover effects. Apply accent-color for
themed checkboxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 d6f668b31d fix: prevent "No crumbs yet" flash on page refresh
Add notesLoaded store flag set after IDB read completes. Gate the empty
state message behind it so it only shows once we know the store is empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 d9c9663af2 style: update toggle icons to show destination mode
Checklist toggle: SquareCheck (→ checklist) / Type (→ text).
Markdown toggle: FileCode (→ markdown) / FileText (→ rich text).
Icons now show what you'll switch to, not the current state.
Remove active color highlight since icon swap communicates state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 fa806c5799 fix: color palette opens upward on mobile to avoid overflow
Change color picker popup from top-full to bottom-full so it appears
above the toolbar instead of overflowing below the editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 948eb0d0fc fix: close sidebar when tapping outside on mobile
Add a semi-transparent backdrop overlay (lg:hidden) behind the sidebar
on mobile. Clicking/tapping the backdrop closes the sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 00f0c82b09 fix: toolbar dropdowns open upward and no longer overflow on mobile
Remove scroll from toolbar, move undo/redo into More dropdown to fit
single line. All dropdowns now open upward (bottom-full) so they don't
overflow below the editor on small screens. Use Ellipsis icon for More.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 c19963135f fix: prevent note content overflow on mobile
Wrap editor content in a scrollable container with max-h-[60vh] so long
notes scroll within the editor instead of pushing it off-screen. Also make
the overlay itself scrollable with bottom padding for small viewports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 eaa0a832e3 style: close sidebar by default on mobile
Use window.matchMedia to check viewport width on mount. Sidebar defaults
to closed, then opens automatically on desktop (>= 1024px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 3edde569f0 chore: add todo.md to gitignore and remove from tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 2df5353d0b style: collapse formatting toolbar into single line with More dropdown on mobile
Reorganize toolbar: inline formatting (bold/italic/strike/underline) as
primary buttons, move blockquote/code/alignment/HR into a "More" dropdown.
Use overflow-x-auto instead of flex-wrap for single-line scrollable toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 9c10dd2679 style: swap icons based on toggle state for markdown and checklist modes
Markdown toggle: Eye (rich text) / Code (raw MD, highlighted).
Checklist toggle: List (text mode) / ListChecks (checklist, highlighted).
Icons now change on toggle so the active state is visually clear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 da483ecaec fix: prevent color picker overflow on mobile
Constrain color picker popup width to viewport minus padding with a
max-width, preventing horizontal overflow on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 0c403eba87 style: add subtle outline to unselected color swatches
Use --border-subtle for unselected swatches instead of transparent,
making each color option visually distinct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 0a245672e1 style: collapse search bar into icon on mobile
On mobile, show a search icon that expands into a full-width search bar
with a back arrow to close. Desktop keeps the always-visible search bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 4b247b2627 style: replace all inline SVGs with Lucide icons
Swap every inline SVG across Header, SearchBar, NoteCard, NoteEditor,
Checklist, and ImageUpload for Lucide components. Use Palette for the
color picker icon instead of the paintbrush. Zero inline SVGs remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 84cc47aabb style: use theme colors for sync status icon
Replace green/blue/yellow/red with palette colors: --primary for synced,
--primary with opacity for syncing, --text-muted for offline, muted red
for error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 c49e127d0f feat: offline-first note loading from IDB cache
Load notes from IndexedDB first for instant display, then sync from
server in background. All mutations now persist to IDB when online,
keeping the cache always current. Eliminates empty state flash on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 beec78fb47 feat: deep link to open notes via URL hash
Opening a note sets the URL to /#note-id, closing clears it.
On page load, the hash is read and the corresponding note is opened.
Uses replaceState to avoid polluting browser history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 a71c8c927a fix: allow sidebar toggle on desktop via menu button
Remove lg:translate-x-0 override that forced sidebar open on desktop.
Main content and footer margin now conditional on sidebar state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 3d69a8ad74 style: remove redundant pin indicator from note card
The hover action button already provides pin/unpin functionality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 85c2bf69e9 feat: allow checking/unchecking checklist items directly from the note card
Checkboxes in the card preview are now interactive — clicking one toggles
the item without opening the editor. Uses stopPropagation to prevent
the card click handler from firing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 6d3ab0ba04 fix: extract tags client-side for offline notes and server-side on sync
- Import extractTags in notes store to populate tags on optimistic notes
- Re-extract tags on offline update when title or content changes
- Run extractTags + syncNoteTags in processSyncPush after transaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 99602d6a24 style: remove bretzel emoji from header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 8f0c18d5fa fix: handle offline note editing with sync queue, toasts, and live sync indicator
- Extract toast store to standalone module so notes store can show toasts
- Add try/catch to all note mutations with optimistic updates + IDB queue on network error
- Wire up startSync/stopSync in layout to enable online/offline event listeners
- Reload notes from server after successful sync push to prevent data loss
- Connect SyncIndicator to real sync status with Lucide cloud icons
- Add 300ms minimum display time for status transitions
- Add e2e tests for offline create, edit, trash, and sync-on-reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Fred Cabassut 4a3219d6c6 Merge pull request #1 from bretzel-app/add-claude-github-actions-1772829898393
Add Claude Code GitHub Workflow
2026-03-06 21:45:29 +01:00
Fred Cabassut 5638cd5204 "Claude Code Review workflow" 2026-03-06 21:45:01 +01:00
Fred Cabassut ff16e05624 "Claude PR Assistant workflow" 2026-03-06 21:45:00 +01:00
Frederic CabassutandClaude Opus 4.6 c80a6dc088 ci: add automated version bump, tag, and release script
Add scripts/release.sh that infers bump type from conventional commits
(major/minor/patch), bumps package.json, commits, tags, and pushes.
Includes safety checks for branch, clean tree, and remote sync.

Also adds a workflow_dispatch GitHub Actions workflow (bump.yml) and
Makefile targets (release, release-patch, release-minor, release-major).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:40:48 +01:00
Frederic CabassutandClaude Opus 4.6 72b9097498 chore: bump version to 0.1.8
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:21:04 +01:00
Frederic CabassutandClaude Opus 4.6 2ab8c063af fix: resolve N+1 queries, add indexes, and improve DB operations
- Batch-fetch tags for notes in a single query instead of per-note loops
- Add composite indexes on notes, note_tags, and sync_log tables
- Wrap sync push in a transaction for atomicity
- Replace O(n) cleanExpiredSessions with single DELETE query
- Use SQL WHERE in getChangesSince instead of loading all notes
- Extract shared syncNoteTags utility to eliminate duplication
- Add root +error.svelte boundary matching app theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:12:39 +01:00
Frederic CabassutandClaude Opus 4.6 3ff08f9e35 chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle
Install project-level skills via skills.sh CLI:
- svelte5-best-practices (runes, snippets, events, migration)
- sveltekit-structure (routing, layouts, error boundaries)
- sveltekit-data-flow (load functions, form actions, serialization)
- typescript-best-practices (type-first development, discriminated unions)
- drizzle-migrations (SQLite schema, query patterns)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:11:36 +01:00
Frederic CabassutandClaude Opus 4.6 6fe2978657 style: redesign UI with parchment theme and JetBrains Mono font
Replace the default dark/white theme with a warm parchment aesthetic
using CSS custom properties, retro pixel overlay, and JetBrains Mono
monospace font across all components. Bump version to 0.1.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:51:10 +01:00
Frederic CabassutandClaude Opus 4.6 9222b46efb fix: pass app version via server load instead of Vite define
The Svelte 5 SSR compiler was optimizing away the __APP_VERSION__
constant set via Vite's define, causing the version to be missing
from the production footer. Use a +layout.server.ts load function
to pass the version from package.json instead.

Also bumps version to 0.1.6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:18:59 +01:00
Frederic CabassutandClaude Opus 4.6 6fada9222a chore: bump version to 0.1.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:55:03 +01:00
Frederic CabassutandClaude Opus 4.6 7c4d83cafd fix: run Dokploy redeploy after Docker image push
Move the Dokploy webhook into a separate deploy job that depends on
the release job, ensuring the image is fully pushed before redeploying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:36:56 +01:00
Frederic CabassutandClaude Opus 4.6 4d897c96aa fix: resolve __APP_VERSION__ type error in svelte-check
Move the global type declaration inside declare global block and
reference the constant via a local variable in the script block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:14:59 +01:00
Frederic CabassutandClaude Opus 4.6 08d6dbc13d feat: display app version in footer
Inject version from package.json at build time via Vite's define and
show it in the layout footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:05:25 +01:00
Frederic CabassutandClaude Opus 4.6 10371f6c94 feat: enable PWA offline support with service worker
Add SvelteKitPWA plugin to generate a service worker that precaches
static assets and uses NetworkFirst for API routes, enabling the app
to load offline. Generate pretzel emoji PWA icons and remove the old
static manifest (now generated by the plugin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:03:34 +01:00
Frederic CabassutandClaude Opus 4.6 ab13461b8f feat: trigger Dokploy redeploy after release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:55:45 +01:00
Frederic CabassutandClaude Opus 4.6 a95388fe3c fix: add .dockerignore to prevent host node_modules in image
Excludes node_modules, build artifacts, and test data from the Docker
build context. Fixes invalid ELF header error when building on macOS
for Linux containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:29:10 +01:00
Frederic CabassutandClaude Opus 4.6 05dd13c08c fix: add workflow_call trigger to CI for reuse by release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 c81d5c8e6a chore: bump version to 0.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:59 +01:00
Frederic CabassutandClaude Opus 4.6 475c457435 fix: type error on tiptap-markdown storage access
Cast editor storage to Record<string, any> since tiptap-markdown
does not ship type declarations for its storage extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:25 +01:00
Frederic CabassutandClaude Opus 4.6 91f241ecc7 feat: push Docker image to GitHub Container Registry on release
Replace generic registry secrets with ghcr.io using the built-in
GITHUB_TOKEN. Use docker/metadata-action for automatic semver tagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:19:12 +01:00
Frederic CabassutandClaude Opus 4.6 acb43bfbf8 feat: rename "notes" to "crumbs" throughout the UI
Replace all user-facing "note(s)" with "crumb(s)" — sidebar, search
placeholder, editor placeholders, empty states. Use Lucide icons
(StickyNote, Archive, Trash2, Tag) in sidebar for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:17:30 +01:00
Frederic CabassutandClaude Opus 4.6 d2bdcb695f feat: add sticky footer with branding
Add "Crumbs by Bretzel — made with 🥨 in Strasbourg" footer pinned
to the bottom of the page using flexbox layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:08:51 +01:00
Frederic CabassutandClaude Opus 4.6 33a8cd6afc feat: remove dark mode support
Remove ThemeToggle component, theme store, dark: Tailwind variants,
and dark color values from NOTE_COLORS. Simplify color logic to use
a single bg property. Delete dark mode e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:07:18 +01:00
Frederic CabassutandClaude Opus 4.6 510d15e548 fix: undo/redo buttons now reactively update on editor changes
Add a tick counter that increments on every TipTap transaction,
propagated from TiptapEditor through NoteEditor to FormattingToolbar.
This forces Svelte 5 to re-evaluate canUndo/canRedo state in the
template since plain object method calls aren't tracked reactively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:00:58 +01:00
Frederic CabassutandClaude Opus 4.6 3db759ab82 feat: icon-based formatting toolbar with dropdowns and link popover
Replace plain text toolbar labels with Lucide SVG icons. Group heading,
list, and alignment options into dropdown menus to reduce toolbar width.
Replace window.prompt link insertion with an inline popover (input, apply,
open, remove). Add underline and text-align TipTap extensions. Remove
task list from TipTap editor (broken styling). Fix task list bullet
rendering in note card preview. Increase editor min-height for markdown
mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:50:52 +01:00
Frederic CabassutandClaude Opus 4.6 2913e19f7c feat: replace textarea with TipTap rich text editor
- Add TipTap WYSIWYG editor (headless @tiptap/core) with StarterKit,
  TaskList, TaskItem, Link, Placeholder, and tiptap-markdown extensions
- Always-visible formatting toolbar with undo/redo, H1-H3, bold, italic,
  strikethrough, inline code, code block, blockquote, bullet/ordered/task
  lists, horizontal rule, and link
- Raw markdown toggle for power users
- Render rich text (HTML) on note cards instead of stripped plain text
- Update all e2e tests for TipTap contenteditable editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:24:33 +01:00
Frederic CabassutandClaude Opus 4.6 30475172c3 fix: render checklist items with checkboxes on note cards
Checklist-mode notes now display as a visual checklist preview (up to
8 items with overflow indicator) instead of raw markdown checkbox syntax.
Regular notes keep the truncated plain-text preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:53 +01:00
Frederic CabassutandClaude Opus 4.6 a550514a39 fix: flaky search e2e test due to duplicate title across test suites
Use a unique title "Grocery Errands" instead of "Shopping List" which
collided with the checklist test suite in the shared test database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:26:54 +01:00
Frederic CabassutandClaude Opus 4.6 b55b6de571 docs: deduplicate README, add OpenAPI spec with auto-generated API docs
- Slim README to essentials, link to docs/ for details
- Add docs/openapi.yaml as single source of truth for the API
- Add scripts/generate-api-docs.ts to produce docs/API.md from the spec
- Add pnpm docs:api / make docs-api target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:24:29 +01:00
Claude 1becc17724 feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:16:45 +01:00
109 changed files with 3089 additions and 15317 deletions
+19 -76
View File
@@ -48,83 +48,17 @@ jobs:
needs: bump
uses: ./.github/workflows/ci.yml
build:
name: Build Docker (${{ matrix.platform }})
needs: [bump, ci]
permissions:
packages: write
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [bump, build]
needs: [bump, ci]
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
@@ -136,6 +70,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -146,11 +83,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -161,7 +104,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, merge]
needs: [bump, release]
permissions:
contents: write
steps:
+9 -29
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, 'claude/**']
pull_request:
branches: [main]
workflow_call:
@@ -19,6 +19,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -35,6 +37,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -59,11 +63,13 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
image: mcr.microsoft.com/playwright:v1.58.0-noble
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -115,31 +121,5 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
provenance: false
tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
# Regression for #93: a host folder bind-mounted at /data is owned by the
# host user (the runner, uid 1001), not the container's node user (1000)
- name: Start with a host-owned bind mount
run: |
mkdir -p "$RUNNER_TEMP/crumbs-data"
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
for i in $(seq 1 30); do
if curl -fsS -o /dev/null http://localhost:3000/login; then
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
fi
sleep 1
done
docker logs crumbs
exit 1
run: docker build -t crumbs:ci .
+18 -73
View File
@@ -13,77 +13,13 @@ jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
name: Build Docker (${{ matrix.platform }})
needs: ci
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: build
needs: ci
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -94,6 +30,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -104,11 +43,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -118,7 +63,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: merge
needs: release
permissions:
contents: write
steps:
+2
View File
@@ -14,6 +14,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
+3 -39
View File
@@ -37,43 +37,9 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
| `--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:
- **`--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
@@ -87,7 +53,7 @@ together.
- **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.
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **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.
@@ -173,15 +139,14 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
- **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 (`EnterWorktree` or `git worktree add .worktrees/<name> -b <branch>`) on a fresh branch from `origin/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>` (or `fix/<name>`) branches, one per worktree
- **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):
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
1. **Lint & Type Check** — `pnpm check`
2. **Unit Tests** — `pnpm test:unit`
3. **Build** — `pnpm build` (depends on steps 1+2)
@@ -221,7 +186,6 @@ Write tests first or alongside features. Tests serve as living documentation of
- 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)
- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project 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 with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.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.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
+3 -5
View File
@@ -2,7 +2,7 @@
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9 --activate
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
@@ -37,10 +37,8 @@ COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Runs as root only long enough to hand the data folder (often a host bind
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
ENTRYPOINT ["crumbs-entrypoint.sh"]
# Use non-root user
USER node
# Configuration
ENV NODE_ENV=production
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
# Starts as root so a bind-mounted data folder owned by the host user can be
# handed to the unprivileged `node` user, then drops privileges before running
# the app. Without this, a host folder mounted at /data is not writable by
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
set -e
if [ "$(id -u)" = "0" ]; then
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
mkdir -p "$dir"
# Only touch entries not already owned by node, so restarts on a large
# attachment folder stay fast.
find "$dir" \! -user node -exec chown node:node {} + ||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
done
exec setpriv --reuid=node --regid=node --init-groups "$@"
fi
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
exec "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.35.2
image: ghcr.io/bretzel-app/crumbs:0.23.0
container_name: crumbs
ports:
- "3000:3000"
+2 -23
View File
@@ -82,9 +82,6 @@ For a single-user app with multiple devices:
- Built-in dark mode support (`dark:` variants)
- New v4 uses Vite plugin (faster builds)
### lowlight (highlight.js)
Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/highlight.ts`) that feeds both the TipTap editor (`@tiptap/extension-code-block-lowlight`, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six `--hl-*` variables in `src/app.css`; a unit test asserts AA contrast against `--code-bg` in both themes.
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
@@ -94,7 +91,7 @@ Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/high
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - User accounts (password hash, `password_login_enabled`, OAuth provider link, role)
- `users` - User accounts (password hash, OAuth provider link, role)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
@@ -106,21 +103,6 @@ Key tables:
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
- `sync_log` - Sync operation history
### Nested Checklist Serialization
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
```
- [ ] Parent item
- [ ] Child item
- [x] Checked child item
- [x] Another parent
```
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
### Collaboration Access Control
Notes support sharing with other users on the same instance:
@@ -162,10 +144,7 @@ Theme switching uses CSS variable overrides on the `<html>` element:
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
draws from a separate vivid `NOTE_CHIPS_DARK` map.
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
## File Organization
+3 -22
View File
@@ -10,24 +10,7 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
### Admin password resets
An admin can reset the password of any user other than themselves. Crumbs generates the new password and shows it once in that user's row — copy it and pass it to the user out of band, because it is not stored anywhere and cannot be shown again once dismissed. If SMTP is configured Crumbs also tries to notify the user that their password was reset, but the attempt is best-effort: a delivery failure is logged on the server and never surfaced in the UI, so a successful reset is not evidence the user was told. The notification never contains the password.
The control appears on every row, but is unavailable in two cases:
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
- **While a generated password is still on screen** — dismiss it first if you need to generate another.
A reset enables password login for that account if it was not already enabled — including OAuth-only accounts — without removing their SSO link. Both sign-in methods can coexist.
A reset **revokes all of the user's sessions** — they must sign in again with the new password (or via OAuth if linked). **Disable password login** is a separate action on rows where password login is enabled; it clears the stored hash, turns off password login, and revokes all sessions. It is not offered on your own row.
A displayed password is only good until that account's password changes again: any later reset — by another admin, or by the user themselves from **Settings > Profile** — silently invalidates one still on screen, and nothing marks it as stale. Separately, if Crumbs cannot confirm a reset it says so and shows the password anyway; keep that one, since it may or may not have been applied, and reset again if the user cannot sign in.
> **No password recovery.** Crumbs has no forgot-password flow. An admin who forgets their own password cannot reset it themselves: the admin control excludes their own row, and the profile flow requires the current password. Another admin can reset you, so keep a second admin account as cheap insurance. Failing that, if an OAuth/SSO provider is configured and your account's email matches your identity there, signing in with that provider gets you back in without a password. An admin can also enable password login on an OAuth-linked account via **Settings > Users**. With no other admin and no provider configured, there is no way back in through the app.
- Admins can reset passwords and revoke sessions for any user via Settings
## OAuth / SSO
@@ -46,14 +29,12 @@ OAuth providers are **auto-enabled** when their environment variables are set
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users** and fill in the **Create User** form
1. Go to **Settings > Users > Invite User**
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in. An admin can later enable password login via **Settings > Users** if the IdP becomes unavailable. If you set a password at invite time, it keeps working after the user's first OAuth sign-in — both methods coexist independently.
### Environment variables
#### Google OAuth
@@ -149,7 +130,7 @@ services:
**4. Pre-create users:**
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
#### Keycloak
-4
View File
@@ -26,10 +26,6 @@ services:
restart: unless-stopped
```
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
`user:` in compose, that step is skipped and the folder must already be writable by that user.
### Reverse Proxy (nginx)
```nginx
+4 -13
View File
@@ -23,7 +23,7 @@
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick) with syntax highlighting for 30+ languages — set the language in the fence (```ts) or from the toolbar picker while the cursor is inside the block; unlabelled blocks stay plain rather than guessing
- Code blocks (triple backtick)
- Inline code (single backtick)
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
@@ -37,11 +37,6 @@
- Check/uncheck items with checkboxes
- Checked items get strikethrough styling
- Enter key adds new item, Backspace removes empty items
- **Nested checklists**: single-level nesting for sub-tasks
- Tab / Shift+Tab to indent or outdent an item (keyboard)
- Drag handle horizontal swipe for mobile indentation
- Checking a parent item checks all its children
- Done section groups checked children under their parent label
### Image Attachments
- Upload images via file picker or drag-and-drop
@@ -71,7 +66,7 @@
### Dark Mode
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — retro aesthetic preserved in both themes
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
- Respects system `prefers-color-scheme` preference by default
- FOUC-free: theme applied before first paint via a blocking inline script
@@ -142,12 +137,8 @@
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
- Works for any account other than your own, including OAuth-only — the reset deliberately enables password login and revokes all sessions
- **Disable password login** turns off password auth, clears the hash, and revokes sessions (not offered on your own row)
- Your own password is changed from Settings > Profile, not here
- Use **Revoke all sessions** separately if you need to force a logout without changing credentials
- Revoke all sessions (force logout) for any user other than yourself
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
### PWA / Offline-First
- Installable as standalone app
@@ -1,557 +0,0 @@
# Dark Theme Palette Rework 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:** Replace the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
**Architecture:** Purely a colour-data and CSS-variable change. `NOTE_COLORS_DARK` in `src/lib/utils/colors.ts` gets new values; a new `NOTE_CHIPS_DARK` map plus `getNoteChip()` serves the picker only. The `[data-theme="dark"]` block in `src/app.css` is rewritten. Consumers (`NoteCard`, `NoteEditor`, the public share page) already resolve colour through `getNoteColor()` and need no changes — only `ColorPicker.svelte` switches functions.
**Tech Stack:** SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
**Spec:** `docs/superpowers/specs/2026-07-31-dark-palette-design.md`
## Global Constraints
- Light mode must not change. Any diff touching `NOTE_COLORS` (the light map) or `:root` colour values other than the new `--accent-check` is out of scope.
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
- Run `pnpm check` before every commit — not just tests. (Project rule.)
- Run `pnpm build` before `pnpm test:e2e` locally, or e2e tests run against a stale bundle.
- Conventional commit prefixes: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
- Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
---
### Task 1: New dark note-card colours
**Files:**
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
- Test: `src/lib/utils/colors.test.ts`
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being unchanged; Task 2's chip values must differ from every value here.
- [ ] **Step 1: Write the failing invariant tests**
Add to `src/lib/utils/colors.test.ts`. These assert the *properties* that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
```ts
/** WCAG relative luminance. */
function relativeLuminance(hex: string): number {
const n = parseInt(hex.slice(1), 16);
const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps muted text above the AA threshold on every dark card', () => {
// This is the invariant the old palette broke: muted/struck-through text
// became unreadable on the lighter surfaces.
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => {
const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg));
expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm test:unit colors`
Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA
tests are forward guards, not regressions of the old palette — the old palette actually clears
both AA thresholds. The distinguishability test is the one that would fail on the old palette
(its closest pair, `fog`/`storm`, sits at RGB distance 7.5).
- [ ] **Step 3: Replace the dark palette**
In `src/lib/utils/colors.ts`, replace the whole `NOTE_COLORS_DARK` map with these values exactly:
```ts
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
```
Note for the implementer: `blossom` and `storm` intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with `coral`/`peach` and `fog`. This is deliberate — see the spec.
- [ ] **Step 4: Update the two hex assertions that pin the old values**
In `src/lib/utils/colors.test.ts`, the existing `getNoteColor` tests assert old hexes. Change them:
```ts
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
```
- [ ] **Step 5: Run the unit tests to verify they pass**
Run: `pnpm test:unit colors`
Expected: PASS, all tests in the file.
- [ ] **Step 6: Update the e2e test that asserts a dark note colour**
`tests/e2e/public-sharing.spec.ts` — the "Shared note follows a dark-mode visitor theme" scenario asserts dark `fog`. `#253946` is `rgb(37, 57, 70)`:
```ts
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
```
In `tests/e2e/dark-mode.spec.ts:84`, the assertion itself is a negative one (`not.toBe` light default) and needs no change, but its comment names the old hex. Update it:
```ts
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
```
- [ ] **Step 7: Verify types, then the full e2e suites that touch colour**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e public-sharing dark-mode organization`
Expected: PASS. (`organization.spec.ts` exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
- [ ] **Step 8: Commit**
```bash
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
```
---
### Task 2: Decouple picker swatches from card surfaces
**Files:**
- Modify: `src/lib/utils/colors.ts` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
- Modify: `src/lib/components/ColorPicker.svelte:2,19`
- Test: `src/lib/utils/colors.test.ts`
- Test: `tests/e2e/dark-mode.spec.ts`
**Interfaces:**
- Consumes: `NOTE_COLORS_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(color: NoteColor, isDark: boolean): string`. No later task depends on these.
- [ ] **Step 1: Write the failing tests**
Add to `src/lib/utils/colors.test.ts`:
```ts
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const toRgb = (hex: string) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
});
```
Extend the import at the top of the file:
```ts
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm test:unit colors`
Expected: FAIL — `getNoteChip is not a function` / `NOTE_CHIPS_DARK` undefined.
- [ ] **Step 3: Add the chip map and accessor**
Append to `src/lib/utils/colors.ts`, after `getNoteColor`:
```ts
/**
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
* reads as a grey dot — small patches lose apparent saturation — so the dark
* picker uses vivid mid-lightness chips that share the card's hue but not its
* lightness. Light mode needs no equivalent: there the pastels are the cards.
*/
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c',
coral: '#d04f43',
peach: '#cd7c42',
sand: '#c7b138',
mint: '#7bbd4c',
sage: '#47b89d',
fog: '#609abe',
storm: '#547bc0',
dusk: '#a965bd',
blossom: '#c05d78',
clay: '#b5954a',
chalk: '#c3c3c8'
};
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false);
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `pnpm test:unit colors`
Expected: PASS.
- [ ] **Step 5: Point the picker at the chips**
In `src/lib/components/ColorPicker.svelte`, change the import on line 2 and the style on line 19:
```svelte
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
```
```svelte
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
```
Leave everything else alone — the 2px `--border-subtle` ring stays, and it's what gives each swatch its boundary regardless of fill.
- [ ] **Step 6: Add the e2e scenario**
Append inside the `test.describe.serial('Dark mode', ...)` block in `tests/e2e/dark-mode.spec.ts`:
```ts
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user opens the colour picker while editing a note
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Swatch Test');
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
// Then a swatch shows its vivid chip rather than the muted card surface
await expect(page.getByTestId('color-coral')).toHaveCSS(
'background-color',
'rgb(208, 79, 67)'
);
await page.getByTestId('close-editor-btn').click();
});
```
- [ ] **Step 7: Verify**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e dark-mode organization`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
```
---
### Task 3: New dark CSS variables and checkbox accent
**Files:**
- Modify: `src/app.css:6-24` (`:root` — add `--accent-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
**Interfaces:**
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
- Produces: `--accent-check` custom property, available in both themes.
- [ ] **Step 1: Add `--accent-check` to `:root`**
In the `:root` block in `src/app.css`, after `--grid-dot`:
```css
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
```
- [ ] **Step 2: Replace the dark block**
Replace the entire `[data-theme="dark"]` block with:
```css
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
}
```
Two of these reverse earlier decisions on purpose: `--primary` is no longer shared with light mode (`#C8860A` reads mustard on ink), and `--border` is a warm mid-grey rather than the mirrored cream `#e8dcc8` (a bright 1px line on a dark background reads as glare, not structure).
- [ ] **Step 3: Point `accent-color` at the new variable**
In the `html, body` rule:
```css
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
```
- [ ] **Step 4: Verify light mode is untouched and dark mode renders**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e dark-mode public-sharing`
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
Then look at it: `pnpm preview`, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
- [ ] **Step 5: Commit**
```bash
git add src/app.css
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
```
---
### Task 4: Sync the browser theme-colour with the new base
**Files:**
- Modify: `src/lib/utils/theme.svelte.ts:4`
- Modify: `src/app.html:21`
- Test: `src/lib/utils/theme.test.ts:40`
**Interfaces:**
- Consumes: `--bg-base` `#141312` from Task 3. These two hardcoded copies must equal it.
- Produces: nothing.
- [ ] **Step 1: Update the failing test first**
In `src/lib/utils/theme.test.ts`:
```ts
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#141312');
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `pnpm test:unit theme`
Expected: FAIL — received `#1a1715`.
- [ ] **Step 3: Update both hardcoded copies**
`src/lib/utils/theme.svelte.ts` line 4:
```ts
const DARK_THEME_COLOR = '#141312';
```
`src/app.html` line 21, inside the FOUC script:
```js
if (m) m.content = '#141312';
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test:unit theme`
Expected: PASS.
- [ ] **Step 5: Verify and commit**
Run: `pnpm check && pnpm test:unit`
Expected: 0 errors, all unit tests pass.
```bash
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
```
---
### Task 5: Document the palette
**Files:**
- Modify: `CLAUDE.md` (the "Design system: Retro Parchment" section)
- Modify: `docs/ARCHITECTURE.md:155-165` (the dark mode section)
**Interfaces:**
- Consumes: everything from Tasks 1-4.
- Produces: nothing.
- [ ] **Step 1: Document the dark palette in `CLAUDE.md`**
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about `src/lib/utils/colors.ts`, add:
```markdown
### 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 ~18% 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 against both `--text` and `--text-muted` on every surface, a narrow lightness band across
the twelve, and no two chips closer than an RGB distance of 25.
`--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.
```
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
In the dark mode section, the line describing note colours currently mentions only `getNoteColor()`. Replace it with:
```markdown
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
draws from a separate vivid `NOTE_CHIPS_DARK` map.
```
- [ ] **Step 3: Verify the docs match the code**
Re-read both edited sections against `src/lib/utils/colors.ts` and `src/app.css`. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md docs/ARCHITECTURE.md
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
```
---
## Final verification
- [ ] `pnpm check` — 0 errors
- [ ] `pnpm test:unit` — all pass
- [ ] `pnpm build && pnpm test:e2e` — full suite passes
- [ ] Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged
File diff suppressed because it is too large Load Diff
@@ -1,205 +0,0 @@
# Dark Theme Palette Rework — Design Spec
**Date:** 2026-07-31
**Status:** Approved
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
## Summary
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
with a systematically generated one, and splits the picker swatch from the card surface — they are
different jobs and should not share a value.
Light mode is unchanged.
## Problems being fixed
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
no consistent relationship, so they don't read as a family.
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
percent of `--bg-base`, so cards lose their edges.
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
twelve swatches become twelve grey dots.
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
with gold and cream on top, the UI reads dim rather than crisp.
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
inverted border reads as a glowing rectangle, not as structure.
## Decisions
| # | Decision | Choice |
|---|---|---|
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
Only dark mode needs the split.
## 1. CSS variables (`src/app.css`)
Replace the `[data-theme="dark"]` block:
```css
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #ece3d3;
--accent-check: #c8a44a;
}
```
Three of these are notable changes rather than tweaks:
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
hard offset shadow still carries the retro structure.
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
### Checkbox accent
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
the checkbox can be dimmed independently of links and the brand:
```css
:root { --accent-check: var(--primary); } /* light: unchanged */
html, body { accent-color: var(--accent-check); }
```
## 2. Note card colours (`src/lib/utils/colors.ts`)
Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink
and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge.
The visible improvement in dark mode comes from the base surfaces, the picker chips, the
softened border, and the lifted gold — not from the card surfaces, which are close in chroma to
the previous dark values.
```ts
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
```
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
different hue in each theme. This is an accepted trade: distinguishable beats identical.
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
All clear WCAG AA.
## 3. Picker chips (`src/lib/utils/colors.ts`)
New map, used only by the picker, only in dark mode:
```ts
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c', coral: '#d04f43', peach: '#cd7c42', sand: '#c7b138',
mint: '#7bbd4c', sage: '#47b89d', fog: '#609abe', storm: '#547bc0',
dusk: '#a965bd', blossom: '#c05d78', clay: '#b5954a', chalk: '#c3c3c8'
};
/** Picker swatch colour: a vivid label, not the card surface. */
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
```
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
apart at 28px.
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
the editor and the public share page keep calling `getNoteColor`.
## 4. Theme-colour meta tag
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
Both become `#141312`.
## Files to modify
| File | Change |
|---|---|
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
through `getNoteColor`, so they inherit the new palette.
## Testing
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
- unknown colour falls back to `default` in both maps
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
before, so it gets asserted rather than eyeballed
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
picker swatch is *not* the card background, which is the bug in user-visible terms.
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
can't judge those.
## Out of scope
- Light mode, including its picker
- The 12-colour set itself (no colours added, removed, or renamed)
- Per-note custom colours
- `prefers-contrast` / high-contrast variants
@@ -1,129 +0,0 @@
# Note Linking — Design Spec
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
**Date**: 2026-08-03
## Overview
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
## Architecture
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
```typescript
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
```
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
```
[Grocery List](crumb-note://<note-id>)
```
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
## Components
### Client
| Component | Change |
|---|---|
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
### Server
| Function | Location | Description |
|---|---|---|
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
## Data Flow
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
4. **Click** — opens the target note in the editor overlay.
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
## Error Handling
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
- **Self-links** — the currently-open note is filtered out of its own picker results.
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
## Testing
**Unit (Vitest)**
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
- `syncNoteLinks` — clears + re-inserts correctly
- `fetchBacklinksForNote` — returns only notes the requesting user can access
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
**E2E (Playwright)**
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
- Renaming the linked note updates the displayed link text next time it's viewed
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
- The linked-to note shows a "Referenced by" backlink to the note that links to it
- A public share page never renders a note-link as clickable, even if the target is also shared
- The note-picker excludes the currently-open note from its own search results
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
## Out of Scope (v1)
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
- Note graph / visual map view
- Ranking or sorting backlinks beyond simple list order
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
## Acceptance Criteria
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
- [ ] Clicking a note-link opens the target note in the editor overlay
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
- [ ] Public share page never renders a note-link as clickable
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
-9
View File
@@ -1,9 +0,0 @@
CREATE TABLE `note_links` (
`source_note_id` text NOT NULL,
`target_note_id` text NOT NULL,
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `users` ADD `password_login_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint
UPDATE `users` SET `password_login_enabled` = 1 WHERE `auth_provider` = 'password';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-14
View File
@@ -29,20 +29,6 @@
"when": 1774037379518,
"tag": "0003_uneven_tomorrow_man",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1785824271578,
"tag": "0004_violet_nicolaos",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1787064677945,
"tag": "0005_green_raider",
"breakpoints": true
}
]
}
+46 -53
View File
@@ -1,9 +1,8 @@
{
"name": "crumbs",
"private": true,
"version": "0.35.2",
"version": "0.23.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -34,66 +33,64 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@sveltejs/adapter-node": "^5.5.7",
"@tiptap/core": "^3.31.0",
"@tiptap/extension-code-block-lowlight": "^3.31.0",
"@tiptap/extension-link": "^3.31.0",
"@tiptap/extension-placeholder": "^3.31.0",
"@tiptap/extension-table": "^3.31.0",
"@tiptap/extension-table-cell": "^3.31.0",
"@tiptap/extension-table-header": "^3.31.0",
"@tiptap/extension-table-row": "^3.31.0",
"@tiptap/extension-task-item": "^3.31.0",
"@tiptap/extension-task-list": "^3.31.0",
"@tiptap/extension-text-align": "^3.31.0",
"@tiptap/extension-underline": "^3.31.0",
"@tiptap/pm": "^3.31.0",
"@tiptap/starter-kit": "^3.31.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.45.1",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.45.2",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.577.0",
"markdown-it": "^15.0.1",
"nanoid": "^5.1.16",
"node-diff3": "^3.2.1",
"nodemailer": "^9.1.0",
"sanitize-html": "^2.17.7",
"svelte-dnd-action": "^0.9.79",
"markdown-it": "^14.1.1",
"nanoid": "^5.1.7",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^14.0.2",
"zod": "^4.5.4",
"uuid": "^11.1.0",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@sveltejs/kit": "^2.70.3",
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1",
"@types/sanitize-html": "^2.16.1",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.7",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"nyc": "^18.0.0",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.13",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite": "^7.3.1",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^3.2.7",
"workbox-window": "^7.4.1",
"yaml": "^2.9.0"
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -104,12 +101,8 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0 <1",
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2",
"postcss>nanoid": ">=3.3.18 <4"
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
}
}
}
+3 -21
View File
@@ -1,5 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE_FILE } from './tests/e2e/global-setup';
export default defineConfig({
testDir: './tests/e2e',
@@ -23,31 +22,14 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] }
},
{
// Logs in once and saves the session cookie for the `app` project.
name: 'login-state',
testMatch: 'auth.setup.ts',
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: ['auth.spec.ts', 'auth.setup.ts'],
dependencies: ['login-state'],
// Every context starts with the admin already logged in. Specs that
// need an unauthenticated baseline opt out with
// `test.use({ storageState: { cookies: [], origins: [] } })`.
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE }
}
],
webServer: {
// The wipe runs here (before the server opens the database), not in
// globalSetup: Playwright starts the webServer before globalSetup, so
// deleting the SQLite files later would orphan the server's open
// database onto a deleted inode. Tests that read the database file
// directly (see linkUserToOAuth in admin-users.spec.ts) must see the
// server's real database file.
command:
'rm -f data/test-crumbs.db data/test-crumbs.db-wal data/test-crumbs.db-shm data/test-crumbs.db-journal && pnpm preview --port 4173',
command: 'pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+2184 -2411
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: crypto.randomUUID()
password: 'bretzel2026!'
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: crypto.randomUUID()
password: 'salzig2026!!'
};
+19 -216
View File
@@ -21,64 +21,29 @@
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
/* Tint source for hover/active washes. Light mode reuses the ink border, so
light styling is byte-identical; dark mode keeps the cream that --border
used to supply, because a mid-grey wash is invisible on a dark surface. */
--hover-wash: var(--border);
/* Code surface: an inset one step below the page so it reads as a panel on
every note colour (the default card is --bg-surface itself, so neither
surface nor base would stand out on it). Token colours must keep AA
contrast against it — see src/lib/utils/highlight.test.ts. */
--code-bg: #dfd5c1;
--hl-keyword: #7d5306;
--hl-string: #325d28;
--hl-number: #1f5c7a;
--hl-comment: #5d5462;
--hl-title: #6a2c7a;
--hl-tag: #8b2e1f;
}
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--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: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
/* Dark code sinks to page level (the ink behind the cards) rather than
lifting, so a snippet on a dark card reads as a cut-out, not a slab. */
--code-bg: #141312;
--hl-keyword: #e0a030;
--hl-string: #8fc97a;
--hl-number: #7fb8e0;
--hl-comment: #a89b8c;
--hl-title: #cfa3e6;
--hl-tag: #e2705c;
--grid-dot: #e8dcc8;
}
html, body {
body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
body.editor-scroll-locked {
overflow: hidden;
accent-color: var(--primary);
}
body::before {
@@ -103,134 +68,20 @@ body::before {
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Prose overrides for parchment theme.
The typography plugin gives every element type its own --tw-prose-* variable,
each defaulting to a fixed grey. Anything not remapped here stays that grey in
dark mode (bold text was near-black on ink — see issue #86), so map them all. */
/* Prose overrides for parchment theme */
.prose {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-lead: var(--text-muted);
--tw-prose-links: var(--primary);
--tw-prose-bold: var(--text);
--tw-prose-counters: var(--text-muted);
--tw-prose-bullets: var(--text-muted);
--tw-prose-hr: var(--border-subtle);
--tw-prose-quotes: var(--text);
--tw-prose-quote-borders: var(--border-subtle);
--tw-prose-captions: var(--text-muted);
--tw-prose-kbd: var(--text);
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
--tw-prose-code: var(--text);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-pre-code: var(--text);
--tw-prose-pre-bg: var(--code-bg);
--tw-prose-th-borders: var(--border-subtle);
--tw-prose-td-borders: var(--border-subtle);
}
/* Code — the body font is already monospace, so the typography plugin's
"shrink code to 86%" default only makes snippets harder to read. Code keeps
the surrounding text size and gets a distinct surface instead. */
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--code-bg);
background-color: var(--bg-surface);
border-radius: 2px;
padding: 0.125rem 0.25rem;
font-size: inherit;
}
/* The chip is the signal now; the plugin's decorative backticks doubled it. */
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
border: 1px solid var(--border-subtle);
border-radius: 2px;
font-size: inherit;
line-height: 1.6;
padding: 0.625rem 0.75rem;
tab-size: 4;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-radius: 0;
padding: 0;
}
/* Card previews are a glance, not a workspace: clip long lines instead of
growing a scrollbar inside the card. The editor and share page keep the
typography default of scrolling horizontally. */
.card-preview :where(pre) {
overflow: hidden;
/* line-clamp is a legacy -webkit-box; without this a wide <pre> takes its
content width and runs out past the card edge instead of being clipped. */
max-width: 100%;
}
/* Syntax highlighting (highlight.js classes via lowlight). Six roles are
enough to tell structure apart without turning a snippet into confetti;
the colours live in --hl-* above so the dark theme can swap them. */
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-type,
.hljs-literal {
color: var(--hl-keyword);
}
.hljs-string,
.hljs-regexp,
.hljs-quote,
.hljs-addition {
color: var(--hl-string);
}
.hljs-number,
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-attr,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: var(--hl-number);
}
.hljs-comment,
.hljs-meta,
.hljs-doctag {
color: var(--hl-comment);
font-style: italic;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hl-title);
}
.hljs-tag,
.hljs-name,
.hljs-deletion {
color: var(--hl-tag);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
@@ -284,20 +135,12 @@ body::before {
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
margin: 0;
padding: 0;
}
/* Nested task lists take full width below the parent item */
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
.prose ul.task-list li > ul.task-list {
flex-basis: 100%;
padding-left: 1.5rem;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
@@ -308,7 +151,6 @@ body::before {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Shared checkbox styles */
@@ -316,7 +158,6 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
@@ -354,41 +195,3 @@ body::before {
.prose ul.task-list li p {
margin: 0;
}
/* Checklist link styles */
.checklist-link {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.checklist-link:hover {
color: var(--primary-hover);
}
[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
/* Custom tooltips */
.tooltip-popup {
position: fixed;
z-index: 9999;
padding: 0.25rem 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
line-height: 1.2;
color: var(--bg-surface);
background: var(--text);
border-radius: 2px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 100ms ease-out;
}
.tooltip-visible {
opacity: 1;
}
+1 -3
View File
@@ -15,9 +15,7 @@ declare global {
} | null;
}
// interface PageData {}
interface PageState {
noteEditorOpen?: boolean;
}
// interface PageState {}
// interface Platform {}
}
}
+2 -6
View File
@@ -2,11 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f0e6d3" />
<script>
(function() {
@@ -18,7 +14,7 @@
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#141312';
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
+71 -279
View File
@@ -3,11 +3,14 @@
import Plus from 'lucide-svelte/icons/plus';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { onDestroy } from 'svelte';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
import LinkPopover from './LinkPopover.svelte';
interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}
interface Props {
content: string;
@@ -20,106 +23,67 @@
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
// Track the last content we emitted so we can distinguish self-originated
// changes from external ones (sync, history restore, etc.)
let lastEmitted = content;
// Re-parse items when content changes externally (not from our own emitChange)
$effect(() => {
if (content !== lastEmitted) {
items = parseChecklist(content);
lastEmitted = content;
}
});
let activeItems = $derived(items.filter((i) => !i.checked));
let doneItems = $derived(items.filter((i) => i.checked));
let doneCount = $derived(doneItems.length);
interface DoneSectionGroup {
parentLabel: ChecklistItem | null;
children: ChecklistItem[];
function generateId(): string {
return crypto.randomUUID();
}
let doneSectionGroups = $derived.by(() => {
const groups: DoneSectionGroup[] = [];
const childrenByParent = new Map<string, ChecklistItem[]>();
const topLevelDone: ChecklistItem[] = [];
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
return { id: generateId(), text: line, checked: false };
});
}
for (const item of doneItems) {
if (item.parentId) {
const siblings = childrenByParent.get(item.parentId) ?? [];
siblings.push(item);
childrenByParent.set(item.parentId, siblings);
} else {
topLevelDone.push(item);
}
}
for (const item of topLevelDone) {
const doneChildren = childrenByParent.get(item.id) ?? [];
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
childrenByParent.delete(item.id);
}
for (const [parentId, children] of childrenByParent) {
const parent = items.find((i) => i.id === parentId);
if (parent) {
groups.push({ parentLabel: parent, children });
} else {
groups.push({ parentLabel: null, children });
}
}
return groups;
});
function serializeChecklist(list: ChecklistItem[]): string {
return list
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
}
function emitChange() {
const serialized = serializeChecklist(items);
lastEmitted = serialized;
onChange(serialized);
onChange(serializeChecklist(items));
}
function toggleItem(id: string) {
items = toggleItemWithCascade(id, items);
emitChange();
}
function updateText(id: string, innerHTML: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = unlinkifyHtml(innerHTML);
item.checked = !item.checked;
emitChange();
}
}
function updateText(id: string, text: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
emitChange();
}
}
function addItem(afterIndex: number) {
const afterItem = items[afterIndex];
const newItem: ChecklistItem = {
id: generateId(),
text: '',
checked: false,
parentId: afterItem?.parentId ?? null
};
const newItem = { id: generateId(), text: '', checked: false };
items.splice(afterIndex + 1, 0, newItem);
items = [...items];
emitChange();
// Focus by unique ID — positional indexing breaks when checked items
// are interleaved in items[] (e.g. reopened notes) because the DOM
// only renders active items as inputs
const newId = newItem.id;
setTimeout(() => {
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
}
function removeItem(id: string) {
if (items.length <= 1) {
if (items[0].parentId) items[0].parentId = null;
items = [...items];
emitChange();
return;
}
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
if (items.length <= 1) return;
const index = items.findIndex((i) => i.id === id);
items.splice(index, 1);
items = [...items];
@@ -135,178 +99,33 @@
e.preventDefault();
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
const current = inputs.findIndex((el) => el.dataset.itemId === id);
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
if (target >= 0 && target < inputs.length) {
e.preventDefault();
inputs[target].focus();
}
} else if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
items = indentItem(id, items, activeItems);
emitChange();
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
items = outdentItem(id, items);
emitChange();
}
}
// Svelte action: sets innerHTML on mount and syncs from external changes only.
// While the user is typing (element is focused), we don't re-render — that would
// destroy the cursor. Linkification happens on blur via the onblur handler.
function editableContent(node: HTMLElement, text: string) {
node.innerHTML = linkifyText(text);
return {
update(newText: string) {
if (document.activeElement !== node) {
node.innerHTML = linkifyText(newText);
}
}
};
}
function handleLinkClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const anchor = target.closest('a');
if (anchor) {
e.preventDefault();
e.stopPropagation();
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
}
// --- Directional lock drag handle ---
// Instead of blocking DnD events (which corrupts its internal state),
// we detect horizontal intent and store the pending operation. DnD runs
// its full course unimpeded. After DnD finalizes, we apply the indent.
let dragStartX = 0;
let dragStartY = 0;
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
let directionLocked = false;
const LOCK_THRESHOLD = 10;
const INDENT_THRESHOLD = 32;
function windowPointerMove(e: PointerEvent) {
if (directionLocked) return;
const dx = Math.abs(e.clientX - dragStartX);
const dy = Math.abs(e.clientY - dragStartY);
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
directionLocked = true;
if (dx <= dy) {
// Vertical — DnD handles it, we're done
removeWindowListeners();
}
// Horizontal — keep listening for pointerup to measure total dx
}
}
function windowPointerUp(e: PointerEvent) {
const dx = e.clientX - dragStartX;
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
pendingIndent = {
id: currentDragItemId,
direction: dx > 0 ? 'indent' : 'outdent'
};
}
removeWindowListeners();
directionLocked = false;
}
function addWindowListeners() {
window.addEventListener('pointermove', windowPointerMove, { capture: true });
window.addEventListener('pointerup', windowPointerUp, { capture: true });
}
function removeWindowListeners() {
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
}
let currentDragItemId = '';
function handleDragPointerDown(e: PointerEvent, id: string) {
dragStartX = e.clientX;
dragStartY = e.clientY;
directionLocked = false;
pendingIndent = null;
currentDragItemId = id;
addWindowListeners();
}
onDestroy(removeWindowListeners);
// --- DnD handlers ---
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
let lastTopLevelId: string | null = null;
return reorderedItems.map((item) => {
if (item.parentId === null) {
lastTopLevelId = item.id;
return item;
}
return { ...item, parentId: lastTopLevelId };
});
}
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
// Merge reordered active items with done items
items = [...e.detail.items, ...items.filter((i) => i.checked)];
}
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
const reorderedActive = recomputeParentIds(e.detail.items);
items = [...reorderedActive, ...items.filter((i) => i.checked)];
items = [...e.detail.items, ...items.filter((i) => i.checked)];
emitChange();
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
if (pendingIndent && pendingIndent.id) {
const { id, direction } = pendingIndent;
pendingIndent = null;
if (direction === 'indent') {
items = indentItem(id, items, activeItems);
} else {
items = outdentItem(id, items);
}
emitChange();
}
}
</script>
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
data-testid={item.parentId ? 'checklist-child-row' : undefined}
animate:flip={{ duration: flipDurationMs }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div use:dragHandle
aria-label="drag handle"
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
data-testid="checklist-drag-handle">
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
@@ -316,30 +135,16 @@
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
contenteditable="true"
use:editableContent={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, item.id)}
onclick={handleLinkClick}
onpaste={handlePaste}
onblur={(e) => {
const el = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
if (el.isConnected && !el.contains(document.activeElement)) {
el.innerHTML = linkifyText(item.text);
}
});
}}
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-placeholder="List item"
aria-placeholder="List item"
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
data-item-id={item.id}
role="textbox"
tabindex="0"
></div>
/>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
@@ -373,42 +178,29 @@
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
{#each doneSectionGroups as group}
{#if group.parentLabel}
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
</div>
{/if}
{#each group.children as item (item.id)}
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if linkPopover}
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
{/if}
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
@@ -16,7 +16,7 @@
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+226 -423
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
import {
Undo2,
Redo2,
@@ -34,120 +33,35 @@
interface Props {
tick?: number;
editor: Editor | undefined;
currentNoteId?: string | null;
}
let { editor, tick, currentNoteId = null }: Props = $props();
let { editor, tick }: Props = $props();
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
// The fence info string may carry an alias (```js) or a language the picker
// doesn't list; surface it as-is rather than silently showing "Plain text".
function codeBlockLanguage(): string {
const lang = getAttrs('codeBlock').language;
return typeof lang === 'string' ? lang : '';
}
function setCodeBlockLanguage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
}
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
let linkUrl: string = $state('');
let linkInput: HTMLInputElement | undefined = $state();
let ignorePositionInvalidationUntil = 0;
let noteSearchResults: { id: string; title: string }[] = $state([]);
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
async function searchNotesForLink(query: string) {
const requestId = ++noteSearchRequestId;
if (!query.trim()) {
noteSearchResults = [];
return;
}
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (requestId !== noteSearchRequestId) return;
if (!res.ok) return;
const results: { id: string; title: string }[] = await res.json();
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
} catch {
// Search failed silently
}
}
let noteSearchRequestId = 0;
function handleLinkInput() {
clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
}
function insertNoteLink(noteId: string) {
if (!editor) return;
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
noteSearchResults = [];
closeDropdowns();
}
const dropdownGap = 4;
const viewportMargin = 10;
function toggleDropdown(name: string, anchorEl: HTMLElement) {
function toggleDropdown(name: string) {
if (openDropdown === name) {
closeDropdowns();
openDropdown = null;
} else {
ignorePositionInvalidationUntil = performance.now() + 600;
openDropdown = name;
dropdownAnchorEl = anchorEl;
if (name === 'link') {
linkUrl = editor?.getAttributes('link').href ?? '';
// Focus input after Svelte renders the popover
requestAnimationFrame(() => linkInput?.focus());
}
}
}
function handleDropdownClick(name: string, event: MouseEvent) {
toggleDropdown(name, event.currentTarget as HTMLElement);
}
function preventToolbarMouseFocus(event: MouseEvent) {
if (event.button === 0) event.preventDefault();
}
function handleCommandClick(command: () => void) {
command();
}
function closeDropdowns() {
openDropdown = null;
dropdownAnchorEl = null;
clearTimeout(noteSearchTimer);
noteSearchResults = [];
}
function closeDropdownsAfterPositionChange() {
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
closeDropdowns();
}
function getDropdownStyle(rect: DOMRect, width: number): string {
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
// coordinates, so the reference height must be innerHeight — visualViewport.height is
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
const bottom = window.innerHeight - rect.top + dropdownGap;
return `bottom: ${bottom}px; left: ${left}px;`;
}
function applyLink() {
@@ -174,15 +88,15 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -198,32 +112,17 @@
closeDropdowns();
}
}
$effect(() => {
if (!openDropdown || !window.visualViewport) return;
window.visualViewport.addEventListener('resize', closeDropdownsAfterPositionChange);
window.visualViewport.addEventListener('scroll', closeDropdownsAfterPositionChange);
return () => {
window.visualViewport?.removeEventListener('resize', closeDropdownsAfterPositionChange);
window.visualViewport?.removeEventListener('scroll', closeDropdownsAfterPositionChange);
};
});
</script>
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<svelte:window onscroll={closeDropdownsAfterPositionChange} onresize={closeDropdownsAfterPositionChange} />
<div
class="flex overflow-x-auto md:flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1 scrollbar-hide"
style="scrollbar-width: none; -ms-overflow-style: none;"
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
onscroll={closeDropdownsAfterPositionChange}
>
<!-- Undo/Redo -->
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
@@ -232,8 +131,7 @@
<Undo2 size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
@@ -246,8 +144,7 @@
<!-- Inline -->
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
@@ -255,8 +152,7 @@
<Bold size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
@@ -264,8 +160,7 @@
<Italic size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
@@ -273,8 +168,7 @@
<Strikethrough size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
@@ -285,10 +179,9 @@
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Heading dropdown -->
<div data-dropdown="heading">
<div class="relative" data-dropdown="heading">
<button
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('heading', e)}
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
@@ -296,13 +189,40 @@
<Heading size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
{/if}
</div>
<!-- List dropdown -->
<div data-dropdown="list">
<div class="relative" data-dropdown="list">
<button
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('list', e)}
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
@@ -310,26 +230,98 @@
<List size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
<!-- Link popover -->
<div data-dropdown="link">
<div class="relative" data-dropdown="link">
<button
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('link', e)}
onclick={() => toggleDropdown('link')}
class={btnClass(isActive('link'))}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
>
<Trash2 size={16} />
</button>
</form>
</div>
{/if}
</div>
<!-- Table dropdown -->
<div data-dropdown="table">
<div class="relative" data-dropdown="table">
<button
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('table', e)}
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
@@ -337,13 +329,68 @@
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-insert"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Alignment dropdown -->
<div data-dropdown="align">
<div class="relative" data-dropdown="align">
<button
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('align', e)}
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
@@ -359,14 +406,49 @@
{/if}
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Block-level formatting -->
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
@@ -374,8 +456,7 @@
<Code size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
@@ -383,36 +464,15 @@
<TextQuote size={iconSize} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
{#if isActive('codeBlock')}
{@const currentLanguage = codeBlockLanguage()}
<select
value={currentLanguage}
onchange={setCodeBlockLanguage}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
title="Code block language"
aria-label="Code block language"
data-testid="format-code-language"
>
<option value="">Plain text</option>
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
<option value={currentLanguage}>{currentLanguage}</option>
{/if}
{#each CODE_LANGUAGES as lang (lang.id)}
<option value={lang.id}>{lang.label}</option>
{/each}
</select>
{/if}
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Divider"
data-testid="format-hr"
@@ -420,260 +480,3 @@
<Minus size={iconSize} />
</button>
</div>
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
{#if openDropdown && dropdownAnchorEl}
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
{#if openDropdown === 'heading'}
<div
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 170)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
{:else if openDropdown === 'list'}
<div
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 190)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{:else if openDropdown === 'link'}
<div
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
style={getDropdownStyle(rect, 220)}
data-dropdown="true"
>
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
oninput={handleLinkInput}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
onmousedown={preventToolbarMouseFocus}
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
title="Remove link"
data-testid="format-link-remove"
>
<Trash2 size={16} />
</button>
</form>
{#if noteSearchResults.length > 0}
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
{#each noteSearchResults as result (result.id)}
<button
type="button"
onmousedown={preventToolbarMouseFocus}
onclick={() => insertNoteLink(result.id)}
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="format-link-note-result"
>
{result.title || 'Untitled'}
</button>
{/each}
</div>
{/if}
</div>
{:else if openDropdown === 'table'}
<div
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 210)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-insert-table"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-column"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-column"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-delete-table"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{:else if openDropdown === 'align'}
<div
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 180)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
{/if}
<style>
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
display: none;
}
</style>
+2 -2
View File
@@ -21,7 +21,7 @@
{:else}
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<Menu class="h-6 w-6 text-[var(--text)]" />
@@ -40,7 +40,7 @@
<!-- Mobile search icon -->
<button
onclick={() => (mobileSearchOpen = true)}
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
aria-label="Search"
>
<Search class="h-5 w-5 text-[var(--text)]" />
-73
View File
@@ -1,73 +0,0 @@
<script lang="ts">
import ExternalLink from 'lucide-svelte/icons/external-link';
import { onMount } from 'svelte';
interface Props {
url: string;
anchor: DOMRect;
onClose: () => void;
}
let { url, anchor, onClose }: Props = $props();
let popoverEl: HTMLDivElement | undefined = $state();
let top = $state(0);
let left = $state(0);
onMount(() => {
if (!popoverEl) return;
const rect = popoverEl.getBoundingClientRect();
// Position below the anchor link, or above if near viewport bottom
const spaceBelow = window.innerHeight - anchor.bottom;
if (spaceBelow >= rect.height + 8) {
top = anchor.bottom + 4;
} else {
top = anchor.top - rect.height - 4;
}
left = anchor.left;
// Clamp to viewport
if (left + rect.width > window.innerWidth - 8) {
left = window.innerWidth - rect.width - 8;
}
if (left < 8) left = 8;
});
function handleClickOutside(e: MouseEvent) {
if (popoverEl && !popoverEl.contains(e.target as Node)) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
function openLink() {
window.open(url, '_blank', 'noopener');
onClose();
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={popoverEl}
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
style="top: {top}px; left: {left}px;"
onclick={(e) => e.stopPropagation()}
data-testid="link-popover"
>
<button
onclick={openLink}
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
data-testid="link-popover-open"
>
<ExternalLink class="h-3.5 w-3.5" />
Open
</button>
</div>
+24 -35
View File
@@ -12,13 +12,10 @@
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
import { tooltip } from '$lib/utils/tooltip.js';
import { linkifyText } from '$lib/utils/checklist.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
interface Props {
@@ -41,14 +38,10 @@
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
? note.content.split('\n').filter(l => l.trim()).map(line => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
? note.content.split('\n').filter(l => l.trim()).map(line => ({
text: line.replace(/^- \[[ x]\] /, ''),
checked: line.startsWith('- [x] ')
}))
: []
);
@@ -118,8 +111,8 @@
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unpin"}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
@@ -132,13 +125,9 @@
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-testid={item.indented ? 'card-checklist-child' : undefined}>
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input
type="checkbox"
checked={item.checked}
@@ -146,7 +135,7 @@
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
<span class="break-words min-w-0">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
@@ -154,7 +143,7 @@
{/if}
</ul>
{:else if note.content}
<div class="card-preview prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
@@ -164,16 +153,16 @@
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Restore"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Delete forever"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -182,8 +171,8 @@
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Pin"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -192,8 +181,8 @@
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unarchive"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -201,8 +190,8 @@
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
@@ -211,8 +200,8 @@
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Leave note"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
@@ -220,8 +209,8 @@
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Trash"}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
+101 -569
View File
@@ -1,11 +1,5 @@
<script module lang="ts">
let bodyScrollLockCount = 0;
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { pushState } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
@@ -13,13 +7,13 @@
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
@@ -30,23 +24,14 @@
import Users from 'lucide-svelte/icons/users';
import Globe from 'lucide-svelte/icons/globe';
import History from 'lucide-svelte/icons/history';
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
import Trash2 from 'lucide-svelte/icons/trash-2';
import ListX from 'lucide-svelte/icons/list-x';
import Archive from 'lucide-svelte/icons/archive';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
note: Note | null;
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
onOpenNote: (noteId: string) => void;
}
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
const { note, isNew = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
@@ -56,11 +41,11 @@
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
@@ -71,200 +56,20 @@
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// Auto-save: track last-saved state to detect real changes
let lastSavedTitle = note?.title ?? '';
let lastSavedContent = note?.content ?? '';
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
let isSaving = false;
let savingPromise: Promise<void> | null = null;
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
// from the original. Track whether auto-save should be active yet.
let autoSaveReady = isNew;
function hasUnsavedChanges(): boolean {
return (
title !== lastSavedTitle ||
content !== lastSavedContent ||
color !== lastSavedColor ||
checklistMode !== lastSavedChecklistMode
);
}
async function performSave() {
if (isSaving) return;
// Snapshot current fields to avoid races with user edits during await
const snap = { title, content, color, checklistMode };
if (!snap.title.trim() && !snap.content.trim()) return;
if (
snap.title === lastSavedTitle &&
snap.content === lastSavedContent &&
snap.color === lastSavedColor &&
snap.checklistMode === lastSavedChecklistMode
) return;
isSaving = true;
try {
if (currentlyNew) {
const created = await createNote(snap);
if (created) {
noteId = created.id;
currentlyNew = false;
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
} else if (noteId) {
const updated = await updateNote(noteId, snap);
if (updated) {
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
}
} finally {
isSaving = false;
}
}
// Debounced auto-save: triggers 2s after the user stops editing
$effect(() => {
const _t = title;
const _c = content;
const _col = color;
const _cm = checklistMode;
if (!autoSaveReady) {
// Absorb TiptapEditor's initial content normalization as the baseline
lastSavedContent = _c;
autoSaveReady = true;
return;
}
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
if (!_t.trim() && !_c.trim()) return;
autoSaveTimer = setTimeout(() => {
savingPromise = performSave();
}, 2000);
return () => clearTimeout(autoSaveTimer);
});
// Safety net: save on tab close / navigation
$effect(() => {
function handleBeforeUnload() {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
});
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
// component (rather than reusing the instance) whenever the user switches to a
// different note while the editor is open. That teardown skips the debounced
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
// could otherwise be silently dropped.
onDestroy(() => {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// svelte-ignore state_referenced_locally
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
let viewportHeight = $state('100dvh');
let viewportTop = $state('0px');
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
function updateViewport() {
if (window.visualViewport) {
viewportHeight = `${window.visualViewport.height}px`;
viewportTop = `${window.visualViewport.offsetTop}px`;
} else {
viewportHeight = `${window.innerHeight}px`;
viewportTop = '0px';
}
}
updateViewport();
const handleVisualViewportChange = () => {
requestAnimationFrame(updateViewport);
};
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
} else {
window.addEventListener('resize', handleVisualViewportChange);
}
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => {
clearTimeout(timer);
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleVisualViewportChange);
window.visualViewport.removeEventListener('scroll', handleVisualViewportChange);
} else {
window.removeEventListener('resize', handleVisualViewportChange);
}
};
});
// Lock body scroll while editor is open. Multiple editor instances can overlap during
// the ghost-click grace period, so keep the class until the last instance unmounts.
$effect(() => {
bodyScrollLockCount += 1;
document.body.classList.add('editor-scroll-locked');
return () => {
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
if (bodyScrollLockCount === 0) {
document.body.classList.remove('editor-scroll-locked');
}
};
return () => clearTimeout(timer);
});
let showShareDialog = $state(false);
let showHistory = $state(false);
let showOverflowMenu = $state(false);
let desktopOverflowBtnEl: HTMLButtonElement | undefined = $state();
let mobileOverflowBtnEl: HTMLButtonElement | undefined = $state();
let overflowAnchorEl: HTMLButtonElement | undefined = $state();
let overflowMenuEl: HTMLDivElement | undefined = $state();
// Close overflow menu when clicking outside
$effect(() => {
if (!showOverflowMenu) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
showOverflowMenu = false;
}
// Use setTimeout to avoid the current click from closing the menu
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
});
const hasDoneItems = $derived(
checklistMode && content.includes('[x]')
);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
// svelte-ignore state_referenced_locally
@@ -340,56 +145,20 @@
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
// component on every note-switch (rather than reusing the instance), a fetch that
// resolves after teardown would otherwise write to $state on an already-destroyed
// instance racing the newly-mounted one.
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (destroyed) return;
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
// note.backlinks is only ever attached server-side by getNote(), which nothing
// else in the client calls when opening a note (the bulk notes store and
// IndexedDB never carry it), so this mirrors the attachments effect above to
// fill it in on open. Same post-teardown guard as above.
$effect(() => {
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}`)
.then((res) => res.ok ? res.json() : null)
.then((data: Note | null) => {
if (destroyed) return;
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
function handleAttachmentUpload(attachment: Attachment) {
const updated = [...attachmentsList, attachment];
attachmentsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
attachmentsList = [...attachmentsList, attachment];
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
@@ -401,14 +170,9 @@
body: JSON.stringify({ featured })
});
if (res.ok) {
const updated = attachmentsList.map((a) =>
attachmentsList = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
attachmentsList = updated;
// Keep the notes store in sync so the card reflects featured state
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
@@ -421,11 +185,7 @@
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
attachmentsList = updated;
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
@@ -436,11 +196,11 @@
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
// pass through to NoteCards underneath (ghost click).
// Use pointer events to detect backdrop clicks. Unlike mouse events,
// pointer events are never synthetically re-dispatched from touch, so
// the tap that opened the editor on mobile cannot ghost-click the overlay.
// Both pointerdown and pointerup must target the overlay itself to close.
let pointerdownOnOverlay = false;
let closing = $state(false);
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
@@ -451,63 +211,35 @@
pointerdownOnOverlay = false;
}
// Close on browser back — Android back button / iOS back swipe. The editor is
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
// leaving the page. On mount, push a shallow-routing history entry; navigating back
// pops it and closes the editor. UI closes pop the entry themselves via
// history.back() so no stale entry is left behind.
let historyEntryPushed = false;
onMount(() => {
// Reuse an existing entry (e.g. back-navigating to a note restored from the
// URL hash) instead of stacking a second one.
if (!page.state.noteEditorOpen) {
pushState('', { noteEditorOpen: true });
}
historyEntryPushed = true;
});
$effect(() => {
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
historyEntryPushed = false;
saveAndClose();
});
function dismissOverlay() {
closing = true;
if (historyEntryPushed) {
historyEntryPushed = false;
history.back();
}
setTimeout(() => onClose(), 400);
}
async function saveAndClose() {
clearTimeout(autoSaveTimer);
// Wait for any in-flight auto-save to finish
if (savingPromise) await savingPromise;
if (!title.trim() && !content.trim()) {
dismissOverlay();
onClose();
return;
}
// Final save if auto-save hasn't caught up yet
if (hasUnsavedChanges()) {
await performSave();
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
}
dismissOverlay();
onClose();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
clearTimeout(autoSaveTimer);
if (savingPromise) await savingPromise;
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
if (!title.trim()) title = 'Untitled';
await performSave();
return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
}
async function toggleImageUpload() {
@@ -535,94 +267,41 @@
requestAnimationFrame(() => textareaEl?.focus());
}
}
async function handleArchive() {
showOverflowMenu = false;
if (noteId) {
await archiveNote(noteId);
dismissOverlay();
}
}
async function handleTrash() {
showOverflowMenu = false;
if (noteId) {
await trashNote(noteId);
dismissOverlay();
}
}
function deleteCheckedItems() {
const items = parseChecklist(content).filter((i) => !i.checked);
content = serializeChecklist(items);
showOverflowMenu = false;
}
function uncheckAllItems() {
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
content = serializeChecklist(items);
showOverflowMenu = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed left-0 w-full z-40 {closing ? '' : 'flex md:items-start justify-center md:overflow-y-auto md:bg-black/50 md:pt-20 md:pb-10 animate-[fade-in_150ms_ease-out]'}"
style="top: {viewportTop}; height: {viewportHeight};"
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
{#if !closing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_150ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
<!-- Mobile Back Button -->
<button
onclick={saveAndClose}
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 flex items-center shrink-0"
aria-label="Back"
>
<ArrowLeft class="h-6 w-6" />
</button>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Mobile Overflow Trigger -->
<button
bind:this={mobileOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
class="md:hidden rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 text-[var(--text)] shrink-0"
data-testid="mobile-overflow-menu-btn"
>
<EllipsisVertical class="h-6 w-6" />
</button>
</div>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Content -->
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
<div class="max-h-[60vh] overflow-y-auto">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
@@ -632,7 +311,7 @@
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
@@ -642,7 +321,6 @@
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
{onOpenNote}
placeholder="Add a crumb..."
/>
{/if}
@@ -650,7 +328,7 @@
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
@@ -662,35 +340,16 @@
</div>
{/if}
<!-- Backlinks -->
{#if backlinksList.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
<div class="flex flex-wrap gap-1.5">
{#each backlinksList as backlink (backlink.id)}
<button
type="button"
onclick={() => onOpenNote(backlink.id)}
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
data-testid="backlink-chip"
>
{backlink.title || 'Untitled'}
</button>
{/each}
</div>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar (Desktop only) -->
<!-- Toolbar -->
<div
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center gap-1">
@@ -698,8 +357,8 @@
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Background color"}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -711,22 +370,51 @@
{/if}
</div>
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
data-testid="checklist-toggle"
>
{#if checklistMode}
<Type class="h-5 w-5" />
{:else}
<SquareCheck class="h-5 w-5" />
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Attachments"}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Share button -->
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
disabled={checklistMode}
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<FileText class="h-5 w-5" />
{:else}
<FileCode class="h-5 w-5" />
{/if}
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Share"}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
data-testid="share-toggle"
>
{#if hasPublicLink}
@@ -738,189 +426,33 @@
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<Users class="h-5 w-5" />
</span>
{/if}
<!-- Archive button (only for saved notes) -->
<!-- History button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
onclick={toggleHistory}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
>
<Archive class="h-5 w-5" />
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
</button>
{/if}
<!-- Overflow menu trigger -->
<button
bind:this={desktopOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
<EllipsisVertical class="h-5 w-5" />
</button>
</div>
<button
onclick={saveAndClose}
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
Close
</button>
</div>
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowAnchorEl}
{@const rect = overflowAnchorEl.getBoundingClientRect()}
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
<div
bind:this={overflowMenuEl}
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
data-testid="overflow-menu"
>
<!-- Mobile-only actions -->
{#if isMobileTrigger}
<!-- Color picker -->
<div class="px-3 py-2 w-56 whitespace-normal">
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
</div>
<!-- Attachments -->
<button
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
Attachments
</button>
<!-- Share -->
{#if isOwner}
<button
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
{#if hasPublicLink}
<Globe class="h-4 w-4 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-4 w-4 text-[var(--primary)]" />
{:else}
<UserPlus class="h-4 w-4" />
{/if}
Share
</button>
{:else if isShared}
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
<Users class="h-4 w-4" />
Shared note
</div>
{/if}
<!-- Archive -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<Archive class="h-4 w-4" />
Archive
</button>
{/if}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{/if}
<!-- Checklist mode toggle -->
<button
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="checklist-toggle"
>
{#if checklistMode}
<Type class="h-4 w-4" />
Switch to text
{:else}
<SquareCheck class="h-4 w-4" />
Checklist mode
{/if}
</button>
<!-- Markdown mode toggle (not available in checklist mode) -->
{#if !checklistMode}
<button
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<FileText class="h-4 w-4" />
Rich text mode
{:else}
<FileCode class="h-4 w-4" />
Markdown mode
{/if}
</button>
{/if}
<!-- History -->
{#if !currentlyNew}
<button
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="history-toggle"
>
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
Version history
</button>
{/if}
<!-- Trash (only for saved notes) -->
{#if !currentlyNew}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
<button
onclick={handleTrash}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--hover-wash)]/10"
data-testid="trash-note-btn"
>
<Trash2 class="h-4 w-4" />
Move to trash
</button>
{/if}
<!-- Checklist-specific actions -->
{#if checklistMode}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{#if hasDoneItems}
<button
onclick={deleteCheckedItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="delete-checked-btn"
>
<Trash2 class="h-4 w-4" />
Delete checked
</button>
{/if}
<button
onclick={uncheckAllItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
Uncheck all
</button>
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
+3 -3
View File
@@ -116,7 +116,7 @@
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close history"
data-testid="close-history-btn"
>
@@ -140,7 +140,7 @@
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
@@ -195,7 +195,7 @@
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
>
Cancel
</button>
+12 -53
View File
@@ -4,12 +4,8 @@
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
import { replaceState } from '$app/navigation';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js';
import { getNote as getIdbNote } from '$lib/sync/idb.js';
interface Props {
filter: NoteFilter;
@@ -20,7 +16,6 @@
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
let newNoteChecklist = $state(false);
// Sync store with route props and reload notes when filter changes
$effect(() => {
@@ -29,33 +24,15 @@
loadNotes(filter);
});
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
// handled by SvelteKit.
function openEditor(note: Note) {
editingNote = note;
replaceState(`#${note.id}`, {});
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
newNoteChecklist = false;
replaceState(location.pathname, {});
}
// Reads from the local IndexedDB cache rather than fetching over the network —
// this keeps note-link navigation instant and offline-safe (matching how the
// rest of the app opens notes), and backlinks are filled in progressively by
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
// the same pattern already used there for attachments.
let openNoteRequestId = 0;
async function openNoteById(noteId: string) {
const requestId = ++openNoteRequestId;
const note = await getIdbNote(noteId);
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
if (note) openEditor(note);
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
@@ -78,24 +55,14 @@
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
<button
onclick={() => (showNewNote = true)}
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
<button
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
use:tooltip={"New checklist"}
data-testid="new-checklist-btn"
>
<SquareCheck class="h-4 w-4" />
</button>
</div>
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</div>
<button
onclick={() => (showNewNote = true)}
@@ -152,17 +119,9 @@
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
while the editor is already open) instead of reusing the same instance
with a new `note` prop — a persisting instance's own back-navigation
effect misreads `openEditor`'s replaceState call as "browser back was
pressed" and closes the editor, and its once-only local state never
resyncs to the new note otherwise. -->
{#key editingNote.id}
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/key}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
+20 -44
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import { searchQuery, searchResults } from '$lib/stores/notes.js';
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { onDestroy } from 'svelte';
interface Props {
onClose?: () => void;
@@ -13,64 +12,41 @@
let { onClose }: Props = $props();
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
let searchController: AbortController | undefined;
$effect(() => {
if (onClose) inputEl?.focus();
});
onDestroy(() => {
searchController?.abort();
searchQuery.set('');
searchResults.set([]);
});
async function handleSearch() {
searchController?.abort();
searchController = undefined;
const trimmedQuery = query.trim();
if (!trimmedQuery) {
searchQuery.set('');
searchResults.set([]);
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
return;
}
const controller = new AbortController();
searchController = controller;
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
signal: controller.signal
});
if (res.ok) {
const results: Note[] = await res.json();
if (searchController !== controller || query.trim() !== trimmedQuery) return;
// Commit the query alongside its results so the view switches to the
// (possibly empty) result set only once it's ready — this avoids
// flashing an empty state over the current notes while the request is
// in flight, and keeps searchQuery consistent with searchResults on
// a failed request (neither updates).
searchResults.set(results);
searchQuery.set(trimmedQuery);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error('Search failed:', error);
}
} finally {
if (searchController === controller) searchController = undefined;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
}
}
function clearSearch() {
searchController?.abort();
searchController = undefined;
query = '';
searchQuery.set('');
searchResults.set([]);
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
}
function close() {
+2 -2
View File
@@ -168,7 +168,7 @@
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<X class="h-4 w-4" />
@@ -277,7 +277,7 @@
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
+1 -1
View File
@@ -66,7 +66,7 @@
{#if showPopover}
<div
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
class="absolute right-0 bottom-full z-20 mb-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="sharing-popover"
>
{#if hasPublicLink}
+36 -117
View File
@@ -6,144 +6,65 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from './tiptap/Table.js';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { NoteLink } from './tiptap/NoteLink.js';
import { editorLowlight } from '$lib/utils/highlight.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
content: string;
onUpdate: (markdown: string) => void;
onEditor?: (editor: Editor) => void;
onTransaction?: () => void;
onOpenNote?: (noteId: string) => void;
placeholder?: string;
}
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
const titleIndexRef = { index: new Map<string, string>() };
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
if (target instanceof Element) {
const checkbox = target.matches('input[type="checkbox"]')
? target
: target.closest('label')?.querySelector('input[type="checkbox"]');
return checkbox !== null && checkbox !== undefined;
}
return false;
}
function isTouchDevice(): boolean {
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
}
function handleWrapperPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
event.preventDefault();
}
}
function handleWrapperClick(event: MouseEvent) {
if (isTaskCheckboxTarget(event.target)) {
return;
}
editor?.commands.focus();
}
function handleWrapperChangeCapture(event: Event) {
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
if (event.target.type !== 'checkbox' || !editor) return;
const taskItem = event.target.closest('li[data-checked]');
if (!taskItem) return;
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
if (taskItemNode?.type.name !== 'taskItem') return;
event.stopPropagation();
editor.view.dispatch(
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
...taskItemNode.attrs,
checked: event.target.checked
})
);
}
onMount(() => {
let destroyed = false;
let editorInstance: Editor | undefined;
(async () => {
const allNotes = await getAllNotes();
if (destroyed) return;
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false, codeBlock: false }),
CodeBlockLowlight.configure({ lowlight: editorLowlight }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
editor = new Editor({
element: element!,
extensions: [
StarterKit,
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
if (destroyed) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Removing this component's DOM node (e.g. a {#key}-driven remount
// in NotesView.svelte when switching to a different note) fires a
// native blur on the still-focused contenteditable, which
// ProseMirror's blur plugin turns into one last transaction —
// synchronously, from inside Svelte's own render/effect pass that's
// doing the removal. Mutating $state right there crashes with
// state_unsafe_mutation; the `destroyed` check alone isn't enough
// since this can fire before onMount's own cleanup runs. Defer to a
// microtask so the write lands after Svelte's current pass finishes.
queueMicrotask(() => {
if (destroyed) return;
editor = editorInstance;
onTransaction?.();
});
}
});
editor = editorInstance;
if (element && !destroyed) {
(element as any).__tiptapEditor = editorInstance;
TaskItem.configure({ nested: true }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Trigger Svelte reactivity for active state checks
editor = editor;
onTransaction?.();
}
});
onEditor?.(editorInstance);
})();
// Expose editor on DOM element for e2e testing
if (element) {
(element as any).__tiptapEditor = editor;
}
onEditor?.(editor);
return () => {
destroyed = true;
editorInstance?.destroy();
editor?.destroy();
};
});
</script>
@@ -151,14 +72,12 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose md:prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
role="textbox"
aria-multiline="true"
tabindex="0"
onpointerdown={handleWrapperPointerDown}
onclick={handleWrapperClick}
onchangecapture={handleWrapperChangeCapture}
onclick={() => editor?.commands.focus()}
></div>
<style>
-101
View File
@@ -1,101 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
export interface NoteLinkOptions {
titleIndexRef: { index: Map<string, string> };
onOpenNote: (noteId: string) => void;
}
export const NoteLink = Node.create<NoteLinkOptions>({
name: 'noteLink',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
titleIndexRef: { index: new Map<string, string>() },
onOpenNote: () => {}
};
},
addAttributes() {
return {
noteId: {
default: null,
parseHTML: (element) => element.getAttribute('data-note-id'),
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
}
};
},
parseHTML() {
return [
{
tag: 'a[href^="crumb-note://"]',
priority: 100,
getAttrs: (element) => {
const href = (element as HTMLElement).getAttribute('href') ?? '';
const noteId = parseNoteLinkHref(href);
return noteId ? { noteId } : false;
}
}
];
},
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
},
addStorage() {
return {
markdown: {
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
}
}
};
},
addNodeView() {
return ({ node, editor, getPos }) => {
const noteId = node.attrs.noteId as string;
const title = this.options.titleIndexRef.index.get(noteId);
const dom = document.createElement('span');
dom.setAttribute('data-note-id', noteId);
dom.className =
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
const label = document.createElement('span');
if (title !== undefined) {
label.textContent = title;
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
label.addEventListener('click', (event) => {
event.preventDefault();
this.options.onOpenNote(noteId);
});
} else {
label.textContent = 'Note not found';
label.className = 'text-[var(--text-muted)] opacity-60';
}
dom.appendChild(label);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = '×';
removeButton.setAttribute('aria-label', 'Remove note link');
removeButton.className =
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
removeButton.addEventListener('click', (event) => {
event.preventDefault();
const pos = getPos();
if (pos === undefined) return;
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
});
dom.appendChild(removeButton);
return { dom };
};
}
});
-164
View File
@@ -1,164 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { Editor, type JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import { Markdown } from 'tiptap-markdown';
import { Table } from './Table.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
function createEditor(content: string | object, markdownOptions?: { html?: boolean }) {
return new Editor({
extensions: [
StarterKit,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown.configure(markdownOptions ?? {})
],
content
});
}
function getMarkdown(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (editor.storage as Record<string, any>).markdown.getMarkdown();
}
function docWithListInCell(): JSONContent {
return {
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }]
},
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }]
}
]
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }
]
}
]
},
{
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }]
}
]
}
]
}
]
};
}
// crumbs#83: a bulleted list inside a table cell survives in the live editor
// (never touches markdown) but breaks once the note is saved and reopened,
// because saving/reopening round-trips the doc through markdown text.
describe('Table markdown round-trip with a bullet list in a cell', () => {
it('preserves a bullet list inside a table cell after saving and reopening', () => {
const editor = createEditor(docWithListInCell());
// "Save": serialize the crumb to markdown, the way onUpdate() does.
const savedMarkdown = getMarkdown(editor);
// "Reopen": load that markdown into a fresh editor, the way opening the
// crumb again does (content is passed in as a markdown string).
const reopened = createEditor(savedMarkdown);
const json = reopened.getJSON() as JSONContent;
const table = json.content?.find((node) => node.type === 'table');
expect(table).toBeDefined();
const rows = table!.content!;
expect(rows).toHaveLength(2);
const [headerRow, bodyRow] = rows;
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
const [listCell, plainCell] = bodyRow.content!;
expect(listCell.content?.[0]?.type).toBe('bulletList');
const items = listCell.content?.[0]?.content ?? [];
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell');
});
it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => {
const editor = createEditor(docWithListInCell());
const savedMarkdown = getMarkdown(editor);
const html = renderMarkdown(savedMarkdown);
expect(html).toContain('<table>');
expect(html).toContain('<ul');
expect(html).toContain('First');
expect(html).toContain('Second');
expect(html).not.toContain('&lt;table&gt;');
});
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] }
]
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] }
]
}
]
}
]
});
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('| A | B |');
expect(savedMarkdown).toContain('| 1 | 2 |');
});
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
const editor = createEditor(docWithListInCell(), { html: false });
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('[table]');
});
});
-141
View File
@@ -1,141 +0,0 @@
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
import { Table as BaseTable } from '@tiptap/extension-table';
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
interface MarkdownSerializerState {
write(text: string): void;
ensureNewLine(): void;
closeBlock(node: ProseMirrorNode): void;
renderInline(node: ProseMirrorNode): void;
inTable: boolean;
}
interface SerializeContext {
editor: { storage: { markdown: { options: { html: boolean } } } };
}
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
node.forEach((child) => nodes.push(child));
return nodes;
}
function hasSpan(cell: ProseMirrorNode): boolean {
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
}
/**
* A cell can only be squeezed onto a single `| cell |` markdown line via
* `state.renderInline()` if its content is itself a textblock (a paragraph,
* whose children are inline text runs). A block child instead - e.g. a
* bullet list - has to be rejected here, or renderInline() misreads its
* block-level children as inline ones and corrupts the row (crumbs#83).
*/
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
const content = cell.firstChild;
return content === null || content.type.isTextblock;
}
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
const rows = childNodes(node);
const firstRow = rows[0];
const bodyRows = rows.slice(1);
if (
childNodes(firstRow).some(
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
) {
return false;
}
return !bodyRows.some((row) =>
childNodes(row).some(
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
);
}
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
function formatBlock(html: string): string {
const element = elementFromString(html).firstElementChild!;
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
return element.outerHTML;
}
/**
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
* option is off, other node types degrade to a `[nodename]` placeholder
* instead of emitting HTML. A table with non-inline cell content has to
* follow the same rule, or it'd be the only node type still leaking raw HTML
* with html mode disabled.
*/
function serializeAsHTML(
context: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!context.editor.storage.markdown.options.html) {
state.write(`[${node.type.name}]`);
state.closeBlock(node);
return;
}
const schema = node.type.schema;
const html = getHTMLFromFragment(Fragment.from(node), schema);
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
state.write(isTopLevel ? formatBlock(html) : html);
state.closeBlock(node);
}
/**
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
* a default for it, but only guards against multi-child/spanning cells, not
* a single block child - see hasOnlyInlineContent above).
*/
export const Table = BaseTable.extend({
addStorage() {
return {
markdown: {
serialize(
this: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!isMarkdownSerializable(node)) {
serializeAsHTML(this, state, node, parent);
return;
}
state.inTable = true;
node.forEach((row, _rowOffset, rowIndex) => {
state.write('| ');
row.forEach((cell, _cellOffset, cellIndex) => {
if (cellIndex) {
state.write(' | ');
}
const cellContent = cell.firstChild;
if (cellContent && cellContent.textContent.trim()) {
state.renderInline(cellContent);
}
});
state.write(' |');
state.ensureNewLine();
if (!rowIndex) {
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
state.write(`| ${delimiterRow} |`);
state.ensureNewLine();
}
});
state.closeBlock(node);
state.inTable = false;
},
parse: {
// handled by markdown-it, same as tiptap-markdown's default table spec
}
}
};
}
});
@@ -1,33 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
describe('parseNoteLinkHref', () => {
it('should extract the note ID from a crumb-note:// href', () => {
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
});
it('should return null for a non-crumb-note href', () => {
expect(parseNoteLinkHref('https://example.com')).toBeNull();
});
it('should return null for an empty string', () => {
expect(parseNoteLinkHref('')).toBeNull();
});
});
describe('serializeNoteLinkMarkdown', () => {
it('should produce a markdown link with the title from the index', () => {
const titleIndex = new Map([['abc-123', 'Grocery List']]);
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
});
it('should fall back to Untitled when the ID is not in the index', () => {
const titleIndex = new Map<string, string>();
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
});
it('should escape markdown-special characters in the title', () => {
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
});
});
@@ -1,22 +0,0 @@
const NOTE_LINK_SCHEME = 'crumb-note://';
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
export function parseNoteLinkHref(href: string): string | null {
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
return href.slice(NOTE_LINK_SCHEME.length) || null;
}
/**
* Escapes markdown-special characters the same way a plain link's visible
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
* the surrounding markdown syntax.
*/
function escapeMarkdownText(text: string): string {
return text.replace(/[[\]\\*_`]/g, '\\$&');
}
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
const title = titleIndex.get(noteId) ?? 'Untitled';
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
}
+5 -124
View File
@@ -3,16 +3,9 @@ import { createTestDb } from './db/test-helpers.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes, randomUUID } from 'crypto';
import { randomBytes } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
import {
verifyPassword,
resetPassword,
findOrLinkOAuthUser,
createUser,
disablePasswordLogin
} from './auth.js';
let db: BetterSQLite3Database<typeof schema>;
@@ -23,16 +16,16 @@ beforeEach(() => {
describe('Auth - Password Hashing', () => {
it('should hash and verify a password correctly', async () => {
const password = randomUUID();
const password = 'mysecurepassword';
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, randomUUID())).toBe(false);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = randomUUID();
const password = 'mysecurepassword';
const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
@@ -44,7 +37,7 @@ describe('Auth - Password Hashing', () => {
describe('Auth - User Setup', () => {
it('should create a user with hashed password', async () => {
const password = randomUUID();
const password = 'testpassword123';
const hash = await argon2.hash(password);
await db.insert(users).values({
@@ -132,115 +125,3 @@ describe('Auth - Sessions', () => {
expect(session).toBeUndefined();
});
});
describe('Auth - password login enabled', () => {
it('verifyPassword succeeds when password login is enabled', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'pw@test.com',
displayName: 'PW User',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const user = await verifyPassword('pw@test.com', password, db);
expect(user?.email).toBe('pw@test.com');
});
it('verifyPassword rejects when password login is disabled even with a hash', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'oauth@test.com',
displayName: 'OAuth User',
passwordHash: hash,
passwordLoginEnabled: false,
authProvider: 'google',
providerId: 'gid-1',
createdAt: new Date()
});
expect(await verifyPassword('oauth@test.com', password, db)).toBeNull();
});
it('resetPassword enables password login on an OAuth-only account', async () => {
const [row] = await db
.insert(users)
.values({
email: 'enable@test.com',
displayName: 'Enable',
passwordLoginEnabled: false,
authProvider: 'none',
createdAt: new Date()
})
.returning();
const newPassword = randomUUID();
await resetPassword(row.id, newPassword, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(true);
expect(await argon2.verify(updated!.passwordHash!, newPassword)).toBe(true);
expect(await verifyPassword('enable@test.com', newPassword, db)).not.toBeNull();
});
it('findOrLinkOAuthUser preserves password login when linking SSO', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'both@test.com',
displayName: 'Both',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const linked = findOrLinkOAuthUser('google', 'google-id-1', 'both@test.com', 'Both', db);
expect(linked?.authProvider).toBe('google');
expect(linked?.passwordLoginEnabled).toBe(true);
expect(await verifyPassword('both@test.com', password, db)).not.toBeNull();
});
it('createUser sets passwordLoginEnabled from whether a password was given', async () => {
const withPassword = await createUser('a@test.com', 'A', 'password123', 'user', db);
expect(withPassword.passwordLoginEnabled).toBe(true);
const oauthOnly = await createUser('b@test.com', 'B', null, 'user', db);
expect(oauthOnly.passwordLoginEnabled).toBe(false);
});
it('disablePasswordLogin clears the hash, disables login, and removes sessions', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
const [row] = await db
.insert(users)
.values({
email: 'disable@test.com',
displayName: 'Disable',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: row.id,
expiresAt: new Date(Date.now() + 86400000)
});
disablePasswordLogin(row.id, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(false);
expect(updated?.passwordHash).toBeNull();
expect(await verifyPassword('disable@test.com', password, db)).toBeNull();
expect(await db.select().from(sessions).where(eq(sessions.userId, row.id)).all()).toHaveLength(0);
});
});
+40 -90
View File
@@ -1,4 +1,4 @@
import { db, sqlite, type Db } from './db/index.js';
import { db, sqlite } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
@@ -16,7 +16,6 @@ function toUser(row: typeof users.$inferSelect): User {
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
passwordLoginEnabled: row.passwordLoginEnabled,
providerId: row.providerId,
createdAt: row.createdAt
};
@@ -30,21 +29,19 @@ export async function isSetupComplete(): Promise<boolean> {
export async function setupUser(
email: string,
password: string,
displayName?: string,
dbInstance: Db = db
displayName?: string
): Promise<User | null> {
const existing = dbInstance.select().from(users).get();
const existing = db.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
const result = dbInstance
const result = db
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
@@ -55,13 +52,12 @@ export async function setupUser(
export async function verifyPassword(
email: string,
password: string,
dbInstance: Db = db
password: string
): Promise<User | null> {
const user = dbInstance
const user = db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.passwordLoginEnabled, true)))
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.get();
if (!user || !user.passwordHash) return null;
@@ -199,18 +195,16 @@ export async function createUser(
email: string,
displayName: string,
password: string | null,
role: 'admin' | 'user' = 'user',
dbInstance: Db = db
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = password ? await argon2.hash(password) : null;
const result = dbInstance
const result = db
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
passwordLoginEnabled: !!password,
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
@@ -233,53 +227,29 @@ export function getUser(userId: number): User | null {
}
export async function deleteUser(userId: number): Promise<void> {
// Atomic cascade: all-or-nothing. Also clear rows from other users that
// reference the deleted user's notes (e.g. sync_log, note_user_state,
// note_collaborators) so foreign-key checks don't block the final delete.
const tx = sqlite.transaction((uid: number) => {
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
sqlite
.prepare(
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid, uid);
sqlite
.prepare(
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
tx(userId);
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
sqlite
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
.run(userId, userId);
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
}
export async function changePassword(
@@ -288,7 +258,7 @@ export async function changePassword(
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash || !user.passwordLoginEnabled) return false;
if (!user || !user.passwordHash) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
@@ -298,26 +268,9 @@ export async function changePassword(
return true;
}
export async function resetPassword(
userId: number,
newPassword: string,
dbInstance: Db = db
): Promise<void> {
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
const hash = await argon2.hash(newPassword);
dbInstance
.update(users)
.set({ passwordHash: hash, passwordLoginEnabled: true })
.where(eq(users.id, userId))
.run();
}
export function disablePasswordLogin(userId: number, dbInstance: Db = db): void {
dbInstance
.update(users)
.set({ passwordHash: null, passwordLoginEnabled: false })
.where(eq(users.id, userId))
.run();
dbInstance.delete(sessions).where(eq(sessions.userId, userId)).run();
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
@@ -326,17 +279,15 @@ export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
* Linking OAuth updates the provider fields only — password login stays as configured.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string,
dbInstance: Db = db
name: string
): User | null {
// First try exact provider+providerId match
const byProvider = dbInstance
const byProvider = db
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
@@ -344,12 +295,11 @@ export function findOrLinkOAuthUser(
if (byProvider) return toUser(byProvider);
// Try matching by email (invite-only: user must already exist)
const byEmail = dbInstance.select().from(users).where(eq(users.email, email)).get();
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user without touching password login.
dbInstance
.update(users)
// Link this OAuth provider to the existing user
db.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
+2 -25
View File
@@ -28,7 +28,6 @@ sqlite.exec(`
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -227,13 +226,6 @@ sqlite.exec(`
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
`);
// Replace old tags unique index with compound index including user_id
@@ -270,32 +262,17 @@ if (pwCol && pwCol.notnull) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, password_login_enabled, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash,
CASE WHEN auth_provider = 'password' THEN 1 ELSE 0 END,
auth_provider, provider_id, created_at FROM users;
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Migration: add password_login_enabled (independent of OAuth provider)
const userColumnsAfterPw = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
if (!userColumnsAfterPw.some((col) => col.name === 'password_login_enabled')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN password_login_enabled INTEGER NOT NULL DEFAULT 0;`);
// Backfill: only accounts whose sign-in method was password keep it enabled.
// OAuth-linked rows may carry a legacy hash; leaving them disabled avoids
// silently widening access — an admin reset is the deliberate enable act.
sqlite.exec(`UPDATE users SET password_login_enabled = 1 WHERE auth_provider = 'password';`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
@@ -1,44 +0,0 @@
import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm';
import { createTestDb } from './test-helpers.js';
import { notes, noteLinks, users } from './schema.js';
describe('noteLinks schema', () => {
it('should store a source/target link row', () => {
const { db } = createTestDb();
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
});
it('should cascade-delete link rows when the source note is deleted', () => {
const { db, sqlite } = createTestDb();
sqlite.pragma('foreign_keys = ON');
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
db.delete(notes).where(eq(notes.id, 'source-note')).run();
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
-19
View File
@@ -6,9 +6,6 @@ export const users = sqliteTable('users', {
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
passwordLoginEnabled: integer('password_login_enabled', { mode: 'boolean' })
.notNull()
.default(false),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
@@ -83,22 +80,6 @@ export const noteTags = sqliteTable(
]
);
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
-9
View File
@@ -19,7 +19,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -69,13 +68,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -174,7 +166,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
displayName: 'Test User',
role: 'admin',
authProvider: 'password',
passwordLoginEnabled: true,
createdAt: new Date()
}).run();
}
+2 -155
View File
@@ -1,8 +1,5 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { lookup } from 'node:dns/promises';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { NoteFilter } from '$lib/types/index.js';
import {
listNotes,
@@ -32,134 +29,6 @@ const NOTE_COLORS = [
'chalk'
] as const;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
const MAX_IMAGE_REDIRECTS = 5;
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
// upload_image can't be used to reach internal network services.
function isPrivateOrReservedIp(address: string): boolean {
if (address.includes(':')) {
const normalized = address.toLowerCase();
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fe80:')) return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
return false;
}
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
return false;
}
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
// (not just the first), so a validated hostname can't have a private record hiding
// behind a public one in the same DNS answer.
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
const records = await lookup(hostname, { all: true });
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
for (const record of records) {
if (isPrivateOrReservedIp(record.address)) {
throw new Error('URL resolves to a private or reserved address');
}
}
return records[0];
}
interface PinnedResponse {
ok: boolean;
status: number;
statusText: string;
headers: { get(name: string): string | null };
arrayBuffer(): Promise<ArrayBuffer>;
}
// Issues the request against the exact address validated by resolveValidatedAddress,
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
// could serve a public address to the validator and a private one to the real connect.
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
return new Promise((resolve, reject) => {
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = requestFn(
{
hostname: url.hostname,
servername: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
timeout: 15000,
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
callback(null, address, family);
}
},
(res) => {
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_IMAGE_BYTES) {
req.destroy(new Error('Image too large (max 10MB)'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const buffer = Buffer.concat(chunks);
const status = res.statusCode ?? 0;
resolve({
ok: status >= 200 && status < 300,
status,
statusText: res.statusMessage ?? '',
headers: {
get: (name: string) => {
const value = res.headers[name.toLowerCase()];
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
}
},
arrayBuffer: async () =>
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
let currentUrl = new URL(imageUrl);
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
}
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
const response = await requestPinnedAddress(currentUrl, address, family);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) return response;
currentUrl = new URL(location, currentUrl);
continue;
}
return response;
}
throw new Error('Too many redirects');
}
export function createMcpServer(userId: number): McpServer {
const server = new McpServer({
name: 'crumbs',
@@ -433,7 +302,7 @@ export function createMcpServer(userId: number): McpServer {
}
try {
const response = await fetchPublicImage(imageUrl);
const response = await fetch(imageUrl);
if (!response.ok) {
return {
content: [
@@ -446,30 +315,8 @@ export function createMcpServer(userId: number): McpServer {
};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.startsWith('image/')) {
return {
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
isError: true
};
}
const contentLength = Number(response.headers.get('content-length'));
if (contentLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const contentType = response.headers.get('content-type') || 'image/png';
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
const filename = `upload_${Date.now()}.${ext}`;
-98
View File
@@ -1,98 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
let db: Db;
const OWNER_ID = 1;
const OTHER_USER_ID = 2;
function seedUser(id: number, email: string) {
db.insert(users)
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string, userId: number, title = '') {
db.insert(notes)
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser(OWNER_ID, 'owner@test.com');
seedUser(OTHER_USER_ID, 'other@test.com');
});
describe('syncNoteLinks', () => {
it('should create link rows for each target', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toHaveLength(2);
});
it('should remove old links and add new ones on re-sync', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', ['target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
});
it('should handle an empty target list (removes all links)', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', []);
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
describe('fetchBacklinksForNote', () => {
it('should return notes that link to the given note', () => {
seedNote('source', OWNER_ID, 'Source Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
});
it('should return an empty array when nothing links to the note', () => {
seedNote('target', OWNER_ID, 'Target Note');
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should exclude a backlink from a source note the requesting user cannot access', () => {
seedNote('source', OTHER_USER_ID, 'Other Users Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should include a backlink from a source note shared with the requesting user', () => {
seedNote('source', OTHER_USER_ID, 'Shared Source');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
db.insert(noteCollaborators)
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
.run();
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
});
});
-48
View File
@@ -1,48 +0,0 @@
import type { Db } from './db/index.js';
import { noteLinks, notes } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm';
import { canAccessNote } from './api-utils.js';
/**
* Sync link rows for a note: removes old associations, inserts the current set.
* Mirrors syncNoteTags — called right after a note's content is saved.
*/
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
for (const targetId of targetIds) {
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
}
}
/**
* Find notes that link to the given note, scoped to what the requesting user
* can access — this both respects normal sharing boundaries and, as a side
* effect, prevents an unrelated note from appearing in someone's backlinks
* just because it happens to reference a note ID it was never shared to see.
*/
export function fetchBacklinksForNote(
db: Db,
noteId: string,
userId: number
): { id: string; title: string }[] {
const linkRows = db
.select({ sourceNoteId: noteLinks.sourceNoteId })
.from(noteLinks)
.where(eq(noteLinks.targetNoteId, noteId))
.all();
if (linkRows.length === 0) return [];
const sourceIds = linkRows.map((r) => r.sourceNoteId);
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
if (accessibleIds.length === 0) return [];
const sourceNotes = db
.select({ id: notes.id, title: notes.title })
.from(notes)
.where(inArray(notes.id, accessibleIds))
.all();
return sourceNotes;
}
-82
View File
@@ -263,46 +263,6 @@ describe('updateNote', () => {
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
expect(updated!.tags).toContain('newtag');
});
it('should merge concurrent content edits using the snapshot created by the first update', () => {
seedNote('n1', {
title: 'Shopping',
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
version: 1
});
shareNote('n1');
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
content: '- [ ] Milk\n- [x] Bread',
baseVersion: 1
});
expect(ownerUpdate!.version).toBe(2);
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
content: '- [x] Milk\n- [ ] Bread',
baseVersion: 1
});
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
});
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
// A metadata-only change bumps the version but creates no snapshot.
updateNote(db, OWNER_ID, 'n1', { pinned: true });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
// A later concurrent content edit based on the pre-bump version therefore has
// no base to merge against — the incoming edit must be preserved, not dropped.
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
expect(edited!.content).toBe('hello world');
});
});
describe('deleteNote', () => {
@@ -374,45 +334,3 @@ describe('reorderNotes', () => {
expect(collabView!.sortOrder).toBe(5);
});
});
describe('note links', () => {
it('should sync note-links when a note is created with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should sync note-links when a note is updated with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([]);
});
it('should return an empty backlinks array for a note nothing links to', () => {
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
const reopened = getNote(db, OWNER_ID, note.id);
expect(reopened?.backlinks).toEqual([]);
});
});
+45 -42
View File
@@ -1,17 +1,15 @@
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { extractNoteLinks } from '$lib/utils/note-links.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot, getBaseContent } from './versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import { createSnapshot } from './versions-service.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import type { NoteFilter } from '$lib/types/index.js';
interface NoteRow {
@@ -157,10 +155,7 @@ export function getNote(db: Db, userId: number, id: string) {
}
const result = hydrateNotes(db, [effectiveNote], userId);
const hydrated = result[0];
if (!hydrated) return null;
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
return result[0] ?? null;
}
export interface CreateNoteInput {
@@ -202,9 +197,6 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
});
syncNoteTags(db, id, extractedTags, userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
return { ...newNote, tags: extractedTags };
}
@@ -246,18 +238,30 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
if (input.content !== undefined) {
// Preserve concurrent edits at line/block granularity when the client
// saved from an older note version.
const hasConcurrentEdit = input.baseVersion !== undefined
&& input.baseVersion < existing.version;
// 3-way merge to preserve concurrent edits from other users
if (input.content !== existing.content) {
const baseSnapshot = input.baseVersion !== undefined
? db.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get()
: db.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, id))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
sharedUpdates.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(db, id, input.baseVersion),
incomingContent: input.content,
currentContent: existing.content
})
: input.content;
if (baseSnapshot && baseSnapshot.content !== existing.content) {
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
} else {
sharedUpdates.content = input.content;
}
} else {
sharedUpdates.content = input.content;
}
hasSharedUpdates = true;
}
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
@@ -275,22 +279,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
}
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(sharedUpdates.content !== undefined && sharedUpdates.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
if (hasSharedUpdates || isOwner) {
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
}
@@ -334,11 +322,26 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(db, id, extractedTags, existing.userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
}
}
// Create a version snapshot only when at least one content field actually changed
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(input.content !== undefined && input.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
return getNote(db, userId, id);
}
+1 -27
View File
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
import { users, notes, noteVersions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
let db: Db;
@@ -44,32 +44,6 @@ beforeEach(() => {
seedUser();
});
describe('getBaseContent', () => {
it('returns null when the base version is unknown', () => {
seedNote();
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
});
it('returns the snapshot content at or before the base version', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
});
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
seedNote();
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
// pruned). Using it as the merge base would treat unseen lines as deletions,
// so getBaseContent must return null rather than fall back to it.
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
});
});
describe('createSnapshot', () => {
it('should store a snapshot for a note', () => {
seedNote();
+2 -30
View File
@@ -1,46 +1,18 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc, lte } from 'drizzle-orm';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
const MAX_VERSIONS_PER_NOTE = 50;
const CONTENT_PREVIEW_LENGTH = 80;
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
export type SnapshotDb = Db | Transaction;
/**
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
* at or before the client's base version. Returns null when the base version is
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
* snapshot as the base — a base newer than what the client actually saw makes the
* merge treat other users' lines as deletions and silently drop them.
*/
export function getBaseContent(
db: SnapshotDb,
noteId: string,
baseVersion: number | undefined
): string | null {
if (baseVersion === undefined) return null;
const snapshot = db
.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return snapshot?.content ?? null;
}
/**
* Create a snapshot of a note's current state.
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
*/
export function createSnapshot(
db: SnapshotDb,
db: Db,
noteId: string,
snapshot: {
version: number;
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
import type { Note } from '$lib/types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
return {
id: 'note-1',
title: 'Test',
content: '- [ ] Item',
color: 'default',
pinned: false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: true,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
version: 1,
tags: [],
attachments: [],
collaborators: [],
isOwner: true,
isShared: false,
...overrides
};
}
describe('mergeNotesByVersion', () => {
it('keeps the local note when it has a higher version than the incoming note', () => {
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('- [x] Checked');
expect(result[0].version).toBe(3);
});
it('uses the incoming note when it has a higher or equal version', () => {
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('- [x] New');
expect(result[0].version).toBe(3);
});
it('uses the incoming note when versions are equal', () => {
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result[0].content).toBe('incoming');
});
it('handles notes not present in the local store', () => {
const incoming = makeNote({ id: 'n2', version: 1 });
const result = mergeNotesByVersion([], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('n2');
});
it('preserves local-only notes not present in the incoming data', () => {
const localOnly = makeNote({ id: 'n1', version: 1, content: 'just created' });
const serverNote = makeNote({ id: 'n2', version: 1, content: 'from server' });
const result = mergeNotesByVersion([localOnly], [serverNote]);
expect(result).toHaveLength(2);
expect(result.find((n) => n.id === 'n1')?.content).toBe('just created');
expect(result.find((n) => n.id === 'n2')?.content).toBe('from server');
});
it('preserves multiple notes correctly during a race condition', () => {
const localNotes = [
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
];
const incomingNotes = [
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
];
const result = mergeNotesByVersion(localNotes, incomingNotes);
expect(result).toHaveLength(2);
// n1: local version 5 > incoming version 4 → keep local
expect(result[0].content).toBe('- [x] Just saved');
// n2: local version 1 < incoming version 2 → use incoming
expect(result[1].content).toBe('updated on server');
});
});
describe('reconcileSearchResults', () => {
it('replaces a stale result with the canonical note', () => {
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
const result = reconcileSearchResults(
[canonical],
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
);
expect(result).toEqual([canonical]);
});
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
expect(result).toEqual([serverHit]);
});
it('drops a result that is trashed in the canonical store', () => {
const trashed = makeNote({ id: 'n1', trashed: true });
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
expect(result).toEqual([]);
});
it('preserves result order while reconciling a mix of cases', () => {
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
const trashed = makeNote({ id: 'n2', trashed: true });
const absent = makeNote({ id: 'n3', title: 'Absent' });
const result = reconcileSearchResults(
[canonical, trashed],
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
);
expect(result).toEqual([canonical, absent]);
});
it('returns an empty array when there are no results', () => {
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
});
});
+5 -61
View File
@@ -8,41 +8,11 @@ export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
export const selectedTag = writable<string | null>(null);
export const notesLoaded = writable(false);
export const searchQuery = writable<string>('');
export const searchResults = writable<Note[]>([]);
/**
* Reconcile search results against the canonical notes store so local edits and
* trashing propagate into the active result set without re-querying the server.
*
* A result absent from the canonical store is kept as-is — it's a server hit not
* yet loaded locally (e.g. created on another device), and dropping it would make
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
* pruned explicitly at their call sites; trashed notes are dropped here.
*/
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
return results.flatMap((result) => {
const canonical = notesMap.get(result.id);
if (!canonical) return [result];
return canonical.trashed ? [] : [canonical];
});
}
// Keep searchResults in sync with edits/trashing in the notes store. These search
// stores (like the others in this module) are client-only state; the subscription
// lives for the app's lifetime and is intentionally never unsubscribed.
notes.subscribe(($notes) => {
searchResults.update(($results) => {
if ($results.length === 0) return $results;
return reconcileSearchResults($notes, $results);
});
});
export const filteredNotes = derived(
[notes, selectedTag, currentFilter, searchQuery, searchResults],
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
let result = $searchQuery.trim() ? $searchResults : $notes;
[notes, selectedTag, currentFilter],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
@@ -83,35 +53,12 @@ function isNetworkError(err: unknown): boolean {
return err instanceof TypeError;
}
/**
* Merge incoming notes with current store, keeping the higher-version note
* when a conflict exists. This prevents a stale loadNotes response from
* overwriting a just-saved note (race between sync interval and updateNote).
*/
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
const currentMap = new Map(current.map((n) => [n.id, n]));
const incomingIds = new Set<string>();
const merged = incoming.map((incomingNote) => {
incomingIds.add(incomingNote.id);
const local = currentMap.get(incomingNote.id);
if (local && local.version > incomingNote.version) return local;
return incomingNote;
});
// Preserve local-only notes (e.g. just created, not yet in server/IDB response)
for (const local of current) {
if (!incomingIds.has(local.id)) {
merged.push(local);
}
}
return merged;
}
export async function loadNotes(filter: NoteFilter = 'all') {
// Load from IDB first for instant display
try {
const cached = await getAllNotes();
if (cached.length > 0) {
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
notes.set(cached);
}
} catch {
// IDB unavailable — continue with server fetch
@@ -124,7 +71,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data: Note[] = await res.json();
notes.update((current) => mergeNotesByVersion(current, data));
notes.set(data);
currentFilter.set(filter);
// Update IDB with server state
@@ -248,7 +195,6 @@ export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
@@ -256,7 +202,6 @@ export async function deleteNote(id: string): Promise<boolean> {
} catch (err) {
if (isNetworkError(err)) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
@@ -295,7 +240,6 @@ export async function leaveNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
+1 -59
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from '../server/db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from '../server/db/schema.js';
@@ -289,64 +289,6 @@ describe('processSyncPush — shared notes', () => {
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
});
it('should create a base snapshot during sync so later queued edits can merge', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: t1,
createdAt: t1,
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
version: 1
});
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 1000,
data: { content: '- [ ] Milk\n- [x] Bread' },
baseVersion: 1
}], OWNER_ID);
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 2000,
data: { content: '- [x] Milk\n- [ ] Bread' },
baseVersion: 1
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
});
it('should retain at most 50 snapshots created by sync updates', async () => {
const createdAt = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: createdAt,
createdAt,
content: 'version 1',
version: 1
});
for (let version = 2; version <= 52; version++) {
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: createdAt.getTime() + version,
data: { content: `version ${version}` },
baseVersion: version - 1
}], OWNER_ID);
}
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(50);
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
});
it('should not allow non-collaborator to update the note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
// Create note without sharing with user 3
+38 -30
View File
@@ -1,12 +1,37 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, sql } from 'drizzle-orm';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import type { SyncQueueItem } from './idb.js';
/**
* Find the base content for 3-way merge from version snapshots.
* Returns the content from the snapshot at or before the given version,
* or the current note content if no snapshot is found.
*/
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
if (baseVersion !== undefined) {
const snapshot = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (snapshot) return snapshot.content;
}
// Fallback: get the most recent snapshot before the current version
const latest = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return latest?.content ?? null;
}
/**
* Process incoming sync changes from client.
*/
@@ -44,17 +69,16 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
// Content: merge only when the client saved from an older note version.
// Content: 3-way merge to preserve concurrent edits from other users
if (change.data.content !== undefined && change.data.content !== note.content) {
const hasConcurrentEdit = change.baseVersion !== undefined
&& change.baseVersion < note.version;
sharedData.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
incomingContent: change.data.content,
currentContent: note.content
})
: change.data.content;
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
if (baseContent !== null && baseContent !== note.content) {
// 3-way merge: base, incoming (local), server current (remote)
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
} else {
// No base or base matches server — take incoming content
sharedData.content = change.data.content;
}
}
if (isOwner) {
@@ -68,22 +92,6 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
const hasActualContentChange =
(change.data.title !== undefined && change.data.title !== note.title) ||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
(change.data.color !== undefined && change.data.color !== note.color) ||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
if (hasActualContentChange) {
createSnapshot(tx, change.noteId, {
version: note.version,
title: note.title,
content: note.content,
checklistMode: note.checklistMode,
color: note.color
});
}
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
-7
View File
@@ -18,12 +18,6 @@ export interface Note {
isOwner?: boolean;
isShared?: boolean;
shareToken?: string;
backlinks?: NoteBacklink[];
}
export interface NoteBacklink {
id: string;
title: string;
}
export interface Collaborator {
@@ -75,7 +69,6 @@ export interface User {
displayName: string;
role: 'admin' | 'user';
authProvider: string;
passwordLoginEnabled: boolean;
providerId: string | null;
createdAt: Date;
}
-95
View File
@@ -1,95 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
canResetPassword,
passwordResetBlockedReason,
requiresPasswordForAccountDeletion,
canDisablePasswordLogin
} from './auth-methods.js';
describe('canResetPassword', () => {
it('allows a reset for a password account', () => {
expect(canResetPassword({ authProvider: 'password', passwordLoginEnabled: true })).toBe(true);
});
it('allows a reset for an OAuth-only account', () => {
expect(canResetPassword({ authProvider: 'none', passwordLoginEnabled: false })).toBe(true);
});
it('allows a reset for an OAuth-linked account', () => {
expect(canResetPassword({ authProvider: 'google', passwordLoginEnabled: true })).toBe(true);
});
});
describe('passwordResetBlockedReason', () => {
it('returns null for every account type', () => {
for (const user of [
{ authProvider: 'password', passwordLoginEnabled: true },
{ authProvider: 'none', passwordLoginEnabled: false },
{ authProvider: 'google', passwordLoginEnabled: true },
{ authProvider: 'oidc', passwordLoginEnabled: false }
]) {
expect(passwordResetBlockedReason(user)).toBeNull();
}
});
});
describe('requiresPasswordForAccountDeletion', () => {
it('requires password for a pure password account', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'password', passwordLoginEnabled: true })
).toBe(true);
});
it('requires password for OAuth-only with admin-enabled password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: true })
).toBe(true);
});
it('does not require password for OAuth-linked accounts even when password login is enabled', () => {
for (const authProvider of ['google', 'github', 'oidc']) {
expect(
requiresPasswordForAccountDeletion({ authProvider, passwordLoginEnabled: true }),
authProvider
).toBe(false);
}
});
it('does not require password for OAuth-only without password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: false })
).toBe(false);
});
});
describe('canDisablePasswordLogin', () => {
it('allows disabling for an OAuth-linked user with password login enabled', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(true);
});
it('blocks disabling on the admin own row', () => {
expect(
canDisablePasswordLogin({ id: 1, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling when password login is already off', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: false, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling on a password-only account', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'password' }, 1)
).toBe(false);
});
it('blocks disabling on OAuth-only with admin-enabled password but no provider link', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'none' }, 1)
).toBe(false);
});
});
-51
View File
@@ -1,51 +0,0 @@
import type { User } from '$lib/types/index.js';
/**
* Whether an admin may set a new password for this account.
*
* Password login is gated on `passwordLoginEnabled`, which resetPassword() sets
* deliberately — so an admin can enable password auth on any account, including
* OAuth-only ones, without clearing their SSO link.
*/
export function canResetPassword(_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>): boolean {
return true;
}
/**
* Why a password reset is blocked, or null when it is allowed.
*
* Eligibility is checked in the UI for the signed-in admin's own row; every
* other account may receive a reset that enables password login.
*/
export function passwordResetBlockedReason(
_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>
): string | null {
return null;
}
/**
* Whether self-service account deletion must verify the user's password.
*
* OAuth-linked accounts may delete with their active SSO session alone, even
* when an admin has enabled password login. Pure password accounts, and
* OAuth-only rows with password login enabled but no provider link, still
* require the password.
*/
export function requiresPasswordForAccountDeletion(
user: Pick<User, 'authProvider' | 'passwordLoginEnabled'>
): boolean {
if (user.authProvider === 'password') return true;
if (user.authProvider === 'none' && user.passwordLoginEnabled) return true;
return false;
}
/** Whether an admin may turn off password login for this account. */
export function canDisablePasswordLogin(
user: Pick<User, 'id' | 'passwordLoginEnabled' | 'authProvider'>,
adminId: number
): boolean {
if (user.id === adminId || !user.passwordLoginEnabled) return false;
// Only when another sign-in method exists — disabling on a password-only row
// would lock the user out with no OAuth fallback.
return user.authProvider !== 'password' && user.authProvider !== 'none';
}
-326
View File
@@ -1,326 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
describe('parseChecklist', () => {
it('parses flat checklist items', () => {
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
});
it('parses nested children with 2-space prefix', () => {
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
});
it('treats orphaned children as top-level', () => {
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
});
it('returns one empty item for blank content', () => {
const items = parseChecklist('');
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
});
it('handles mixed top-level and nested items', () => {
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
const items = parseChecklist(content);
expect(items).toHaveLength(5);
expect(items[1].parentId).toBe(items[0].id);
expect(items[2].parentId).toBe(items[0].id);
expect(items[4].parentId).toBe(items[3].id);
});
});
describe('serializeChecklist', () => {
it('serializes flat items', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Milk', checked: false, parentId: null },
{ id: '2', text: 'Eggs', checked: true, parentId: null }
];
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
});
it('serializes nested items with 2-space prefix', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Groceries', checked: false, parentId: null },
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
];
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
});
it('groups children under parents regardless of array order', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '3', text: 'B', checked: false, parentId: null },
{ id: '2', text: 'A1', checked: false, parentId: '1' }
];
const result = serializeChecklist(items);
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
});
it('round-trips parse then serialize', () => {
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
const items = parseChecklist(original);
expect(serializeChecklist(items)).toBe(original);
});
});
describe('toggleItemWithCascade', () => {
it('checking a parent checks all children', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
];
const result = toggleItemWithCascade('1', items);
expect(result.every((i) => i.checked)).toBe(true);
});
it('unchecking a parent unchecks all children', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: true, parentId: null },
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
];
const result = toggleItemWithCascade('1', items);
expect(result.every((i) => !i.checked)).toBe(true);
});
it('checking a child does not affect parent', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '2', text: 'Child', checked: false, parentId: '1' }
];
const result = toggleItemWithCascade('2', items);
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
});
it('unchecking a child with deleted parent orphans it', () => {
const items: ChecklistItem[] = [
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
];
const result = toggleItemWithCascade('2', items);
expect(result[0].checked).toBe(false);
expect(result[0].parentId).toBeNull();
});
it('unchecking a child repositions it after its parent and siblings', () => {
// Simulates: parent + 2 children, one child checked (at end of array as done item)
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '3', text: 'Child B', checked: false, parentId: '1' },
{ id: '4', text: 'Other', checked: false, parentId: null },
{ id: '2', text: 'Child A', checked: true, parentId: '1' } // done, at end
];
const result = toggleItemWithCascade('2', items);
// Child A should be repositioned after Child B (last sibling), not stay at end
const ids = result.map(i => i.id);
expect(ids).toEqual(['1', '3', '2', '4']);
expect(result.find(i => i.id === '2')!.checked).toBe(false);
});
});
describe('indentItem', () => {
it('sets parentId to nearest preceding top-level item', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: null }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
});
it('no-op if already a child', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result).toEqual(items);
});
it('no-op if first item (no parent above)', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null }
];
const active = items.filter(i => !i.checked);
const result = indentItem('1', items, active);
expect(result[0].parentId).toBeNull();
});
it('reparents children to grandparent when indenting a parent', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: null },
{ id: '3', text: 'C', checked: false, parentId: '2' },
{ id: '4', text: 'D', checked: false, parentId: '2' }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
});
});
describe('outdentItem', () => {
it('sets parentId to null', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
});
it('no-op if already top-level', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null }
];
const result = outdentItem('1', items);
expect(result[0].parentId).toBeNull();
});
it('adopts consecutive siblings below with same parentId', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' },
{ id: '3', text: 'C', checked: false, parentId: '1' },
{ id: '4', text: 'D', checked: false, parentId: '1' },
{ id: '5', text: 'E', checked: false, parentId: null }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
});
it('stops adoption at first non-sibling', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' },
{ id: '5', text: 'E', checked: false, parentId: null },
{ id: '3', text: 'C', checked: false, parentId: '1' }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
});
});
describe('linkifyText', () => {
it('converts https URLs to anchor tags', () => {
expect(linkifyText('visit https://example.com today')).toBe(
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
);
});
it('converts http URLs to anchor tags', () => {
expect(linkifyText('go to http://example.com')).toBe(
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
);
});
it('converts bare www URLs with https href', () => {
expect(linkifyText('check www.example.com')).toBe(
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
);
});
it('handles multiple URLs in one string', () => {
const result = linkifyText('see https://a.com and https://b.com');
expect(result).toContain('href="https://a.com"');
expect(result).toContain('href="https://b.com"');
});
it('returns plain text unchanged when no URLs present', () => {
expect(linkifyText('just some text')).toBe('just some text');
});
it('HTML-escapes user content to prevent XSS', () => {
expect(linkifyText('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('HTML-escapes text around URLs', () => {
expect(linkifyText('<b>bold</b> https://example.com')).toContain('&lt;b&gt;bold&lt;/b&gt;');
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
});
it('handles URL at start of string', () => {
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
});
it('handles URL at end of string', () => {
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
});
it('does not linkify partial matches without protocol or www', () => {
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
});
it('handles URLs with paths and query strings', () => {
const url = 'https://example.com/path?q=1&b=2';
const result = linkifyText(url);
expect(result).toContain('href="https://example.com/path?q=1&amp;b=2"');
});
it('strips trailing punctuation from URLs', () => {
expect(linkifyText('see https://example.com.')).toBe(
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
);
});
it('returns empty string for empty input', () => {
expect(linkifyText('')).toBe('');
});
});
describe('unlinkifyHtml', () => {
it('strips anchor tags and returns text content', () => {
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
.toBe('visit https://example.com today');
});
it('returns plain text unchanged', () => {
expect(unlinkifyHtml('just text')).toBe('just text');
});
it('handles multiple anchor tags', () => {
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
.toBe('https://a.com and https://b.com');
});
it('returns empty string for empty input', () => {
expect(unlinkifyHtml('')).toBe('');
});
it('handles div and br tags from contenteditable', () => {
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
});
it('decodes HTML entities after stripping tags', () => {
expect(unlinkifyHtml('Tom &amp; Jerry')).toBe('Tom & Jerry');
expect(unlinkifyHtml('&lt;b&gt;bold&lt;/b&gt;')).toBe('<b>bold</b>');
expect(unlinkifyHtml('say &quot;hello&quot;')).toBe('say "hello"');
});
it('converts &nbsp; from contenteditable to regular spaces', () => {
expect(unlinkifyHtml('hello&nbsp;world')).toBe('hello world');
expect(unlinkifyHtml('a&nbsp;&nbsp;b')).toBe('a b');
});
});
-199
View File
@@ -1,199 +0,0 @@
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
parentId: string | null;
}
export function generateId(): string {
return crypto.randomUUID();
}
export function isChild(item: ChecklistItem): boolean {
return item.parentId !== null;
}
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
return items.filter((i) => i.parentId === parentId);
}
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
}
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
const target = items.find((i) => i.id === id)!;
const newChecked = !target.checked;
let result = items.map((item) => {
if (item.id === id) return { ...item, checked: newChecked };
if (item.parentId === id) return { ...item, checked: newChecked };
return item;
});
// When unchecking a child: reposition it after its parent/siblings in the array
// (otherwise it stays at the end of the array where done items live)
if (!newChecked && target.parentId) {
const parentExists = result.some((i) => i.id === target.parentId);
if (!parentExists) {
// Parent deleted — orphan it
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
} else {
// Remove the item from its current position
const uncheckedItem = result.find((i) => i.id === id)!;
result = result.filter((i) => i.id !== id);
// Find the last sibling or the parent, insert after it
let insertAfter = -1;
for (let i = 0; i < result.length; i++) {
if (result[i].id === target.parentId || result[i].parentId === target.parentId) {
insertAfter = i;
}
}
if (insertAfter >= 0) {
result.splice(insertAfter + 1, 0, uncheckedItem);
} else {
result.push(uncheckedItem);
}
}
}
return result;
}
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
const item = items.find((i) => i.id === id);
if (!item || item.parentId !== null) return items;
const activeIndex = activeItems.findIndex((i) => i.id === id);
let newParentId: string | null = null;
for (let i = activeIndex - 1; i >= 0; i--) {
if (activeItems[i].parentId === null) {
newParentId = activeItems[i].id;
break;
}
}
if (!newParentId) return items;
return items.map((i) => {
if (i.id === id) return { ...i, parentId: newParentId };
if (i.parentId === id) return { ...i, parentId: newParentId };
return i;
});
}
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
const index = items.findIndex((i) => i.id === id);
const item = items[index];
if (!item || item.parentId === null) return items;
const originalParentId = item.parentId;
const adoptees: string[] = [];
for (let i = index + 1; i < items.length; i++) {
if (items[i].parentId === originalParentId) {
adoptees.push(items[i].id);
} else {
break;
}
}
return items.map((i) => {
if (i.id === id) return { ...i, parentId: null };
if (adoptees.includes(i.id)) return { ...i, parentId: id };
return i;
});
}
export function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
let lastTopLevelId: string | null = null;
return text.split('\n').map((line) => {
if (line.startsWith(' - [x] ')) {
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
}
if (line.startsWith(' - [ ] ')) {
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
}
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
if (line.startsWith('- [x] ')) {
item.text = line.slice(6);
item.checked = true;
} else if (line.startsWith('- [ ] ')) {
item.text = line.slice(6);
} else {
item.text = line;
}
lastTopLevelId = item.id;
return item;
});
}
export function serializeChecklist(items: ChecklistItem[]): string {
const topLevel = items.filter((i) => i.parentId === null);
const childMap = new Map<string, ChecklistItem[]>();
for (const item of items) {
if (item.parentId) {
const siblings = childMap.get(item.parentId) ?? [];
siblings.push(item);
childMap.set(item.parentId, siblings);
}
}
const lines: string[] = [];
for (const parent of topLevel) {
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
const children = childMap.get(parent.id) ?? [];
for (const child of children) {
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
}
}
return lines.join('\n');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
export function linkifyText(text: string): string {
if (!text) return '';
const matches: { index: number; match: string; href: string }[] = [];
let m: RegExpExecArray | null;
const regex = new RegExp(URL_REGEX.source, 'g');
while ((m = regex.exec(text)) !== null) {
const match = m[0];
const href = match.startsWith('www.') ? `https://${match}` : match;
matches.push({ index: m.index, match, href });
}
if (matches.length === 0) return escapeHtml(text);
let result = '';
let lastIndex = 0;
for (const { index, match, href } of matches) {
result += escapeHtml(text.slice(lastIndex, index));
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
lastIndex = index + match.length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
export function unlinkifyHtml(html: string): string {
if (!html) return '';
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
}
+4 -123
View File
@@ -1,12 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
@@ -15,13 +8,13 @@ describe('getNoteColor', () => {
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
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('#21201f');
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
});
it('has matching keys in light and dark color maps', () => {
@@ -30,115 +23,3 @@ describe('getNoteColor', () => {
expect(lightKeys).toEqual(darkKeys);
});
});
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** WCAG relative luminance. */
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps muted text above the AA threshold on every dark card', () => {
// The shipped dark theme failed this at 3.77:1 with its previous muted
// colour (#9a8e7e). This guard exists so a future palette edit cannot
// reintroduce it — it does not discriminate the old surfaces, which were
// darker and so scored higher against the lifted muted colour.
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps every pair of dark surfaces distinguishable', () => {
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
// colours read as one dark smudge. Threshold 8 clears that while leaving
// headroom above the current closest pair, coral/peach at 12.0.
const entries = Object.entries(NOTE_COLORS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a.bg, b.bg);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
}
}
});
});
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a, b);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
// A chip set to card-lightness values could still pass the pairwise
// distance test above (25 apart) while being just as dark as the cards,
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
// side of the 0.1 threshold below.
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
const maxCardLuminance = Math.max(...cardLuminances);
expect(maxCardLuminance).toBeLessThan(0.1);
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
}
});
});
+12 -38
View File
@@ -16,18 +16,18 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
};
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
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 {
@@ -39,29 +39,3 @@ export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]
value: value as NoteColor,
label
}));
/**
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
* reads as a grey dot — small patches lose apparent saturation — so the dark
* picker uses vivid mid-lightness chips that share the card's hue but not its
* lightness. Light mode needs no equivalent: there the pastels are the cards.
*/
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c',
coral: '#d04f43',
peach: '#cd7c42',
sand: '#c7b138',
mint: '#7bbd4c',
sage: '#47b89d',
fog: '#609abe',
storm: '#547bc0',
dusk: '#a965bd',
blossom: '#c05d78',
clay: '#b5954a',
chalk: '#c3c3c8'
};
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false);
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
+10 -49
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentUpdate } from './content-merge.js';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
@@ -89,56 +89,17 @@ describe('mergeContent (3-way)', () => {
});
});
describe('mergeContentUpdate', () => {
it('should merge an incoming update against current content when a base is available', () => {
const result = mergeContentUpdate({
baseContent: '- [ ] Milk\n- [ ] Bread',
incomingContent: '- [x] Milk\n- [ ] Bread',
currentContent: '- [ ] Milk\n- [x] Bread'
});
expect(result).toBe('- [x] Milk\n- [x] Bread');
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
});
it('should keep the incoming edit when no base snapshot is available', () => {
// Without a common ancestor a 3-way merge is impossible; dropping the
// incoming edit would silently lose the user's changes.
const result = mergeContentUpdate({
baseContent: null,
incomingContent: '- [x] Milk',
currentContent: '- [ ] Bread'
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
expect(result).toBe('- [x] Milk');
});
it('should keep current content when the incoming edit equals it', () => {
const result = mergeContentUpdate({
baseContent: null,
incomingContent: 'same',
currentContent: 'same'
});
expect(result).toBe('same');
});
it('should keep current content when the incoming edit matches the base (no local change)', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'base',
currentContent: 'server changed'
});
expect(result).toBe('server changed');
});
it('should take the incoming edit when the server is unchanged from the base', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'incoming change',
currentContent: 'base'
});
expect(result).toBe('incoming change');
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
});
});
+6 -23
View File
@@ -78,28 +78,11 @@ export function mergeContent(base: string, local: string, remote: string): strin
}
/**
* Merge an incoming content snapshot into the current server content.
*
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
* change that created no snapshot — a 3-way merge is impossible, so keep the
* incoming edit rather than silently discarding the user's changes.
* 2-way merge fallback when no base is available.
* Uses an empty string as the base, so all lines from both sides appear
* as additions. For conflicts (same region), prefers remote.
*/
export function mergeContentUpdate(input: {
baseContent: string | null;
incomingContent: string;
currentContent: string;
}): string {
const { baseContent, incomingContent, currentContent } = input;
if (incomingContent === currentContent) return currentContent;
if (baseContent === incomingContent) return currentContent;
if (baseContent === currentContent) return incomingContent;
if (baseContent === null) {
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
return incomingContent;
}
return mergeContent(baseContent, incomingContent, currentContent);
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, remote);
}
-156
View File
@@ -1,156 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CODE_LANGUAGES, editorLowlight, highlightCode, isKnownLanguage, lowlight } from './highlight.js';
describe('highlightCode', () => {
it('wraps tokens in hljs-* spans for a known language', () => {
const html = highlightCode('const x = 1;', 'javascript');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('accepts highlight.js aliases the way a fence info string carries them', () => {
expect(highlightCode('let a = 1', 'js')).toContain('hljs-keyword');
expect(highlightCode('print(1)', 'py')).toContain('hljs-built_in');
expect(highlightCode('<div></div>', 'html')).toContain('hljs-tag');
});
it('returns null for an absent or unknown language so the caller falls back to plain text', () => {
expect(highlightCode('SELECT 1', null)).toBeNull();
expect(highlightCode('SELECT 1', '')).toBeNull();
expect(highlightCode('SELECT 1', 'not-a-language')).toBeNull();
});
it('escapes HTML in the source so a snippet cannot inject markup', () => {
const html = highlightCode('const s = "<img src=x onerror=alert(1)>";', 'javascript');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('keeps highlight.js sub-scope classes alongside the hljs-* class', () => {
const html = highlightCode('function foo() {}', 'javascript');
expect(html).toContain('<span class="hljs-title function_">foo</span>');
});
it('round-trips the source text exactly once tags are stripped', () => {
const source = 'if (a < b && c > d) {\n\treturn "x&y";\n}';
const html = highlightCode(source, 'typescript')!;
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(text).toBe(source);
});
});
describe('CODE_LANGUAGES', () => {
it('lists only languages the shared lowlight instance can actually highlight', () => {
for (const { id } of CODE_LANGUAGES) {
expect(isKnownLanguage(id), id).toBe(true);
}
});
it('has no duplicate ids', () => {
const ids = CODE_LANGUAGES.map((l) => l.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('editorLowlight', () => {
it('never auto-detects: an unlabelled block comes back as a single plain text node', () => {
const result = editorLowlight.highlightAuto('SELECT * FROM users;');
expect(result.children).toEqual([{ type: 'text', value: 'SELECT * FROM users;' }]);
});
it('highlights a labelled block with the same grammars as the renderer', () => {
const viaEditor = editorLowlight.highlight('python', 'def f(): pass');
const viaRenderer = lowlight.highlight('python', 'def f(): pass');
expect(viaEditor.children).toEqual(viaRenderer.children);
});
it('reports the languages the picker offers as registered', () => {
for (const { id } of CODE_LANGUAGES) {
expect(editorLowlight.registered(id), id).toBe(true);
}
expect(editorLowlight.listLanguages()).toContain('javascript');
});
});
// The palette lives in CSS, so the contrast guarantees are checked against the
// stylesheet itself rather than a TypeScript mirror that could drift.
describe('code palette in src/app.css', () => {
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf8');
const light = readVars(css.slice(css.indexOf(':root {'), css.indexOf('[data-theme="dark"] {')));
const dark = readVars(css.slice(css.indexOf('[data-theme="dark"] {')));
const tokenNames = ['--hl-keyword', '--hl-string', '--hl-number', '--hl-comment', '--hl-title', '--hl-tag'];
it('defines the code surface and every token colour in both themes', () => {
for (const name of ['--code-bg', ...tokenNames]) {
expect(light[name], `${name} light`).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark[name], `${name} dark`).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('keeps AA contrast (4.5:1) between every token and the code surface', () => {
for (const [theme, vars] of [
['light', light],
['dark', dark]
] as const) {
for (const name of tokenNames) {
const ratio = contrastRatio(vars[name], vars['--code-bg']);
expect(ratio, `${theme} ${name} ${vars[name]} on ${vars['--code-bg']}`).toBeGreaterThanOrEqual(4.5);
}
}
});
it('keeps AA contrast between body text and the code surface', () => {
expect(contrastRatio(light['--text'], light['--code-bg'])).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(dark['--text'], dark['--code-bg'])).toBeGreaterThanOrEqual(4.5);
});
it('keeps token colours distinguishable from each other', () => {
for (const vars of [light, dark]) {
for (let i = 0; i < tokenNames.length; i++) {
for (let j = i + 1; j < tokenNames.length; j++) {
const distance = rgbDistance(vars[tokenNames[i]], vars[tokenNames[j]]);
expect(distance, `${tokenNames[i]} vs ${tokenNames[j]}`).toBeGreaterThanOrEqual(40);
}
}
}
});
});
function readVars(block: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const match of block.matchAll(/(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) {
vars[match[1]] = match[2].toLowerCase();
}
return vars;
}
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
-108
View File
@@ -1,108 +0,0 @@
import { common, createLowlight } from 'lowlight';
/**
* One lowlight instance shared by the TipTap editor (live decorations) and the
* markdown-it renderer (card previews, share page), so a fence is coloured
* identically wherever it shows up.
*
* There is deliberately no language auto-detection. highlight.js guesses
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
* as plain text on the code surface; the language comes from the fence info
* string (```ts) or the toolbar picker.
*/
export const lowlight = createLowlight(common);
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
type HighlightNode = HighlightRoot['children'][number];
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
{ id: 'bash', label: 'Bash' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'css', label: 'CSS' },
{ id: 'diff', label: 'Diff' },
{ id: 'go', label: 'Go' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'xml', label: 'HTML / XML' },
{ id: 'ini', label: 'INI / TOML' },
{ id: 'java', label: 'Java' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'json', label: 'JSON' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'less', label: 'Less' },
{ id: 'lua', label: 'Lua' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'objectivec', label: 'Objective-C' },
{ id: 'perl', label: 'Perl' },
{ id: 'php', label: 'PHP' },
{ id: 'python', label: 'Python' },
{ id: 'r', label: 'R' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'rust', label: 'Rust' },
{ id: 'scss', label: 'SCSS' },
{ id: 'shell', label: 'Shell session' },
{ id: 'sql', label: 'SQL' },
{ id: 'swift', label: 'Swift' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'vbnet', label: 'VB.NET' },
{ id: 'wasm', label: 'WebAssembly' },
{ id: 'yaml', label: 'YAML' }
];
export function isKnownLanguage(lang: string | null | undefined): lang is string {
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
}
/**
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
* block, or `null` when the language is absent or unknown so the caller can
* fall back to plain escaped text.
*/
export function highlightCode(code: string, lang: string | null | undefined): string | null {
if (!isKnownLanguage(lang)) return null;
return toHtml(lowlight.highlight(lang, code).children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// lowlight emits only `element` (span with a className list) and `text`
// nodes, so a full hast serializer would be dead weight here.
function toHtml(nodes: HighlightNode[]): string {
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml(node.value);
} else if (node.type === 'element') {
const classes = node.properties?.className;
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
}
}
return html;
}
/**
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
* with auto-detection stubbed out so an unlabelled block stays plain (see the
* note at the top of this file).
*/
export const editorLowlight = {
highlight: (language: string, value: string) => lowlight.highlight(language, value),
highlightAuto: (value: string): HighlightRoot => ({
type: 'root',
children: [{ type: 'text', value }],
data: { language: undefined, relevance: 0 }
}),
listLanguages: () => lowlight.listLanguages(),
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
};
-99
View File
@@ -35,40 +35,6 @@ describe('renderMarkdown', () => {
expect(html).toContain('const x = 1;');
});
it('should syntax-highlight a fenced block that names its language', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('<code class="language-ts">');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('should leave an unlabelled fence as plain text rather than guessing a language', () => {
const html = renderMarkdown('```\nSELECT * FROM users;\n```');
expect(html).toContain('<pre><code>SELECT * FROM users;');
expect(html).not.toContain('hljs-');
});
it('should leave a fence with an unknown language as plain text but keep its label', () => {
const html = renderMarkdown('```brainfuck\n+++\n```');
expect(html).toContain('<code class="language-brainfuck">+++');
expect(html).not.toContain('hljs-');
});
it('should keep code in a highlighted fence escaped', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;<span class="hljs-name">script</span>&gt;');
});
it('should strip classes other than highlighting classes from raw HTML spans and code', () => {
const html = renderMarkdown(
'<p><span class="hljs-keyword">kept</span> <span class="evil">dropped</span> <code class="language-js other">c</code></p>'
);
expect(html).toContain('<span class="hljs-keyword">kept</span>');
expect(html).toContain('<span>dropped</span>');
expect(html).toContain('<code class="language-js">c</code>');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
@@ -96,58 +62,6 @@ describe('renderMarkdown', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => {
// crumbs#83: a bulleted list inside a table cell can't be expressed in
// plain GFM, so the editor falls back to embedding the whole table as
// raw HTML in the saved markdown. The preview/share renderer must
// actually render that block, not show it as literal escaped text.
const html = renderMarkdown(
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
);
expect(html).toContain('<table>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('<li>Second</li>');
expect(html).not.toContain('&lt;table&gt;');
});
it('should strip a script tag from raw HTML content', () => {
// Note content isn't only ever written through the Tiptap editor - the
// MCP server and the notes API accept a raw markdown string directly,
// so this renderer can't trust that any HTML it's asked to render is
// safe just because rendering HTML at all is now allowed.
const html = renderMarkdown('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).toContain('hello');
});
it('should still render task-list checkboxes after sanitization', () => {
const html = renderMarkdown('[ ] todo\n\n[x] done');
expect(html).toContain('type="checkbox"');
expect(html).toContain('checked');
expect(html).toContain('disabled');
expect(html).toContain('class="task-checkbox"');
});
it('should render an empty task item as a checkbox, not literal brackets', () => {
const html = renderMarkdown('- [ ] Train tickets\n- [ ]');
expect(html).not.toContain('[ ]');
expect(html.match(/type="checkbox"/g)).toHaveLength(2);
});
it('should render an empty checked task item as a checked checkbox', () => {
const html = renderMarkdown('- [x]');
expect(html).not.toContain('[x]');
expect(html).toContain('<input type="checkbox" checked disabled class="task-checkbox">');
expect(html).toContain('class="task-list"');
});
it('should not treat brackets glued to text as a task marker', () => {
const html = renderMarkdown('- [ ]x');
expect(html).not.toContain('type="checkbox"');
expect(html).toContain('[ ]x');
});
});
describe('stripMarkdown', () => {
@@ -171,16 +85,3 @@ describe('stripMarkdown', () => {
expect(stripMarkdown('')).toBe('');
});
});
describe('renderMarkdown note-link handling', () => {
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
expect(html).not.toContain('<a');
expect(html).toContain('Grocery List');
});
it('should still render a normal external link as a real anchor', () => {
const html = renderMarkdown('See [Example](https://example.com) for details');
expect(html).toContain('<a href="https://example.com"');
});
});
+7 -61
View File
@@ -1,59 +1,24 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { highlightCode } from './highlight.js';
const md = new MarkdownIt({
html: true,
html: false,
linkify: true,
typographer: true,
breaks: true,
// Returning '' makes markdown-it escape and wrap the block itself, so an
// unlabelled or unknown-language fence still renders as plain <pre><code>.
highlight: (code, lang) => highlightCode(code, lang) ?? ''
breaks: true
});
// Raw HTML has to render (not just be escaped as visible text): a table with
// a list in a cell can't be expressed in plain GFM, so the editor falls back
// to embedding it as an HTML block (see tiptap/Table.ts). Note content also
// isn't only ever written through that editor - the MCP server and the notes
// API accept a raw markdown string directly - so any HTML this renders has to
// be sanitized regardless of where it came from.
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// Syntax highlighting is carried by classes: `language-*` on the <code>
// of a fence and `hljs-*` (plus highlight.js sub-scopes like `function_`)
// on the token spans. Nothing else may put a class on these tags.
allowedClasses: {
code: [/^language-[\w+#.-]+$/],
span: [/^hljs-[\w-]+$/, /^language-[\w+#.-]+$/, /^[a-z]+_{1,2}$/]
},
// Keep markdown-it's bare void-element style (<br>, not <br />) so
// sanitizing doesn't change output for content that had no raw HTML to
// begin with.
selfClosing: []
};
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline') continue;
const content = tokens[i].content;
// markdown-it trims trailing whitespace from inline content, so an item
// with no text after the marker arrives as exactly "[ ]" — match the
// marker followed by a space or end-of-content.
const marker = /^\[( |x)\](?: |$)/.exec(content);
if (marker) {
const checked = marker[1] === 'x';
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
const checked = content.startsWith('[x] ');
const checkbox = checked
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
: '<input type="checkbox" disabled class="task-checkbox" /> ';
tokens[i].content = content.slice(marker[0].length);
tokens[i].content = content.slice(4);
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
// Prepend checkbox
const token = new state.Token('html_inline', '', 0);
@@ -63,7 +28,7 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
// Add task-list class to parent <ul> (once)
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'bullet_list_open') {
const cls = String(tokens[j].attrGet('class') ?? '');
const cls = tokens[j].attrGet('class') ?? '';
if (!cls.includes('task-list')) {
tokens[j].attrJoin('class', 'task-list');
}
@@ -74,27 +39,8 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
}
});
md.core.ruler.after('task-lists', 'note-links', (state) => {
for (const token of state.tokens) {
if (token.type !== 'inline' || !token.children) continue;
const children = token.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type !== 'link_open') continue;
const href = String(children[i].attrGet('href') ?? '');
if (!href.startsWith('crumb-note://')) continue;
let closeIndex = i + 1;
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
children.splice(closeIndex, 1);
children.splice(i, 1);
i -= 1;
}
}
});
export function renderMarkdown(content: string): string {
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
return md.render(content);
}
export function stripMarkdown(content: string): string {
-35
View File
@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extractNoteLinks } from './note-links.js';
describe('extractNoteLinks', () => {
it('should extract a single note-link reference', () => {
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
});
it('should extract multiple distinct references', () => {
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
});
it('should deduplicate repeated references', () => {
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
expect(extractNoteLinks(content)).toEqual(['id-1']);
});
it('should return an empty array for content with no note-links', () => {
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
});
it('should return an empty array for empty content', () => {
expect(extractNoteLinks('')).toEqual([]);
});
it('should not match crumb-note:// occurring inside a code block', () => {
const content = '```\n[A](crumb-note://id-1)\n```';
expect(extractNoteLinks(content)).toEqual([]);
});
it('should not match crumb-note:// occurring inside inline code', () => {
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
});
});
-17
View File
@@ -1,17 +0,0 @@
/**
* Extract crumb-note://<id> references from markdown content.
* Mirrors extractTags' code-block/inline-code exclusion so a reference
* mentioned as an example inside a code sample isn't treated as a real link.
*/
export function extractNoteLinks(content: string): string[] {
if (!content) return [];
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
if (!matches) return [];
const ids = matches.map((m) => m.replace('crumb-note://', ''));
return [...new Set(ids)];
}
-84
View File
@@ -1,84 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
const DIGIT = /[23456789]/;
const SYMBOL = /[!@#$%^&*]/;
/** Visually ambiguous characters the charset deliberately excludes. */
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
describe('PASSWORD_ALPHABET', () => {
it('is exactly 64 distinct characters', () => {
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
// A charset edit that breaks this silently biases every password.
expect(PASSWORD_ALPHABET).toHaveLength(64);
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
});
it('excludes every visually ambiguous character', () => {
for (const char of AMBIGUOUS) {
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
}
});
});
describe('generatePassword', () => {
it('returns a 20-character password by default', () => {
expect(generatePassword()).toHaveLength(20);
});
it('honours an explicit length', () => {
expect(generatePassword(8)).toHaveLength(8);
expect(generatePassword(32)).toHaveLength(32);
expect(generatePassword(64)).toHaveLength(64);
});
it('always includes at least one character from each of the four groups', () => {
// Not a statistical bet: the function only ever returns a class-complete
// password or throws, so every sample must pass.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
expect(password, `sample ${i}`).toMatch(LOWERCASE);
expect(password, `sample ${i}`).toMatch(UPPERCASE);
expect(password, `sample ${i}`).toMatch(DIGIT);
expect(password, `sample ${i}`).toMatch(SYMBOL);
}
});
it('never emits a visually ambiguous character', () => {
// 0 O o 1 l I survive neither being read aloud nor retyped.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
for (const char of AMBIGUOUS) {
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
}
}
});
it('draws only from the pinned 64-character charset', () => {
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
for (let i = 0; i < 20; i++) {
expect(generatePassword(), `sample ${i}`).toMatch(charset);
}
});
it('produces a different password on each call', () => {
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
expect(passwords.size).toBe(20);
});
it('throws below the 8-character server minimum', () => {
expect(() => generatePassword(7)).toThrow(/8/);
expect(() => generatePassword(0)).toThrow(/8/);
expect(() => generatePassword(-1)).toThrow(/8/);
});
it('does not use Math.random anywhere in the module', () => {
// Randomness must come from nanoid's CSPRNG, not Math.random.
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
expect(source).not.toContain('Math.random');
});
});
-59
View File
@@ -1,59 +0,0 @@
import { customAlphabet } from 'nanoid';
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
// password survives being read aloud or retyped from a screenshot.
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
const DIGITS = '23456789'; // no 0, no 1
const SYMBOLS = '!@#$%^&*';
/**
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
* favoured over another. At 64 symbols a 20-character password carries 120 bits
* of entropy — 119.79 once conditioned on the class-completeness retry below.
* Do not change the size without understanding both consequences.
*/
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
/** The server rejects anything shorter. */
const MIN_LENGTH = 8;
/**
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
* respectively — unreachable in practice. The bound exists so a future charset
* edit cannot turn the retry into an infinite loop.
*/
const MAX_ATTEMPTS = 100;
const randomString = customAlphabet(PASSWORD_ALPHABET);
function containsAnyOf(password: string, group: string): boolean {
return [...password].some((char) => group.includes(char));
}
/**
* Generates a random password containing at least one lowercase letter, one
* uppercase letter, one digit and one symbol.
*
* A password missing a group is discarded whole and regenerated, which keeps the
* result uniform over exactly the set of valid passwords. Forcing characters
* into chosen positions and shuffling would need its own unbiased index source.
*/
export function generatePassword(length = 20): string {
if (length < MIN_LENGTH) {
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
}
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const password = randomString(length);
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
}
throw new Error(
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#141312';
const DARK_THEME_COLOR = '#1a1715';
let darkMode = $state(false);
+1 -1
View File
@@ -37,7 +37,7 @@ describe('applyTheme', () => {
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#141312');
expect(meta?.getAttribute('content')).toBe('#1a1715');
});
it('updates meta theme-color for light mode', () => {
-83
View File
@@ -1,83 +0,0 @@
/**
* Custom tooltip action — drop-in replacement for title attributes.
* Usage: <button use:tooltip={"Label"}>
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
*/
export type TooltipPosition = 'top' | 'bottom';
interface TooltipOptions {
text: string;
position?: TooltipPosition;
}
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
if (typeof value === 'string') return { text: value, position: 'top' };
return { text: value.text, position: value.position ?? 'top' };
}
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
let opts = parseOptions(value);
let el: HTMLDivElement | null = null;
let timeout: ReturnType<typeof setTimeout> | undefined;
function show() {
timeout = setTimeout(() => {
if (!opts.text) return;
el = document.createElement('div');
el.className = 'tooltip-popup';
el.textContent = opts.text;
document.body.appendChild(el);
position();
}, 400);
}
function position() {
if (!el) return;
const rect = node.getBoundingClientRect();
const tipRect = el.getBoundingClientRect();
let top: number;
if (opts.position === 'bottom') {
top = rect.bottom + 6;
} else {
top = rect.top - tipRect.height - 6;
}
let left = rect.left + rect.width / 2 - tipRect.width / 2;
// Clamp to viewport
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
el.style.top = `${top}px`;
el.style.left = `${left}px`;
el.classList.add('tooltip-visible');
}
function hide() {
clearTimeout(timeout);
if (el) {
el.remove();
el = null;
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('pointerdown', hide);
// Remove native title to prevent double tooltip
const nativeTitle = node.getAttribute('title');
if (nativeTitle) node.removeAttribute('title');
return {
update(newValue: string | TooltipOptions) {
opts = parseOptions(newValue);
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('pointerdown', hide);
if (nativeTitle) node.setAttribute('title', nativeTitle);
}
};
}
+33 -430
View File
@@ -1,16 +1,6 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { canResetPassword, passwordResetBlockedReason, canDisablePasswordLogin } from '$lib/utils/auth-methods.js';
import { generatePassword } from '$lib/utils/password.js';
import { tooltip } from '$lib/utils/tooltip.js';
import ShieldCheck from 'lucide-svelte/icons/shield-check';
import ShieldOff from 'lucide-svelte/icons/shield-off';
import KeyRound from 'lucide-svelte/icons/key-round';
import LockKeyhole from 'lucide-svelte/icons/lock-keyhole';
import LogOut from 'lucide-svelte/icons/log-out';
import Trash2 from 'lucide-svelte/icons/trash-2';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
let { data } = $props();
// svelte-ignore state_referenced_locally
@@ -25,213 +15,6 @@
let createMsg = $state('');
let createError = $state(false);
/**
* One row's password-reset state.
*
* The generated plaintext lives here and nowhere else — never localStorage,
* sessionStorage, IndexedDB, a sync payload or a log — so navigating away is
* enough to lose it. State is per row rather than shared because two rows'
* outcomes would otherwise overwrite each other, and a password destroyed that
* way is unrecoverable.
*/
type ResetState = {
inFlight: boolean;
/** Revealed plaintext. Set on success and on an unconfirmed outcome. */
password: string | null;
/** Row message. Always rendered *alongside* `password`, never instead of it. */
message: string | null;
copyStatus: string | null;
/**
* Reason the server refused, kept even after the message is dismissed. This
* list snapshots `authProvider` at page load and never refetches, so a row can
* still look eligible after the account moved to OAuth; without this the
* control would stay available and invite the same failure forever.
*/
serverReason: string | null;
};
const IDLE_RESET: ResetState = {
inFlight: false,
password: null,
message: null,
copyStatus: null,
serverReason: null
};
const SELF_RESET_REASON = 'Change your own password in Profile.';
/**
* The request left the device but no answer came back, so the server may well
* have stored this password. Revealing it anyway is deliberate: it is the only
* copy that exists, and dropping it could leave the account holding a credential
* nobody knows.
*/
const UNCONFIRMED_MESSAGE =
'Could not confirm the reset. This password may or may not have been applied. Keep it — if the user cannot sign in, reset again once you are back online.';
/**
* Definite, not hedged: the service worker caches only `GET /api/*`, so a PATCH
* issued while offline never leaves the browser and nothing can have changed.
*/
const OFFLINE_MESSAGE = "You're offline — nothing was changed. Try again when reconnected.";
let resets = $state<Record<number, ResetState>>({});
let announcement = $state('');
const resetButtons: Record<number, HTMLButtonElement | null> = {};
function userLabel(user: User): string {
return user.displayName || user.email;
}
/**
* Updates the live region's text. The region itself is rendered empty from the
* first paint and never inserted alongside its content — a region added to the
* DOM together with its text is not reliably announced, least of all by
* TalkBack.
*/
function announce(text: string) {
announcement = text;
}
/** Why this row's password cannot be reset, or null when it can. */
function resetBlockedReason(user: User): string | null {
// Takes precedence over eligibility: an admin changes their own password in
// the profile flow, which verifies the current one.
if (user.id === data.user?.id) return SELF_RESET_REASON;
const serverReason = resets[user.id]?.serverReason;
if (serverReason) return serverReason;
if (!canResetPassword(user)) {
// auth-methods guarantees a non-empty reason whenever the check fails; the
// ?? only satisfies the nullable return type, as on the API route.
return passwordResetBlockedReason(user) ?? 'Password login is not available for this account.';
}
return null;
}
/**
* How one row's control presents itself. `unavailable` is expressed with
* `aria-disabled`, never the `disabled` attribute: a disabled button leaves the
* tab order, so a keyboard or screen-reader user loses the element they just
* activated, and a `title` tooltip explains nothing on a touch screen.
*/
function resetControl(user: User): {
reason: string | null;
inFlight: boolean;
unavailable: boolean;
} {
const state = resets[user.id];
const reason = resetBlockedReason(user);
const inFlight = state?.inFlight === true;
return {
reason,
inFlight,
unavailable: reason !== null || inFlight || state?.password != null
};
}
/** Merges a partial update into one row's state, leaving untouched fields alone. */
function patchReset(userId: number, patch: Partial<ResetState>) {
resets[userId] = { ...IDLE_RESET, ...resets[userId], ...patch };
}
function revealUnconfirmed(user: User, password: string) {
patchReset(user.id, { inFlight: false, password, message: UNCONFIRMED_MESSAGE });
announce(
`Could not confirm the password reset for ${userLabel(user)}. The generated password is shown in their row — keep it.`
);
}
async function resetUserPassword(user: User) {
// The control stays focusable while unavailable, so activation is refused
// here instead of by the browser.
if (resetControl(user).unavailable) return;
// Checked before the request, so the offline outcome is reported as the
// certainty it is rather than as a lost response.
if (navigator.onLine === false) {
patchReset(user.id, { message: OFFLINE_MESSAGE, copyStatus: null });
announce(`${userLabel(user)}: ${OFFLINE_MESSAGE}`);
return;
}
const password = generatePassword();
patchReset(user.id, { inFlight: true, message: null, copyStatus: null });
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: password })
});
if (res.ok) {
patchReset(user.id, { inFlight: false, password, message: null });
announce(
`New password generated for ${userLabel(user)}. It is shown in their row — copy it now, it cannot be shown again.`
);
return;
}
if (res.status === 404) {
users = users.filter((u) => u.id !== user.id);
// No account, no credential: drop the row's plaintext with the row.
delete resets[user.id];
announce(
`${userLabel(user)} no longer exists. The row was removed and no password was set.`
);
return;
}
const body = await res.json().catch(() => ({}));
const message = typeof body.message === 'string' && body.message ? body.message : null;
if (message !== null && res.status < 500) {
// A refusal the server states outright: nothing was stored, so no
// password is revealed and the reason sticks to the control.
patchReset(user.id, { inFlight: false, message, serverReason: message });
announce(`Password reset failed for ${userLabel(user)}. ${message}`);
return;
}
// A status without a readable reason says nothing about what the server did.
revealUnconfirmed(user, password);
} catch {
// The response was lost, not refused — see UNCONFIRMED_MESSAGE.
revealUnconfirmed(user, password);
}
}
async function copyPassword(user: User) {
const password = resets[user.id]?.password;
if (!password) return;
try {
// Absent outside a secure context, which a LAN-IP HTTP deployment is not,
// so the failure below has to be a working fallback rather than a dead end.
await navigator.clipboard.writeText(password);
patchReset(user.id, { copyStatus: 'Copied to clipboard.' });
announce(`Password for ${userLabel(user)} copied to clipboard.`);
} catch {
patchReset(user.id, {
copyStatus: 'Could not copy automatically — select the password and copy it by hand.'
});
announce(
`Could not copy the password for ${userLabel(user)}. Select it in their row and copy it by hand.`
);
}
}
function dismissReset(user: User) {
const serverReason = resets[user.id]?.serverReason ?? null;
// A server refusal explains the control's state, which outlives the message
// being dismissed here.
if (serverReason) resets[user.id] = { ...IDLE_RESET, serverReason };
else delete resets[user.id];
announce(`Password reset message for ${userLabel(user)} cleared.`);
// Never drop the admin on <body>: give the row's control the focus back.
resetButtons[user.id]?.focus();
}
async function createUser() {
createMsg = '';
if (!newEmail) {
@@ -287,17 +70,9 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
delete resets[userId];
createMsg = 'User deleted';
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to delete user';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
// ignore
}
}
@@ -332,37 +107,6 @@
// ignore
}
}
async function disablePasswordLogin(user: User) {
if (!canDisablePasswordLogin(user, data.user?.id ?? -1)) return;
if (
!confirm(
`Disable password login for ${userLabel(user)}? They will need to sign in via their linked OAuth provider. All sessions will be revoked.`
)
) {
return;
}
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disablePasswordLogin: true })
});
if (res.ok) {
const updated = await res.json();
users = users.map((u) => (u.id === user.id ? updated : u));
createMsg = `Password login disabled for ${userLabel(user)}`;
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to disable password login';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
</script>
<!-- Create User -->
@@ -421,183 +165,42 @@
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
<!-- Rendered empty on first paint; only its text is ever replaced, so both
reveals and per-row failures are announced from a region assistive tech
has already been watching. -->
<p
role="status"
aria-live="polite"
data-testid="reset-password-live"
class="sr-only"
>{announcement}</p>
<div class="space-y-3">
{#each users as user (user.id)}
{@const reset = resets[user.id]}
{@const control = resetControl(user)}
{@const roleAction = user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
{@const resetLabel = control.inFlight ? 'Resetting…' : 'Reset password'}
<div
class="flex flex-col gap-3 rounded-sm border border-[var(--border-subtle)] p-4"
data-testid="user-row-{user.id}"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
{/if}
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<!-- One uniform 44x44 square per action, replacing four text labels that
wrapped onto three lines at phone width. `flex-wrap` is what keeps the
squares square: flex compresses items only on a line that overflows, so
breaking the line means no button is ever asked to shrink. Drop it and
the four flatten to 40x44 at 320px — under the minimum, on a row whose
Delete is irreversible. They hold one line down to 336px, which is
exactly 4x44 plus the gaps, and take a second line below that: correctly
sized targets over two lines beat cramped ones on one. The container's
own `shrink-0` is a different mechanism and does carry weight — from
640px the row turns `sm:flex-row`, where the truncating name block would
otherwise squeeze the strip. On the buttons themselves that same token
would do nothing, `flex-wrap` having already spared them. Each icon
carries both a tooltip, for a mouse, and an aria-label, since an icon
alone has no accessible name. -->
<div class="flex shrink-0 flex-wrap items-center gap-1">
<button
onclick={() => toggleRole(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={roleAction}
aria-label={roleAction}
>
{#if user.role === 'admin'}
<ShieldOff class="h-4 w-4" />
{:else}
<ShieldCheck class="h-4 w-4" />
{/if}
</button>
<!-- Offered on every row, the admin's own included: a control that
vanishes is the confusion this replaces. -->
<button
bind:this={resetButtons[user.id]}
onclick={() => resetUserPassword(user)}
data-testid="reset-password-btn-{user.id}"
aria-disabled={control.unavailable}
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
use:tooltip={resetLabel}
aria-label={resetLabel}
class="inline-flex size-11 items-center justify-center rounded-sm transition-colors duration-150 ease-out {control.unavailable
? 'cursor-not-allowed border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
: 'text-[var(--text)] hover:bg-[var(--hover-wash)]/10'}"
>
{#if control.inFlight}
<!-- The label went with the text, so in-flight has to be carried by
the icon and by the accessible name above. -->
<LoaderCircle class="h-4 w-4 animate-spin" />
{:else}
<KeyRound class="h-4 w-4" />
{/if}
</button>
{#if canDisablePasswordLogin(user, data.user?.id ?? -1)}
<button
onclick={() => disablePasswordLogin(user)}
data-testid="disable-password-btn-{user.id}"
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={'Disable password login'}
aria-label="Disable password login"
>
<LockKeyhole class="h-4 w-4" />
</button>
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
{/if}
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={'Revoke all sessions'}
aria-label="Revoke all sessions"
>
<LogOut class="h-4 w-4" />
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
use:tooltip={'Delete user'}
aria-label="Delete user"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</div>
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout"
>
Revoke
</button>
<button
onclick={() => deleteUser(user.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
</button>
{/if}
</div>
{#if control.reason || reset?.message || reset?.password}
<div class="flex flex-col gap-2">
{#if control.reason}
<!-- Visible text, not a tooltip: a title attribute is unreachable
by touch, and colour alone explains nothing. -->
<p
id="reset-password-reason-{user.id}"
data-testid="reset-password-reason-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{control.reason}</p>
{/if}
{#if reset?.message}
<p
data-testid="reset-password-error-{user.id}"
class="rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-2 text-xs text-[var(--error-text)]"
>{reset.message}</p>
{/if}
{#if reset?.password}
<!-- Rendered independently of the message above, so a failure can
never replace a password that is already the account's only
credential. -->
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border)] bg-[var(--bg-base)] p-3 shadow-[var(--card-shadow)]">
<p class="text-xs text-[var(--text-muted)]">
New password for {userLabel(user)} — shown once, not stored anywhere.
</p>
<code
data-testid="generated-password-{user.id}"
class="block break-all select-all text-sm text-[var(--text)]"
>{reset.password}</code>
</div>
{/if}
{#if reset?.message || reset?.password}
<!-- 44px targets with a wide gap: dismissing is irreversible, so a
mis-tap next to Copy would destroy a live credential. -->
<div class="flex flex-wrap items-center gap-3">
{#if reset?.password}
<button
onclick={() => copyPassword(user)}
data-testid="copy-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--border-subtle)] px-3 text-xs text-[var(--text)] transition-colors duration-150 ease-out hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
>
Copy
</button>
{/if}
<button
onclick={() => dismissReset(user)}
data-testid="dismiss-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--destructive)] px-3 text-xs text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
>
Dismiss
</button>
</div>
{/if}
{#if reset?.copyStatus}
<p
data-testid="copy-status-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{reset.copyStatus}</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
+10 -31
View File
@@ -1,23 +1,17 @@
<script lang="ts">
import { getNoteColor } from '$lib/utils/colors.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { linkifyText } from '$lib/utils/checklist.js';
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
const { data } = $props();
// Both palettes are emitted as custom properties so the card follows the visitor's theme on the
// first paint — the CSS variables in app.css switch on [data-theme], which app.html sets before
// hydration, so a JS-resolved single colour would flash the wrong palette on this SSR page.
const noteColor = data.color as NoteColor;
const bgLight = getNoteColor(noteColor, false);
const bgDark = getNoteColor(noteColor, true);
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const renderedContent = renderMarkdown(data.content);
@@ -25,14 +19,10 @@
? data.content
.split('\n')
.filter((l: string) => l.trim())
.map((line: string) => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
.map((line: string) => ({
text: line.replace(/^- \[[ x]\] /, ''),
checked: line.startsWith('- [x] ')
}))
: [];
const activeChecklistItems = checklistItems.filter((i) => !i.checked);
@@ -59,8 +49,8 @@
<div class="mx-4 w-full max-w-2xl py-10">
<article
class="note-surface rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="--note-bg: {bgLight}; --note-bg-dark: {bgDark}"
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="background-color: {bgColor}"
data-testid="shared-note"
>
<div class="px-6 py-5">
@@ -74,10 +64,9 @@
<ul class="space-y-2" data-testid="shared-note-checklist">
{#each sortedChecklistItems as item}
<li
class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked
class="flex items-start gap-2 text-sm {item.checked
? 'text-[var(--text-muted)] line-through'
: 'text-[var(--text)]'}"
data-testid={item.indented ? 'shared-checklist-child' : undefined}
>
<input
type="checkbox"
@@ -85,7 +74,7 @@
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
/>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
<span class="break-words min-w-0">{item.text}</span>
</li>
{/each}
</ul>
@@ -132,13 +121,3 @@
</a>
</div>
</div>
<style>
.note-surface {
background-color: var(--note-bg);
}
:global([data-theme='dark']) .note-surface {
background-color: var(--note-bg-dark);
}
</style>
+1 -26
View File
@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions, disablePasswordLogin } from '$lib/server/auth.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
@@ -15,9 +15,6 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (body.role !== undefined) {
if (body.role !== 'admin' && body.role !== 'user') {
throw error(400, 'Invalid role');
}
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
@@ -34,13 +31,7 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
if (body.newPassword.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
// Prevent admin from resetting their own password — changing your own
// password goes through the profile flow, which verifies the current one.
if (userId === admin.id) {
throw error(400, 'Cannot reset your own password — change it from your profile instead');
}
await resetPassword(userId, body.newPassword);
revokeAllSessions(userId);
// Notify user of password reset (fire-and-forget)
if (isEmailConfigured() && user.email) {
@@ -48,22 +39,6 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
}
}
if (body.disablePasswordLogin) {
if (userId === admin.id) {
throw error(400, 'Cannot disable password login on your own account');
}
if (!user.passwordLoginEnabled) {
throw error(400, 'Password login is not enabled for this account');
}
if (user.authProvider === 'password' || user.authProvider === 'none') {
throw error(
400,
'Password login can only be disabled when the account is linked to OAuth'
);
}
disablePasswordLogin(userId);
}
if (body.revokeSessions) {
revokeAllSessions(userId);
}
+2 -4
View File
@@ -3,7 +3,6 @@ import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
import { requiresPasswordForAccountDeletion } from '$lib/utils/auth-methods.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
@@ -12,9 +11,8 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
const body = await request.json();
// Password required for pure password accounts; OAuth-linked accounts may delete
// with their active SSO session even when password login was admin-enabled.
if (requiresPasswordForAccountDeletion(user)) {
// OAuth users don't have passwords — allow deletion without password
if (user.authProvider === 'password') {
if (!body.password) throw error(400, 'Password is required');
const verified = await verifyPassword(user.email, body.password);
if (!verified) throw error(401, 'Invalid password');
@@ -23,7 +23,6 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
@@ -31,7 +31,6 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=3600'
}
+1 -94
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.33.0",
"version": "0.21.9",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
@@ -541,47 +541,6 @@
}
}
},
"/api/notes/{id}/share": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions": {
"parameters": [
{
@@ -769,58 +728,6 @@
}
}
},
"/api/shared/{token}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Shared"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/shared/{token}/attachment/{attachmentId}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "attachmentId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Shared"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/sync": {
"post": {
"tags": [
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
import { test as setup } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures';
import { STORAGE_STATE_FILE } from './global-setup';
/**
* Log in as the admin once and persist the session cookie. The `app` project
* loads this storage state into every browser context, so tests start already
* authenticated instead of each one submitting the login form (~0.8s per test,
* roughly half of a typical test's duration).
*
* The admin's own session is never revoked by any test: admin-users only
* resets/revokes victim accounts, and auth.spec's logout scenario runs in the
* auth-setup project with its own context before this setup executes.
*/
setup('authenticate as admin and save storage state', async ({ page }) => {
await setupAndLogin(page);
await page.context().storageState({ path: STORAGE_STATE_FILE });
});
+1 -5
View File
@@ -1,8 +1,4 @@
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
@@ -82,7 +78,7 @@ test.describe('Multi-user', () => {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: userPassword,
password: 'userpassword123',
role: 'user'
}
});
-83
View File
@@ -1,83 +0,0 @@
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Auto-save', () => {
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
// When the user starts a new note and types a title and content
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-saved Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('This should be saved automatically');
// Then the note is persisted after the auto-save delay (2s debounce)
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
// And closing the editor preserves the note
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
});
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
// Given a note titled "Edit Me" exists
await createNote(page, 'Edit Me', 'Original content');
// When the user opens the note and changes the title
await noteCard(page, 'Edit Me').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Edited Title');
// Then the updated title is auto-saved after the debounce delay
// Wait for the API call that confirms the save
await page.waitForResponse(
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
{ timeout: 5000 }
);
// And the note card reflects the change while editor is still open
await expect(noteCard(page, 'Edited Title')).toBeVisible();
// Cleanup: close the editor
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
// Given the current number of notes
const noteCards = page.locator('[data-testid="note-card"]');
const countBefore = await noteCards.count();
// When the user opens a new note but leaves it empty
await page.getByTestId('new-note-btn').click();
// And waits longer than the auto-save delay
await page.waitForTimeout(3000);
// And closes the empty editor
await page.getByTestId('close-editor-btn').click();
// Then no new note was created
const countAfter = await noteCards.count();
expect(countAfter).toBe(countBefore);
});
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
// When the user creates a note and waits for auto-save
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Content that survives');
// Wait for auto-save to fire
await page.waitForResponse(
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
{ timeout: 5000 }
);
// When the page is reloaded without closing the editor
await page.reload();
await page.waitForLoadState('networkidle');
// Then the note is still visible
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
});
});
+15 -322
View File
@@ -1,20 +1,13 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/** Toggle checklist mode via the overflow menu. */
async function toggleChecklistMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('checklist-toggle').click();
}
/** Create a checklist note via UI. Leaves the editor closed. */
async function createChecklistNote(page: Page, title: string, items: string[]) {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill(title);
await toggleChecklistMode(page);
await page.getByTestId('checklist-toggle').click();
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).focus();
await page.keyboard.type(items[i]);
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
if (i < items.length - 1) {
await page.getByTestId('checklist-input').nth(i).press('Enter');
}
@@ -29,7 +22,7 @@ test.describe('Checklist', () => {
await page.getByTestId('new-note-btn').click();
// When the user enables checklist mode
await toggleChecklistMode(page);
await page.getByTestId('checklist-toggle').click();
// Then the checklist component is displayed
await expect(page.getByTestId('checklist')).toBeVisible();
@@ -47,7 +40,7 @@ test.describe('Checklist', () => {
// Then the checklist is displayed with the saved item
await expect(page.getByTestId('checklist')).toBeVisible();
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
});
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
@@ -67,37 +60,11 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
});
test('Scenario: Sequential check and uncheck both persist correctly', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk" exists
await createChecklistNote(page, 'Tasks2', ['Buy milk']);
// When the user checks the item and closes
await noteCard(page, 'Tasks2').click();
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await page.getByTestId('close-editor-btn').click();
// Then the checked state persists
await noteCard(page, 'Tasks2').click();
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
// When the user unchecks the item from the done section and closes
await page.getByTestId('checklist-done-checkbox').first().click();
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
await page.getByTestId('close-editor-btn').click();
// Then the unchecked state also persists (not reverted by 3-way merge)
await noteCard(page, 'Tasks2').click();
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
});
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given a checklist with one item
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
// When the user presses Enter
await page.getByTestId('checklist-input').first().press('Enter');
@@ -129,15 +96,14 @@ test.describe('Checklist', () => {
// Then focus moves to the newly created empty item
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
await expect(page.getByTestId('checklist-input').nth(1)).toHaveValue('');
});
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
// Given a checklist with two items where the second is empty
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
@@ -152,19 +118,17 @@ test.describe('Checklist', () => {
// Given a checklist note with two items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Done task');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Pending task');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
// When the user completes the first item
await page.getByTestId('checklist-checkbox').first().click();
// Then only the pending item remains in the active list
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
// And the done section shows the completed item
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
@@ -172,288 +136,17 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
});
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
// Given a checklist with three items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Second');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Third');
// When the user presses ArrowUp from the third item
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
// Then focus moves to the second item
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
// When the user presses ArrowDown
await page.getByTestId('checklist-input').nth(1).press('ArrowDown');
// Then focus moves back to the third item
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
});
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
// Given a checklist with two top-level items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child');
// When the user presses Tab on the second item
await page.getByTestId('checklist-input').nth(1).press('Tab');
// Then the item becomes indented
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
// When the user presses Shift+Tab
await page.getByTestId('checklist-input').nth(1).press('Shift+Tab');
// Then the item returns to top-level
await expect(page.getByTestId('checklist-child-row')).toHaveCount(0);
});
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and two children
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Buy groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Eggs');
// When the user checks the parent
await page.getByTestId('checklist-checkbox').first().click();
// Then all items move to done section
await expect(page.getByTestId('checklist-toggle-done')).toContainText('3 done');
await expect(page.getByTestId('checklist-input')).toHaveCount(0);
});
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user checks only the child
await page.getByTestId('checklist-checkbox').nth(1).click();
// Then the done section shows a parent label above the checked child
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await expect(page.getByTestId('checklist-done-parent-label')).toBeVisible();
await expect(page.getByTestId('checklist-done-parent-label')).toContainText('Groceries');
});
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and child, all checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Shopping');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user unchecks the parent from the done section
await page.getByTestId('checklist-done-checkbox').first().click();
// Then both parent and child return to active
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
});
test('Scenario: Nested items persist after close and reopen', async ({ authenticatedPage: page }) => {
// Given a checklist with nested items
await createChecklistNote(page, 'Nested Persist', ['Parent', 'Child']);
// Reopen and indent the second item
await noteCard(page, 'Nested Persist').click();
await page.getByTestId('checklist-input').nth(1).press('Tab');
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
await page.getByTestId('close-editor-btn').click();
// When the user reopens the note
await noteCard(page, 'Nested Persist').click();
// Then the nesting is preserved
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
});
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child 1');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user presses Enter on the child
await page.getByTestId('checklist-input').nth(1).press('Enter');
// Then a new child is created at the same level
await expect(page.getByTestId('checklist-child-row')).toHaveCount(2);
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
});
test('Scenario: NoteCard preview shows indentation for nested items', async ({ authenticatedPage: page }) => {
// Given a checklist note with nested items
await createChecklistNote(page, 'Indent Preview Note', ['Parent', 'Child']);
await noteCard(page, 'Indent Preview Note').click();
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('close-editor-btn').click();
// Then the NoteCard preview shows the child indented (scoped to this card)
const card = noteCard(page, 'Indent Preview Note');
await expect(card.getByTestId('card-checklist-child')).toHaveCount(1);
});
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
// Given checklist mode is enabled on a new note
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-toggle').click();
await expect(page.getByTestId('checklist')).toBeVisible();
// When the user disables checklist mode
await toggleChecklistMode(page);
await page.getByTestId('checklist-toggle').click();
// Then the rich text editor is displayed again
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
await expect(page.getByTestId('checklist')).not.toBeVisible();
});
test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => {
// Given a checklist note with two checked and one unchecked item
await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']);
await noteCard(page, 'Cleanup').click();
await page.getByTestId('checklist-checkbox').nth(1).click();
await page.getByTestId('checklist-checkbox').nth(1).click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user deletes checked items via the overflow menu
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('delete-checked-btn').click();
// Then only the unchecked item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => {
// Given a checklist note with checked items
await createChecklistNote(page, 'Reset', ['Item A', 'Item B']);
await noteCard(page, 'Reset').click();
await page.getByTestId('checklist-checkbox').first().click();
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user unchecks all via the overflow menu
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('uncheck-all-btn').click();
// Then all items are back in the active section
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
// Given a new checklist note
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
// When the user types a URL and leaves the field
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('check https://example.com today');
await page.getByTestId('note-title-input').focus();
// Then the URL is rendered as a link
const link = page.getByTestId('checklist-input').first().locator('a');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', 'https://example.com');
});
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
// When the user clicks the link
await page.getByTestId('checklist-input').first().locator('a').click();
// Then the link popover appears with an Open button
await expect(page.getByTestId('link-popover')).toBeVisible();
await expect(page.getByTestId('link-popover-open')).toBeVisible();
});
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
// Given a link popover is open
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
await page.getByTestId('checklist-input').first().locator('a').click();
await expect(page.getByTestId('link-popover')).toBeVisible();
// When the user presses Escape
await page.keyboard.press('Escape');
// Then the popover is dismissed
await expect(page.getByTestId('link-popover')).not.toBeVisible();
});
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL is checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('checklist-checkbox').first().click();
// Then the done section shows the URL as a link
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
});
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
// Given a checklist note with a URL
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Link Note');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('visit https://example.com');
await page.getByTestId('close-editor-btn').click();
// Then the NoteCard preview shows the URL as a link
const card = noteCard(page, 'Link Note');
await expect(card.locator('a.checklist-link')).toBeVisible();
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
});
});
-164
View File
@@ -1,164 +0,0 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
async function runTiptapCommand(page: Page, commandFn: string) {
await page.getByTestId('tiptap-editor').evaluate(
(el, fn) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
new Function('editor', fn)(editor);
},
commandFn
);
}
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Write note content as markdown, then return to the rich editor. */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
}
test.describe('Code snippets', () => {
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a JavaScript code block
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('JS Snippet');
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
await waitForTiptapEditor(page);
// Then the editor colours the keyword and number as separate tokens
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
// When the note is closed
await page.getByTestId('close-editor-btn').click();
// Then the card preview shows the same highlighting
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
});
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a code block with no language
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Plain Snippet');
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
await waitForTiptapEditor(page);
// Then the editor shows the block without any token colouring
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
// And so does the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
});
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
authenticatedPage: page
}) => {
// Given the user is editing a note with a plain code block
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
await waitForTiptapEditor(page);
// And the language picker is offered only while the cursor is in the block
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
const picker = page.getByTestId('format-code-language');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('');
// When the user picks Python
await picker.selectOption('python');
// Then the block is highlighted as Python
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
await expect(picker).toHaveValue('python');
// And the markdown fence carries the language
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
});
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with a paragraph
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'just prose');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
// Then no language picker is shown
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
});
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
// Given a note with a code block and a paragraph
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sized Snippet');
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
await waitForTiptapEditor(page);
// Then the code block is set at the same size as the prose around it
const editor = page.getByTestId('tiptap-editor');
const fontSize = (locator: ReturnType<Page['locator']>) =>
locator.evaluate((el) => getComputedStyle(el).fontSize);
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
// And the same holds on the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
});
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
authenticatedPage: page
}) => {
// Given a note with a code line far wider than a card
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Wide Snippet');
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
await page.getByTestId('close-editor-btn').click();
// Then the card's code block stays inside the card and does not scroll horizontally
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
const pre = preview.locator('pre');
await expect(pre).toBeVisible();
const overflow = await pre.evaluate((el) => {
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
const container = el.parentElement!.getBoundingClientRect();
return {
x: style.overflowX,
wider: el.scrollWidth > el.clientWidth,
spill: box.right - container.right
};
});
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
expect(overflow.x).toBe('hidden');
expect(overflow.spill).toBeLessThanOrEqual(1);
});
});
+2 -62
View File
@@ -1,4 +1,4 @@
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
import { test, expect, createNote } from './helpers/fixtures.js';
test.describe.serial('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
@@ -81,67 +81,7 @@ test.describe.serial('Dark mode', () => {
const noteCard = page.getByTestId('note-card').first();
await expect(noteCard).toBeVisible();
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
// Dark default color is #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
expect(bg).not.toBe('rgb(250, 245, 235)');
});
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user opens the colour picker while editing a note
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Swatch Test');
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
// Then a swatch shows its vivid chip rather than the muted card surface
await expect(page.getByTestId('color-coral')).toHaveCSS(
'background-color',
'rgb(208, 79, 67)'
);
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Bold text in notes uses the theme text colour in dark mode', async ({
authenticatedPage: page
}) => {
// Given a note with bold content exists
await page.goto('/');
await page.waitForLoadState('networkidle');
await createNote(page, 'Bold Contrast Note', 'Plain then **bold words** here');
// And dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When viewing the note on the notes page
await page.goto('/');
await page.waitForLoadState('networkidle');
const preview = noteCard(page, 'Bold Contrast Note').getByTestId('note-content-preview');
const boldInCard = preview.locator('strong');
await expect(boldInCard).toHaveText('bold words');
// Then the bold text is rendered in the dark theme's text colour (--text = #ece3d3),
// not the typography plugin's hardcoded near-black
await expect(boldInCard).toHaveCSS('color', 'rgb(236, 227, 211)');
// And the same holds inside the editor
await noteCard(page, 'Bold Contrast Note').click();
const boldInEditor = page.getByTestId('tiptap-editor').locator('.tiptap strong');
await expect(boldInEditor).toHaveText('bold words');
await expect(boldInEditor).toHaveCSS('color', 'rgb(236, 227, 211)');
await page.getByTestId('close-editor-btn').click();
});
});
+5 -93
View File
@@ -1,5 +1,5 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { devices, type Page } from '@playwright/test';
import type { Page } from '@playwright/test';
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
async function runTiptapCommand(page: Page, commandFn: string) {
@@ -13,48 +13,11 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
/**
* Read the `checked` attribute of the first task item from the live ProseMirror
* document. This reflects the editor's document state, not just the native
* checkbox widget — so it catches a widget/document desync where the DOM
* checkbox toggles but the transaction never updates the node.
*/
async function firstTaskItemChecked(page: Page): Promise<boolean> {
return page.getByTestId('tiptap-editor').evaluate((el) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
let checked: boolean | undefined;
editor.state.doc.descendants((node: any) => {
if (checked === undefined && node.type.name === 'taskItem') {
checked = Boolean(node.attrs.checked);
return false;
}
return true;
});
if (checked === undefined) throw new Error('No task item found in document');
return checked;
});
}
/** Toggle markdown mode via the overflow menu. */
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Type content into the TipTap editor via markdown mode (reliable for e2e) */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('markdown-toggle').click();
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
await page.getByTestId('markdown-toggle').click();
}
test.describe('Rich text formatting', () => {
@@ -65,14 +28,13 @@ test.describe('Rich text formatting', () => {
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies bold formatting
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleBold().run()');
// Then the selected text appears bold in the editor
await expect(editor.locator('strong')).toHaveText('world');
// And the markdown content contains bold syntax
await toggleMarkdownMode(page);
await page.getByTestId('markdown-toggle').click();
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
});
@@ -108,7 +70,6 @@ test.describe('Rich text formatting', () => {
// Given the user is editing a note with selected text
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'visit example');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
// When the user opens the link popover and enters a URL
@@ -134,7 +95,7 @@ test.describe('Rich text formatting', () => {
await expect(editor.locator('strong')).toHaveText('bold');
// When the user toggles markdown mode
await toggleMarkdownMode(page);
await page.getByTestId('markdown-toggle').click();
// Then the raw markdown textarea is shown with the markdown source
await expect(page.getByTestId('note-content-input')).toBeVisible();
@@ -142,52 +103,3 @@ test.describe('Rich text formatting', () => {
await expect(page.getByTestId('note-content-input')).toHaveValue('Hello **bold** world');
});
});
test.describe('Rich text formatting on mobile', () => {
const pixel7 = devices['Pixel 7'];
test.use({
viewport: pixel7.viewport,
userAgent: pixel7.userAgent,
deviceScaleFactor: pixel7.deviceScaleFactor,
isMobile: pixel7.isMobile,
hasTouch: pixel7.hasTouch
});
test('Scenario: Tapping a task-list checkbox does not focus the editor', async ({ authenticatedPage: page }) => {
// Given a regular text note contains a task list in the rich-text editor
await page.getByTestId('new-note-fab').click();
await page.getByTestId('mobile-overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
await page.getByTestId('note-content-input').fill('- [ ] Mobile task');
await page.getByTestId('mobile-overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
const richTextEditor = page.getByTestId('tiptap-editor').locator('.tiptap');
const checkbox = richTextEditor.locator('input[type="checkbox"]');
await expect(checkbox).not.toBeChecked();
await expect(richTextEditor).not.toBeFocused();
await richTextEditor.evaluate((element) => {
element.dataset.focusCount = '0';
element.addEventListener('focus', () => {
element.dataset.focusCount = String(Number(element.dataset.focusCount) + 1);
});
});
// When the checkbox is tapped, the change propagates to the editor's document
// without focus ever entering the editor (which would open the mobile keyboard)
await checkbox.tap();
await expect(checkbox).toBeChecked();
expect(await firstTaskItemChecked(page)).toBe(true);
await expect(richTextEditor).not.toBeFocused();
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
// And unchecking propagates to the document too, still without focusing the editor
await checkbox.tap();
await expect(checkbox).not.toBeChecked();
expect(await firstTaskItemChecked(page)).toBe(false);
await expect(richTextEditor).not.toBeFocused();
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
});
});
+9 -20
View File
@@ -1,24 +1,13 @@
import { writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */
export const STORAGE_STATE_FILE = './data/test-storage-state.json';
const TEST_DB = './data/test-crumbs.db';
export default function globalSetup() {
// The test database is wiped before the webServer starts (see the
// webServer.command in playwright.config.ts), NOT here: Playwright brings
// the webServer up before globalSetup runs, so deleting the SQLite files
// here would orphan the server's open database onto a deleted inode and
// tests that read the database file directly would see an empty schema.
// Generate random passwords for this test run, shared across all workers
writeFileSync(
TEST_CREDENTIALS_FILE,
JSON.stringify({
testPassword: `test-${randomUUID()}`,
collabPassword: `collab-${randomUUID()}`,
userPassword: `user-${randomUUID()}`
})
);
// Clean test database before E2E run so each run starts fresh
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${TEST_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
}
}
}
+4 -22
View File
@@ -1,30 +1,17 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
import { readFileSync } from 'fs';
import { collectCoverage } from './coverage';
import { TEST_CREDENTIALS_FILE } from '../global-setup';
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = testPassword;
const TEST_PASSWORD = 'testpassword123';
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_NAME = 'Collaborator User';
/**
* Find a specific note card by its exact title text. Matches only the
* card's <h3> title, not its full text content — with note-linking, a
* card's content preview can render another note's title as plain text
* (e.g. a stripped note-link), so a substring match against the whole
* card can ambiguously resolve to more than one card.
*/
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return page.locator('[data-testid="note-card"]').filter({
has: page.locator('h3', { hasText: new RegExp(`^${escaped}$`) })
});
return page.locator('[data-testid="note-card"]', { hasText: title });
}
/**
@@ -113,11 +100,6 @@ export async function createNote(page: Page, title: string, content?: string) {
await editor.pressSequentially(content);
}
await page.getByTestId('close-editor-btn').click();
// Wait for the editor to fully unmount, not just start closing. The close is
// animated and dismisses via an async history.back() (see NoteEditor); returning
// before it settles lets a following page.goto race that navigation and abort it
// (net::ERR_ABORTED). The outer overlay is removed only when the close completes.
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
await expect(noteCard(page, title)).toBeVisible();
}

Some files were not shown because too many files have changed in this diff Show More