Compare commits

...
142 Commits
Author SHA1 Message Date
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
github-actions[bot] b06c1514d2 chore: bump version to 0.25.0 2026-03-21 22:52:14 +00:00
Frederic CabassutandClaude Opus 4.6 2267474cd5 fix: address PR review — a11y, checked detection, test fixes
- Remove unused flushSync import
- Fix removeItem single-item branch: emit change and trigger reactivity
- Remove misleading role="button" + tabindex from drag handle
- Use anchored regex for checked detection instead of line.includes('[x]')
  in NoteCard and public share page (prevents false positive on item text)
- Fix NoteCard preview E2E test: scope assertion to specific card, use
  unique title to avoid collision
- Restore arrow key navigation E2E test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 0124c5e7f6 fix: unchecked child repositions after its parent and siblings
When unchecking a child from the done section, it stayed at the end of
the items array (where done items live), appearing as the last active
item instead of under its parent. Now repositions after the last sibling
or parent in the array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 ffcabad30e docs: add nested checklists to features and architecture docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 dee5129d59 test: add E2E tests for nested checklist interactions
7 new scenarios: Tab/Shift+Tab, parent cascade check, child done with
parent label, uncheck parent restores group, nesting persists on reopen,
Enter on child creates sibling, NoteCard preview indentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 087fe8c713 feat: NoteCard and public share page support nested checklist items
Update checklist preview parsers to detect 2-space child prefix and
render indented items with extra left padding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 2306883503 feat: nested checklist UI — indentation, parent labels, drag-to-indent
Checklist component changes:
- Import logic from checklist utility module
- Tab/Shift+Tab indent/outdent with sibling adoption
- Indented rendering (pl-8) for child items in active section
- Done section groups checked children under read-only parent labels
- Enter on child creates sibling at same indent level
- Backspace on parent orphans children to top-level
- Directional lock drag: horizontal = indent/outdent, vertical = reorder
  (uses window capture-phase listeners + pending indent applied after
  DnD finalize to avoid corrupting svelte-dnd-action internal state)
- flushSync for state updates from window event listeners

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 9fb8acc719 feat: checklist utility module with parentId and nesting logic
Extract checklist logic into testable utility module:
- ChecklistItem type with parentId field for single-level nesting
- parseChecklist/serializeChecklist with 2-space prefix for children
- toggleItemWithCascade: parent check/uncheck cascades to children
- indentItem/outdentItem with sibling adoption rules
- 21 unit tests covering parse, serialize, toggle, indent, outdent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 8155bd4ff5 fix: address PR review — unique test titles and assertion between clicks
- Rename "Groceries" to "Focus Test List" to avoid title collision
- Add assertion (1 done) between consecutive checkbox clicks to prevent
  race condition where second click toggles the same item back

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:27:38 +01:00
Claude 6278f11184 fix: preserve local-only notes during loadNotes merge
mergeNotesByVersion only iterated incoming notes, dropping any
local-only notes (e.g. just created but not yet in the server/IDB
response). This caused note cards to disappear briefly after creation
when loadNotes raced with createNote.

Also rename E2E test note title to avoid name collision with existing
"Groceries" test.

https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
Claude bd3a14d518 chore: update openapi.json to match v0.24.0
https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
Claude ee017ab550 fix: prevent checklist state loss from sync race condition
Two issues caused checked items to revert after closing:

1. loadNotes() blindly overwrote the store with notes.set(), so a stale
   response from the periodic sync (every 30s) could replace a just-saved
   note with older server data. Now merges by version number, keeping the
   higher-versioned note.

2. Checklist component ignored external content prop changes (from sync or
   history restore). Added $effect that re-parses items when the content
   prop changes from an external source, using lastEmitted tracking to
   distinguish self-originated vs external updates.

https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
github-actions[bot] 65b7a9fe36 chore: pin docker-compose to v0.24.0 [skip ci] 2026-03-21 09:32:08 +00:00
github-actions[bot] a6cd5baadb chore: bump version to 0.24.0 2026-03-21 09:20:54 +00:00
Frederic CabassutandClaude Opus 4.6 f9d29a192a feat: arrow key navigation between checklist items
ArrowUp/ArrowDown move focus between active checklist inputs.
At list boundaries, focus stays on the current item (no wrap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:17:58 +01:00
Frederic CabassutandClaude Opus 4.6 dd100683c9 fix: rename test note to avoid name collision in CI
- "Shopping" matched existing "Shopping List" note from another test
- Renamed to "Groceries" to avoid strict mode violation
- Reworded comments: "interleaved" → "checked items before active ones"

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

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

Introduced in e5688c9 (active/done split).

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut 35cd888547 feat: add theme preference type (system/light/dark) 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 73b07a3be6 feat: add dark theme CSS variables and grid-dot extraction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 57b9d89e2e docs: add dark mode implementation plan
10-task plan covering CSS variables, preferences, FOUC prevention,
reactive theme application, component updates, and testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 e9aafd4f92 docs: revise dark mode spec after review
Address reviewer feedback: null-check meta element, use var(--border-subtle)
for card-shadow, specify .svelte.ts for reactivity, add data-testid attrs,
clarify SSR guards, resolve (auth) layout via root layout, defer landing page.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:29:57 +01:00
github-actions[bot] 155bb15f1c chore: pin docker-compose to v0.22.2 [skip ci] 2026-03-17 22:44:48 +00:00
109 changed files with 10514 additions and 1672 deletions
+76 -14
View File
@@ -48,17 +48,83 @@ 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:
packages: write
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
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:
ref: ${{ needs.bump.outputs.tag }}
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
@@ -80,15 +146,11 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- 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
@@ -99,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
+73 -13
View File
@@ -13,13 +13,77 @@ 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
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
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
@@ -40,15 +104,11 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- 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
@@ -58,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:
+45
View File
@@ -0,0 +1,45 @@
name: Screenshots
on:
workflow_dispatch:
permissions:
contents: write
jobs:
screenshots:
name: Capture Landing Page Screenshots
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install Playwright with system deps
run: pnpm exec playwright install --with-deps chromium
- name: Build app
run: pnpm build
env:
DATABASE_URL: ./data/screenshots.db
- name: Capture screenshots
run: pnpm exec tsx scripts/screenshots.ts
env:
DATABASE_URL: ./data/screenshots.db
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add website/assets/screenshot-*.png
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "chore: update landing page screenshots [skip ci]"
git push
+3
View File
@@ -44,4 +44,7 @@ todo.md
# Worktrees
.worktrees/
# Superpowers brainstorm artifacts
.superpowers/
package-lock.json
+1 -1
View File
@@ -146,7 +146,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 ./
+4
View File
@@ -11,6 +11,10 @@
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
<p align="center">
<img src="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
</p>
## Install
### Docker (Recommended)
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.22.0
image: ghcr.io/bretzel-app/crumbs:0.29.0
container_name: crumbs
ports:
- "3000:3000"
+108
View File
@@ -410,6 +410,114 @@ Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=
---
## Sharing
### `POST /api/notes/{id}/share`
Create a public share link
Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `201`
```json
{
"token": "token",
"url": "url"
}
```
**Errors:** `404` Note not found or not owner
---
### `DELETE /api/notes/{id}/share`
Revoke a public share link
Owner-only. Removes the public share link for the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `204`
**Errors:** `404` Note not found or not owner
---
### `GET /api/shared/{token}`
Get a shared note (public)
No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
> No authentication required.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | |
**Response:** `200`
```json
{
"title": "title",
"content": "content",
"checklistMode": true,
"color": "default",
"attachments": [
{
"id": "id",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"featured": true,
"createdAt": "2025-01-01T00:00:00.000Z"
}
],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
```
**Errors:** `404` Invalid token or note is trashed, `429` Rate limit exceeded
---
### `GET /api/shared/{token}/attachment/{attachmentId}`
Get an attachment from a shared note (public)
No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
> No authentication required.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | |
| `attachmentId` | path | string | yes | |
| `thumb` | query | string | no | Set to '1' to get the thumbnail version (1) |
**Response:** `200`
**Errors:** `404` Invalid token, note trashed, or attachment not found, `429` Rate limit exceeded
---
## Users
### `GET /api/users/search`
+26
View File
@@ -100,8 +100,24 @@ Key tables:
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
- `sync_log` - Sync operation history
### Nested Checklist Serialization
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
```
- [ ] Parent item
- [ ] Child item
- [x] Checked child item
- [x] Another parent
```
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
### Collaboration Access Control
Notes support sharing with other users on the same instance:
@@ -135,6 +151,16 @@ MCP tool handlers ┘
### Tools (14)
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
## Dark Mode
Theme switching uses CSS variable overrides on the `<html>` element:
- **CSS**: `[data-theme="dark"]` attribute on `<html>` overrides all `--bg-*`, `--text-*`, `--border-*`, and `--primary-*` variables defined in `src/app.css`
- **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
## File Organization
```
+20 -2
View File
@@ -37,6 +37,11 @@
- Check/uncheck items with checkboxes
- Checked items get strikethrough styling
- Enter key adds new item, Backspace removes empty items
- **Nested checklists**: single-level nesting for sub-tasks
- Tab / Shift+Tab to indent or outdent an item (keyboard)
- Drag handle horizontal swipe for mobile indentation
- Checking a parent item checks all its children
- Done section groups checked children under their parent label
### Image Attachments
- Upload images via file picker or drag-and-drop
@@ -64,6 +69,12 @@
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to note theme
### 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
- Respects system `prefers-color-scheme` preference by default
- FOUC-free: theme applied before first paint via a blocking inline script
### Pin Notes
- Pin important notes to always appear at top
- Pinned section separated from unpinned notes
@@ -81,13 +92,20 @@
- Permanently delete from trash
### Note Sharing / Collaboration
- **Public link sharing**: share any note via a public URL — no login required for viewers
- Toggle public link on/off from the share dialog
- Copy shareable URL with one click
- Read-only public page with full markdown/checklist/image rendering
- Public links are automatically invalidated when a note is trashed
- Rate-limited public endpoints (60 req/min per IP)
- Globe icon on note cards indicates a public link is active
- Share notes with other users on the same instance
- User search autocomplete for adding collaborators
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
- Per-user state: pin, archive, and sort order are independent per user
- Owner-only actions: trash, delete, share/unshare
- Collaborator indicator (Users icon) on shared note cards
- Hover popover showing collaborator names
- Sharing indicator on note cards (Globe for public, Users for collaborators-only)
- Hover popover showing sharing status and collaborator names
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
+139
View File
@@ -237,6 +237,104 @@ paths:
schema:
$ref: '#/components/schemas/Success'
/api/notes/{id}/share:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
post:
summary: Create a public share link
description: Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
tags: [Sharing]
responses:
'201':
description: Share link created
content:
application/json:
schema:
type: object
properties:
token:
type: string
url:
type: string
format: uri
'404':
description: Note not found or not owner
delete:
summary: Revoke a public share link
description: Owner-only. Removes the public share link for the note.
tags: [Sharing]
responses:
'204':
description: Share link revoked
'404':
description: Note not found or not owner
/api/shared/{token}:
parameters:
- name: token
in: path
required: true
schema:
type: string
get:
summary: Get a shared note (public)
description: No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
tags: [Sharing]
security: []
responses:
'200':
description: Shared note data
content:
application/json:
schema:
$ref: '#/components/schemas/SharedNoteData'
'404':
description: Invalid token or note is trashed
'429':
description: Rate limit exceeded
/api/shared/{token}/attachment/{attachmentId}:
parameters:
- name: token
in: path
required: true
schema:
type: string
- name: attachmentId
in: path
required: true
schema:
type: string
get:
summary: Get an attachment from a shared note (public)
description: No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
tags: [Sharing]
security: []
parameters:
- name: thumb
in: query
schema:
type: string
enum: ['1']
description: Set to '1' to get the thumbnail version
responses:
'200':
description: Image binary
content:
image/*:
schema:
type: string
format: binary
'404':
description: Invalid token, note trashed, or attachment not found
'429':
description: Rate limit exceeded
/api/users/search:
get:
summary: Search users
@@ -520,6 +618,47 @@ components:
description: API key (crumbs_...)
schemas:
PublicAttachment:
type: object
description: Sanitized attachment metadata for public sharing (no server paths)
properties:
id:
type: string
filename:
type: string
mimeType:
type: string
size:
type: integer
featured:
type: boolean
createdAt:
type: string
format: date-time
SharedNoteData:
type: object
properties:
title:
type: string
content:
type: string
checklistMode:
type: boolean
color:
type: string
enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk]
attachments:
type: array
items:
$ref: '#/components/schemas/PublicAttachment'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
Success:
type: object
properties:
@@ -0,0 +1,781 @@
# Dark Mode 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:** Add a dark parchment theme variant with system/light/dark toggle in Settings > Preferences.
**Architecture:** CSS `[data-theme="dark"]` attribute on `<html>` overrides CSS variables in `app.css`. A blocking inline script in `app.html` prevents FOUC. Theme preference uses the existing `UserPreferences` key-value system. A reactive `isDarkMode` rune in `theme.svelte.ts` drives note card color switching.
**Tech Stack:** SvelteKit, Svelte 5 runes, CSS custom properties, Tailwind CSS v4, Vitest, Playwright
**Spec:** `docs/superpowers/specs/2026-03-20-dark-mode-design.md`
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/app.css` | Add `--grid-dot` to `:root`, add `[data-theme="dark"]` block, update `body::before` |
| Modify | `src/app.html` | Add FOUC-prevention inline script |
| Modify | `src/lib/types/preferences.ts` | Add `theme` field to `UserPreferences` |
| Create | `src/lib/utils/theme.svelte.ts` | `applyTheme()`, reactive `isDarkMode` rune |
| Modify | `src/lib/utils/colors.ts` | Add `NOTE_COLORS_DARK`, export `getNoteColor()` |
| Modify | `src/routes/+layout.svelte` | Add theme `$effect` watcher |
| Modify | `src/routes/(app)/settings/preferences/+page.svelte` | Add theme segmented toggle |
| Modify | `src/lib/components/NoteCard.svelte` | Use `getNoteColor()` with `isDarkMode` |
| Modify | `src/lib/components/NoteEditor.svelte` | Use `getNoteColor()` with `isDarkMode` |
| Modify | `src/lib/components/ColorPicker.svelte` | Use dark color swatches when `isDarkMode` |
| Skip | `src/routes/(share)/s/[token]/+page.svelte` | Out of scope per spec (public share pages) |
| Modify | `.gitignore` | Add `.superpowers/` |
| Create | `tests/unit/theme.test.ts` | Unit tests for `applyTheme` and `isDarkMode` |
| Create | `tests/unit/colors.test.ts` | Unit tests for `getNoteColor` |
| Create | `tests/e2e/dark-mode.spec.ts` | E2E tests for theme toggle, persistence, system detection |
---
### Task 1: CSS Dark Theme Variables
**Files:**
- Modify: `src/app.css:6-23` (`:root` block) and `src/app.css:36` (`body::before`)
- [ ] **Step 1: Add `--grid-dot` to `:root` and add `[data-theme="dark"]` block**
In `src/app.css`, add `--grid-dot: #1a1a2e;` to the end of the `:root` block, then add the dark theme block after `:root`:
```css
:root {
--bg-base: #f0e6d3;
--bg-surface: #faf5eb;
--primary: #C8860A;
--primary-hover: #E8A020;
--text: #1a1a2e;
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
--destructive: #a63d2f;
--error-bg: #f5e0db;
--error-border: #d4a69e;
--error-text: #8b2e1f;
--success-bg: #dce8d4;
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
```
- [ ] **Step 2: Update `body::before` to use `--grid-dot` variable**
Replace the hardcoded color in the `body::before` rule:
```css
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
```
- [ ] **Step 3: Verify by temporarily adding `data-theme="dark"` to `app.html`**
In `src/app.html`, temporarily change `<html lang="en">` to `<html lang="en" data-theme="dark">`. Run `make dev` and visually confirm dark background and light text. Then revert the change.
- [ ] **Step 4: Commit**
```bash
git add src/app.css
git commit -m "feat: add dark theme CSS variables and grid-dot extraction"
```
---
### Task 2: Theme Preference Type
**Files:**
- Modify: `src/lib/types/preferences.ts`
- [ ] **Step 1: Add `theme` field to `UserPreferences`**
```typescript
export interface UserPreferences {
theme: 'system' | 'light' | 'dark';
defaultNoteMode: 'richtext' | 'markdown';
defaultNoteColor: NoteColor;
hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed';
notifyOnShare: boolean;
notifyOnCollabRemoved: boolean;
notifyOnNoteDeleted: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
theme: 'system',
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
};
```
Do NOT add `theme` to `BOOLEAN_PREF_KEYS`.
- [ ] **Step 2: Commit**
```bash
git add src/lib/types/preferences.ts
git commit -m "feat: add theme preference type (system/light/dark)"
```
---
### Task 3: Theme Utility — `applyTheme` and `isDarkMode`
**Files:**
- Create: `src/lib/utils/theme.svelte.ts`
- Test: `tests/unit/theme.test.ts`
- [ ] **Step 1: Write failing tests for theme utility**
Note: `theme.svelte.ts` uses module-scope `$state` which requires the Svelte compiler. The Vitest node environment does not process `.svelte.ts` runes correctly, so we test only the DOM side-effects of `applyTheme` (attribute and meta tag changes). The reactive `isDarkMode` / `getIsDarkMode()` behavior is covered by E2E tests instead.
Create `tests/unit/theme.test.ts`:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({ browser: true }));
describe('applyTheme', () => {
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
beforeEach(async () => {
// Reset DOM
document.documentElement.removeAttribute('data-theme');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.remove();
const metaEl = document.createElement('meta');
metaEl.setAttribute('name', 'theme-color');
metaEl.setAttribute('content', '#f0e6d3');
document.head.appendChild(metaEl);
// Reset module state
vi.resetModules();
const mod = await import('$lib/utils/theme.svelte.js');
applyTheme = mod.applyTheme;
});
it('sets data-theme="dark" for dark mode', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('removes data-theme for light mode', () => {
document.documentElement.setAttribute('data-theme', 'dark');
applyTheme('light');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
});
it('updates meta theme-color for light mode', () => {
applyTheme('dark');
applyTheme('light');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
});
it('falls back to system for unknown values', () => {
// matchMedia returns false by default in jsdom → resolves to light
applyTheme('invalid' as 'system' | 'light' | 'dark');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
applyTheme('system');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm vitest run tests/unit/theme.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement `theme.svelte.ts`**
Create `src/lib/utils/theme.svelte.ts`:
```typescript
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
let darkMode = $state(false);
export function getIsDarkMode(): boolean {
return darkMode;
}
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
if (!browser) return;
// Validate input
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
theme = 'system';
}
// Resolve system preference
let resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
const isDark = resolved === 'dark';
darkMode = isDark;
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm vitest run tests/unit/theme.test.ts`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/utils/theme.svelte.ts tests/unit/theme.test.ts
git commit -m "feat: add theme utility with applyTheme and isDarkMode"
```
---
### Task 4: Note Card Dark Colors
**Files:**
- Modify: `src/lib/utils/colors.ts`
- Test: `tests/unit/colors.test.ts`
- [ ] **Step 1: Write failing tests for `getNoteColor`**
Create `tests/unit/colors.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
expect(getNoteColor('default', false)).toBe('#faf5eb');
expect(getNoteColor('coral', false)).toBe('#faafa8');
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
});
it('has matching keys in light and dark color maps', () => {
const lightKeys = Object.keys(NOTE_COLORS).sort();
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
expect(lightKeys).toEqual(darkKeys);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm vitest run tests/unit/colors.test.ts`
Expected: FAIL — `getNoteColor` and `NOTE_COLORS_DARK` not found
- [ ] **Step 3: Add dark colors and helper to `colors.ts`**
Update `src/lib/utils/colors.ts`:
```typescript
import type { NoteColor } from '$lib/types/index.js';
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#faf5eb', label: 'Default' },
coral: { bg: '#faafa8', label: 'Coral' },
peach: { bg: '#f39f76', label: 'Peach' },
sand: { bg: '#fff8b8', label: 'Sand' },
mint: { bg: '#e2f6d3', label: 'Mint' },
sage: { bg: '#b4ddd3', label: 'Sage' },
fog: { bg: '#d4e4ed', label: 'Fog' },
storm: { bg: '#aeccdc', label: 'Storm' },
dusk: { bg: '#d3bfdb', label: 'Dusk' },
blossom: { bg: '#f6e2dd', label: 'Blossom' },
clay: { bg: '#e9e3d4', label: 'Clay' },
chalk: { bg: '#efeff1', label: 'Chalk' }
};
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' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
return (map[color] ?? map.default).bg;
}
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
value: value as NoteColor,
label
}));
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm vitest run tests/unit/colors.test.ts`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/utils/colors.ts tests/unit/colors.test.ts
git commit -m "feat: add dark note card colors and getNoteColor helper"
```
---
### Task 5: FOUC Prevention Script
**Files:**
- Modify: `src/app.html`
- [ ] **Step 1: Add inline script to `app.html`**
Insert after the `<meta name="theme-color">` tag (line 6) and before `%sveltekit.head%` (line 15):
```html
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
```
- [ ] **Step 2: Commit**
```bash
git add src/app.html
git commit -m "feat: add FOUC-prevention script for dark mode"
```
---
### Task 6: Reactive Theme in Root Layout
**Files:**
- Modify: `src/routes/+layout.svelte`
- [ ] **Step 1: Add theme `$effect` to root layout**
The root layout currently imports CSS and registers the PWA service worker. Add the theme `$effect` here so both `(app)` and `(auth)` layouts get the theme applied. **Important:** Keep `initPreferences()` in `(app)/+layout.svelte` — do NOT move it here. The `(app)` layout reads `sidebarDefaultState` immediately after init, and moving init to the root layout would create a race condition. The root layout only watches the preference reactively via `$effect`, which fires after preferences are loaded.
On auth pages (login/setup), `initPreferences()` hasn't been called, so `prefs.theme` will be `'system'` (the default). The FOUC script already handles the initial theme from localStorage, and the `$effect` will apply the correct theme once it runs.
```svelte
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
import { browser } from '$app/environment';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { applyTheme } from '$lib/utils/theme.svelte.js';
let { children } = $props();
let mediaQuery: MediaQueryList | undefined;
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
const prefs = $derived(getPreferences());
onMount(async () => {
if (pwaInfo) {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
immediate: true,
onRegistered(r: ServiceWorkerRegistration | undefined) {
console.log('SW Registered:', r);
},
onRegisterError(error: Error) {
console.log('SW registration error', error);
}
});
}
});
$effect(() => {
if (!browser) return;
const theme = prefs.theme ?? 'system';
applyTheme(theme);
// Listen for system preference changes when in system mode
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
if (theme === 'system') {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemListener = () => applyTheme('system');
mediaQuery.addEventListener('change', systemListener);
}
});
onDestroy(() => {
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
});
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
{@html webManifest}
</svelte:head>
{@render children()}
```
**Note:** `initPreferences()` stays in `(app)/+layout.svelte`. The root layout only adds the theme `$effect` and does not call `initPreferences()`.
- [ ] **Step 2: Verify dev server works**
Run `make dev`, open the app, go to Settings > Preferences. The theme toggle doesn't exist yet, but the app should render without errors. Check browser console for any issues.
- [ ] **Step 4: Commit**
```bash
git add src/routes/+layout.svelte src/routes/\(app\)/+layout.svelte
git commit -m "feat: add reactive theme watcher in root layout"
```
---
### Task 7: Theme Toggle in Settings
**Files:**
- Modify: `src/routes/(app)/settings/preferences/+page.svelte`
- [ ] **Step 1: Add theme segmented toggle**
Add as the first preference section (before "Default note mode"):
```svelte
<!-- Theme -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
<div class="flex gap-1" role="group" aria-label="Theme">
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
<button
onclick={() => updatePreference('theme', value)}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
```
- [ ] **Step 2: Manually test**
Run `make dev`. Go to Settings > Preferences. Click System / Light / Dark and confirm:
- Clicking "Dark" immediately applies dark theme
- Clicking "Light" reverts to light theme
- Clicking "System" follows OS preference
- Refreshing the page persists the choice (no FOUC)
- [ ] **Step 3: Commit**
```bash
git add src/routes/\(app\)/settings/preferences/+page.svelte
git commit -m "feat: add theme toggle to preferences settings"
```
---
### Task 8: Update Components to Use Dark Note Colors
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:2,29-32`
- Modify: `src/lib/components/NoteEditor.svelte:12,194-197`
- Modify: `src/lib/components/ColorPicker.svelte`
**Important:** `getIsDarkMode()` must be called **inside** `$effect` or `$derived` blocks for reactivity. Calling it as a top-level `const` would capture the value once and never update when the theme changes. Each component already has an `$effect` block where it computes card/bg styles — add `getIsDarkMode()` inside those existing blocks.
Public share page (`src/routes/(share)/s/[token]/+page.svelte`) is out of scope per the spec.
- [ ] **Step 1: Update `NoteCard.svelte`**
Replace `NOTE_COLORS` import with `getNoteColor` and `getIsDarkMode`:
```svelte
<!-- Change import at top of script -->
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Inside the existing $effect block where cardStyle is computed (around lines 29-32): -->
$effect(() => {
// ... existing logic ...
const bg = getNoteColor(note.color, getIsDarkMode());
cardStyle = `background-color: ${bg}`;
});
```
Remove the `NOTE_COLORS` import. `getIsDarkMode()` is called inside the `$effect` so it re-runs when the theme changes.
- [ ] **Step 2: Update `NoteEditor.svelte`**
Same pattern — inside the existing `$effect` where `bgStyle` is computed:
```svelte
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Inside the existing $effect (around lines 194-197): -->
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
```
Remove the `NOTE_COLORS` import.
- [ ] **Step 3: Update `ColorPicker.svelte`**
The ColorPicker shows circular swatches. Use `$derived` for the dark mode check so swatches update reactively:
```svelte
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Use getIsDarkMode() in the template directly (it's reactive in Svelte 5 template expressions): -->
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
```
Remove `NOTE_COLORS` import if no longer needed.
- [ ] **Step 4: Verify all components render correctly in both themes**
Run `make dev`. Toggle between light and dark in Settings > Preferences. Check:
- Note cards show correct dark/light background colors
- Color picker shows dark/light swatches
- Note editor uses correct background color
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/NoteCard.svelte src/lib/components/NoteEditor.svelte src/lib/components/ColorPicker.svelte
git commit -m "feat: use dark note card colors in all components"
```
---
### Task 9: E2E Tests
**Files:**
- Create: `tests/e2e/dark-mode.spec.ts`
- [ ] **Step 1: Write E2E tests**
Create `tests/e2e/dark-mode.spec.ts`:
```typescript
import { test, expect } from './helpers/fixtures.js';
test.describe('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
authenticatedPage: page
}) => {
// Given the user is on the preferences page
await page.goto('/settings/preferences');
// When the user selects dark theme
await page.getByTestId('pref-theme-dark').click();
// Then the page applies dark theme
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the page is reloaded
await page.reload();
// Then the dark theme persists without flash
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
});
test('Scenario: Light mode removes dark theme attribute', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user selects light theme
await page.getByTestId('pref-theme-light').click();
// Then the dark theme attribute is removed
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
});
test('Scenario: System mode follows OS dark preference', async ({
authenticatedPage: page
}) => {
// Given the OS prefers dark mode
await page.emulateMedia({ colorScheme: 'dark' });
// When the user selects system theme
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-system').click();
// Then the page applies dark theme from system preference
// Use toHaveAttribute with timeout to handle async $effect execution
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark', { timeout: 5000 });
});
test('Scenario: Note cards use dark colors in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-dark').click();
// When viewing the notes page
await page.goto('/');
// Then note cards should have dark background colors
const noteCard = page.getByTestId('note-card').first();
if (await noteCard.isVisible()) {
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
// Dark default color is #2a2520 = rgb(42, 37, 32)
expect(bg).not.toBe('rgb(250, 245, 235)'); // Not the light default
}
});
});
```
- [ ] **Step 2: Run E2E tests**
Run: `pnpm test:e2e tests/e2e/dark-mode.spec.ts`
Expected: All PASS
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/dark-mode.spec.ts
git commit -m "test: add E2E tests for dark mode toggle and persistence"
```
---
### Task 10: Housekeeping and Type Check
**Files:**
- Modify: `.gitignore`
- [ ] **Step 1: Add `.superpowers/` to `.gitignore`**
Append to `.gitignore`:
```
# Superpowers brainstorm artifacts
.superpowers/
```
- [ ] **Step 2: Run type check**
Run: `pnpm check`
Expected: No errors. If there are type errors, fix them.
- [ ] **Step 3: Run full test suite**
Run: `make test`
Expected: All unit and E2E tests pass.
- [ ] **Step 4: Commit**
```bash
git add .gitignore
git commit -m "chore: add .superpowers/ to gitignore"
```
@@ -0,0 +1,190 @@
# Dark Mode / Theme Toggle — Design Spec
**Issue:** #26
**Date:** 2026-03-20
**Status:** Draft
## Summary
Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
## Decisions
- **Toggle location:** Settings > Preferences only (no header icon)
- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
- **Implementation:** CSS `[data-theme="dark"]` attribute on `<html>`, variable overrides in `app.css`
- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
- **Default:** `system` (follows `prefers-color-scheme`)
- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
## 1. CSS Variable Overrides
Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
```css
:root {
/* ...existing vars... */
--grid-dot: #1a1a2e; /* new — extracted from body::before */
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
```
`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
Update `body::before` to use the new variable:
```css
body::before {
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
}
```
## 2. Theme Preference Storage
Extend the existing `UserPreferences` system:
- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
- **Default:** `'system'`
- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
Files to modify:
- `src/lib/types/preferences.ts` — add `theme` field and default
The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
## 3. FOUC Prevention
Add a blocking `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
```html
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
```
This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
## 4. Reactive Theme Application
Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates `<meta name="theme-color">`. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
## 5. Note Card Dark Colors
Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
| Color | Light | Dark |
|-------|-------|------|
| default | `#faf5eb` | `#2a2520` |
| coral | `#faafa8` | `#4a2522` |
| peach | `#f39f76` | `#4a3020` |
| sand | `#fff8b8` | `#3a3520` |
| mint | `#e2f6d3` | `#2a3a22` |
| sage | `#b4ddd3` | `#223a32` |
| fog | `#d4e4ed` | `#222e3a` |
| storm | `#aeccdc` | `#1e2a35` |
| dusk | `#d3bfdb` | `#352540` |
| blossom | `#f6e2dd` | `#3a2830` |
| clay | `#e9e3d4` | `#302e28` |
| chalk | `#efeff1` | `#2e2e30` |
Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
## 6. Settings UI
Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
```svelte
<!-- Theme -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
<div class="flex gap-1" role="group" aria-label="Theme">
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
<button
onclick={() => updatePreference('theme', value)}
class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
```
Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
## 7. Landing Page
Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
## 8. Prose / Typography Overrides
The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
## 9. Color Picker in Dark Mode
The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
## 10. Testing
### Unit tests
- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
- `colors.ts` — test `getNoteColor` returns correct color for light/dark
### E2E tests
- Theme toggle in preferences persists and applies (use `data-testid` selectors)
- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
- No FOUC on page reload in dark mode
- Note cards render with correct dark colors
## 11. PWA Manifest
The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The `<meta name="theme-color">` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
## Out of Scope
- Header quick-toggle icon
- Per-note theme override
- Custom color theme builder
- Dark mode for public shared note pages (can be added later)
- Dark mode for landing page (separate task)
@@ -0,0 +1,232 @@
# Public Note Sharing — Design Spec
**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
**Date**: 2026-03-20
## Overview
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
## Schema
New `sharedNotes` table in `src/lib/server/db/schema.ts`:
```typescript
export const sharedNotes = sqliteTable('shared_notes', {
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
});
```
- `noteId` as primary key — one share per note, no separate `id` needed
- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
- `createdAt` set in service layer (`new Date()`), not via SQL default
- CASCADE delete — deleting the note removes the share
- Token: `nanoid(21)` — ~128 bits of entropy
- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
## Types
Add to `src/lib/types/index.ts`:
```typescript
export interface NoteShare {
noteId: string;
token: string;
createdAt: Date;
expiresAt: Date | null;
}
```
Extend the `Note` interface:
```typescript
export interface Note {
// ...existing fields
shareToken?: string; // populated during hydration if a public share exists
}
```
## Service Layer
New file: `src/lib/server/shares-service.ts`
| Function | Params | Returns | Description |
|---|---|---|---|
| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
| `fetchSharesForNotes` | `db, noteIds` | `Map<string, string>` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
`SharedNoteData` type (returned to public viewers):
```typescript
interface SharedNoteData {
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
attachments: Attachment[];
createdAt: Date;
updatedAt: Date;
}
```
## API Endpoints
### `POST /api/notes/[id]/share`
- **Auth**: Required (owner only)
- **Body**: None
- **Response**: `{ token: string, url: string }`
- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
### `DELETE /api/notes/[id]/share`
- **Auth**: Required (owner only)
- **Response**: `204 No Content`
- **Behavior**: Deletes share row. Link immediately returns 404.
### `GET /api/shared/[token]`
- **Auth**: None (public)
- **Rate limit**: 60 requests/min per IP (in-memory)
- **Response**: `SharedNoteData` JSON
- **Error cases**:
- Invalid/unknown token → 404
- Note is trashed → 404
- Rate limited → 429
### `GET /api/shared/[token]/attachment/[attachmentId]`
- **Auth**: None (public)
- **Rate limit**: Same as above
- **Response**: Image binary (proxied from attachment storage)
- **Behavior**: Verify attachment belongs to the shared note, then serve
## Routes
```
src/routes/
(share)/
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
s/[token]/
+page.server.ts # Load shared note by token via shares-service
+page.svelte # Read-only note renderer
```
### Auth Middleware
Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
### Public Page Design
- Parchment background (`--bg-base`)
- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
- Note title in JetBrains Mono
- Rendered markdown content or checklist (read-only)
- Inline images from attachments
- No tags, no edit controls, no sidebar, no navigation
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
## UI Components
### ShareDialog Changes
Add a "Public link" section **above** the existing collaborators section:
**Inactive state:**
- Globe icon + "Public link" label + toggle switch (off)
- Subtitle: "Anyone with the link can view this note (read-only)"
**Active state:**
- Globe icon + "Public link" label + toggle switch (on, gold)
- URL input (readonly) + "Copy" button
- Subtitle: "Toggle off to revoke access. The link will return a 404."
- Border highlight in `--primary` gold
Toggle on → calls `POST /api/notes/[id]/share`, displays URL
Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
Copy button → clipboard API, brief "Copied!" feedback
### NoteCard Share Indicator
Single icon slot in top-right of NoteCard (replaces showing both icons):
| State | Icon | Color |
|---|---|---|
| Public link active | `Globe` (Lucide) | `--primary` gold |
| Collaborators only | `Users` (Lucide) | `--text-muted` |
| Both | `Globe` (Lucide) | `--primary` gold |
| Neither | No icon | — |
**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
- "Shared publicly via link" (if public share exists)
- "Shared with N collaborator(s)" (if collaborators exist)
- Both lines if both exist
### NoteEditor Share Button
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
## Rate Limiting
New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
```typescript
// In-memory store: Map<IP, { count: number, windowStart: number }>
// 60 requests per minute per IP
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
```
Resets on server restart. No database table needed.
## Hydration
`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
## Security
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
- Trashed notes return 404 even if share token is valid
- Only note owners can create/revoke shares — collaborators cannot
- No user information exposed on public page (no username, no email, no tags)
- Rate limiting prevents brute-force enumeration
- Attachments served through share-specific route (validates attachment belongs to shared note)
## Out of Scope (v1)
- Expiry dates / auto-expire
- Password-protected shares
- Edit access through share links
- Share analytics / view counts
- Email notifications for share events
- E2EE interaction (no E2EE exists)
## Acceptance Criteria
- [ ] Share toggle in ShareDialog creates/revokes public link
- [ ] Unique nanoid(21) token generated per shared note
- [ ] Public `/s/[token]` route renders note without auth
- [ ] Copy link to clipboard with feedback
- [ ] Revoke share → link returns 404
- [ ] Globe indicator on NoteCard for publicly shared notes
- [ ] Combined tooltip showing all sharing state
- [ ] Trashed notes return 404 even if shared
- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
- [ ] In-memory rate limiting on public endpoints
- [ ] Attachments accessible on public page
@@ -0,0 +1,322 @@
# Screenshot Automation for Landing Page
## Overview
A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
## Architecture
```
scripts/
screenshots.ts # Main orchestrator: seed + capture
fixtures/
bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
```
Single script (`scripts/screenshots.ts`) that:
1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
2. Deletes any existing screenshot DB to start fresh
3. Seeds data via API calls (setup admin, login, create notes/users/keys)
4. Navigates to each view and captures screenshots at specific viewports
5. Saves PNGs directly to `website/assets/`
6. Kills the preview server in `finally` block
## Seed Data — Bretzel Universe
### Users
| User | Email | Role | Purpose |
|------|-------|------|---------|
| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
### Tags
Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
### Notes
| # | Title | Color | Mode | Tags | Pinned | Special |
|---|-------|-------|------|------|--------|---------|
| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
### Note Content Details
**Bretzel Ingredients** (checklist):
- [x] 500g bread flour
- [x] 300ml warm water
- [x] 10g salt
- [ ] 7g dry yeast
- [ ] 30g butter (softened)
- [ ] Baking soda for the bath
- [ ] Coarse salt for topping
**Self-Hosting Stack** (rich text — used for editor screenshot):
```
Current homelab setup:
**Crumbs** — notes (obviously)
**42** — holiday budget tracking
**Dokploy** — deployment platform
**Outline** — team wiki
**Navidrome** — music streaming
**Vaultwarden** — passwords
**Nextcloud** — files & calendar
**Plausible** — analytics
Everything below daily reverse proxy.
```
**The Best Software Is the Software You Own** (rich text):
```
Self-host everything. Trust no cloud.
The best software is the software you own.
Your data. Your server. Your rules.
If it can't run on your hardware, it's not really yours.
```
**Back to Retro Gaming** (rich text):
```
Games to replay this summer:
Chrono Trigger (SNES) — best RPG ever made
The Legend of Zelda: Link's Awakening (GB)
Castlevania: Symphony of the Night (PS1)
Advance Wars (GBA)
Final Fantasy Tactics (PS1)
The pixel art era was peak game design.
```
**Weekend Plans** (rich text):
```
Saturday:
- Farmer's market (get sourdough starter)
- Fix the Dokploy SSL cert renewal
- Bake a batch of bretzels
Sunday:
- Retro gaming marathon
- Update the self-hosting stack
- Plan next trip on 42
```
**Ancient Flute Harmonica Discovered** (rich text):
```
Archaeologists found a 40,000-year-old flute carved from vulture bone.
The oldest known musical instrument.
Imagine the first song ever played.
```
**Baking code for the...** (checklist):
```
- [x] Set up CI/CD pipeline
- [x] Write the CRDT sync logic
- [ ] Bake the docker image
- [ ] Deploy to the homelab
- [ ] Celebrate with a bretzel
```
### Image Attachments
**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
- Generated programmatically as a PNG using raw pixel data (no external dependencies)
- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
- Attached as featured image to "Bretzel Ingredients" note
- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
### Version History
Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
### API Keys
Create via API after seeding:
| Name | Purpose |
|------|---------|
| Claude Code | For MCP integration screenshot |
| N8N | For automation screenshot |
## Screenshot Capture Plan
### Desktop (1280x800)
| # | Filename | View | Capture Steps |
|---|----------|------|---------------|
| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
### Mobile (390x844)
| # | Filename | View | Capture Steps |
|---|----------|------|---------------|
| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
### Capture Settings
- Format: PNG
- Full page: false (viewport only)
- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
- Wait for `networkidle` after each navigation before capturing
## GitHub Actions Workflow
**File**: `.github/workflows/screenshots.yml`
```yaml
name: Screenshots
on:
workflow_dispatch:
permissions:
contents: write
jobs:
screenshots:
name: Capture Landing Page Screenshots
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers and system deps
run: pnpm exec playwright install --with-deps chromium
- name: Build app
run: pnpm build
env:
DATABASE_URL: ./data/screenshots.db
- name: Capture screenshots
run: pnpm exec tsx scripts/screenshots.ts
env:
DATABASE_URL: ./data/screenshots.db
- name: Commit screenshots
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add website/assets/screenshot-*.png
git diff --cached --quiet && echo "No screenshot changes" && exit 0
git commit -m "chore: update landing page screenshots [skip ci]"
git push origin main
```
## Script Structure
```typescript
// scripts/screenshots.ts
import { chromium, type Page } from 'playwright';
import { spawn, type ChildProcess } from 'child_process';
import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
const BASE = 'http://localhost:4173';
const DESKTOP = { width: 1280, height: 800 };
const MOBILE = { width: 390, height: 844 };
const OUTPUT = 'website/assets';
async function main() {
// 1. Start preview server (spawn child process, wait for port 4173)
const server = await startPreviewServer();
// 2. Launch browser
const browser = await chromium.launch();
try {
// 3. Seed data (desktop context — API calls work at any viewport)
const seedContext = await browser.newContext({ viewport: DESKTOP });
const seedPage = await seedContext.newPage();
await seed(seedPage);
// 4. Desktop screenshots
await captureDesktop(seedPage);
await seedContext.close();
// 5. Mobile screenshots
const mobileContext = await browser.newContext({ viewport: MOBILE });
const mobilePage = await mobileContext.newPage();
await login(mobilePage);
await captureMobile(mobilePage);
await mobileContext.close();
} finally {
await browser.close();
server.close();
}
}
async function seed(page: Page) {
// Setup admin via POST /api/auth/setup
// Login via POST /api/auth/login (sets session cookie on page context)
// Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
// → capture note IDs from responses for later use
// Create collaborator user via POST /api/admin/users
// → capture alice.id (numeric) from response
// Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
// Create version history: PATCH /api/notes/:id × 4-5 with varying content
// Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
// Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
}
async function captureDesktop(page: Page) {
// D1: Grid view
// D2: Editor (Self-Hosting Stack)
// D3: Checklist (Bretzel Ingredients)
// D4: Sharing dialog
// D5: Version history
// D6: Attachments
// D7: API/MCP settings
}
async function captureMobile(page: Page) {
// M1: Grid view
// M2: Editor
// M3: Checklist
}
```
## Landing Page Updates
After the screenshots are generated, `website/index.html` needs updating:
- Add new screenshot entries for: checklist, sharing, history, attachments, API
- Update alt text and labels
- Hero image stays as `screenshot-grid.png`
- Mobile section adds checklist screenshot, replaces sidebar screenshot
## Dependencies
No new dependencies. Uses:
- `playwright` (already in devDependencies for e2e tests)
- `tsx` (already in devDependencies for other scripts)
- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
- SvelteKit's preview server (spawned as child process via `pnpm preview`)
## Open Questions
None — all decisions resolved during brainstorming.
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE `note_versions` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`version` integer NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`checklist_mode` integer DEFAULT false NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_versions_note_id_idx` ON `note_versions` (`note_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `note_versions_note_id_version_unique` ON `note_versions` (`note_id`,`version`);--> statement-breakpoint
CREATE TABLE `shared_notes` (
`note_id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shared_notes_token_unique` ON `shared_notes` (`token`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1773150888301,
"tag": "0002_deep_black_widow",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1774037379518,
"tag": "0003_uneven_tomorrow_man",
"breakpoints": true
}
]
}
+13 -7
View File
@@ -1,8 +1,9 @@
{
"name": "crumbs",
"private": true,
"version": "0.22.2",
"version": "0.30.1",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -51,10 +52,11 @@
"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",
"nanoid": "^5.1.7",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
@@ -65,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",
@@ -75,18 +77,19 @@
"@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",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"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"
},
@@ -100,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"
}
}
}
+1865 -1004
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
import { chromium } from '@playwright/test';
import { spawn, type ChildProcess } from 'child_process';
import { existsSync, unlinkSync, mkdirSync } from 'fs';
import { seed, login } from './screenshots/seed';
import { captureDesktop, captureMobile } from './screenshots/capture';
import {
BASE_URL,
SCREENSHOT_DB,
DESKTOP_VIEWPORT,
MOBILE_VIEWPORT,
OUTPUT_DIR
} from './screenshots/constants';
function cleanDatabase(): void {
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${SCREENSHOT_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
console.log(` deleted ${path}`);
}
}
}
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok || res.status === 302) return;
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Server not reachable at ${url} after ${timeoutMs}ms`);
}
function startServer(): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/'));
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const server = spawn('pnpm', ['preview', '--port', '4173'], {
env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' },
stdio: 'pipe'
});
server.on('error', reject);
server.on('exit', (code) => {
if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`));
});
// Poll until server responds (stdout detection is unreliable in CI)
waitForServer(BASE_URL, 60_000)
.then(() => resolve(server))
.catch(reject);
});
}
async function main(): Promise<void> {
console.log('Screenshot automation starting...\n');
// 1. Clean DB
console.log('Cleaning database...');
cleanDatabase();
// 2. Ensure output dir exists
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
// 3. Start preview server
console.log('Starting preview server...');
const server = await startServer();
console.log('Server ready.\n');
const browser = await chromium.launch();
try {
// 4. Seed data (in desktop context)
console.log('Seeding data...');
const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
const desktopPage = await desktopContext.newPage();
await seed(desktopPage);
console.log('Seed complete.\n');
// 5. Desktop screenshots
await captureDesktop(desktopPage);
await desktopContext.close();
console.log('');
// 6. Mobile screenshots (fresh context with mobile viewport)
const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT });
const mobilePage = await mobileContext.newPage();
await login(mobilePage);
await captureMobile(mobilePage);
await mobileContext.close();
console.log('\nAll screenshots captured successfully!');
} finally {
await browser.close();
server.kill('SIGTERM');
// Force exit — pnpm spawns vite as a grandchild process that
// server.kill() doesn't reach, leaving the CI job hanging
process.exit(0);
}
}
main().catch((err) => {
console.error('Screenshot automation failed:', err);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
import type { Page, Locator } from '@playwright/test';
import { join } from 'path';
import { BASE_URL, OUTPUT_DIR } from './constants';
async function screenshot(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(OUTPUT_DIR, name), type: 'png' });
console.log(` captured ${name}`);
}
async function waitForApp(page: Page): Promise<void> {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(300);
}
/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */
function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) });
}
export async function captureDesktop(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: 'reduce' });
console.log('Capturing desktop screenshots...');
// D1: Grid view (also used as hero)
await page.goto(BASE_URL);
await waitForApp(page);
await screenshot(page, 'screenshot-grid.png');
// D2: Rich text editor — open "Self-Hosting Stack"
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-editor.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D3: Checklist — open "Bretzel Ingredients"
await noteCard(page, 'Bretzel Ingredients').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-checklist.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D4: Sharing dialog — open "Weekend Plans", click share
await noteCard(page, 'Weekend Plans').click();
await page.waitForSelector('[data-testid="note-editor"]');
await page.getByTestId('share-toggle').click();
await page.waitForSelector('[data-testid="share-dialog"]');
await waitForApp(page);
await screenshot(page, 'screenshot-sharing.png');
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D5: Version history — open "Self-Hosting Stack", click history
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await page.getByTestId('history-toggle').click();
await page.waitForSelector('[data-testid="history-panel"]');
// Wait for version items to load from API
await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 });
const versionItems = page.getByTestId('version-item');
if ((await versionItems.count()) > 1) {
await versionItems.nth(1).click();
}
await waitForApp(page);
await screenshot(page, 'screenshot-history.png');
// Close history panel first (overlay blocks close-editor-btn)
await page.getByTestId('close-history-btn').click();
await waitForApp(page);
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D6: API/MCP settings
await page.goto(`${BASE_URL}/settings/mcp`);
await waitForApp(page);
await screenshot(page, 'screenshot-api.png');
}
export async function captureMobile(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: 'reduce' });
console.log('Capturing mobile screenshots...');
// M1: Mobile grid view
await page.goto(BASE_URL);
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-grid.png');
// M2: Mobile editor — open "Self-Hosting Stack"
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-editor.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// M3: Mobile checklist — open "Bretzel Ingredients"
await noteCard(page, 'Bretzel Ingredients').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-checklist.png');
}
+18
View File
@@ -0,0 +1,18 @@
export const BASE_URL = 'http://localhost:4173';
export const SCREENSHOT_DB = './data/screenshots.db';
export const OUTPUT_DIR = 'website/assets';
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: crypto.randomUUID()
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: crypto.randomUUID()
};
+271
View File
@@ -0,0 +1,271 @@
import type { Page } from '@playwright/test';
import { BASE_URL, ADMIN, COLLABORATOR } from './constants';
type HttpMethod = 'post' | 'patch' | 'get' | 'delete';
async function api(page: Page, method: HttpMethod, path: string, data?: unknown) {
const res = await page.request[method](
`${BASE_URL}${path}`,
data !== undefined ? { data } : undefined
);
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path} failed: ${res.status()} ${await res.text()}`);
}
const text = await res.text();
return text ? JSON.parse(text) : {};
}
interface NoteData {
key: string;
title: string;
content: string;
color: string;
pinned?: boolean;
checklistMode?: boolean;
}
function getNotesData(): NoteData[] {
// Created in reverse display order so newest (first created) appears last
// and the grid reads naturally top-left to bottom-right
return [
{
key: 'bakingcode',
title: 'Crumbs v0.23 Release',
content: `- [x] CRDT sync logic
- [x] MCP server integration
- [ ] Bake the Docker image
- [ ] Deploy to the homelab
- [ ] Celebrate with a bretzel`,
color: 'clay',
checklistMode: true
},
{
key: 'coding-music',
title: 'Coding Playlists',
content: `Curated on **Navidrome** for maximum focus.
### Late Night Focus
1. *Tycho* — Dive (ambient electronic)
2. *Boards of Canada* — Music Has the Right to Children
3. *Nujabes* — Modal Soul
4. *C418* — Minecraft Volume Alpha
### Saturday Baking
For slow mornings in the kitchen:
1. *Khruangbin* — Con Todo El Mundo
2. *Tatsuro Yamashita* — For You
#music #personal`,
color: 'chalk'
},
{
key: 'weekend',
title: 'Weekend Plans',
content: `### Saturday
- Farmer's market — get sourdough starter
- Fix the Dokploy SSL cert renewal
- Bake a batch of bretzels
### Sunday
- Retro gaming marathon on *RetroAssembly*
- Update the self-hosting stack
- Plan next trip on **42**
> A weekend well spent brings a week of content.
#personal #weekend`,
color: 'mint'
},
{
key: 'manifesto',
title: '',
content: `The best software is the software you **own**. Self-host everything. Trust no cloud. 🥨
#personal #selfhosting`,
color: 'coral'
},
{
key: 'retro',
title: 'Back to Retro Gaming',
content: `Games running on **RetroAssembly** this summer:
- **Chrono Trigger** (SNES) — best RPG ever made
- **Link's Awakening** (GB) — pure portable magic
- **Symphony of the Night** (PS1) — the *definitive* Metroidvania
- **Advance Wars** (GBA) — turn-based perfection
- **Final Fantasy Tactics** (PS1) — political intrigue + job system
> The pixel art era was peak game design.
#gaming #retro`,
color: 'peach'
},
{
key: 'selfhosting',
title: 'Self-Hosting Stack',
content: `All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **Dokploy** — deployment platform
- **Outline** — team wiki
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Just getting started. More to come.
#homelab #devops`,
color: 'fog'
},
{
key: 'bretzel',
title: 'Bretzel Ingredients',
content: `- [x] 500g bread flour
- [x] 300ml warm water
- [x] 10g salt
- [ ] 7g dry yeast
- [ ] 30g butter (softened)
- [ ] Baking soda for the bath
- [ ] Coarse salt for topping`,
color: 'sand',
checklistMode: true
}
];
}
function getVersionHistoryContents(): string[] {
return [
// Version 2: Add Navidrome + RetroAssembly
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Growing the stack.
#homelab #devops`,
// Version 3: Add 42 + Umami
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **42** — holiday budget tracking
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
- **Umami** — privacy-first analytics
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
The stack is getting serious.
#homelab #devops`,
// Version 4: Final version
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **42** — holiday budget tracking
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
- **Umami** — privacy-first analytics
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Everything below daily reverse proxy. The dream stack is *almost* complete.
#homelab #devops`
];
}
export async function seed(page: Page): Promise<void> {
// 1. Setup admin (sets session cookie)
await api(page, 'post', '/api/auth/setup', {
email: ADMIN.email,
displayName: ADMIN.displayName,
password: ADMIN.password
});
console.log(' admin created');
// 2. Create collaborator user
const alice = await api(page, 'post', '/api/admin/users', {
email: COLLABORATOR.email,
displayName: COLLABORATOR.displayName,
password: COLLABORATOR.password,
role: 'user'
});
const aliceId: number = alice.id;
console.log(' collaborator created (id:', aliceId, ')');
// 3. Create notes (order matters — newest appear first in grid)
const noteData = getNotesData();
const noteIds: Record<string, string> = {};
for (const note of noteData) {
const created = await api(page, 'post', '/api/notes', {
title: note.title,
content: note.content,
color: note.color,
pinned: note.pinned ?? false,
checklistMode: note.checklistMode ?? false
});
noteIds[note.key] = created.id;
console.log(' note created:', note.title);
}
// 4. Share "Weekend Plans" with Alice
await api(page, 'post', `/api/notes/${noteIds['weekend']}/collaborators`, {
userId: aliceId
});
console.log(' shared "Weekend Plans" with Alice');
// 5. Create version history for "Self-Hosting Stack"
const versionContents = getVersionHistoryContents();
for (let i = 0; i < versionContents.length; i++) {
await api(page, 'patch', `/api/notes/${noteIds['selfhosting']}`, {
content: versionContents[i]
});
console.log(` version ${i + 2} created for "Self-Hosting Stack"`);
}
// 6. Create API keys
await api(page, 'post', '/api/settings/api-keys', { name: 'Claude Code' });
await api(page, 'post', '/api/settings/api-keys', { name: 'N8N' });
console.log(' API keys created');
}
export async function login(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/login`);
await page.getByTestId('email-input').fill(ADMIN.email);
await page.getByTestId('password-input').fill(ADMIN.password);
await page.getByTestId('login-btn').click();
await page.waitForURL('**/');
await page.waitForLoadState('networkidle');
}
+80 -3
View File
@@ -20,11 +20,35 @@
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
}
body {
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
overscroll-behavior-y: none;
}
body.editor-scroll-locked {
overflow: hidden;
}
body::before {
@@ -33,7 +57,7 @@ body::before {
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
@@ -49,6 +73,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);
@@ -116,12 +145,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;
@@ -132,6 +169,7 @@ body::before {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Shared checkbox styles */
@@ -139,6 +177,7 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
@@ -176,3 +215,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 {}
}
}
+20 -1
View File
@@ -2,8 +2,27 @@
<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() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
+3 -1
View File
@@ -8,7 +8,9 @@ const PUBLIC_PATHS = [
'/setup',
'/api/auth/login',
'/api/auth/setup',
'/api/auth/oauth'
'/api/auth/oauth',
'/s/',
'/api/shared'
];
export const handle: Handle = async ({ event, resolve }) => {
+5
View File
@@ -0,0 +1,5 @@
// Mock for $app/environment used in Vitest unit tests
export const browser = true;
export const dev = false;
export const building = false;
export const version = 'test';
+11
View File
@@ -0,0 +1,11 @@
/**
* Polyfill Svelte 5 runes for Vitest.
* The Svelte compiler transforms these in .svelte and .svelte.ts files,
* but Vitest doesn't run the Svelte compiler, so we provide plain-JS stubs.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
(globalThis as any).$state = <T>(initial: T): T => initial;
(globalThis as any).$derived = <T>(value: T): T => value;
(globalThis as any).$effect = (_fn: () => void): void => {};
(globalThis as any).$props = (): any => ({});
+279 -67
View File
@@ -3,14 +3,11 @@
import Plus from 'lucide-svelte/icons/plus';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { onDestroy } from 'svelte';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}
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;
@@ -23,64 +20,106 @@
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
// Track the last content we emitted so we can distinguish self-originated
// changes from external ones (sync, history restore, etc.)
let lastEmitted = content;
// Re-parse items when content changes externally (not from our own emitChange)
$effect(() => {
if (content !== lastEmitted) {
items = parseChecklist(content);
lastEmitted = content;
}
});
let activeItems = $derived(items.filter((i) => !i.checked));
let doneItems = $derived(items.filter((i) => i.checked));
let doneCount = $derived(doneItems.length);
function generateId(): string {
return crypto.randomUUID();
interface DoneSectionGroup {
parentLabel: ChecklistItem | null;
children: ChecklistItem[];
}
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
return { id: generateId(), text: line, checked: false };
});
}
let doneSectionGroups = $derived.by(() => {
const groups: DoneSectionGroup[] = [];
const childrenByParent = new Map<string, ChecklistItem[]>();
const topLevelDone: ChecklistItem[] = [];
function serializeChecklist(list: ChecklistItem[]): string {
return list
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
}
for (const item of doneItems) {
if (item.parentId) {
const siblings = childrenByParent.get(item.parentId) ?? [];
siblings.push(item);
childrenByParent.set(item.parentId, siblings);
} else {
topLevelDone.push(item);
}
}
for (const item of topLevelDone) {
const doneChildren = childrenByParent.get(item.id) ?? [];
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
childrenByParent.delete(item.id);
}
for (const [parentId, children] of childrenByParent) {
const parent = items.find((i) => i.id === parentId);
if (parent) {
groups.push({ parentLabel: parent, children });
} else {
groups.push({ parentLabel: null, children });
}
}
return groups;
});
function emitChange() {
onChange(serializeChecklist(items));
const serialized = serializeChecklist(items);
lastEmitted = serialized;
onChange(serialized);
}
function toggleItem(id: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.checked = !item.checked;
emitChange();
}
items = toggleItemWithCascade(id, items);
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();
}
}
function addItem(afterIndex: number) {
const newItem = { id: generateId(), text: '', checked: false };
const afterItem = items[afterIndex];
const newItem: ChecklistItem = {
id: generateId(),
text: '',
checked: false,
parentId: afterItem?.parentId ?? null
};
items.splice(afterIndex + 1, 0, newItem);
items = [...items];
emitChange();
const newId = newItem.id;
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
}
function removeItem(id: string) {
if (items.length <= 1) return;
if (items.length <= 1) {
if (items[0].parentId) items[0].parentId = null;
items = [...items];
emitChange();
return;
}
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
const index = items.findIndex((i) => i.id === id);
items.splice(index, 1);
items = [...items];
@@ -96,33 +135,178 @@
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<HTMLElement>('[data-testid="checklist-input"]'));
const current = inputs.findIndex((el) => el.dataset.itemId === id);
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
if (target >= 0 && target < inputs.length) {
e.preventDefault();
inputs[target].focus();
}
} else if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
items = indentItem(id, items, activeItems);
emitChange();
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
items = outdentItem(id, items);
emitChange();
}
}
// Svelte action: sets innerHTML on mount and syncs from external changes only.
// While the user is typing (element is focused), we don't re-render — that would
// destroy the cursor. Linkification happens on blur via the onblur handler.
function editableContent(node: HTMLElement, text: string) {
node.innerHTML = linkifyText(text);
return {
update(newText: string) {
if (document.activeElement !== node) {
node.innerHTML = linkifyText(newText);
}
}
};
}
function handleLinkClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const anchor = target.closest('a');
if (anchor) {
e.preventDefault();
e.stopPropagation();
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
}
// --- Directional lock drag handle ---
// Instead of blocking DnD events (which corrupts its internal state),
// we detect horizontal intent and store the pending operation. DnD runs
// its full course unimpeded. After DnD finalizes, we apply the indent.
let dragStartX = 0;
let dragStartY = 0;
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
let directionLocked = false;
const LOCK_THRESHOLD = 10;
const INDENT_THRESHOLD = 32;
function windowPointerMove(e: PointerEvent) {
if (directionLocked) return;
const dx = Math.abs(e.clientX - dragStartX);
const dy = Math.abs(e.clientY - dragStartY);
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
directionLocked = true;
if (dx <= dy) {
// Vertical — DnD handles it, we're done
removeWindowListeners();
}
// Horizontal — keep listening for pointerup to measure total dx
}
}
function windowPointerUp(e: PointerEvent) {
const dx = e.clientX - dragStartX;
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
pendingIndent = {
id: currentDragItemId,
direction: dx > 0 ? 'indent' : 'outdent'
};
}
removeWindowListeners();
directionLocked = false;
}
function addWindowListeners() {
window.addEventListener('pointermove', windowPointerMove, { capture: true });
window.addEventListener('pointerup', windowPointerUp, { capture: true });
}
function removeWindowListeners() {
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
}
let currentDragItemId = '';
function handleDragPointerDown(e: PointerEvent, id: string) {
dragStartX = e.clientX;
dragStartY = e.clientY;
directionLocked = false;
pendingIndent = null;
currentDragItemId = id;
addWindowListeners();
}
onDestroy(removeWindowListeners);
// --- DnD handlers ---
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
let lastTopLevelId: string | null = null;
return reorderedItems.map((item) => {
if (item.parentId === null) {
lastTopLevelId = item.id;
return item;
}
return { ...item, parentId: lastTopLevelId };
});
}
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
// Merge reordered active items with done items
items = [...e.detail.items, ...items.filter((i) => i.checked)];
}
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
items = [...e.detail.items, ...items.filter((i) => i.checked)];
const reorderedActive = recomputeParentIds(e.detail.items);
items = [...reorderedActive, ...items.filter((i) => i.checked)];
emitChange();
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
if (pendingIndent && pendingIndent.id) {
const { id, direction } = pendingIndent;
pendingIndent = null;
if (direction === 'indent') {
items = indentItem(id, items, activeItems);
} else {
items = outdentItem(id, items);
}
emitChange();
}
}
</script>
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
data-testid={item.parentId ? 'checklist-child-row' : undefined}
animate:flip={{ duration: flipDurationMs }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div use:dragHandle
aria-label="drag handle"
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
@@ -132,15 +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"
@@ -174,29 +373,42 @@
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
{#each doneSectionGroups as group}
{#if group.parentLabel}
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
</div>
{/if}
{#each group.children as item (item.id)}
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
{/each}
</div>
{/if}
</div>
{/if}
{#if linkPopover}
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
{/if}
</div>
+3 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
interface Props {
@@ -12,11 +13,10 @@
<div class="flex flex-wrap gap-1" data-testid="color-picker">
{#each COLOR_OPTIONS as option}
{@const colors = NOTE_COLORS[option.value]}
<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: {colors.bg}"
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+327 -218
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() {
@@ -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(--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
onmousedown={preventToolbarMouseFocus}
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
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/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>
+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>
+36 -24
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import SharingIndicator from './SharingIndicator.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
@@ -11,10 +12,13 @@
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
import { tooltip } from '$lib/utils/tooltip.js';
import { linkifyText } from '$lib/utils/checklist.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
interface Props {
@@ -26,8 +30,7 @@
let { note, onEdit, fullHeight = false }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
cardStyle = `background-color: ${colors.bg}`;
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
});
let cardStyle = $state('');
@@ -38,10 +41,14 @@
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
? note.content.split('\n').filter(l => l.trim()).map(line => ({
text: line.replace(/^- \[[ x]\] /, ''),
checked: line.startsWith('- [x] ')
}))
? note.content.split('\n').filter(l => l.trim()).map(line => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
: []
);
@@ -62,7 +69,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] max-h-[17rem] overflow-hidden {fullHeight ? 'h-full' : ''}"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -101,17 +108,18 @@
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.isShared && note.collaborators}
<CollaboratorPopover
collaborators={note.collaborators}
ownerName={note.isOwner ? 'You' : undefined}
{#if note.shareToken || (note.isShared && note.collaborators)}
<SharingIndicator
shareToken={note.shareToken}
collaborators={note.collaborators ?? []}
isOwner={note.isOwner ?? true}
/>
{/if}
{#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"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
@@ -124,9 +132,13 @@
{/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.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<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}>
<input
type="checkbox"
checked={item.checked}
@@ -134,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}
@@ -153,7 +165,7 @@
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
@@ -161,7 +173,7 @@
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -171,7 +183,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -181,7 +193,7 @@
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -190,7 +202,7 @@
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
@@ -200,7 +212,7 @@
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
@@ -209,7 +221,7 @@
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
+520 -97
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,9 +13,10 @@
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 { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
@@ -21,15 +28,24 @@
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
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
@@ -39,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
@@ -54,30 +70,198 @@
// 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
let currentShareToken = $state<string | undefined>(note?.shareToken);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
const hasPublicLink = $derived(!!currentShareToken);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
// Update the note in the store
if (noteId) {
notes.update((list) =>
list.map((n) =>
@@ -89,6 +273,19 @@
}
}
function handleShareUpdate(token: string | null) {
currentShareToken = token ?? undefined;
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, shareToken: token ?? undefined }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
@@ -141,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) {
@@ -153,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);
@@ -168,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);
}
@@ -176,15 +388,14 @@
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${colors.bg}`;
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;
@@ -195,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() {
@@ -251,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(--border)]/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(--border)]/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)} />
@@ -295,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>
@@ -312,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}
@@ -326,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">
@@ -342,7 +634,7 @@
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -354,87 +646,216 @@
{/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"
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"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if isShared}
{#if hasPublicLink}
<Globe class="h-5 w-5 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<UserPlus class="h-5 w-5" />
{/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}
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
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(--border)]/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(--border)]/10"
data-testid="close-editor-btn"
>
Close
</button>
</div>
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowAnchorEl}
{@const rect = overflowAnchorEl.getBoundingClientRect()}
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
<div
bind:this={overflowMenuEl}
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
data-testid="overflow-menu"
>
<!-- Mobile-only actions -->
{#if isMobileTrigger}
<!-- Color picker -->
<div class="px-3 py-2 w-56 whitespace-normal">
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
</div>
<!-- Attachments -->
<button
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
Uncheck all
</button>
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
@@ -442,8 +863,10 @@
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
shareToken={currentShareToken}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
onShareUpdate={handleShareUpdate}
/>
{/if}
+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() {
+108 -2
View File
@@ -3,7 +3,12 @@
import X from 'lucide-svelte/icons/x';
import UserMinus from 'lucide-svelte/icons/user-minus';
import Search from 'lucide-svelte/icons/search';
import Globe from 'lucide-svelte/icons/globe';
import Link from 'lucide-svelte/icons/link';
import Copy from 'lucide-svelte/icons/copy';
import Check from 'lucide-svelte/icons/check';
import { showToast } from '$lib/stores/toast.js';
import { page } from '$app/state';
interface UserResult {
id: number;
@@ -15,17 +20,65 @@
noteId: string;
collaborators: Collaborator[];
ownerName: string;
shareToken?: string;
onClose: () => void;
onUpdate: (collaborators: Collaborator[]) => void;
onShareUpdate: (token: string | null) => void;
}
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
const { noteId, collaborators, ownerName, shareToken, onClose, onUpdate, onShareUpdate }: Props = $props();
let publicShareToken = $state(shareToken ?? null);
let copying = $state(false);
let togglingShare = $state(false);
let searchQuery = $state('');
let searchResults = $state<UserResult[]>([]);
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const shareUrl = $derived(publicShareToken ? `${page.url.origin}/s/${publicShareToken}` : null);
async function togglePublicShare() {
togglingShare = true;
try {
if (publicShareToken) {
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'DELETE' });
if (!res.ok) {
showToast('Failed to remove share link', 'error');
return;
}
publicShareToken = null;
onShareUpdate(null);
showToast('Public link removed', 'success');
} else {
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'POST' });
if (!res.ok) {
showToast('Failed to create share link', 'error');
return;
}
const data = await res.json();
publicShareToken = data.token;
onShareUpdate(data.token);
showToast('Public link created', 'success');
}
} catch {
showToast('Failed to update share link', 'error');
} finally {
togglingShare = false;
}
}
async function copyShareUrl() {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
copying = true;
setTimeout(() => (copying = false), 2000);
} catch {
showToast('Failed to copy link', 'error');
}
}
function handleSearch(query: string) {
clearTimeout(searchTimeout);
if (!query.trim()) {
@@ -38,7 +91,6 @@
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const users: UserResult[] = await res.json();
// Filter out existing collaborators
const collabIds = new Set(collaborators.map((c) => c.userId));
searchResults = users.filter((u) => !collabIds.has(u.id));
}
@@ -123,6 +175,60 @@
</button>
</div>
<!-- Public link section -->
<div class="px-4 pt-3 pb-3" data-testid="public-link-section">
<div
class="rounded-sm border p-3 transition-colors {publicShareToken
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border-subtle)]'}"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Globe class="h-4 w-4 {publicShareToken ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'}" />
<span class="text-sm font-medium text-[var(--text)]">Public link</span>
</div>
<button
onclick={togglePublicShare}
disabled={togglingShare}
class="relative h-5 w-9 rounded-sm transition-colors {publicShareToken
? 'bg-[var(--primary)]'
: 'bg-[var(--border-subtle)]'}"
title={publicShareToken ? 'Disable public link' : 'Enable public link'}
data-testid="public-share-toggle"
>
<span
class="absolute top-0.5 h-4 w-4 rounded-sm bg-[var(--bg-surface)] shadow-[1px_1px_0px_var(--border-subtle)] transition-all {publicShareToken
? 'left-[1.125rem]'
: 'left-0.5'}"
></span>
</button>
</div>
{#if publicShareToken && shareUrl}
<div class="mt-2 flex items-center gap-1" data-testid="share-url-container">
<div class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-1.5">
<Link class="h-3 w-3 shrink-0 text-[var(--text-muted)]" />
<span class="truncate text-xs text-[var(--text-muted)]" data-testid="share-url-text">{shareUrl}</span>
</div>
<button
onclick={copyShareUrl}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] p-1.5 hover:border-[var(--primary)] hover:bg-[var(--primary)]/5 transition-colors"
title="Copy link"
data-testid="copy-share-url-btn"
>
{#if copying}
<Check class="h-4 w-4 text-[var(--success-text)]" />
{:else}
<Copy class="h-4 w-4 text-[var(--text-muted)]" />
{/if}
</button>
</div>
{/if}
</div>
</div>
<div class="border-t border-[var(--border-subtle)]"></div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
@@ -0,0 +1,95 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Globe from 'lucide-svelte/icons/globe';
import Users from 'lucide-svelte/icons/users';
interface Props {
shareToken?: string;
collaborators: Collaborator[];
isOwner: boolean;
}
const { shareToken, collaborators, isOwner }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
const hasPublicLink = $derived(!!shareToken);
const hasCollaborators = $derived(collaborators.length > 0);
const tooltipLines = $derived.by(() => {
const lines: string[] = [];
if (hasPublicLink) lines.push('Shared publicly via link');
if (hasCollaborators) {
lines.push(`Shared with ${collaborators.length} collaborator${collaborators.length > 1 ? 's' : ''}`);
}
return lines;
});
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 {hasPublicLink ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'} hover:text-[var(--primary)]"
title={tooltipLines.join(', ')}
aria-label={tooltipLines.join(', ')}
onclick={handleClick}
data-testid="sharing-indicator"
>
{#if hasPublicLink}
<Globe class="h-4 w-4" />
{:else}
<Users class="h-4 w-4" />
{/if}
</button>
{#if showPopover}
<div
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="sharing-popover"
>
{#if hasPublicLink}
<div class="mb-1 flex items-center gap-1.5">
<Globe class="h-3 w-3 text-[var(--primary)]" />
<span class="text-xs font-semibold text-[var(--primary)]">Public link active</span>
</div>
{/if}
{#if hasCollaborators}
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if isOwner}
<li class="text-xs text-[var(--text)]">
You <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
+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 -18
View File
@@ -227,24 +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 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(
+7
View File
@@ -124,6 +124,13 @@ sqlite.exec(`
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS shared_notes (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE TABLE IF NOT EXISTS note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
+9
View File
@@ -190,6 +190,15 @@ export const userPreferences = sqliteTable(
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
);
export const sharedNotes = sqliteTable('shared_notes', {
noteId: text('note_id')
.primaryKey()
.references(() => notes.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' })
});
export const noteVersions = sqliteTable(
'note_versions',
{
+7
View File
@@ -137,6 +137,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
);
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE TABLE shared_notes (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE TABLE note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// We need to re-import for each test to get a fresh module state
let checkIpRateLimit: (ip: string) => boolean;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./ip-rate-limit.js');
checkIpRateLimit = mod.checkIpRateLimit;
});
describe('checkIpRateLimit', () => {
it('allows requests under the limit', () => {
for (let i = 0; i < 60; i++) {
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
}
});
it('blocks requests over the limit', () => {
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.2.3.4');
}
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
});
it('tracks different IPs independently', () => {
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.1.1.1');
}
expect(checkIpRateLimit('1.1.1.1')).toBe(false);
expect(checkIpRateLimit('2.2.2.2')).toBe(true);
});
it('resets after the window expires', () => {
vi.useFakeTimers();
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.2.3.4');
}
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
// Advance past the 1-minute window
vi.advanceTimersByTime(61_000);
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
vi.useRealTimers();
});
});
+30
View File
@@ -0,0 +1,30 @@
const store = new Map<string, { count: number; windowStart: number }>();
const WINDOW_MS = 60_000; // 1 minute
const MAX_REQUESTS = 60;
const MAX_ENTRIES = 10_000;
let cleanupCounter = 0;
export function checkIpRateLimit(ip: string): boolean {
const now = Date.now();
const entry = store.get(ip);
if (!entry || now - entry.windowStart > WINDOW_MS) {
store.set(ip, { count: 1, windowStart: now });
pruneIfNeeded(now);
return true;
}
entry.count++;
return entry.count <= MAX_REQUESTS;
}
function pruneIfNeeded(now: number) {
cleanupCounter++;
if (cleanupCounter < 100 || store.size < MAX_ENTRIES) {
return;
}
cleanupCounter = 0;
for (const [key, entry] of store) {
if (now - entry.windowStart > WINDOW_MS) {
store.delete(key);
}
}
}
+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', () => {
+35 -45
View File
@@ -1,14 +1,15 @@
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';
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 {
@@ -33,6 +34,7 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
const shareMap = fetchSharesForNotes(db, noteIds);
return noteRows.map((note) => {
const collaborators = collabMap.get(note.id) ?? [];
@@ -44,7 +46,8 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
attachments: attachmentMap.get(note.id) ?? [],
collaborators,
isOwner,
isShared
isShared,
shareToken: shareMap.get(note.id) ?? undefined
};
});
}
@@ -235,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; }
@@ -276,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();
}
@@ -322,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);
}
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, users, attachments } from './db/schema.js';
import type { Db } from './db/index.js';
import {
fetchSharesForNotes,
createShare,
revokeShare,
getShareByNoteId,
getSharedNote
} from './shares-service.js';
let db: Db;
const OWNER_ID = 1;
function seedUsers() {
db.insert(users)
.values([
{
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
}
])
.run();
}
function seedNote(
id: string,
overrides: Partial<typeof notes.$inferInsert> = {}
) {
const now = new Date();
db.insert(notes)
.values({
id,
userId: OWNER_ID,
title: 'Test Note',
content: 'Test content',
createdAt: now,
updatedAt: now,
...overrides
})
.run();
return id;
}
beforeEach(() => {
const result = createTestDb();
db = result.db;
seedUsers();
});
describe('createShare', () => {
it('creates a share and returns a token', () => {
seedNote('note-1');
const { token } = createShare(db, 'note-1', OWNER_ID);
expect(token).toBeDefined();
expect(token.length).toBe(21);
});
it('returns the same token on duplicate creation (idempotent)', () => {
seedNote('note-1');
const first = createShare(db, 'note-1', OWNER_ID);
const second = createShare(db, 'note-1', OWNER_ID);
expect(first.token).toBe(second.token);
});
it('throws 404 for non-existent note', () => {
expect(() => createShare(db, 'nonexistent', OWNER_ID)).toThrow();
});
});
describe('revokeShare', () => {
it('removes the share token', () => {
seedNote('note-1');
createShare(db, 'note-1', OWNER_ID);
revokeShare(db, 'note-1', OWNER_ID);
const share = getShareByNoteId(db, 'note-1');
expect(share).toBeNull();
});
});
describe('getShareByNoteId', () => {
it('returns null when no share exists', () => {
seedNote('note-1');
expect(getShareByNoteId(db, 'note-1')).toBeNull();
});
it('returns share when one exists', () => {
seedNote('note-1');
const { token } = createShare(db, 'note-1', OWNER_ID);
const share = getShareByNoteId(db, 'note-1');
expect(share).not.toBeNull();
expect(share!.token).toBe(token);
});
});
describe('getSharedNote', () => {
it('returns note data for valid token', () => {
seedNote('note-1', { title: 'Shared Title', content: 'Shared content' });
const { token } = createShare(db, 'note-1', OWNER_ID);
const data = getSharedNote(db, token);
expect(data).not.toBeNull();
expect(data!.title).toBe('Shared Title');
expect(data!.content).toBe('Shared content');
});
it('returns null for trashed note', () => {
seedNote('note-1', { trashed: true });
const { token } = createShare(db, 'note-1', OWNER_ID);
expect(getSharedNote(db, token)).toBeNull();
});
it('returns null for invalid token', () => {
expect(getSharedNote(db, 'invalid-token')).toBeNull();
});
it('includes attachments', () => {
seedNote('note-1');
db.insert(attachments)
.values({
id: 'att-1',
userId: OWNER_ID,
noteId: 'note-1',
filename: 'image.png',
mimeType: 'image/png',
size: 1024,
path: '/data/attachments/att-1.png',
createdAt: new Date()
})
.run();
const { token } = createShare(db, 'note-1', OWNER_ID);
const data = getSharedNote(db, token);
expect(data!.attachments).toHaveLength(1);
expect(data!.attachments[0].filename).toBe('image.png');
// Verify server paths are not exposed
expect(data!.attachments[0]).not.toHaveProperty('path');
expect(data!.attachments[0]).not.toHaveProperty('thumbnailPath');
});
});
describe('fetchSharesForNotes', () => {
it('returns empty map for no notes', () => {
const result = fetchSharesForNotes(db, []);
expect(result.size).toBe(0);
});
it('returns tokens for shared notes', () => {
seedNote('note-1');
seedNote('note-2');
seedNote('note-3');
const { token: token1 } = createShare(db, 'note-1', OWNER_ID);
const { token: token2 } = createShare(db, 'note-2', OWNER_ID);
const result = fetchSharesForNotes(db, ['note-1', 'note-2', 'note-3']);
expect(result.size).toBe(2);
expect(result.get('note-1')).toBe(token1);
expect(result.get('note-2')).toBe(token2);
expect(result.get('note-3')).toBeUndefined();
});
});
+117
View File
@@ -0,0 +1,117 @@
import type { Db } from './db/index.js';
import { sharedNotes, notes, attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { requireNoteOwnership } from './api-utils.js';
import type { SharedNoteData } from '$lib/types/index.js';
/**
* Batch fetch share tokens for multiple notes.
* Returns a Map of noteId → token.
*/
export function fetchSharesForNotes(db: Db, noteIds: string[]): Map<string, string> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({ noteId: sharedNotes.noteId, token: sharedNotes.token })
.from(sharedNotes)
.where(inArray(sharedNotes.noteId, noteIds))
.all();
const map = new Map<string, string>();
for (const row of rows) {
map.set(row.noteId, row.token);
}
return map;
}
/**
* Create a public share link for a note. Idempotent — returns existing token if already shared.
*/
export function createShare(db: Db, noteId: string, userId: number): { token: string } {
requireNoteOwnership(db, noteId, userId);
const existing = db
.select({ token: sharedNotes.token })
.from(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.get();
if (existing) return { token: existing.token };
const token = nanoid(21);
db.insert(sharedNotes)
.values({ noteId, token, createdAt: new Date() })
.run();
return { token };
}
/**
* Revoke a public share link for a note.
*/
export function revokeShare(db: Db, noteId: string, userId: number): void {
requireNoteOwnership(db, noteId, userId);
db.delete(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.run();
}
/**
* Get share info for a specific note.
*/
export function getShareByNoteId(db: Db, noteId: string) {
return db
.select()
.from(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.get() ?? null;
}
/**
* Get a shared note by its public token. Returns null if note is trashed or token is invalid.
*/
export function getSharedNote(db: Db, token: string): (SharedNoteData & { noteId: string }) | null {
const share = db
.select()
.from(sharedNotes)
.where(eq(sharedNotes.token, token))
.get();
if (!share) return null;
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, share.noteId), eq(notes.trashed, false)))
.get();
if (!note) return null;
const noteAttachments = db
.select()
.from(attachments)
.where(eq(attachments.noteId, share.noteId))
.all();
const publicAttachments = noteAttachments.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
featured: a.featured,
createdAt: a.createdAt
}));
return {
noteId: note.id,
title: note.title,
content: note.content,
checklistMode: note.checklistMode,
color: note.color as SharedNoteData['color'],
attachments: publicAttachments,
createdAt: note.createdAt,
updatedAt: note.updatedAt
};
}
+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;
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
import type { Note } from '$lib/types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
return {
id: 'note-1',
title: 'Test',
content: '- [ ] Item',
color: 'default',
pinned: false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: true,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
version: 1,
tags: [],
attachments: [],
collaborators: [],
isOwner: true,
isShared: false,
...overrides
};
}
describe('mergeNotesByVersion', () => {
it('keeps the local note when it has a higher version than the incoming note', () => {
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('- [x] Checked');
expect(result[0].version).toBe(3);
});
it('uses the incoming note when it has a higher or equal version', () => {
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('- [x] New');
expect(result[0].version).toBe(3);
});
it('uses the incoming note when versions are equal', () => {
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
const result = mergeNotesByVersion([local], [incoming]);
expect(result[0].content).toBe('incoming');
});
it('handles notes not present in the local store', () => {
const incoming = makeNote({ id: 'n2', version: 1 });
const result = mergeNotesByVersion([], [incoming]);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('n2');
});
it('preserves local-only notes not present in the incoming data', () => {
const localOnly = makeNote({ id: 'n1', version: 1, content: 'just created' });
const serverNote = makeNote({ id: 'n2', version: 1, content: 'from server' });
const result = mergeNotesByVersion([localOnly], [serverNote]);
expect(result).toHaveLength(2);
expect(result.find((n) => n.id === 'n1')?.content).toBe('just created');
expect(result.find((n) => n.id === 'n2')?.content).toBe('from server');
});
it('preserves multiple notes correctly during a race condition', () => {
const localNotes = [
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
];
const incomingNotes = [
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
];
const result = mergeNotesByVersion(localNotes, incomingNotes);
expect(result).toHaveLength(2);
// n1: local version 5 > incoming version 4 → keep local
expect(result[0].content).toBe('- [x] Just saved');
// n2: local version 1 < incoming version 2 → use incoming
expect(result[1].content).toBe('updated on server');
});
});
describe('reconcileSearchResults', () => {
it('replaces a stale result with the canonical note', () => {
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
const result = reconcileSearchResults(
[canonical],
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
);
expect(result).toEqual([canonical]);
});
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
expect(result).toEqual([serverHit]);
});
it('drops a result that is trashed in the canonical store', () => {
const trashed = makeNote({ id: 'n1', trashed: true });
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
expect(result).toEqual([]);
});
it('preserves result order while reconciling a mix of cases', () => {
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
const trashed = makeNote({ id: 'n2', trashed: true });
const absent = makeNote({ id: 'n3', title: 'Absent' });
const result = reconcileSearchResults(
[canonical, trashed],
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
);
expect(result).toEqual([canonical, absent]);
});
it('returns an empty array when there are no results', () => {
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
});
});
+61 -5
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') {
@@ -53,12 +83,35 @@ function isNetworkError(err: unknown): boolean {
return err instanceof TypeError;
}
/**
* Merge incoming notes with current store, keeping the higher-version note
* when a conflict exists. This prevents a stale loadNotes response from
* overwriting a just-saved note (race between sync interval and updateNote).
*/
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
const currentMap = new Map(current.map((n) => [n.id, n]));
const incomingIds = new Set<string>();
const merged = incoming.map((incomingNote) => {
incomingIds.add(incomingNote.id);
const local = currentMap.get(incomingNote.id);
if (local && local.version > incomingNote.version) return local;
return incomingNote;
});
// Preserve local-only notes (e.g. just created, not yet in server/IDB response)
for (const local of current) {
if (!incomingIds.has(local.id)) {
merged.push(local);
}
}
return merged;
}
export async function loadNotes(filter: NoteFilter = 'all') {
// Load from IDB first for instant display
try {
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
}
} catch {
// IDB unavailable — continue with server fetch
@@ -71,7 +124,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data: Note[] = await res.json();
notes.set(data);
notes.update((current) => mergeNotesByVersion(current, data));
currentFilter.set(filter);
// Update IDB with server state
@@ -195,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;
}
@@ -202,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');
@@ -240,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;
}
+1
View File
@@ -4,6 +4,7 @@ import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.j
describe('preferences defaults', () => {
it('should have correct default values', () => {
expect(DEFAULT_PREFERENCES).toEqual({
theme: 'system',
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
+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))
+27
View File
@@ -17,6 +17,7 @@ export interface Note {
collaborators?: Collaborator[];
isOwner?: boolean;
isShared?: boolean;
shareToken?: string;
}
export interface Collaborator {
@@ -118,5 +119,31 @@ export interface NoteVersion {
createdAt: Date;
}
export interface NoteShare {
noteId: string;
token: string;
createdAt: Date;
expiresAt: Date | null;
}
export interface PublicAttachment {
id: string;
filename: string;
mimeType: string;
size: number;
featured: boolean;
createdAt: Date;
}
export interface SharedNoteData {
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
attachments: PublicAttachment[];
createdAt: Date;
updatedAt: Date;
}
export type { UserPreferences } from './preferences.js';
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
+2
View File
@@ -1,6 +1,7 @@
import type { NoteColor } from './index.js';
export interface UserPreferences {
theme: 'system' | 'light' | 'dark';
defaultNoteMode: 'richtext' | 'markdown';
defaultNoteColor: NoteColor;
hideFooter: boolean;
@@ -11,6 +12,7 @@ export interface UserPreferences {
}
export const DEFAULT_PREFERENCES: UserPreferences = {
theme: 'system',
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
+326
View File
@@ -0,0 +1,326 @@
import { describe, it, expect } from 'vitest';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
describe('parseChecklist', () => {
it('parses flat checklist items', () => {
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
});
it('parses nested children with 2-space prefix', () => {
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
});
it('treats orphaned children as top-level', () => {
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
});
it('returns one empty item for blank content', () => {
const items = parseChecklist('');
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
});
it('handles mixed top-level and nested items', () => {
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
const items = parseChecklist(content);
expect(items).toHaveLength(5);
expect(items[1].parentId).toBe(items[0].id);
expect(items[2].parentId).toBe(items[0].id);
expect(items[4].parentId).toBe(items[3].id);
});
});
describe('serializeChecklist', () => {
it('serializes flat items', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Milk', checked: false, parentId: null },
{ id: '2', text: 'Eggs', checked: true, parentId: null }
];
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
});
it('serializes nested items with 2-space prefix', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Groceries', checked: false, parentId: null },
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
];
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
});
it('groups children under parents regardless of array order', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '3', text: 'B', checked: false, parentId: null },
{ id: '2', text: 'A1', checked: false, parentId: '1' }
];
const result = serializeChecklist(items);
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
});
it('round-trips parse then serialize', () => {
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
const items = parseChecklist(original);
expect(serializeChecklist(items)).toBe(original);
});
});
describe('toggleItemWithCascade', () => {
it('checking a parent checks all children', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
];
const result = toggleItemWithCascade('1', items);
expect(result.every((i) => i.checked)).toBe(true);
});
it('unchecking a parent unchecks all children', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: true, parentId: null },
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
];
const result = toggleItemWithCascade('1', items);
expect(result.every((i) => !i.checked)).toBe(true);
});
it('checking a child does not affect parent', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '2', text: 'Child', checked: false, parentId: '1' }
];
const result = toggleItemWithCascade('2', items);
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
});
it('unchecking a child with deleted parent orphans it', () => {
const items: ChecklistItem[] = [
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
];
const result = toggleItemWithCascade('2', items);
expect(result[0].checked).toBe(false);
expect(result[0].parentId).toBeNull();
});
it('unchecking a child repositions it after its parent and siblings', () => {
// Simulates: parent + 2 children, one child checked (at end of array as done item)
const items: ChecklistItem[] = [
{ id: '1', text: 'Parent', checked: false, parentId: null },
{ id: '3', text: 'Child B', checked: false, parentId: '1' },
{ id: '4', text: 'Other', checked: false, parentId: null },
{ id: '2', text: 'Child A', checked: true, parentId: '1' } // done, at end
];
const result = toggleItemWithCascade('2', items);
// Child A should be repositioned after Child B (last sibling), not stay at end
const ids = result.map(i => i.id);
expect(ids).toEqual(['1', '3', '2', '4']);
expect(result.find(i => i.id === '2')!.checked).toBe(false);
});
});
describe('indentItem', () => {
it('sets parentId to nearest preceding top-level item', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: null }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
});
it('no-op if already a child', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result).toEqual(items);
});
it('no-op if first item (no parent above)', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null }
];
const active = items.filter(i => !i.checked);
const result = indentItem('1', items, active);
expect(result[0].parentId).toBeNull();
});
it('reparents children to grandparent when indenting a parent', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: null },
{ id: '3', text: 'C', checked: false, parentId: '2' },
{ id: '4', text: 'D', checked: false, parentId: '2' }
];
const active = items.filter(i => !i.checked);
const result = indentItem('2', items, active);
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
});
});
describe('outdentItem', () => {
it('sets parentId to null', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
});
it('no-op if already top-level', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null }
];
const result = outdentItem('1', items);
expect(result[0].parentId).toBeNull();
});
it('adopts consecutive siblings below with same parentId', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' },
{ id: '3', text: 'C', checked: false, parentId: '1' },
{ id: '4', text: 'D', checked: false, parentId: '1' },
{ id: '5', text: 'E', checked: false, parentId: null }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
});
it('stops adoption at first non-sibling', () => {
const items: ChecklistItem[] = [
{ id: '1', text: 'A', checked: false, parentId: null },
{ id: '2', text: 'B', checked: false, parentId: '1' },
{ id: '5', text: 'E', checked: false, parentId: null },
{ id: '3', text: 'C', checked: false, parentId: '1' }
];
const result = outdentItem('2', items);
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
});
});
describe('linkifyText', () => {
it('converts https URLs to anchor tags', () => {
expect(linkifyText('visit https://example.com today')).toBe(
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
);
});
it('converts http URLs to anchor tags', () => {
expect(linkifyText('go to http://example.com')).toBe(
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
);
});
it('converts bare www URLs with https href', () => {
expect(linkifyText('check www.example.com')).toBe(
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
);
});
it('handles multiple URLs in one string', () => {
const result = linkifyText('see https://a.com and https://b.com');
expect(result).toContain('href="https://a.com"');
expect(result).toContain('href="https://b.com"');
});
it('returns plain text unchanged when no URLs present', () => {
expect(linkifyText('just some text')).toBe('just some text');
});
it('HTML-escapes user content to prevent XSS', () => {
expect(linkifyText('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('HTML-escapes text around URLs', () => {
expect(linkifyText('<b>bold</b> https://example.com')).toContain('&lt;b&gt;bold&lt;/b&gt;');
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
});
it('handles URL at start of string', () => {
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
});
it('handles URL at end of string', () => {
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
});
it('does not linkify partial matches without protocol or www', () => {
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
});
it('handles URLs with paths and query strings', () => {
const url = 'https://example.com/path?q=1&b=2';
const result = linkifyText(url);
expect(result).toContain('href="https://example.com/path?q=1&amp;b=2"');
});
it('strips trailing punctuation from URLs', () => {
expect(linkifyText('see https://example.com.')).toBe(
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
);
});
it('returns empty string for empty input', () => {
expect(linkifyText('')).toBe('');
});
});
describe('unlinkifyHtml', () => {
it('strips anchor tags and returns text content', () => {
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
.toBe('visit https://example.com today');
});
it('returns plain text unchanged', () => {
expect(unlinkifyHtml('just text')).toBe('just text');
});
it('handles multiple anchor tags', () => {
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
.toBe('https://a.com and https://b.com');
});
it('returns empty string for empty input', () => {
expect(unlinkifyHtml('')).toBe('');
});
it('handles div and br tags from contenteditable', () => {
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
});
it('decodes HTML entities after stripping tags', () => {
expect(unlinkifyHtml('Tom &amp; Jerry')).toBe('Tom & Jerry');
expect(unlinkifyHtml('&lt;b&gt;bold&lt;/b&gt;')).toBe('<b>bold</b>');
expect(unlinkifyHtml('say &quot;hello&quot;')).toBe('say "hello"');
});
it('converts &nbsp; from contenteditable to regular spaces', () => {
expect(unlinkifyHtml('hello&nbsp;world')).toBe('hello world');
expect(unlinkifyHtml('a&nbsp;&nbsp;b')).toBe('a b');
});
});
+199
View File
@@ -0,0 +1,199 @@
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
parentId: string | null;
}
export function generateId(): string {
return crypto.randomUUID();
}
export function isChild(item: ChecklistItem): boolean {
return item.parentId !== null;
}
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
return items.filter((i) => i.parentId === parentId);
}
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
}
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
const target = items.find((i) => i.id === id)!;
const newChecked = !target.checked;
let result = items.map((item) => {
if (item.id === id) return { ...item, checked: newChecked };
if (item.parentId === id) return { ...item, checked: newChecked };
return item;
});
// When unchecking a child: reposition it after its parent/siblings in the array
// (otherwise it stays at the end of the array where done items live)
if (!newChecked && target.parentId) {
const parentExists = result.some((i) => i.id === target.parentId);
if (!parentExists) {
// Parent deleted — orphan it
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
} else {
// Remove the item from its current position
const uncheckedItem = result.find((i) => i.id === id)!;
result = result.filter((i) => i.id !== id);
// Find the last sibling or the parent, insert after it
let insertAfter = -1;
for (let i = 0; i < result.length; i++) {
if (result[i].id === target.parentId || result[i].parentId === target.parentId) {
insertAfter = i;
}
}
if (insertAfter >= 0) {
result.splice(insertAfter + 1, 0, uncheckedItem);
} else {
result.push(uncheckedItem);
}
}
}
return result;
}
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
const item = items.find((i) => i.id === id);
if (!item || item.parentId !== null) return items;
const activeIndex = activeItems.findIndex((i) => i.id === id);
let newParentId: string | null = null;
for (let i = activeIndex - 1; i >= 0; i--) {
if (activeItems[i].parentId === null) {
newParentId = activeItems[i].id;
break;
}
}
if (!newParentId) return items;
return items.map((i) => {
if (i.id === id) return { ...i, parentId: newParentId };
if (i.parentId === id) return { ...i, parentId: newParentId };
return i;
});
}
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
const index = items.findIndex((i) => i.id === id);
const item = items[index];
if (!item || item.parentId === null) return items;
const originalParentId = item.parentId;
const adoptees: string[] = [];
for (let i = index + 1; i < items.length; i++) {
if (items[i].parentId === originalParentId) {
adoptees.push(items[i].id);
} else {
break;
}
}
return items.map((i) => {
if (i.id === id) return { ...i, parentId: null };
if (adoptees.includes(i.id)) return { ...i, parentId: id };
return i;
});
}
export function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
let lastTopLevelId: string | null = null;
return text.split('\n').map((line) => {
if (line.startsWith(' - [x] ')) {
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
}
if (line.startsWith(' - [ ] ')) {
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
}
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
if (line.startsWith('- [x] ')) {
item.text = line.slice(6);
item.checked = true;
} else if (line.startsWith('- [ ] ')) {
item.text = line.slice(6);
} else {
item.text = line;
}
lastTopLevelId = item.id;
return item;
});
}
export function serializeChecklist(items: ChecklistItem[]): string {
const topLevel = items.filter((i) => i.parentId === null);
const childMap = new Map<string, ChecklistItem[]>();
for (const item of items) {
if (item.parentId) {
const siblings = childMap.get(item.parentId) ?? [];
siblings.push(item);
childMap.set(item.parentId, siblings);
}
}
const lines: string[] = [];
for (const parent of topLevel) {
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
const children = childMap.get(parent.id) ?? [];
for (const child of children) {
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
}
}
return lines.join('\n');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
export function linkifyText(text: string): string {
if (!text) return '';
const matches: { index: number; match: string; href: string }[] = [];
let m: RegExpExecArray | null;
const regex = new RegExp(URL_REGEX.source, 'g');
while ((m = regex.exec(text)) !== null) {
const match = m[0];
const href = match.startsWith('www.') ? `https://${match}` : match;
matches.push({ index: m.index, match, href });
}
if (matches.length === 0) return escapeHtml(text);
let result = '';
let lastIndex = 0;
for (const { index, match, href } of matches) {
result += escapeHtml(text.slice(lastIndex, index));
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
lastIndex = index + match.length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
export function unlinkifyHtml(html: string): string {
if (!html) return '';
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
expect(getNoteColor('default', false)).toBe('#faf5eb');
expect(getNoteColor('coral', false)).toBe('#faafa8');
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
});
it('has matching keys in light and dark color maps', () => {
const lightKeys = Object.keys(NOTE_COLORS).sort();
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
expect(lightKeys).toEqual(darkKeys);
});
});
+20
View File
@@ -15,6 +15,26 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
chalk: { bg: '#efeff1', label: 'Chalk' }
};
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' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
return (map[color] ?? map.default).bg;
}
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
value: value as NoteColor,
label
+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);
}
+42
View File
@@ -0,0 +1,42 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
let darkMode = $state(false);
export function getIsDarkMode(): boolean {
return darkMode;
}
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
if (!browser) return;
// Validate input
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
theme = 'system';
}
// Resolve system preference
let resolved = theme;
if (theme === 'system') {
const prefersDark =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
resolved = prefersDark ? 'dark' : 'light';
}
const isDark = resolved === 'dark';
darkMode = isDark;
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
}
}
+70
View File
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({ browser: true }));
describe('applyTheme', () => {
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
beforeEach(async () => {
// Reset DOM
document.documentElement.removeAttribute('data-theme');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.remove();
const metaEl = document.createElement('meta');
metaEl.setAttribute('name', 'theme-color');
metaEl.setAttribute('content', '#f0e6d3');
document.head.appendChild(metaEl);
// Reset module state
vi.resetModules();
const mod = await import('$lib/utils/theme.svelte.js');
applyTheme = mod.applyTheme;
});
it('sets data-theme="dark" for dark mode', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('removes data-theme for light mode', () => {
document.documentElement.setAttribute('data-theme', 'dark');
applyTheme('light');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
});
it('updates meta theme-color for light mode', () => {
applyTheme('dark');
applyTheme('light');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
});
it('falls back to system for unknown values', () => {
// matchMedia returns false by default in jsdom → resolves to light
applyTheme('invalid' as 'system' | 'light' | 'dark');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
applyTheme('system');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
+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);
}
};
}
@@ -9,6 +9,22 @@
<div class="space-y-8">
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
<!-- Theme -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
<div class="flex gap-1" role="group" aria-label="Theme">
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
<button
onclick={() => updatePreference('theme', value as 'system' | 'light' | 'dark')}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
<!-- Default note mode -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
+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
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>Not Found - Crumbs</title>
</svelte:head>
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
{page.status}
</h1>
<p class="mt-4 text-sm text-[var(--text-muted)]">
{page.error?.message ?? 'This shared note does not exist or has been removed.'}
</p>
</div>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)] text-[var(--text)]">
{@render children()}
</div>
@@ -0,0 +1,28 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { getSharedNote } from '$lib/server/shares-service.js';
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
import { db } from '$lib/server/db/index.js';
export const load: PageServerLoad = async ({ params, getClientAddress }) => {
const ip = getClientAddress();
if (!checkIpRateLimit(ip)) {
throw error(429, 'Too many requests');
}
const data = getSharedNote(db, params.token);
if (!data) {
throw error(404, 'This shared note does not exist or has been removed.');
}
return {
token: params.token,
title: data.title,
content: data.content,
checklistMode: data.checklistMode,
color: data.color,
attachments: data.attachments,
createdAt: data.createdAt,
updatedAt: data.updatedAt
};
};
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { linkifyText } from '$lib/utils/checklist.js';
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
const { data } = $props();
const noteColor = data.color as NoteColor;
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const renderedContent = renderMarkdown(data.content);
const checklistItems: ChecklistItem[] = data.checklistMode
? data.content
.split('\n')
.filter((l: string) => l.trim())
.map((line: string) => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
: [];
const activeChecklistItems = checklistItems.filter((i) => !i.checked);
const doneChecklistItems = checklistItems.filter((i) => i.checked);
const sortedChecklistItems = [...activeChecklistItems, ...doneChecklistItems];
const imageAttachments = (data.attachments as PublicAttachment[]).filter((a) =>
a.mimeType.startsWith('image/')
);
function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
</script>
<svelte:head>
<title>{data.title || 'Shared Note'} — Crumbs</title>
<meta name="description" content={data.content.slice(0, 160)} />
</svelte:head>
<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}"
data-testid="shared-note"
>
<div class="px-6 py-5">
{#if data.title}
<h1 class="mb-4 text-xl font-semibold text-[var(--text)]" data-testid="shared-note-title">
{data.title}
</h1>
{/if}
{#if data.checklistMode && checklistItems.length > 0}
<ul class="space-y-2" data-testid="shared-note-checklist">
{#each sortedChecklistItems as item}
<li
class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked
? 'text-[var(--text-muted)] line-through'
: 'text-[var(--text)]'}"
data-testid={item.indented ? 'shared-checklist-child' : undefined}
>
<input
type="checkbox"
checked={item.checked}
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
/>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
</ul>
{:else if data.content}
<div
class="prose prose-sm max-w-none text-[var(--text)]"
data-testid="shared-note-content"
>
{@html renderedContent}
</div>
{/if}
{#if imageAttachments.length > 0}
<div class="mt-4 grid grid-cols-2 gap-2" data-testid="shared-note-images">
{#each imageAttachments as attachment}
<img
src="/api/shared/{data.token}/attachment/{attachment.id}"
alt={attachment.filename}
class="w-full rounded-sm border border-[var(--border-subtle)]"
loading="lazy"
/>
{/each}
</div>
{/if}
</div>
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-6 py-3"
>
<span class="text-xs text-[var(--text-muted)]">
{formatDate(data.updatedAt)}
</span>
</div>
</article>
<div class="mt-6 text-center">
<a
href="https://crumbs.bretzel.app"
target="_blank"
rel="noopener noreferrer"
class="font-['Press_Start_2P'] text-[8px] tracking-wider text-[var(--text-muted)] hover:text-[var(--primary)] transition-colors"
>
POWERED BY CRUMBS
</a>
</div>
</div>
+30 -1
View File
@@ -1,9 +1,16 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
import { browser } from '$app/environment';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { applyTheme } from '$lib/utils/theme.svelte.js';
let { children } = $props();
let mediaQuery: MediaQueryList | undefined;
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
const prefs = $derived(getPreferences());
onMount(async () => {
if (pwaInfo) {
@@ -20,6 +27,28 @@
}
});
$effect(() => {
if (!browser) return;
const theme = prefs.theme ?? 'system';
applyTheme(theme);
// Listen for system preference changes when in system mode
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
if (theme === 'system') {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemListener = () => applyTheme('system');
mediaQuery.addEventListener('change', systemListener);
}
});
onDestroy(() => {
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
});
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
@@ -0,0 +1,17 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { createShare, revokeShare } from '$lib/server/shares-service.js';
import { db } from '$lib/server/db/index.js';
export const POST: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
const { token } = createShare(db, params.id, userId);
return json({ token, url: `${url.origin}/s/${token}` }, { status: 201 });
};
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
revokeShare(db, params.id, userId);
return new Response(null, { status: 204 });
};
+20
View File
@@ -0,0 +1,20 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getSharedNote } from '$lib/server/shares-service.js';
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
import { db } from '$lib/server/db/index.js';
export const GET: RequestHandler = async ({ params, getClientAddress }) => {
const ip = getClientAddress();
if (!checkIpRateLimit(ip)) {
throw error(429, 'Too many requests');
}
const data = getSharedNote(db, params.token);
if (!data) {
throw error(404, 'Not found');
}
const { noteId: _, ...publicData } = data;
return json(publicData);
};
@@ -0,0 +1,41 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getSharedNote } from '$lib/server/shares-service.js';
import { getAttachment } from '$lib/server/attachments.js';
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
import { db } from '$lib/server/db/index.js';
import { readFile } from 'fs/promises';
export const GET: RequestHandler = async ({ params, url, getClientAddress }) => {
const ip = getClientAddress();
if (!checkIpRateLimit(ip)) {
throw error(429, 'Too many requests');
}
const data = getSharedNote(db, params.token);
if (!data) {
throw error(404, 'Not found');
}
const attachment = await getAttachment(db, params.attachmentId, data.noteId);
if (!attachment) {
throw error(404, 'Attachment not found');
}
const wantThumb = url.searchParams.get('thumb') === '1';
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
try {
const buffer = await readFile(filePath);
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=3600'
}
});
} catch {
throw error(404, 'Attachment not found');
}
};
+94 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.21.9",
"version": "0.27.1",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
@@ -541,6 +541,47 @@
}
}
},
"/api/notes/{id}/share": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions": {
"parameters": [
{
@@ -728,6 +769,58 @@
}
}
},
"/api/shared/{token}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Shared"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/shared/{token}/attachment/{attachmentId}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "attachmentId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Shared"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/sync": {
"post": {
"tags": [
+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();
});
});
+347 -14
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');
@@ -73,11 +106,38 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
});
test('Scenario: Enter key focuses new item on reopened note with checked items before active ones', async ({ authenticatedPage: page }) => {
// Given a saved checklist note where checked items precede active items
// (this happens when reopening a note — parseChecklist preserves the
// saved order rather than grouping active items first)
await createChecklistNote(page, 'Groceries', ['Milk', 'Eggs', 'Bread', 'Butter']);
// Reopen and check items 1 and 2 so saved content has checked items first
await noteCard(page, 'Groceries').click();
await page.getByTestId('checklist-checkbox').nth(0).click(); // Milk → done
await page.getByTestId('checklist-checkbox').nth(0).click(); // Eggs (now first active) → done
await page.getByTestId('close-editor-btn').click();
// Reopen — parseChecklist restores saved order: [x]Milk, [x]Eggs, [ ]Bread, [ ]Butter
// The checked items precede active ones in items[], but only active ones render as inputs
await noteCard(page, 'Groceries').click();
await expect(page.getByTestId('checklist-input')).toHaveCount(2); // Bread, Butter active
// When the user presses Enter on the first active item (Bread)
await page.getByTestId('checklist-input').first().press('Enter');
// Then focus moves to the newly created empty item
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
});
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);
@@ -92,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');
@@ -110,17 +172,288 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
});
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
// Given a checklist with three items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Second');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Third');
// When the user presses ArrowUp from the third item
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
// Then focus moves to the second item
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
// When the user presses ArrowDown
await page.getByTestId('checklist-input').nth(1).press('ArrowDown');
// Then focus moves back to the third item
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
});
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
// Given a checklist with two top-level items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child');
// When the user presses Tab on the second item
await page.getByTestId('checklist-input').nth(1).press('Tab');
// Then the item becomes indented
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
// When the user presses Shift+Tab
await page.getByTestId('checklist-input').nth(1).press('Shift+Tab');
// Then the item returns to top-level
await expect(page.getByTestId('checklist-child-row')).toHaveCount(0);
});
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and two children
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Buy groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Eggs');
// When the user checks the parent
await page.getByTestId('checklist-checkbox').first().click();
// Then all items move to done section
await expect(page.getByTestId('checklist-toggle-done')).toContainText('3 done');
await expect(page.getByTestId('checklist-input')).toHaveCount(0);
});
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user checks only the child
await page.getByTestId('checklist-checkbox').nth(1).click();
// Then the done section shows a parent label above the checked child
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await expect(page.getByTestId('checklist-done-parent-label')).toBeVisible();
await expect(page.getByTestId('checklist-done-parent-label')).toContainText('Groceries');
});
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and child, all checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Shopping');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user unchecks the parent from the done section
await page.getByTestId('checklist-done-checkbox').first().click();
// Then both parent and child return to active
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
});
test('Scenario: Nested items persist after close and reopen', async ({ authenticatedPage: page }) => {
// Given a checklist with nested items
await createChecklistNote(page, 'Nested Persist', ['Parent', 'Child']);
// Reopen and indent the second item
await noteCard(page, 'Nested Persist').click();
await page.getByTestId('checklist-input').nth(1).press('Tab');
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
await page.getByTestId('close-editor-btn').click();
// When the user reopens the note
await noteCard(page, 'Nested Persist').click();
// Then the nesting is preserved
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
});
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child 1');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user presses Enter on the child
await page.getByTestId('checklist-input').nth(1).press('Enter');
// Then a new child is created at the same level
await expect(page.getByTestId('checklist-child-row')).toHaveCount(2);
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
});
test('Scenario: NoteCard preview shows indentation for nested items', async ({ authenticatedPage: page }) => {
// Given a checklist note with nested items
await createChecklistNote(page, 'Indent Preview Note', ['Parent', 'Child']);
await noteCard(page, 'Indent Preview Note').click();
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('close-editor-btn').click();
// Then the NoteCard preview shows the child indented (scoped to this card)
const card = noteCard(page, 'Indent Preview Note');
await expect(card.getByTestId('card-checklist-child')).toHaveCount(1);
});
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
// Given checklist mode is enabled on a new note
await page.getByTestId('new-note-btn').click();
await 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');
});
});
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, createNote } from './helpers/fixtures.js';
test.describe.serial('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
authenticatedPage: page
}) => {
// Given the user is on the preferences page
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
// When the user selects dark theme
// Set up response listener BEFORE clicking (response may arrive before waitForResponse)
const prefSaved = page.waitForResponse((r) => r.url().includes('/api/preferences') && r.ok());
await page.getByTestId('pref-theme-dark').click();
// Then the page applies dark theme
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// Wait for preference to persist to server before reloading
await prefSaved;
// When the page is reloaded
await page.reload();
await page.waitForLoadState('networkidle');
// Then the dark theme persists (FOUC script applies it before paint)
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
});
test('Scenario: Light mode removes dark theme attribute', 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 selects light theme
await page.getByTestId('pref-theme-light').click();
// Then the dark theme attribute is removed
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
});
test('Scenario: System mode follows OS dark preference', async ({
authenticatedPage: page
}) => {
// Given the OS prefers dark mode
await page.emulateMedia({ colorScheme: 'dark' });
// When the user navigates to preferences with system theme active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-system').click();
// Then the page applies dark theme from system preference
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
test('Scenario: Note cards use dark colors in dark mode', async ({
authenticatedPage: page
}) => {
// Given a note exists
await page.goto('/');
await page.waitForLoadState('networkidle');
await createNote(page, 'Dark Mode Test Note');
// And dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When viewing the notes page
await page.goto('/');
await page.waitForLoadState('networkidle');
// Then note cards should have dark background colors
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)
expect(bg).not.toBe('rgb(250, 245, 235)');
});
});
+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');
+150
View File
@@ -0,0 +1,150 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/**
* Create a note via API and return it.
*/
async function createNoteViaApi(page: Page, title: string, content = 'Public share content') {
const res = await page.request.post('/api/notes', {
data: { title, content }
});
return res.json();
}
/**
* Create a public share link for a note via API and return the token.
*/
async function createShareViaApi(page: Page, noteId: string): Promise<string> {
const res = await page.request.post(`/api/notes/${noteId}/share`);
const data = await res.json();
return data.token;
}
test.describe('Public Note Sharing', () => {
test('Scenario: User creates a public share link', async ({
authenticatedPage: page
}) => {
// Given a note titled "Public Test" exists
const note = await createNoteViaApi(page, 'Public Test', 'Hello world');
await page.reload();
await page.waitForLoadState('networkidle');
// When the owner opens the share dialog and enables the public link
await noteCard(page, 'Public Test').click();
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await page.getByTestId('public-share-toggle').click();
// Then a share URL is displayed
await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 });
const urlText = await page.getByTestId('share-url-text').textContent();
expect(urlText).toContain('/s/');
});
test('Scenario: Public page renders note content without auth', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note exists
const note = await createNoteViaApi(page, 'Shared Publicly', 'This is **public** content');
const token = await createShareViaApi(page, note.id);
// When an unauthenticated user visits the share URL
const context = await browser.newContext();
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note content is rendered
await expect(publicPage.getByTestId('shared-note')).toBeVisible();
await expect(publicPage.getByTestId('shared-note-title')).toHaveText('Shared Publicly');
await expect(publicPage.getByTestId('shared-note-content')).toContainText('public');
await context.close();
});
test('Scenario: User revokes share and link returns 404', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note exists
const note = await createNoteViaApi(page, 'Revoke Test', 'Will be revoked');
const token = await createShareViaApi(page, note.id);
// And the public page is accessible
const context = await browser.newContext();
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
await expect(publicPage.getByTestId('shared-note')).toBeVisible();
// When the owner revokes the share
const revokeRes = await page.request.delete(`/api/notes/${note.id}/share`);
expect(revokeRes.status()).toBe(204);
// Then the public page returns 404
const res = await publicPage.request.get(`/api/shared/${token}`);
expect(res.status()).toBe(404);
await context.close();
});
test('Scenario: Trashed note share link returns 404', async ({
authenticatedPage: page
}) => {
// Given a publicly shared note that is then trashed
const note = await createNoteViaApi(page, 'Trash Share Test', 'Will be trashed');
const token = await createShareViaApi(page, note.id);
// When the note is trashed
await page.request.patch(`/api/notes/${note.id}`, {
data: { trashed: true }
});
// Then the shared API returns 404
const res = await page.request.get(`/api/shared/${token}`);
expect(res.status()).toBe(404);
});
test('Scenario: Globe icon appears on shared note card', async ({
authenticatedPage: page
}) => {
// Given a note with a public share link
const note = await createNoteViaApi(page, 'Globe Icon Test');
await createShareViaApi(page, note.id);
// When the user views the notes list
await page.reload();
await page.waitForLoadState('networkidle');
// Then the note card shows a sharing indicator
const card = noteCard(page, 'Globe Icon Test');
await expect(card).toBeVisible();
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
});
test('Scenario: Copy link button copies to clipboard', async ({
authenticatedPage: page
}) => {
// Given a note with a public share link
const note = await createNoteViaApi(page, 'Copy Link Test');
await createShareViaApi(page, note.id);
await page.reload();
await page.waitForLoadState('networkidle');
// When the owner opens the share dialog
await noteCard(page, 'Copy Link Test').click();
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
// Then the share URL is already visible (token was created via API)
await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 });
// And clicking copy shows the check icon
// Grant clipboard permissions for the test
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByTestId('copy-share-url-btn').click();
// The clipboard should contain the share URL
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toContain('/s/');
});
});
+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();
});
});
+2 -2
View File
@@ -54,8 +54,8 @@ test.describe('Note Sharing', () => {
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
// Then the owner sees a collaborator indicator
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
// Then the owner sees a sharing indicator
await expect(noteCard(page, 'Share Test').getByTestId('sharing-indicator')).toBeVisible();
// And the collaborator sees the shared note in their list
await collabPage.reload();
+3 -1
View File
@@ -4,12 +4,14 @@ import path from 'path';
export default defineConfig({
resolve: {
alias: {
$lib: path.resolve(__dirname, 'src/lib')
$lib: path.resolve(__dirname, 'src/lib'),
'$app/environment': path.resolve(__dirname, 'src/lib/__mocks__/app-environment.ts')
}
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
setupFiles: ['src/lib/__mocks__/svelte-runes.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov', 'json-summary'],
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 99 KiB

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