Compare commits

..
86 Commits
Author SHA1 Message Date
github-actions[bot] 3c1fb4a79d chore: bump version to 0.31.0 2026-07-31 10:05:34 +00:00
Frederic Cabassut a46387172c fix(theme): restore hover-wash contrast and correct doc claims from dark palette review
Separates --hover-wash from --border so the softened dark-mode border grey
no longer collapses hover/active tint washes to near-invisibility (measured
5% wash on --bg-surface: 4.7 -> 17.0 RGB distance). Light mode is unaffected
since --hover-wash falls back to var(--border) in :root.

Also fixes stale/incorrect claims surfaced by the final review: the
checkbox rule and missing CSS variables in CLAUDE.md, the "warm parchment"
description of dark mode in FEATURES.md, the chip contrast claim in the
palette design doc (chips are 3:1 UI, not 4:1 text), and adds a measured
lightness-floor test guarding against chips regressing to card lightness.
2026-07-31 12:03:39 +02:00
Frederic Cabassut 2095c32347 docs: document the dark palette and the card-vs-chip distinction 2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 e8bef26b4f fix(theme): sync browser theme-colour with the new dark base
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic Cabassut 3041de4b2f feat(theme): replace dark surfaces, borders and accents with the warm ink palette 2026-07-31 12:03:39 +02:00
Frederic Cabassut 4d77f7f819 feat(theme): give the dark colour picker vivid identifiable chips 2026-07-31 12:03:39 +02:00
Frederic Cabassut a2463ac316 test(theme): widen distinguishability threshold headroom
The distinguishability threshold (10) sat only 2 units below the palette's
measured closest pair (coral/peach at 12.0), making it brittle to a minor
future nudge. Lower to 8: still fails the old palette's closest pair
(fog/storm at 7.55) while giving the current palette 4 units of headroom.
Also corrects the spec's lightness-change figure from -0.1 (computed from
rounded HSL) to the precise -0.18.
2026-07-31 12:03:39 +02:00
Frederic Cabassut ffc49f2676 test(theme): replace lightness-band test with pairwise distinguishability
The lightness-band guard was looser than the palette it was meant to catch
(old band 0.0175 vs new 0.0246) and the muted-AA test never failed against
the old palette (5.08:1), so both docs' stated expectations and one spec
claim ("chroma roughly doubles") were measured falsehoods. Replace the band
test with an RGB-distance pairwise check, which does discriminate (old
closest pair fog/storm = 7.5, new closest pair coral/peach = 12), fix the
misleading test comment, and correct the spec/plan docs to match reality.
2026-07-31 12:03:39 +02:00
Frederic Cabassut 9600c62288 feat(theme): rebuild dark note colours as a consistent family 2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 365f941240 docs: add dark palette rework implementation plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 edee5f7cd4 docs: add dark theme palette rework design spec
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
Frederic CabassutandClaude Opus 5 b5109e7522 fix(sharing): make public note colors follow the visitor's theme
The public share page picked the note background from the light palette
unconditionally, while app.html applies data-theme="dark" on every page.
A visitor in dark mode therefore got a light pastel card rendered with the
dark theme's light text colour — washed out and barely readable.

Emit both palettes as custom properties and let [data-theme] select one, so
the correct colour lands on the first paint of this SSR page instead of
flashing after hydration.

Reported by a user over email.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:03:39 +02:00
github-actions[bot] 4e3b6dfd1c chore: pin docker-compose to v0.30.1 [skip ci] 2026-07-15 09:06:34 +00:00
github-actions[bot] e8fef27268 chore: bump version to 0.30.1 2026-07-15 08:59:27 +00:00
Frederic CabassutandClaude Opus 4.8 fc9ab5b237 test(e2e): deflake dark-mode by awaiting full editor close in createNote
createNote returned as soon as the note card appeared (editor merely
"closing"), but the editor now dismisses via an async history.back() plus a
400ms onClose (added with close-on-back navigation, #71). A following
page.goto could race that in-flight navigation and abort it
(net::ERR_ABORTED) — surfacing only under the slower coverage-instrumented
CI build. Wait for the editor overlay to fully unmount so the close
navigation settles before createNote returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:59:01 +02:00
github-actions[bot] b37361ee8c chore: bump version to 0.30.0 2026-07-15 08:29:43 +00:00
Claude 12f45267f7 feat(editor): close the note editor with browser back navigation
The mobile editor is fullscreen with no backdrop to tap, so the Android
back button / iOS back swipe now dismisses it instead of leaving the
page. The editor pushes a shallow-routing history entry on mount and
closes when it is popped; UI closes pop the entry themselves so history
stays clean. NotesView's hash rewriting now goes through SvelteKit's
replaceState so the router keeps handling popstate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1N5Vi12zTez7LNqRvYjUD
2026-07-15 10:25:54 +02:00
Frederic CabassutandClaude Opus 4.8 d96b20f244 fix(sync): prevent silent content loss in concurrent-edit merges
Address review feedback on the concurrent-edit merge change:

- When no base snapshot is available (e.g. a metadata-only change bumped
  the version without creating a snapshot, or the base was pruned),
  mergeContentUpdate now keeps the incoming edit instead of discarding it
  in favour of the server copy, and logs a warning. Dropping it silently
  lost the user's offline edits.
- Unify getBaseContent into one shared helper in versions-service and drop
  sync's "fall back to the latest snapshot" branch: a base newer than what
  the client actually saw made the 3-way merge treat other users' lines as
  deletions and silently remove them.
- Remove the now-dead mergeContentTwoWay helper.
- Add coverage: null-base keeps incoming (unit), a metadata-bump concurrent
  edit is preserved (integration), and getBaseContent returns null rather
  than a newer snapshot. Verified the null-base cases fail against the old
  drop-incoming behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:11:43 +02:00
Romain LE DISEZandCodex bba0ddd311 fix(sync): merge concurrent note content edits
Create version snapshots before mutating note content so later concurrent
saves can find a base version. Reuse the content merge helper for direct
updates and sync pushes, with tests covering fallback and queued-edit
merge behavior.

Co-authored-by: Codex <codex@openai.com>
2026-07-15 10:11:43 +02:00
Frederic CabassutandClaude Opus 4.8 460f39298b fix(search): preserve unsynced hits and avoid empty-state flash
Address review feedback on the search-results preservation change:

- reconcileSearchResults now keeps a result absent from the canonical
  notes store (a server hit not yet loaded locally, e.g. from another
  device) instead of dropping it, so valid matches no longer vanish
  mid-session. Locally deleted/left notes are still pruned at their call
  sites; trashed notes are still dropped.
- SearchBar commits searchQuery only once its results arrive, so the view
  no longer flashes an empty state over the current notes while a request
  is in flight, and searchQuery stays consistent with searchResults on a
  failed request.
- Expand reconcile unit tests (keep-absent, trashed-drop, mixed order,
  empty) and add an e2e test asserting notes stay visible during an
  in-flight search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:57:00 +02:00
Romain LE DISEZandGoogle Gemini b802f17504 fix: preserve search results when background sync runs
Keep canonical notes separate from active search results so periodic
reloads no longer replace the filtered view.

Cancel superseded requests and ignore stale responses to prevent older
queries from overwriting newer results. Reconcile results with canonical
notes so updates propagate and removed or trashed notes disappear.

Add unit and end-to-end coverage for reconciliation, background sync,
and out-of-order search responses.

Co-authored-by: Google Gemini <gemini@google.com>
2026-07-15 09:57:00 +02:00
Frederic CabassutandClaude Opus 4.8 32c39b0000 test(editor): assert checkbox toggle updates the editor document
Read the ProseMirror document's taskItem `checked` attribute after
tapping the mobile task checkbox, not just the native checkbox widget.
The widget toggles regardless of whether the fix's setNodeMarkup
transaction runs, so the previous assertions could not detect a
widget/document desync. Verified this fails against the pre-fix code
and passes with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:25:34 +02:00
Romain LE DISEZandCodex 59b7a77f3a fix(editor): avoid mobile focus on task checkboxes
Intercept touch checkbox changes before TipTap's focusing handler and
update the task-item transaction directly. This prevents the mobile
keyboard from starting to open while preserving checkbox state changes.

Strengthen the mobile regression test to assert that checking and
unchecking emit no editor focus events.

Co-authored-by: Codex <codex@openai.com>
2026-07-15 09:25:34 +02:00
github-actions[bot] 9d62276682 chore: pin docker-compose to v0.29.0 [skip ci] 2026-07-12 17:21:10 +00:00
github-actions[bot] cfc03b54be chore: bump version to 0.29.0 2026-07-12 17:14:42 +00:00
Frederic CabassutandClaude Fable 5 4b7e346206 fix(editor): keep toolbar and dropdowns above the Android keyboard
Add interactive-widget=resizes-content to the viewport meta so the
Android keyboard shrinks the layout viewport through the native inset
path. Chromium's visualViewport.height can under-report the visible
height by the system nav-bar height, which left the editor toolbar
floating above the keyboard; the native resize path is accurate. iOS
ignores the attribute and keeps using the visualViewport tracking in
NoteEditor.

Position toolbar dropdown portals with window.innerHeight instead of
visualViewport.height: CSS bottom on position:fixed and
getBoundingClientRect() both use layout-viewport coordinates, so
mixing in the visual-viewport height pushed dropdowns behind the
keyboard while it was open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:19:19 +02:00
Romain LE DISEZ 5fb0337d03 fix(editor): address PR 67 review feedback
Restore portal dropdown test IDs so the formatting e2e suite can
find heading and link controls again.

Let the mobile formatting toolbar pan horizontally, keep dropdown
portals from drifting by closing them on scroll or resize, and use
visualViewport height for keyboard-aware positioning.

Remove the app-wide maximum-scale lock, keep editor focus targets at
16px on mobile to avoid input auto-zoom, and move toolbar scrollbar
styling to a top-level scoped style block.

Align the remove-link action with the destructive design token, use
the standard 150ms editor slide-up animation, and replace
save/restore body overflow locking with a resilient body class.
2026-07-10 16:19:19 +02:00
Romain LE DISEZandGoogle Gemini ab8b273400 feat(ui): improve mobile editor layout
Make editor full-screen natively on mobile with back button. Add
visualViewport tracking to keep bottom toolbars above the software keyboard.

disable pull-to-refresh for native PWA feel
prevent auto-zoom on input focus in mobile Safari
prevent background scrolling when editor is open
prevent touch scroll on editor toolbars to stop background scrolling
hide visual gap at bottom of editor when iOS keyboard opens
move note actions to top-right menu on mobile devices
wrap background colors in mobile menu to reduce width
make formatting toolbar scrollable on mobile and extract dropdowns
remove empty else block causing svelte compilation error
add missing data-dropdown attributes to fix click interception in toolbar portals
keep mobile keyboard open when using toolbar menus and prevent right-edge overflow

Co-authored-by: Google Gemini <gemini@google.com>
2026-07-10 16:19:19 +02:00
github-actions[bot] 57cbab3cec chore: pin docker-compose to v0.28.4 [skip ci] 2026-06-09 19:17:14 +00:00
github-actions[bot] 760235e667 chore: bump version to 0.28.4 2026-06-09 19:10:42 +00:00
Claude 0cc64fd8f3 fix(deps): patch high/critical security advisories
Bumps runtime- and build-reachable dependencies to patched versions and
adds pnpm overrides for transitive advisories. Clears all high/critical
findings (1 critical + 12 high -> 0); remaining moderates/lows are
dev-tooling only and stripped from the production image by pnpm prune.

Direct bumps:
- drizzle-orm ^0.44.7 -> ^0.45.2  (SQL injection via unescaped identifiers, GHSA-gpj5-g38j-94v9)
- @sveltejs/kit ^2.53.4 -> ^2.63.1 (adapter-node BODY_SIZE_LIMIT bypass, GHSA-2crg-3p73-43xp)
- vite ^7.3.1 -> ^7.3.5, vitest/@vitest/coverage-v8 ^3.2.4 -> ^3.2.6 (dev-server file read; vitest UI RCE)

Transitive overrides:
- devalue >=5.8.1   (DoS via sparse-array deserialization, GHSA-77vg-94rm-hx3p)
- path-to-regexp@8 >=8.4.0 (ReDoS via express/router under @modelcontextprotocol/sdk, GHSA-j3q9-mxjg-w52f)
- fast-uri >=3.1.2  (path traversal / host confusion via ajv, GHSA-q3j6-qgpj-74h6)

Verified: pnpm check (0 errors), 264 unit tests pass, production build succeeds.
2026-06-09 21:08:39 +02:00
Claude dbade3595e ci: run on push to main only to avoid duplicate PR runs
A push to a claude/** branch with an open PR triggered both the push
and pull_request events. The concurrency group keys on github.ref,
which differs between the two (refs/heads/... vs refs/pull/N/merge),
so neither run was cancelled and the whole pipeline (including E2E)
ran twice. Drop claude/** from the push trigger; branch work is now
validated once via the pull_request event.
2026-06-09 20:58:58 +02:00
Claude 585be91be5 chore: pin pnpm via packageManager field for CI and tooling
Adds a packageManager field (pnpm@9.15.9) to package.json so corepack
and pnpm/action-setup@v4 resolve a single pinned pnpm 9 version that
matches lockfileVersion 9.0. Removes the floating 'version: latest' from
the CI and screenshots workflows so they honor the packageManager field
instead of pulling pnpm 10+, which breaks native module builds.

Follow-up to #63.
2026-06-09 20:54:29 +02:00
Romain LE DISEZandGoogle Gemini c2bb6ef75a fix(docker): pin pnpm version to 9 to match lockfile and fix build errors
Co-authored-by: Google Gemini <gemini@google.com>
2026-06-09 20:50:22 +02:00
github-actions[bot] 2fb1237f04 chore: pin docker-compose to v0.28.3 [skip ci] 2026-04-22 19:52:23 +00:00
github-actions[bot] 808217eb55 chore: bump version to 0.28.3 2026-04-22 19:46:17 +00:00
Claude ea403dcbee test: upload a real attachment in the user-deletion coverage test
The "notes, tags, and attachments" scenario previously only created a
note and an API key — no attachment. Upload a PNG via the browser
context so Origin is set correctly for SvelteKit's CSRF check, giving
the atomic-delete path genuine attachment rows to clean up.

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
Claude 5719fc3459 fix: make admin user deletion atomic and surface errors in UI
deleteUser() ran each child-table cleanup as a separate statement, so a
foreign-key violation on any step (e.g. sync_log rows from other users
referencing the deleted user's notes) would abort mid-way and leave the
database in a partial state. The API swallowed the error and the admin
page silently ignored the non-ok response, so clicking Delete and
confirming appeared to do nothing.

- Wrap the full cascade in a single sqlite transaction so the deletion
  is atomic.
- Also remove rows in sync_log, note_user_state, note_collaborators,
  note_versions, and attachments that belong to other users but
  reference the deleted user's notes, so the final DELETE FROM notes
  cannot trip a foreign-key check.
- Surface delete failures in the users settings UI instead of silently
  swallowing them.
- Add data-testids on the user row and delete button to support tests.
- Add e2e coverage for the admin user deletion flow (happy path,
  cancelled confirmation, and a user with notes / tags / attachments /
  preferences / api keys).

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
github-actions[bot] b2e7b2c96d chore: pin docker-compose to v0.28.2 [skip ci] 2026-04-10 20:31:53 +00:00
github-actions[bot] d912b8f512 chore: bump version to 0.28.2 2026-04-10 20:26:06 +00:00
Claude ba1b071395 fix: use currentTarget and contains() in blur handler per review
Use e.currentTarget instead of e.target so the reference is always the
contenteditable div, not a descendant <a> tag. Use el.contains() instead
of strict equality to avoid linkifying while focus is still inside the
editable element.

https://claude.ai/code/session_01VZceWPhHMHCA6SELBdCPfN
2026-04-10 21:53:20 +02:00
Claude 86cfc8c5f6 fix: defer contenteditable blur linkification to unblock mobile tap events
When a contenteditable loses focus, the onblur handler immediately modified
innerHTML to linkify URLs. On mobile, this DOM mutation causes a layout reflow
between touchstart and click, making the browser cancel taps on the adjacent
delete button. Deferring via requestAnimationFrame ensures the click event
fires before the DOM is modified.

https://claude.ai/code/session_01VZceWPhHMHCA6SELBdCPfN
2026-04-10 21:53:20 +02:00
github-actions[bot] 76b04549af chore: pin docker-compose to v0.28.1 [skip ci] 2026-04-10 06:57:56 +00:00
github-actions[bot] 880b890cac chore: bump version to 0.28.1 2026-04-10 06:52:06 +00:00
Frederic CabassutandClaude Opus 4.6 0a30145996 fix: decode &nbsp; entities from contenteditable in checklist items
Browsers inject &nbsp; in contenteditable elements, which was not being
decoded by unlinkifyHtml, causing double-escaped "&nbsp;" to appear in
checklist item text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:51:14 +02:00
github-actions[bot] cccdcd2036 chore: pin docker-compose to v0.28.0 [skip ci] 2026-04-08 21:34:12 +00:00
github-actions[bot] 88f2dc6273 chore: bump version to 0.28.0 2026-04-08 21:28:43 +00:00
Frederic Cabassut d3b98fddf2 fix: address PR review feedback
- Add noreferrer to rel attribute to prevent Referer header leaks
- Strip newlines from pasted text to prevent checklist corruption
- Add aria-placeholder for screen reader accessibility
- Dismiss LinkPopover on scroll/resize
- Stop keydown propagation on links in NoteCard preview
2026-04-08 22:36:19 +02:00
Frederic Cabassut 70c258f3b8 chore: remove design spec and plan docs from feature branch 2026-04-08 22:36:19 +02:00
Frederic Cabassut 4c1083e14d chore: remove playwright-mcp logs 2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 49c28a33ff chore: remove accidental files from feature branch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 4c13d345fc fix: use Svelte action for contenteditable to prevent cursor destruction
The reactive {#html} binding inside contenteditable replaced innerHTML on
every keystroke, destroying the cursor and causing text duplication.

Instead, use a use:editableContent action that sets innerHTML on mount
and only syncs when the element is NOT focused. Linkification happens
on blur, matching Google Keep's behavior. Update e2e tests to blur
before asserting link visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic Cabassut 0ece0b7b7f test: add e2e tests for checklist hyperlink support 2026-04-08 22:36:19 +02:00
Frederic Cabassut f6642ac7b3 test: update e2e tests for contenteditable checklist inputs 2026-04-08 22:36:19 +02:00
Frederic Cabassut 653e990071 feat: linkify URLs in shared note checklist view 2026-04-08 22:36:19 +02:00
Frederic Cabassut 04fdffb111 feat: linkify URLs in NoteCard checklist preview 2026-04-08 22:36:19 +02:00
Frederic Cabassut 5eb7a84a88 feat: linkify URLs in checklist done section 2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 beb4209baf fix: decode HTML entities in unlinkifyHtml and fix indentation
- Add entity decoding (&amp; &lt; &gt; &quot;) to unlinkifyHtml to
  prevent double-encoding on contenteditable edit cycles
- Fix indentation of LinkPopover import and state declaration
- Move LinkPopover inside root div
- Add unit test for entity decoding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic Cabassut 377ce4b72c feat: replace checklist text inputs with contenteditable for inline link rendering 2026-04-08 22:36:19 +02:00
Frederic Cabassut 740a89df63 feat: add LinkPopover component for checklist hyperlinks 2026-04-08 22:36:19 +02:00
Frederic Cabassut 17216bd17d style: add checklist-link CSS and contenteditable placeholder styles 2026-04-08 22:36:19 +02:00
Frederic Cabassut c753c4b5ce feat: add linkifyText and unlinkifyHtml utilities for checklist hyperlinks 2026-04-08 22:36:19 +02:00
github-actions[bot] aee262d70c chore: pin docker-compose to v0.27.1 [skip ci] 2026-03-25 07:32:04 +00:00
github-actions[bot] 53f5bdcbd8 chore: bump version to 0.27.1 2026-03-25 07:26:53 +00:00
Frederic CabassutandClaude Opus 4.6 b6dbdbe9a5 fix: prevent click-through to NoteCards when closing editor overlay
On installed PWAs, closing the editor by tapping the overlay would cause
the browser's delayed synthetic click to land on the NoteCard underneath,
immediately reopening a different note. Fixed by keeping a transparent
shield in the DOM for 400ms after close to absorb the ghost click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:15:58 +01:00
github-actions[bot] 21efb1d811 chore: pin docker-compose to v0.27.0 [skip ci] 2026-03-24 22:56:34 +00:00
github-actions[bot] e2ee18893f chore: bump version to 0.27.0 2026-03-24 22:50:50 +00:00
Frederic CabassutandClaude Opus 4.6 be53b4b26e fix: sync attachment changes to store and skip spurious auto-save
Two issues fixed:

1. Attachment operations (upload, feature, remove) only updated the local
   editor state, not the notes store. Before auto-save, this was masked
   because saveAndClose always called updateNote, whose PATCH response
   refreshed the store. With auto-save skipping redundant saves, the store
   was never updated with attachment changes. Now attachment handlers
   update the store directly.

2. TiptapEditor fires onUpdate on init with round-tripped markdown that
   can differ from the original. Added an autoSaveReady guard to absorb
   this initial normalization as the baseline, preventing spurious saves
   when reopening existing notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 042c4c06e5 fix: rename test note to avoid collision with offline test
The auto-save test used "Survive Reload" which collides with the same
title in offline.spec.ts when tests run in parallel with a shared DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 9d1c898b84 fix: address review feedback on auto-save
- Snapshot fields at start of performSave to avoid race with user edits
  during async save (fields could change between await and lastSaved update)
- Only update lastSaved tracking on successful save, so failed saves
  are retried on the next debounce cycle
- Stop restoring title after Untitled fallback in autoSave(), which was
  re-triggering the auto-save effect
- Capture note count before opening editor in empty-note e2e test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 07b27c01e7 feat: auto-save notes with debounced persistence
Notes are now automatically saved 2 seconds after the user stops editing,
preventing data loss when the browser tab is closed without closing the
editor first. A beforeunload handler provides an additional safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 4f4cc69b9f fix: replace hardcoded test passwords with runtime-generated UUIDs
Generate random passwords in global-setup and share via temp file so
all Playwright workers use the same credentials. Screenshot scripts
use module-level crypto.randomUUID() since they run single-process.
Unit test auth passwords also use randomUUID().

Eliminates security scanner alerts for passwords committed to git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:29:45 +01:00
Frederic CabassutandClaude Opus 4.6 3f81f22026 fix: align checkboxes in richtext task list previews
Add missing nested task list rule for .prose context (matching TipTap's
existing rule) and adjust checkbox vertical alignment with margin-top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:13 +01:00
github-actions[bot] 9331351c6d chore: pin docker-compose to v0.26.0 [skip ci] 2026-03-22 19:16:52 +00:00
github-actions[bot] cd93c18f92 chore: bump version to 0.26.0 2026-03-22 19:11:39 +00:00
Frederic CabassutandClaude Opus 4.6 172ab88b33 feat: custom tooltip action replacing native title attributes
Add a reusable `use:tooltip` Svelte action as a drop-in replacement for
native title attributes. Styled to match the retro parchment theme with
JetBrains Mono font, dark background, and 400ms hover delay.

Replace all title attributes in NoteEditor, NoteCard, and NotesView
with the custom tooltip. The action supports top/bottom positioning
and auto-clamps to viewport edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:09:32 +01:00
Frederic CabassutandClaude Opus 4.6 c82f720b02 fix: add cursor pointer to create crumb buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:04:23 +01:00
Frederic CabassutandClaude Opus 4.6 548aa14d63 fix: add cursor pointer to create crumb button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:26 +01:00
Frederic CabassutandClaude Opus 4.6 c66217e6f1 feat: add checklist shortcut to create button
Add a checkbox icon next to the "Add a crumb..." button that opens a new
note directly in checklist mode, similar to Google Keep's quick-create
bar. The NoteEditor accepts a new initialChecklistMode prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:53:01 +01:00
Frederic CabassutandClaude Opus 4.6 6c9781fb24 fix: sharing indicator popover opens downward to avoid overflow clipping
The popover was opening upward (bottom-full) which got clipped by the
NoteCard's overflow-hidden. Changed to open downward (top-full) so it
stays within the card's visual area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:50:48 +01:00
Frederic CabassutandClaude Opus 4.6 b44cd40e36 fix: align nested checkboxes in rich text task lists
Nested TipTap task lists (indented children) broke the flex layout
because the nested ul was treated as an inline flex child. Now the
li uses flex-wrap with align-items: flex-start, and nested task lists
take full width with left padding for indentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:49:10 +01:00
Frederic CabassutandClaude Opus 4.6 8b9f9dfee8 feat: add overflow menu with checklist actions and action bar cleanup
- Add vertical dots (⋮) overflow menu to NoteEditor bottom action bar
- New checklist actions: "Delete checked" and "Uncheck all"
- Move secondary actions to menu: checklist toggle, markdown toggle,
  version history, move to trash
- Promote archive to the action bar for quick access
- Keep in bar: color picker, image upload, share, archive, ⋮, close
- Menu renders as fixed-position portal to escape card overflow-hidden
- Click-outside handler closes the menu
- Update e2e tests to use overflow menu for checklist/markdown toggles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:02 +01:00
Frederic CabassutandClaude Opus 4.6 5a2a63e2bb fix: prevent 3-way merge from reverting sequential single-user edits
The server's 3-way content merge was incorrectly triggering on sequential
edits by the same user. When a user checked then unchecked a checklist
item, the merge saw local === base (both unchecked) and kept the server's
checked state, silently discarding the uncheck.

Now the merge only runs when there's an actual concurrent edit
(baseVersion < existing.version), meaning another user saved between the
client's load and this save. For single-user sequential edits, the
client's content is used directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:51:14 +01:00
Frederic CabassutandClaude Opus 4.6 6eb35e9df2 perf: add BuildX + GHA caching to CI docker validation build
CI only needs to verify the Dockerfile builds on amd64 — multi-arch
is handled by the release workflow with native ARM runners. Switching
from bare `docker build` to BuildX with GHA layer caching speeds up
subsequent builds significantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:28:48 +01:00
Frederic CabassutandClaude Opus 4.6 f1b42f115a perf: parallelize multi-arch Docker builds with native ARM runners
Replace sequential QEMU-emulated builds with parallel native builds
using a matrix strategy (ubuntu-latest for amd64, ubuntu-24.04-arm
for arm64) and manifest merging. This eliminates the ~5-10x QEMU
overhead and should cut Docker build time roughly in half.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:06:37 +01:00
github-actions[bot] 133217d552 chore: pin docker-compose to v0.25.0 [skip ci] 2026-03-21 23:03:17 +00:00
64 changed files with 5337 additions and 1683 deletions
+75 -18
View File
@@ -48,13 +48,20 @@ jobs:
needs: bump
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
build:
name: Build Docker (${{ matrix.platform }})
needs: [bump, ci]
permissions:
contents: write
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:
@@ -65,13 +72,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
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
@@ -83,17 +83,74 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
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
runs-on: ubuntu-latest
needs: [bump, build]
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
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- 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: 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: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -104,7 +161,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
needs: [bump, merge]
permissions:
contents: write
steps:
+12 -8
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main, 'claude/**']
branches: [main]
pull_request:
branches: [main]
workflow_call:
@@ -19,8 +19,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -37,8 +35,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -68,8 +64,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -121,5 +115,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: docker build -t crumbs:ci .
uses: docker/build-push-action@v6
with:
context: .
push: false
provenance: false
tags: crumbs:ci
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
+72 -17
View File
@@ -13,10 +13,18 @@ jobs:
ci:
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
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
@@ -25,13 +33,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
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
@@ -43,17 +44,71 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
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
runs-on: ubuntu-latest
needs: build
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
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- 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: 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: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -63,7 +118,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
needs: merge
permissions:
contents: write
steps:
-2
View File
@@ -14,8 +14,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
+34 -2
View File
@@ -37,9 +37,41 @@ 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 |
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
@@ -53,7 +85,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **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(--primary)` globally — gold checkboxes match the theme.
- **Checkboxes**: Use `accent-color: var(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
@@ -146,7 +178,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
GitHub Actions with two workflows:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
1. **Lint & Type Check** — `pnpm check`
2. **Unit Tests** — `pnpm test:unit`
3. **Build** — `pnpm build` (depends on steps 1+2)
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.24.0
image: ghcr.io/bretzel-app/crumbs:0.30.1
container_name: crumbs
ports:
- "3000:3000"
+4 -1
View File
@@ -159,7 +159,10 @@ 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 color variant per theme
- **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.
## File Organization
+1 -1
View File
@@ -71,7 +71,7 @@
### Dark Mode
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — 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
@@ -0,0 +1,557 @@
# 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
@@ -0,0 +1,205 @@
# 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
+11 -7
View File
@@ -1,8 +1,9 @@
{
"name": "crumbs",
"private": true,
"version": "0.25.0",
"version": "0.31.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -51,7 +52,7 @@
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
@@ -66,7 +67,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
@@ -76,7 +77,7 @@
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^3.2.6",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"nyc": "^18.0.0",
@@ -85,10 +86,10 @@
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^7.3.5",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"vitest": "^3.2.6",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
@@ -102,7 +103,10 @@
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2"
}
}
}
+1546 -1004
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: 'bretzel2026!'
password: crypto.randomUUID()
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: 'salzig2026!!'
password: crypto.randomUUID()
};
+84 -16
View File
@@ -21,29 +21,44 @@
--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);
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--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: #e8dcc8;
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
}
body {
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
body.editor-scroll-locked {
overflow: hidden;
}
body::before {
@@ -68,6 +83,11 @@ 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 */
.prose {
--tw-prose-body: var(--text);
@@ -135,12 +155,20 @@ body::before {
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: center;
align-items: flex-start;
flex-wrap: wrap;
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;
@@ -151,6 +179,7 @@ body::before {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Shared checkbox styles */
@@ -158,6 +187,7 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
@@ -195,3 +225,41 @@ 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;
}
+3 -1
View File
@@ -15,7 +15,9 @@ declare global {
} | null;
}
// interface PageData {}
// interface PageState {}
interface PageState {
noteEditorOpen?: boolean;
}
// interface Platform {}
}
}
+6 -2
View File
@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- 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="theme-color" content="#f0e6d3" />
<script>
(function() {
@@ -14,7 +18,7 @@
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
if (m) m.content = '#141312';
}
} catch(e) {}
})();
+73 -15
View File
@@ -6,7 +6,8 @@
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 } from '$lib/utils/checklist.js';
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
import LinkPopover from './LinkPopover.svelte';
interface Props {
content: string;
@@ -19,6 +20,7 @@
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.)
@@ -85,10 +87,10 @@
emitChange();
}
function updateText(id: string, text: string) {
function updateText(id: string, innerHTML: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
item.text = unlinkifyHtml(innerHTML);
emitChange();
}
}
@@ -106,7 +108,7 @@
emitChange();
const newId = newItem.id;
setTimeout(() => {
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
}
@@ -133,11 +135,11 @@
e.preventDefault();
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
const inputs = document.querySelectorAll<HTMLElement>('[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<HTMLInputElement>('[data-testid="checklist-input"]'));
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) {
@@ -155,6 +157,44 @@
}
}
// 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
@@ -276,16 +316,30 @@
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
contenteditable="true"
use:editableContent={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
onkeydown={(e) => handleKeydown(e, item.id)}
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"
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"
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"
@@ -324,7 +378,7 @@
{#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)]">{group.parentLabel.text}</span>
<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)}
@@ -336,7 +390,7 @@
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>
<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"
@@ -352,5 +406,9 @@
{/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, getNoteColor } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteChip } 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: {getNoteColor(option.value, getIsDarkMode())}"
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+330 -221
View File
@@ -44,24 +44,57 @@
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;
function toggleDropdown(name: string) {
const dropdownGap = 4;
const viewportMargin = 10;
function toggleDropdown(name: string, anchorEl: HTMLElement) {
if (openDropdown === name) {
openDropdown = null;
closeDropdowns();
} 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;
}
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() {
@@ -88,15 +121,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(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
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' : ''}`;
}
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(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
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' : ''}`;
}
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(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
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' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -112,17 +145,32 @@
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 flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
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;"
data-testid="formatting-toolbar"
onscroll={closeDropdownsAfterPositionChange}
>
<!-- Undo/Redo -->
<button
onclick={() => editor?.chain().focus().undo().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
@@ -131,7 +179,8 @@
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
@@ -144,7 +193,8 @@
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
@@ -152,7 +202,8 @@
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
@@ -160,7 +211,8 @@
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
@@ -168,7 +220,8 @@
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
@@ -179,9 +232,10 @@
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
<div data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('heading', e)}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
@@ -189,40 +243,13 @@
<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 class="relative" data-dropdown="list">
<div data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('list', e)}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
@@ -230,98 +257,26 @@
<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 class="relative" data-dropdown="link">
<div data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('link', e)}
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 class="relative" data-dropdown="table">
<div data-dropdown="table">
<button
onclick={() => toggleDropdown('table')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('table', e)}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
@@ -329,68 +284,13 @@
<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 class="relative" data-dropdown="align">
<div data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('align', e)}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
@@ -406,49 +306,14 @@
{/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
onclick={() => editor?.chain().focus().toggleCode().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
@@ -456,7 +321,8 @@
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
@@ -464,7 +330,8 @@
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
@@ -472,7 +339,8 @@
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
class={btnClass()}
title="Divider"
data-testid="format-hr"
@@ -480,3 +348,244 @@
<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}
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>
</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(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/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(--border)]/10 lg:hidden"
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
aria-label="Search"
>
<Search class="h-5 w-5 text-[var(--text)]" />
+73
View File
@@ -0,0 +1,73 @@
<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>
+23 -18
View File
@@ -12,6 +12,8 @@
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;
@@ -116,8 +118,8 @@
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
@@ -130,7 +132,10 @@
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
<!-- 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(); }}>
{#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}>
@@ -141,7 +146,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">{item.text}</span>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
{#if checklistItems.length > 8}
@@ -159,16 +164,16 @@
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"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(--border)]/10"
title="Delete forever"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -177,8 +182,8 @@
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -187,8 +192,8 @@
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -196,8 +201,8 @@
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
@@ -206,8 +211,8 @@
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
@@ -215,8 +220,8 @@
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
+499 -96
View File
@@ -1,5 +1,11 @@
<script module lang="ts">
let bodyScrollLockCount = 0;
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { pushState } from '$app/navigation';
import { page } from '$app/state';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
@@ -7,7 +13,7 @@
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { updateNote, createNote, trashNote, archiveNote } 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';
@@ -24,14 +30,22 @@
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;
}
const { note, isNew = false, onClose }: Props = $props();
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
@@ -41,7 +55,7 @@
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
@@ -56,20 +70,186 @@
// 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);
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
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);
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');
}
};
});
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
@@ -158,7 +338,13 @@
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
const updated = [...attachmentsList, attachment];
attachmentsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
@@ -170,9 +356,14 @@
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
const updated = 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);
@@ -185,7 +376,11 @@
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
attachmentsList = updated;
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
@@ -196,11 +391,11 @@
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// 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.
// 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).
let pointerdownOnOverlay = false;
let closing = $state(false);
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
@@ -211,35 +406,63 @@
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()) {
onClose();
dismissOverlay();
return;
}
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
// Final save if auto-save hasn't caught up yet
if (hasUnsavedChanges()) {
await performSave();
}
onClose();
dismissOverlay();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
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;
}
async function toggleImageUpload() {
@@ -267,41 +490,94 @@
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 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]"
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};"
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
{#if !closing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
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]"
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]"
style={bgStyle}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- 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');
}
}}
/>
<!-- 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>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto">
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
@@ -311,7 +587,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-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-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
@@ -328,7 +604,7 @@
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
@@ -342,14 +618,14 @@
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar -->
<!-- Toolbar (Desktop only) -->
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center gap-1">
@@ -357,8 +633,8 @@
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -370,51 +646,22 @@
{/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(--border)]/10"
title="Image attachments"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</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) -->
<!-- Share button -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if hasPublicLink}
@@ -426,33 +673,189 @@
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
<Users class="h-5 w-5" />
</span>
{/if}
<!-- History button (only for saved notes) -->
<!-- Archive button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={toggleHistory}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
<Archive class="h-5 w-5" />
</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="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/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(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/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(--border)]/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(--hover-wash)]/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(--border)]/10"
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"
>
Cancel
</button>
+29 -11
View File
@@ -4,8 +4,11 @@
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';
interface Props {
filter: NoteFilter;
@@ -16,6 +19,7 @@
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(() => {
@@ -24,15 +28,19 @@
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;
history.replaceState(null, '', `#${note.id}`);
replaceState(`#${note.id}`, {});
}
function closeEditor() {
editingNote = null;
showNewNote = false;
history.replaceState(null, '', location.pathname);
newNoteChecklist = false;
replaceState(location.pathname, {});
}
function handleReorder(noteIds: string[]) {
@@ -55,14 +63,24 @@
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<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 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>
</div>
<button
onclick={() => (showNewNote = true)}
@@ -119,7 +137,7 @@
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
{/if}
{#if editingNote}
+44 -20
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import { searchQuery, searchResults } 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;
@@ -12,41 +13,64 @@
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() {
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchController?.abort();
searchController = undefined;
const trimmedQuery = query.trim();
if (!trimmedQuery) {
searchQuery.set('');
searchResults.set([]);
return;
}
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
const controller = new AbortController();
searchController = controller;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
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;
}
}
function clearSearch() {
searchController?.abort();
searchController = undefined;
query = '';
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchQuery.set('');
searchResults.set([]);
}
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(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/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(--border)]/10" data-testid="share-collaborator">
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/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 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]"
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]"
data-testid="sharing-popover"
>
{#if hasPublicLink}
+52 -2
View File
@@ -27,6 +27,54 @@
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
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(() => {
editor = new Editor({
element: element!,
@@ -72,12 +120,14 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose md: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"
onclick={() => editor?.commands.focus()}
onpointerdown={handleWrapperPointerDown}
onclick={handleWrapperClick}
onchangecapture={handleWrapperChangeCapture}
></div>
<style>
+5 -5
View File
@@ -3,7 +3,7 @@ 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 } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
@@ -16,16 +16,16 @@ beforeEach(() => {
describe('Auth - Password Hashing', () => {
it('should hash and verify a password correctly', async () => {
const password = 'mysecurepassword';
const password = randomUUID();
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
expect(await argon2.verify(hash, randomUUID())).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = 'mysecurepassword';
const password = randomUUID();
const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
describe('Auth - User Setup', () => {
it('should create a user with hashed password', async () => {
const password = 'testpassword123';
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
+47 -23
View File
@@ -227,29 +227,53 @@ export function getUser(userId: number): User | null {
}
export async function deleteUser(userId: number): Promise<void> {
// 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();
// 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);
}
export async function changePassword(
+40
View File
@@ -263,6 +263,46 @@ 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', () => {
+31 -44
View File
@@ -1,6 +1,6 @@
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
@@ -8,8 +8,8 @@ import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot } from './versions-service.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import { createSnapshot, getBaseContent } from './versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import type { NoteFilter } from '$lib/types/index.js';
interface NoteRow {
@@ -238,30 +238,18 @@ 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) {
// 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();
// 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;
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;
}
sharedUpdates.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(db, id, input.baseVersion),
incomingContent: input.content,
currentContent: existing.content
})
: input.content;
hasSharedUpdates = true;
}
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
@@ -279,6 +267,22 @@ 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();
}
@@ -325,23 +329,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
}
}
// 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);
}
+27 -1
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 } from './versions-service.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
let db: Db;
@@ -44,6 +44,32 @@ 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();
+30 -2
View File
@@ -1,18 +1,46 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { eq, and, desc, lte } 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: Db,
db: SnapshotDb,
noteId: string,
snapshot: {
version: number;
+47 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeNotesByVersion } from './notes.js';
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
import type { Note } from '$lib/types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
@@ -97,3 +97,49 @@ describe('mergeNotesByVersion', () => {
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([]);
});
});
+36 -3
View File
@@ -8,11 +8,41 @@ 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],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
[notes, selectedTag, currentFilter, searchQuery, searchResults],
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
let result = $searchQuery.trim() ? $searchResults : $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
@@ -218,6 +248,7 @@ 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;
}
@@ -225,6 +256,7 @@ 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');
@@ -263,6 +295,7 @@ 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;
}
+59 -1
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 } from '../server/db/schema.js';
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } 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,6 +289,64 @@ 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
+30 -38
View File
@@ -1,37 +1,12 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
import { mergeContentUpdate } 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.
*/
@@ -69,16 +44,17 @@ 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: 3-way merge to preserve concurrent edits from other users
// Content: merge only when the client saved from an older note version.
if (change.data.content !== undefined && change.data.content !== note.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;
}
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;
}
if (isOwner) {
@@ -92,6 +68,22 @@ 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))
+105 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
describe('parseChecklist', () => {
it('parses flat checklist items', () => {
@@ -220,3 +220,107 @@ describe('outdentItem', () => {
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');
});
});
+47
View File
@@ -150,3 +150,50 @@ export function serializeChecklist(items: ChecklistItem[]): string {
}
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, '"');
}
+123 -4
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
@@ -8,13 +15,13 @@ describe('getNoteColor', () => {
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
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('#2a2520');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
it('has matching keys in light and dark color maps', () => {
@@ -23,3 +30,115 @@ 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);
}
});
});
+38 -12
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: '#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' }
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' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
@@ -39,3 +39,29 @@ 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;
}
+49 -10
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
import { mergeContent, mergeContentUpdate } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
});
});
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
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');
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
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'
});
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
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');
});
});
+23 -6
View File
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
}
/**
* 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.
* 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.
*/
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, 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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
const DARK_THEME_COLOR = '#141312';
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('#1a1715');
expect(meta?.getAttribute('content')).toBe('#141312');
});
it('updates meta theme-color for light mode', () => {
+83
View File
@@ -0,0 +1,83 @@
/**
* 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);
}
};
}
+13 -2
View File
@@ -70,9 +70,16 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
createMsg = 'User deleted';
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to delete user';
createError = true;
}
} catch {
// ignore
createMsg = 'Connection error';
createError = true;
}
}
@@ -167,7 +174,10 @@
<div class="space-y-3">
{#each users as user (user.id)}
<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="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
data-testid="user-row-{user.id}"
>
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
@@ -195,6 +205,7 @@
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
+20 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } 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 {
@@ -11,8 +12,12 @@
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 bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const bgLight = getNoteColor(noteColor, false);
const bgDark = getNoteColor(noteColor, true);
const renderedContent = renderMarkdown(data.content);
@@ -54,8 +59,8 @@
<div class="mx-4 w-full max-w-2xl py-10">
<article
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="background-color: {bgColor}"
class="note-surface rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="--note-bg: {bgLight}; --note-bg-dark: {bgDark}"
data-testid="shared-note"
>
<div class="px-6 py-5">
@@ -80,7 +85,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">{item.text}</span>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
</ul>
@@ -127,3 +132,13 @@
</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 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.24.0",
"version": "0.27.1",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+153
View File
@@ -0,0 +1,153 @@
import { test, expect } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
/** Create a victim user via the admin API, returning the user's id. */
async function createVictim(
page: import('@playwright/test').Page,
email: string,
displayName = email.split('@')[0]
): Promise<number> {
const res = await page.request.post('/api/admin/users', {
data: { email, displayName, password: userPassword, role: 'user' }
});
expect(res.status()).toBe(201);
const user = await res.json();
return user.id;
}
test.describe.serial('Admin — User Deletion', () => {
test('Scenario: Admin deletes a user from the users list', async ({
authenticatedPage: page
}) => {
// Given a disposable user exists
const victimId = await createVictim(page, 'delete-me@test.com', 'Delete Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And accepts the confirmation dialog for deletion
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete request succeeds
expect(res.status()).toBe(200);
// And the user disappears from the list
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the list is reloaded correctly on refresh (backend is consistent)
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
});
test('Scenario: Deletion is cancelled when the admin dismisses the confirmation', async ({
authenticatedPage: page
}) => {
// Given a user exists
const victimId = await createVictim(page, 'keep-me@test.com', 'Keep Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And dismisses the confirmation dialog
page.once('dialog', (dialog) => dialog.dismiss());
await page.getByTestId(`delete-user-btn-${victimId}`).click();
// Then the user remains in the list
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Deleting a user with notes, tags, and attachments succeeds', async ({
authenticatedPage: page,
browser
}) => {
// Given a user with full data exists (note, tag, attachment, preferences, api key)
const email = 'heavy-user@test.com';
const victimId = await createVictim(page, email, 'Heavy User');
// Log in as the victim in a fresh context so they create their own data
const ctx = await browser.newContext();
const victimPage = await ctx.newPage();
await victimPage.goto('/login');
await victimPage.getByTestId('email-input').fill(email);
await victimPage.getByTestId('password-input').fill(userPassword);
await victimPage.getByTestId('login-btn').click();
await victimPage.waitForURL('/');
// Create a note with a tag
const noteRes = await victimPage.request.post('/api/notes', {
data: { title: 'Victim Note #todo', content: 'content #work' }
});
expect(noteRes.ok()).toBe(true);
const note = await noteRes.json();
// Upload an attachment on the note. Use a browser-context fetch so the
// Origin header is set correctly for SvelteKit's CSRF check.
const imageBase64 = readFileSync(TEST_IMAGE_PATH).toString('base64');
const attachmentStatus = await victimPage.evaluate(
async ({ noteId, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), 'test-image.png');
const r = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: form
});
return r.status;
},
{ noteId: note.id, base64: imageBase64 }
);
expect(attachmentStatus).toBe(201);
// Write a preference and create an API key to exercise every table
await victimPage.request.put('/api/preferences', {
data: { defaultNoteMode: 'markdown' }
});
await victimPage.request.post('/api/settings/api-keys', { data: { name: 'test-key' } });
await ctx.close();
// When the admin deletes the user with all their data
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete succeeds atomically (no partial state)
expect(res.status()).toBe(200);
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the user cannot log in afterwards
const loginRes = await page.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(loginRes.status()).toBe(401);
});
});
+5 -1
View File
@@ -1,4 +1,8 @@
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 }) => {
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: 'userpassword123',
password: userPassword,
role: 'user'
}
});
+83
View File
@@ -0,0 +1,83 @@
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();
});
});
+196 -35
View File
@@ -1,13 +1,20 @@
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 page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
await page.getByTestId('checklist-input').nth(i).focus();
await page.keyboard.type(items[i]);
if (i < items.length - 1) {
await page.getByTestId('checklist-input').nth(i).press('Enter');
}
@@ -22,7 +29,7 @@ test.describe('Checklist', () => {
await page.getByTestId('new-note-btn').click();
// When the user enables checklist mode
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
// Then the checklist component is displayed
await expect(page.getByTestId('checklist')).toBeVisible();
@@ -40,7 +47,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()).toHaveValue('Buy milk');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
});
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
@@ -60,11 +67,37 @@ 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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
// When the user presses Enter
await page.getByTestId('checklist-input').first().press('Enter');
@@ -96,14 +129,15 @@ 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)).toHaveValue('');
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
});
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
@@ -118,17 +152,19 @@ 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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Done task');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('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()).toHaveValue('Pending task');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
// And the done section shows the completed item
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
@@ -139,12 +175,15 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First');
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).fill('Second');
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).fill('Third');
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');
@@ -162,10 +201,12 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Parent');
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).fill('Child');
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');
@@ -183,13 +224,16 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Buy groceries');
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).fill('Milk');
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).fill('Eggs');
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();
@@ -202,10 +246,12 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Groceries');
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).fill('Milk');
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
@@ -220,10 +266,12 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Shopping');
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).fill('Milk');
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');
@@ -256,10 +304,12 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Parent');
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).fill('Child 1');
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
@@ -285,14 +335,125 @@ test.describe('Checklist', () => {
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 page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await expect(page.getByTestId('checklist')).toBeVisible();
// When the user disables checklist mode
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
// 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');
});
});
+27 -1
View File
@@ -81,7 +81,33 @@ 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 #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
// Dark default color is #21201f = rgb(33, 32, 31); 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();
});
});
+83 -5
View File
@@ -1,5 +1,5 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
import { devices, 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,11 +13,40 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
/**
* 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 page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
}
test.describe('Rich text formatting', () => {
@@ -34,7 +63,7 @@ test.describe('Rich text formatting', () => {
await expect(editor.locator('strong')).toHaveText('world');
// And the markdown content contains bold syntax
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
});
@@ -95,7 +124,7 @@ test.describe('Rich text formatting', () => {
await expect(editor.locator('strong')).toHaveText('bold');
// When the user toggles markdown mode
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
// Then the raw markdown textarea is shown with the markdown source
await expect(page.getByTestId('note-content-input')).toBeVisible();
@@ -103,3 +132,52 @@ 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');
});
});
+13 -1
View File
@@ -1,6 +1,8 @@
import { existsSync, unlinkSync } from 'fs';
import { existsSync, unlinkSync, writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
const TEST_DB = './data/test-crumbs.db';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
@@ -10,4 +12,14 @@ export default function globalSetup() {
unlinkSync(path);
}
}
// 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()}`
})
);
}
+11 -2
View File
@@ -1,12 +1,16 @@
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 = 'testpassword123';
const TEST_PASSWORD = testPassword;
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
@@ -100,6 +104,11 @@ 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();
}
+34
View File
@@ -23,6 +23,40 @@ test.describe('Notes CRUD', () => {
await expect(page.getByText('Updated Title')).toBeVisible();
});
test('Scenario: Browser back closes the open editor instead of leaving the page', async ({ authenticatedPage: page }) => {
// Given a note titled "Back Closes Me" exists
await createNote(page, 'Back Closes Me', 'Some content');
// Given the note is open in the editor
await noteCard(page, 'Back Closes Me').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor is closed and the notes list is still shown
await expect(page.getByTestId('note-editor')).not.toBeVisible();
await expect(noteCard(page, 'Back Closes Me')).toBeVisible();
});
test('Scenario: Closing the editor leaves browser history clean', async ({ authenticatedPage: page }) => {
// Given a note titled "Clean History" exists
await createNote(page, 'Clean History');
// Given the user opened and closed the note
await noteCard(page, 'Clean History').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
await page.getByTestId('close-editor-btn').click();
await expect(page.getByTestId('note-editor')).not.toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor does not reopen and the note's URL is gone from history
await expect(page.getByTestId('note-editor')).not.toBeVisible();
expect(page.url()).not.toContain('#');
});
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists
await createNote(page, 'Delete Me');
+48
View File
@@ -121,6 +121,54 @@ test.describe('Public Note Sharing', () => {
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
});
test('Scenario: Shared note follows a dark-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Dark Visitor Note', 'Readable in dark mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers dark mode opens the share URL
const context = await browser.newContext({ colorScheme: 'dark' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the dark "fog" surface, so its text stays legible
await expect(publicPage.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
await context.close();
});
test('Scenario: Shared note follows a light-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Light Visitor Note', 'Readable in light mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers light mode opens the share URL
const context = await browser.newContext({ colorScheme: 'light' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the light "fog" surface
await expect(publicPage.locator('html')).not.toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(212, 228, 237)'
);
await context.close();
});
test('Scenario: Copy link button copies to clipboard', async ({
authenticatedPage: page
}) => {
+80
View File
@@ -31,4 +31,84 @@ test.describe('Search', () => {
// Then no notes are displayed
await expect(page.getByTestId('note-card')).toHaveCount(0);
});
test('Scenario: Background sync preserves active search results', async ({ authenticatedPage: page }) => {
await createNote(page, 'Sync Search Target 3812925');
await createNote(page, 'Sync Search Other 3812925');
await page.getByTestId('search-input').fill('Sync Search Target 3812925');
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
const notesReloaded = page.waitForResponse(
(response) => response.url().includes('/api/notes?filter=all') && response.ok()
);
await page.getByTestId('sync-indicator').click();
await notesReloaded;
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
});
test('Scenario: A stale response cannot replace newer search results', async ({ authenticatedPage: page }) => {
await createNote(page, 'Race Slow 3812925');
await createNote(page, 'Race Fast 3812925');
const slowResponse = await page.request.get('/api/search?q=Race%20Slow%203812925');
const fastResponse = await page.request.get('/api/search?q=Race%20Fast%203812925');
const slowResults = await slowResponse.json();
const fastResults = await fastResponse.json();
await page.route('**/api/search?*', async (route) => {
const requestQuery = new URL(route.request().url()).searchParams.get('q');
if (requestQuery === 'Race Slow 3812925') {
await new Promise((resolve) => setTimeout(resolve, 250));
await route.fulfill({ json: slowResults });
return;
}
await route.fulfill({ json: fastResults });
});
const searchInput = page.getByTestId('search-input');
const slowRequestStarted = page.waitForRequest(
(request) => new URL(request.url()).searchParams.get('q') === 'Race Slow 3812925'
);
await searchInput.fill('Race Slow 3812925');
await slowRequestStarted;
await searchInput.fill('Race Fast 3812925');
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
await page.waitForTimeout(300);
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
});
test('Scenario: Current notes stay visible while a search request is in flight', async ({ authenticatedPage: page }) => {
// Given two notes exist
await createNote(page, 'Flash Alpha 771903');
await createNote(page, 'Flash Beta 771903');
// And the search request is held open before it can respond
let releaseSearch: () => void = () => {};
const searchGate = new Promise<void>((resolve) => {
releaseSearch = resolve;
});
await page.route('**/api/search?*', async (route) => {
await searchGate;
await route.continue();
});
// When the user starts a search
await page.getByTestId('search-input').fill('Flash Alpha 771903');
// Then the current notes stay on screen instead of flashing an empty state
await expect(page.getByText('Flash Beta 771903')).toBeVisible();
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
// And once the results arrive, only the match remains
releaseSearch();
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
await expect(page.getByText('Flash Beta 771903')).not.toBeVisible();
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.25.0</span>
<span class="footer-version">v0.31.0</span>
</div>
</footer>