Compare commits

...
91 Commits
Author SHA1 Message Date
github-actions[bot] a0f663d9d6 chore: pin docker-compose to v0.35.2 [skip ci] 2026-10-11 07:56:13 +00:00
github-actions[bot] 9cf0c0285b chore: bump version to 0.35.2 2026-10-11 07:49:40 +00:00
Claude faf926c081 fix(editor): open existing notes in the user's default note mode
Existing notes initialised raw markdown mode from note.checklistMode
instead of the defaultNoteMode preference, so users with Markdown as
default always got the rich text editor when reopening a note.

Fixes #96

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017neWQAAxi6nZYmwRkDThsy
2026-10-11 09:46:43 +02:00
github-actions[bot] 31732e8144 chore: pin docker-compose to v0.35.1 [skip ci] 2026-10-06 07:11:38 +00:00
github-actions[bot] d71dbe0f4a chore: bump version to 0.35.1 2026-10-06 07:05:30 +00:00
Claude effe7e3e72 fix(docker): make bind-mounted data folders writable on startup
The image switched to the node user (uid 1000) at build time, so a host
folder mounted at /data kept its host ownership and SQLite failed with
SQLITE_CANTOPEN in a restart loop. Named and anonymous volumes worked only
because Docker copies the image's /data ownership into them.

The container now starts as root, hands DATA_DIR and the database folder
to node (only entries not already owned by it), and execs the app via
setpriv as node. Containers started with an explicit `user:` skip this.

Adds a CI step that runs the image with a host-owned bind mount.

Fixes #93

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MHECwBhBpM248EFSBi4L5
2026-10-06 08:43:59 +02:00
github-actions[bot] 6f4938180a chore: pin docker-compose to v0.35.0 [skip ci] 2026-09-18 06:22:43 +00:00
github-actions[bot] eb65425220 chore: bump version to 0.35.0 2026-09-18 06:15:07 +00:00
Claude 011113b7e1 fix(e2e): keep preview server keep-alive sockets open longer than clients
CI on main (run 310, commit 58e5b5d) failed one public-sharing scenario
with "apiRequestContext.post: socket hang up" on its first request, with
no server-side error. The same tree had passed E2E on the PR run minutes
earlier, and the failing call is the first request of the test.

That is the keep-alive race: Playwright's request context reuses idle
sockets to `vite preview`, whose Node http server closes an idle socket
after 5s. A request that lands on a socket the server is closing at that
instant dies client-side. Raising the preview server's keepAliveTimeout
to 65s (headersTimeout 66s, which Node requires to be larger) makes the
client always the side that closes, so the race cannot occur. A tiny Vite
plugin does this only for `vite preview`; production runs `node build`
and is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-18 08:12:40 +02:00
Claude 58e5b5dd66 feat(editor): make code snippets readable and syntax highlighted
Closes #90.

Code blocks and inline code inherited the typography plugin's defaults,
which shrank them to ~86% of an already-monospace body and painted them
--bg-surface — the same colour as the default note card — so a snippet
was only recognisable by being smaller. Long lines also grew a horizontal
scrollbar inside card previews.

- New --code-bg surface (one step below the page in light mode, page-level
  ink in dark) with a subtle border, and code keeps the surrounding text
  size. Decorative backtick pseudo-elements dropped now that the chip
  carries the signal.
- Card previews clip wide code instead of scrolling; editor and share page
  keep horizontal scroll.
- Syntax highlighting via one shared lowlight instance feeding both the
  TipTap editor (extension-code-block-lowlight) and the markdown-it
  renderer, so a fence looks identical in the editor, on cards and on the
  share page. Only highlight.js "common" grammars are bundled.
- No auto-detection: highlight.js misreads note-sized snippets too often
  (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the
  language comes from the fence info string or a toolbar picker shown
  while the cursor is in a code block.
- Six --hl-* token colours per theme; a unit test parses app.css and
  asserts AA contrast against --code-bg and mutual distinguishability.
- Sanitizer now allows only language-* / hljs-* classes on code and span.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-17 21:42:35 +02:00
Frederic CabassutandClaude Fable 5.1 7bf06edd97 perf(e2e): log in once and share the admin session via storage state
Every test in the `app` project submitted the login form through the
authenticatedPage fixture: a page load, an Argon2 verification on the
server, a redirect and a networkidle wait. Over 137 tests that was
roughly half of the median test's 1.8s.

A new `login-state` setup project (tests/e2e/auth.setup.ts) authenticates
once after auth.spec has created the account and saves the session cookie
to data/test-storage-state.json. The `app` project loads it into every
context, so setupAndLogin takes its "session still valid" path and the
fixture is reduced to a page load.

pwa.spec and mcp.spec describe anonymous visitors and opt out with an
empty storageState so their baseline is unchanged.

Local run, 4 workers: summed test time 276s -> 256s, median 1.8s -> 1.6s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:29:46 +02:00
github-actions[bot] af7186675e chore: pin docker-compose to v0.34.2 [skip ci] 2026-09-07 17:43:47 +00:00
github-actions[bot] b0ecc6b74d chore: bump version to 0.34.2 2026-09-07 17:37:47 +00:00
Frederic CabassutandClaude Fable 5.1 521acc718c docs: require all work to happen in a dedicated worktree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 082b8632c6 fix: render empty task list items as checkboxes in note previews
The task-list markdown rule only matched a marker followed by a space
("[ ] text"). markdown-it trims trailing whitespace from inline content,
so an item with no text arrived as exactly "[ ]" and rendered as literal
brackets in the card preview, while the editor showed a checkbox.

Match the marker followed by a space or end-of-content instead, and strip
only what was matched so "[ ]x" glued to text stays literal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:53 +02:00
Frederic CabassutandClaude Fable 5.1 6bdcc3c214 fix(theme): map all prose colour variables to theme tokens for dark mode
The typography plugin gives each prose element its own --tw-prose-*
variable with a hardcoded grey default. Only body, headings, links and
code were remapped to parchment tokens, so bold text (and quotes, kbd,
bullets, counters, hr and table borders) kept their light-mode greys in
dark mode. Bold rendered near-black on the dark surface.

Map every variable to a theme token so [data-theme="dark"] flips them
all, and add an e2e scenario asserting bold text in the card preview and
editor computes to --text in dark mode.

Fixes #86

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:13:32 +02:00
Frederic Cabassut e8c30c518c revert: pin better-sqlite3 back to v11.10.0, breaks CI native builds
v13.0.3 has no prebuilt binary for the exact platform/Node combo used
by both this project's Docker build stage (node:22-slim) and the
GitHub Actions E2E runner, so it falls back to compiling from source
via node-gyp. Neither environment has python3/make available for that
fallback, so both the Docker Build and E2E Tests CI jobs failed with
"Could not find any Python installation" / "not found: make".

This wasn't fixing a known vulnerability (audit was already clean), so
revert rather than start patching the Docker image and CI workflow's
system packages for a version-currency bump.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 84145de3b1 chore(deps): remove obsolete @types/uuid stub
uuid v14 ships its own type definitions; @types/uuid now publishes as
an empty stub explicitly saying it's no longer needed.
2026-09-02 09:34:03 +02:00
Frederic Cabassut ea66e23178 chore(deps): update better-sqlite3 to v13 (N-API rewrite)
v13 rewrote the native binding on top of N-API for better cross-version
binary compatibility; no application-facing API changes for the query
methods this app uses. Compiled and verified against the full DB test
suite (notes, versions, shares, collaborators, tags, search, api-keys).
Bumped @types/better-sqlite3 to match.
2026-09-02 09:34:03 +02:00
Frederic Cabassut feda983393 chore(deps): update argon2 to v0.45.1
No API changes (build system tweaks, dropped Node 18/20 support - this
project already targets Node 22+). Verified against the password
hash/verify test suite.
2026-09-02 09:34:03 +02:00
Frederic Cabassut bf5bfdd98e chore(deps): update uuid to v14
No code changes needed - the project is already full ESM ("type":
"module") and only uses the ESM v4 import, so v12's CommonJS removal
doesn't apply here.
2026-09-02 09:34:03 +02:00
Frederic Cabassut 12ff6d9c4d chore(deps): update markdown-it to v15 for quadratic-complexity fixes
v15.0.1 fixes quadratic-complexity bugs in the linkify inline rule and
fuzzy-link replacement - relevant here since this app renders notes
with linkify: true. Also drops @types/markdown-it, which v15 makes
redundant by bundling its own TypeScript declarations (conflicting
otherwise), and adapts two call sites to the now-more-precise
attrGet() return type (string | number | null vs the community types'
string | null).
2026-09-02 09:34:03 +02:00
Frederic Cabassut 44963bf684 chore(deps): fix nanoid vulnerability and update dependencies within semver range
- Add a scoped pnpm override forcing postcss's transitive nanoid to
  >=3.3.18 <4, patching GHSA-2v37-7h3g-55p8 (indefinite loop in custom
  generators with size=0). Scoped to postcss's own dependency rather
  than the top-level nanoid so the app's own nanoid@5 usage is
  untouched.
- Update all other dependencies within their existing package.json
  semver ranges (tiptap, markdown-it, nodemailer, svelte-dnd-action,
  zod, sveltejs/kit, svelte, svelte-check, tsx and their @types).

pnpm audit: 1 high -> 0 vulnerabilities.
2026-09-02 09:34:03 +02:00
github-actions[bot] dfaf1c474d chore: pin docker-compose to v0.34.1 [skip ci] 2026-09-01 10:08:28 +00:00
github-actions[bot] 881a85a09e chore: bump version to 0.34.1 2026-09-01 10:01:25 +00:00
Frederic Cabassut 27c3e7dccd fix(editor): render the table-with-list HTML fallback safely instead of escaping it
The table markdown fallback (previous commit) embeds raw HTML in saved
markdown when a cell holds a bullet list, since GFM can't express block
content in a table cell. But the read-only renderer (note-card preview,
public share page) ran with html:false, so that HTML rendered as
escaped literal text instead of a table - moving the bug rather than
fixing it.

Enable html mode and sanitize the rendered output with sanitize-html.
Sanitizing matters beyond this one fallback: note content isn't only
ever written through the editor - the MCP server and notes API accept
a raw markdown string directly, bypassing the editor's schema, so any
HTML this renders has to be treated as untrusted regardless of source.

Also make the table's HTML fallback respect the editor's own `html`
option (falls back to a `[table]` placeholder when disabled), matching
every other node type's markdown storage in tiptap-markdown.

Found via a 3-agent adversarial review of the previous fix.
2026-09-01 12:00:36 +02:00
Frederic Cabassut 64f96b21a3 fix(editor): preserve bullet lists inside table cells on save/reopen
tiptap-markdown's bundled table serializer only rejects cells with
multiple children or spans, not a single non-paragraph block child. A
cell holding just a bullet list slipped through and got fed to
renderInline(), which is only valid for textblocks, corrupting the row
on save. Now such cells fall back to serializing the whole table as
raw HTML, same as tiptap-markdown does for other unsupported layouts.

Fixes #83
2026-09-01 12:00:36 +02:00
github-actions[bot] ff24fb6d5b chore: pin docker-compose to v0.34.0 [skip ci] 2026-08-19 13:22:25 +00:00
github-actions[bot] 7f6ad2dc12 chore: bump version to 0.34.0 2026-08-19 13:15:42 +00:00
Frederic Cabassut e4606cfc70 feat(auth): enable password login on OAuth-only accounts, with disable control
Admins can now reset the password of an OAuth-only user, which creates
their credentials and enables password login; the new 'Disable password
login' row action removes those credentials again. Resetting a password
revokes all existing sessions.

- add users.password_login_enabled column (migration 0005)
- admin reset: set password, enable login, revoke sessions
- disable action: clear password, disable login (only on OAuth-linked rows)
- account endpoint: refuse password updates on OAuth-linked users
- e2e: wipe the test DB before the webServer starts (Playwright starts
  the server before globalSetup, so wiping there orphaned the DB file)
- row-actions strip test: five square actions, wrap on phone widths
2026-08-19 13:18:56 +02:00
github-actions[bot] e315b8c7ec chore: pin docker-compose to v0.33.0 [skip ci] 2026-08-18 10:39:09 +00:00
github-actions[bot] 7d9bd737ab chore: bump version to 0.33.0 2026-08-18 10:32:17 +00:00
Frederic CabassutandClaude Opus 5 4812eaf1ed docs: correct the admin user-management claims
Issue #77 was caused by these docs. FEATURES.md promised "Reset
passwords for any user" when no such control existed, so a user
reasonably filed a bug against a promise the software never made. The
docs were describing the API surface, not the UI. Now that the control
ships, they describe what it actually does — and the same audit turned up
three neighbouring claims that were already wrong.

Corrected:
- "Reset passwords for any user" — it is any user other than yourself,
  whose account signs in with a password, and Crumbs generates the
  password and shows it once rather than the admin choosing one.
- "Revoke all sessions ... for any user" — Revoke has always been
  self-excluded in the UI. Wrong before this feature existed.
- The AUTH.md sentence covering both capabilities in one breath, so
  fixing only the reset half would have left the revoke half wrong.
- Two references to a "Settings > Users > Invite User" affordance that
  does not exist; the section is headed Create User. Same defect as #77
  — documentation promising UI that was never built — one of them buried
  in the Authentik setup guide.

Newly documented, all previously silent:
- The sole-admin dead end, and its bound. There is no forgot-password
  flow, the reset control excludes your own row, and Profile requires
  your current password. Another admin can reset you. Failing that, an
  OAuth sign-in recovers the account when a provider is configured and
  the email matches — at the cost of handing the account to that
  provider permanently. Only with no second admin and no provider is
  there genuinely no way back in through the app. An earlier draft
  stated that absolutely, which would have pushed an operator toward
  restoring from backup when one OAuth sign-in would have done.
- The OAuth pre-link trap: a password set during invite works only until
  the first OAuth sign-in, which hands the account to the provider.
  Ticking OAuth-only avoids the surprise. Tracked for a real fix in #78.
- That the reset notification is best-effort. Both layers swallow send
  errors, so a successful reset is not evidence the user was told.
- That a displayed password is only good until that account's password
  changes again, by any route, with nothing marking it stale.
- That an unconfirmed reset shows the password anyway, and that one
  should be kept and retried.
- That the control is also unavailable while a password is on screen —
  the one unavailable state that shows no reason, so an admin meeting a
  dead button has somewhere to look.

No openapi.yaml or API.md change: neither documents /api/admin/* at all,
so the new 400 needs no spec edit and pnpm docs:api regenerates nothing
relevant. That gap predates this work.

Verified: pnpm check 0 errors / 14 pre-existing warnings. Every factual
claim traced to code by the author and again by an independent reviewer,
which is how the false recovery claim was caught.

Reviewed-by: adversarial-review (13 of 14 claims verified true on the
first pass; failed for one false absolute, since bounded)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 afce024cac refactor(wu-003b): one uniform icon per row action, and guard the reveal
Follow-up to d5c7ab6 after visual review. The 44px Reset button sat
beside ~24px siblings, and four text labels wrapped — Delete dropped to
its own line at 900px, three lines at 360px.

All four row actions are now identical 44x44 icon buttons on one line:
shield-check/shield-off, key-round, log-out, trash-2. Each carries a
tooltip for a mouse and an aria-label, since an icon alone has no
accessible name. In-flight swaps to a spinning loader-circle and an
"Resetting…" accessible name, so the e2e assertion reads the accessible
name rather than visible text that no longer exists.

`flex-wrap` on the strip is what keeps the squares square: flex
compresses items only on a line that overflows, so breaking the line
means no button is ever asked to shrink. Without it the four flatten to
40x44 at 320px — under the minimum, on a row whose Delete is
irreversible. They hold one line down to 336px, exactly 4x44 plus the
gaps, and take a second line below that. Correctly sized targets over
two lines beat cramped ones on one.

A first attempt also put `shrink-0` on each button and credited it in a
comment. Measurement disproved that: strip the token and the geometry is
byte-identical at every width from 280 to 900, because `flex-wrap` has
already spared the buttons from ever shrinking. The dead tokens are gone
and the comment now names the real mechanism, while noting that the
container's identical-looking `shrink-0` DOES carry weight — from 640px
the row turns sm:flex-row, where the truncating name block would
otherwise squeeze the strip.

Two things stay deliberately as they were. Copy and Dismiss remain text
buttons: Dismiss irreversibly destroys a credential the server may
already hold, and an icon-only cross beside Copy is the one place
ambiguity could cost a password. The blocked-reset reason remains visible
in-row text rather than moving into the tooltip, which needs hover the
reporter's Android device cannot give.

Also closes a gap in the reveal's regression cover. The rule that an
unconfirmed outcome REVEALS the generated password protects the only copy
of a credential the server may already hold, but only the network-abort
trigger was tested. Two more scenarios now pin the lines that decide it:
a 5xx carrying a parseable message (guarding `res.status < 500`) and a
4xx with an unreadable body (guarding `.json().catch()`), the latter
being what a reverse proxy returns as an HTML error page.

DoD items verified:
- [x] 1-3. four identical >=44px icon buttons, no wrap at 360px+,
      per-icon subpath imports at 16-20px
- [x] 4-5. Copy/Dismiss still text; reason still visible in-row text
- [x] 6. spinner plus "Resetting…" accessible name, aria-disabled
- [x] 7-9. no disabled attribute, focusable, no request when
      unavailable; Dismiss focus return; all 10 data-testids kept
- [x] 10. CSS variables only, no hover-only visibility
- [x] 11-12. 5xx-with-reason and unparseable-body both reveal
- [x] 13. distinct victim emails, no new failed logins (suite: 3 of 5)
- [x] 14. no regression in the 22 properties of d5c7ab6

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
admin-users.spec.ts 31 passed; full e2e suite 129 passed. Removing
flex-wrap fails the new scenario with "Expected: >= 44, Received: 40",
reproduced here, so the guard covers the token that actually matters.

Reviewed-by: adversarial-review (PASS on the icon work; a second
reviewer failed the first attempt for crediting shrink-0 with an effect
mutation testing showed it does not have)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 ffde151b75 feat(wu-003): add admin reset-password control to Settings > Users
Closes the UI gap in issue #77. The endpoint, resetPassword() and the
notification email have all existed since the feature was built; only
the control was missing, which is why the docs promised something the
screen did not offer.

The control renders on EVERY row, the admin's own included. It is
enabled only for accounts that sign in with a password and that are not
the signed-in admin; otherwise it is presented as unavailable WITH A
VISIBLE REASON. A control that simply vanishes is the confusion this
replaces, so silence was not an option.

No new control uses the `disabled` attribute. Unavailable is expressed
with aria-disabled, the element stays focusable, and the handler refuses
activation itself — a disabled button leaves the tab order, so a
keyboard or TalkBack user loses the element they just activated, and a
title tooltip explains nothing on a touch screen. The reporter is on
Android.

Two rules protect the credential, and both are enforced by the shape of
the state rather than by convention:

- An unconfirmed outcome REVEALS the password. If the request left the
  device and no answer came back, the server may well have stored it,
  making the revealed string the only copy in existence. Discarding it
  could leave an account holding a credential nobody knows.
- `password` and `message` are independent fields, so an error has
  nowhere to write that would replace a reveal. Revealed rows are also
  marked unavailable, so a second reset cannot be issued until Dismiss.

Offline is a definite state, not a lost response: the service worker
caches only GET /api/*, so a PATCH never leaves the browser. It is
checked before anything is generated, and reported as the certainty it
is.

The live region is rendered empty on first paint and only its text is
replaced, because a region inserted together with its content is not
reliably announced. It announces that a password appeared and where —
never the secret itself, which a polite region would speak aloud once,
unrepeatably, to whoever is in earshot.

DoD items verified (22):
- [x] 1-2. control on every row; enabled iff eligible and not self,
      else aria-disabled with a visible, aria-describedby reason
- [x] 3-4. PATCHes newPassword; "Resetting…" and no double-submit
- [x] 5-6. revealed as selectable text, announced; stays unavailable
- [x] 7. Copy confirms success; failure offers a manual-copy hint
- [x] 8-10. 4xx reveals nothing; offline definite; unknown REVEALS
- [x] 11-12. errors never replace a reveal; both announced
- [x] 13-14. 404 drops the row and its plaintext; rows isolated
- [x] 15-17. Dismiss clears and restores focus; 44px targets;
      activating an aria-disabled control fires no request
- [x] 18-19. victim login from a separate context, old password 401
      last; plaintext in no storage, no IndexedDB, no console
- [x] 20-22. CSS variables only, no hover-only; untruncated at 360px;
      declarative Gherkin with distinct victim emails

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
admin-users.spec.ts 28 passed; full e2e suite 126 passed.

The reviewer additionally mutation-tested the security core, each
mutation rebuilt and caught with clean attribution: revealing null on an
unconfirmed outcome, forcing the offline pre-flight false (which falls
into the unknown path and would reveal a password that never left the
browser), dropping the state merge in patchReset, swapping aria-disabled
for disabled (which breaks focus return), and deleting the activation
guard — the last proving the force-click assertions test the component
refusing rather than Playwright declining to click.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 a6f86ec1f0 fix(wu-002): refuse password resets that could never work
Second slice of issue #77. PATCH /api/admin/users/[id] accepted
{ newPassword } for any account and returned 200, but resetPassword()
writes only passwordHash while verifyPassword() matches only rows whose
authProvider is 'password'. So for an OAuth-only account (the
"OAuth-only" checkbox stores 'none') or anyone who has signed in via
OAuth (authProvider becomes the provider name), the endpoint wrote a
hash that could never be used to sign in, returned success, and fired a
"your password was reset" email. It lied.

The endpoint already guards both other self-affecting operations
server-side — "Cannot change your own role" and "Cannot delete your own
account" — but a newPassword targeting the requesting admin succeeded.

Two early returns in the newPassword branch, ordered length -> self ->
eligibility so the existing length 400 keeps precedence. Both are placed
before the resetPassword() call and before the email block: that
ordering is what guarantees no unusable hash is written and no
misleading email is sent, and it is a property of control flow rather
than of an assertion, because neither is observable from a test.
passwordHash never leaves the server (toUser omits it) and
isEmailConfigured() is false under e2e, so an assertion either way would
pass for a broken implementation too.

Eligibility comes from canResetPassword() rather than an inline
comparison, so the endpoint and the settings UI cannot drift; the 400
message comes from passwordResetBlockedReason() so the API reason and
the UI explanation are the same string.

DoD items verified:
- [x] 1. eligible user other than self still 200s, new password works
- [x] 2. authProvider 'none' returns 400 naming the reason
- [x] 3. positive control: an eligible user CAN log in after a reset
- [x] 4. targeting own id returns 400 naming the reason
- [x] 5. sub-8-char password still 400s for length, beating eligibility
- [x] 6. both guards precede the write and the email (diff-verified)
- [x] 7. role, revokeSessions and DELETE byte-unchanged (15+/0-)
- [x] 8. eligibility imported from wu-001, not re-implemented
- [x] 9. declarative Given/When/Then, distinct victim emails throughout

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files;
pnpm build && pnpm test:e2e admin-users.spec.ts 14 passed (7 auth-setup
+ 3 pre-existing + 4 new). The reviewer additionally reverted the
endpoint to its pre-change form and confirmed both guard scenarios fail
with "Expected: 400, Received: 200", so they are genuine regression
tests rather than assertions of behaviour that already held.

Reviewed-by: adversarial-review (PASS)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
Frederic CabassutandClaude Opus 5 51dd844f96 feat(wu-001): add password generator and reset-eligibility utilities
Groundwork for issue #77 (admin cannot reset other users' passwords).
Two pure, colocated-tested modules shared by the API route and the
settings UI that follow.

password.ts — generatePassword() over a charset pinned at exactly 64
characters. 64 is a power of two, so nanoid's customAlphabet takes its
exactly-uniform masking path (256 % 64 === 0) rather than the rejection
branch; 120 bits of entropy at the default length of 20. Visually
ambiguous characters (0 O o 1 l I) are excluded so a password survives
being read aloud or retyped. Class coverage is guaranteed by discarding
and regenerating a whole password that is missing a group, which stays
uniform over exactly the set of valid passwords — rather than forcing
characters into positions and shuffling, which would need its own
unbiased index source.

auth-methods.ts — canResetPassword() and passwordResetBlockedReason(),
the single source of truth for eligibility so the endpoint and the
component cannot drift. Password login is gated on authProvider
(verifyPassword matches only rows where it equals 'password'), so
writing a hash for any other provider stores a credential that can
never be used to sign in.

The reason lookup is a Map, not an object literal: an object inherits
Object.prototype, so a lookup for 'toString' or '__proto__' resolves to
an inherited member and the nullish fallback never fires — returning a
function where the signature promises string | null. A Map has no such
keys, so totality holds by construction instead of by a guard a later
refactor could drop.

DoD items verified:
- [x] 1. generatePassword() returns 20 chars; explicit length honoured
- [x] 2. always >=1 lowercase, uppercase, digit and symbol
- [x] 3. never contains 0 O o 1 l I
- [x] 4. randomness from nanoid customAlphabet; no Math.random
- [x] 5. throws for length < 8 (the server minimum)
- [x] 6. retry bounded at 100 with an unconditional throw (inspection)
- [x] 7. canResetPassword true only for 'password'
- [x] 8. totality: every blocked input yields a non-empty string
- [x] 9. exact reason copy per provider; null when allowed
- [x] 10. colocated tests pass

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files (from
288 / 24); coverage 45.52 lines, 45.52 statements, 54.01 functions,
84.87 branches — every metric above both its .coverage-thresholds.json
floor and the previous baseline, new files at 100%.

Reviewed-by: adversarial-review (PASS, second reviewer; first found a
prototype-chain totality violation, since fixed and re-reviewed fresh)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00
github-actions[bot] 0d21f7efda chore: pin docker-compose to v0.32.0 [skip ci] 2026-08-05 09:03:49 +00:00
github-actions[bot] 60c5f2298c chore: bump version to 0.32.0 2026-08-05 08:57:08 +00:00
Frederic CabassutandClaude Sonnet 5 1ced23033e fix(e2e): match note-card locator on exact title, not substring content
noteCard() filtered on hasText, matching against a card's *entire*
rendered text — with note-linking, a card's content preview can now
render a linked note's title as plain text (a stripped note-link), so
a substring match against the whole card ambiguously resolves to more
than one card whenever one note's title happens to appear inside
another's preview. Scoped the match to the card's <h3> title element
with an anchored exact-match regex instead.

This was failing CI on two tests: the note-linking suite's own
backlink scenario, and — since test note titles aren't otherwise
isolated across files in the same run — an unrelated notes-crud test
whose note title happened to collide with a leftover "Linker Note"
card's rendered preview.

Verified: full e2e suite (114/114) passes locally against this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:51:08 +02:00
Frederic CabassutandClaude Sonnet 5 3eb8d2eeab fix: close note-switching race conditions and restore offline-first note open
Three issues found in review, all around switching to a different note
while the editor is already open (via a note-link or backlink chip):

- TiptapEditor's onTransaction/onUpdate had no guard against writing
  state after the component is torn down. Removing a focused editor's
  DOM node fires a native blur, which ProseMirror turns into one last
  transaction synchronously during that same teardown pass, crashing
  with state_unsafe_mutation. Deferred the onTransaction write to a
  microtask (checked against a destroyed flag) so it lands after
  Svelte's current pass finishes; guarded onUpdate synchronously.

- openEditor/openNoteById in NotesView.svelte awaited a network fetch
  before ever showing the editor, for every note open (not just
  link-driven navigation) — a regression for an offline-first app, and
  the source of a real race: two rapid note-link clicks left two
  fetches in flight with no ordering guarantee, so whichever resolved
  last won, not whichever was clicked last. Reverted to opening
  instantly from local/IndexedDB data (with a request-id guard against
  the residual race), and instead fetch backlinks lazily in
  NoteEditor.svelte only if not already present — mirroring the
  existing attachments-fetch pattern already in that file. Also
  applied the same post-teardown guard to that pre-existing attachments
  effect, since remounting on note-switch newly exposes it to the same
  class of race.

- FormattingToolbar's closeDropdowns() cleared search results but never
  cancelled the pending debounce timer, so a stale query could
  repopulate results after the dropdown had already closed. Added the
  missing clearTimeout, plus a try/catch around the search fetch for
  consistency with the pattern used elsewhere in the app.

Verified: pnpm check (0 errors), full unit suite (316/316), and the
existing e2e suite including all note-linking scenarios (19/19) all
pass against these changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:51:08 +02:00
Arkaprava Gaine 534d84224d tests for formating and note linking 2026-08-05 10:51:08 +02:00
Arkaprava Gaine bbfde5bac0 custom utility component of tiptap to solve the thing : the 2026-08-05 10:51:08 +02:00
Arkaprava Gaine f33a4037b1 utils and markdown changes needed for crumb:// linking to work 2026-08-05 10:51:08 +02:00
Arkaprava Gaine e6d1f18311 adding UI for note-linking 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 1eebd009ac server functions and service wiring on save and attach backlinks 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 1ca2ba5b79 extrcation notes utility for parsing note links 2026-08-05 10:51:08 +02:00
Arkaprava Gaine 331c56981d add notelinks tabel for note to note references 2026-08-05 10:51:08 +02:00
Frederic Cabassut c640868957 Revert "feat: add extractNoteLinks utility for parsing note-link references"
This reverts commit 39e247a46c.
2026-08-05 10:32:26 +02:00
Frederic CabassutandClaude Sonnet 5 dc20ac0f60 fix(ci): pin e2e Docker image to playwright v1.62.1 to match npm package
The @playwright/test npm package was bumped to ^1.62.1 during an earlier
dependency-vulnerability pass, but the CI container image stayed pinned
to v1.58.0-noble — Playwright requires the container's bundled browser
binaries to exactly match the npm package's version, so e2e jobs have
been failing with "Executable doesn't exist" ever since that bump landed
on main, unrelated to any feature branch's own changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:30:01 +02:00
Frederic Cabassut c341fe2b86 feat: add extractNoteLinks utility for parsing note-link references 2026-08-03 15:46:19 +02:00
Frederic CabassutandClaude Sonnet 5 35fceac5a1 docs: add note-linking implementation plan
11 TDD tasks covering schema, extraction/sync/backlink service functions,
the NoteLink tiptap node (parse/serialize + NodeView + title index),
toolbar note-search UI, click-to-navigate wiring, the backlinks UI
section, preview/public rendering, and e2e coverage.

Self-review caught and fixed three real bugs before this was usable:
a test referencing a USER_ID constant that doesn't exist in the actual
notes-service.test.ts (it's OWNER_ID), a posAtDOM-based position lookup
in the NodeView's remove button where tiptap's own getPos() is the
correct API, and a title-index gap where trashed notes remain in a
note's own IndexedDB cache (only collaborators have them filtered out
at sync time) — so the index needed its own explicit trashed filter or
a trashed note's links would never grey out for its owner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:25:55 +02:00
Frederic CabassutandClaude Sonnet 5 895edc05a3 docs: address review gap in note-linking spec — link removal affordance
Re-reviewed the committed spec and found instant-navigate-on-click left
no way to ever remove or repoint an existing note-link, since external
links solve this via a click-triggered popover that navigation now
replaces. Resolved with a hover-revealed x on the chip (touch-visible
by default), keeping the chosen click-to-navigate behavior intact.

Also verified three feasibility risks while reviewing: tiptap-markdown's
custom node serialization API is real and documented; version restore
already goes through updateNote() so link-sync comes for free; and the
line-based content-merge algorithm is markdown-agnostic, so concurrent
edits can't corrupt a note-link reference any more than any other text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 14:59:03 +02:00
Frederic CabassutandClaude Sonnet 5 50041d2298 docs: add note-linking design spec
Brainstormed with the user for issue #74. Notes can reference each
other via the existing link toolbar, rendered as a live tiptap node
(not a mark) so the label always tracks the target's current title,
plus a backlinks section on the referenced note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 14:45:06 +02:00
Frederic CabassutandClaude Sonnet 5 d4ed358ba3 fix(security): patch dependency CVEs and close SSRF/XSS gaps in MCP image upload
Bumps deps to clear all 69 pnpm audit findings (nodemailer 8->9 for the
raw-option SSRF/arbitrary-file-read CVE; svelte, hono/express/qs chain,
linkify-it, and the rest resolved within their existing semver ranges
via a lockfile refresh). Caps the previously-unbounded cookie override
at <1 so it can't silently jump to the API-incompatible 2.x line again.

Hardens the MCP upload_image tool, which fetched an arbitrary caller-
supplied URL and stored whatever content-type came back with no checks:
- validates every resolved DNS record (not just the first) against
  private/loopback/link-local/multicast ranges, then pins the actual
  connection to the validated address so a second, unvalidated lookup
  at connect time can't be used for DNS-rebinding SSRF
- rejects non-image content-types and enforces the same 10MB cap as
  the manual upload endpoint
- adds X-Content-Type-Options: nosniff to both attachment-serving
  routes as defense in depth

Also validates the admin role field server-side (previously trusted
the client-supplied string), and fixes two incidental regressions
surfaced by the dependency bump: StarterKit now bundles its own
Link/Underline extensions (disabled to avoid duplicate registration),
and FormattingToolbar could call methods on an already-destroyed
editor instance during the markdown-mode toggle race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 13:43:25 +02:00
github-actions[bot] 97523ea087 chore: pin docker-compose to v0.31.0 [skip ci] 2026-07-31 10:12:07 +00:00
github-actions[bot] 3c1fb4a79d chore: bump version to 0.31.0 2026-07-31 10:05:34 +00:00
Frederic Cabassut a46387172c fix(theme): restore hover-wash contrast and correct doc claims from dark palette review
Separates --hover-wash from --border so the softened dark-mode border grey
no longer collapses hover/active tint washes to near-invisibility (measured
5% wash on --bg-surface: 4.7 -> 17.0 RGB distance). Light mode is unaffected
since --hover-wash falls back to var(--border) in :root.

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

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

Reported by a user over email.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Follow-up to #63.
2026-06-09 20:54:29 +02:00
Romain LE DISEZandGoogle Gemini c2bb6ef75a fix(docker): pin pnpm version to 9 to match lockfile and fix build errors
Co-authored-by: Google Gemini <gemini@google.com>
2026-06-09 20:50:22 +02:00
github-actions[bot] 2fb1237f04 chore: pin docker-compose to v0.28.3 [skip ci] 2026-04-22 19:52:23 +00:00
97 changed files with 12901 additions and 2846 deletions
+18 -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:
@@ -63,13 +59,11 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -131,5 +125,21 @@ jobs:
push: false
provenance: false
tags: crumbs:ci
load: true
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
# Regression for #93: a host folder bind-mounted at /data is owned by the
# host user (the runner, uid 1001), not the container's node user (1000)
- name: Start with a host-owned bind mount
run: |
mkdir -p "$RUNNER_TEMP/crumbs-data"
docker run -d --name crumbs -p 3000:3000 -v "$RUNNER_TEMP/crumbs-data:/data" crumbs:ci
for i in $(seq 1 30); do
if curl -fsS -o /dev/null http://localhost:3000/login; then
test -f "$RUNNER_TEMP/crumbs-data/crumbs.db" && exit 0
fi
sleep 1
done
docker logs crumbs
exit 1
-2
View File
@@ -14,8 +14,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
+39 -3
View File
@@ -37,9 +37,43 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
| `--success-bg/text` | warm greens | Success states |
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
| `--code-bg` | `#dfd5c1` (light) / `#141312` (dark) | Code block and inline code surface — one step below the page so it stands out on every note colour, including the default card which is `--bg-surface` itself |
| `--hl-keyword` … `--hl-tag` | six token colours per theme | Syntax highlighting roles (keyword, string, number, comment, title, tag). Must keep AA contrast against `--code-bg`; asserted in `src/lib/utils/highlight.test.ts` |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
### Dark theme
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
mechanical inversion of light mode — two differences are deliberate:
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
mustard on a dark background.
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
Note colours have two representations, and they are not interchangeable:
| Map | Function | Used by | Role |
|---|---|---|---|
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
A dark card sits at low lightness, where a 28px picker circle would read as a grey dot — small
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
AA contrast (4.5:1) against both `--text` and `--text-muted` on every dark surface, no two dark card
surfaces closer than an RGB distance of 8, no two dark chips closer than an RGB distance of 25, and
`getNoteChip()` returning the card colour in light mode but a distinct chip in dark mode.
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
together.
### Typography
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
@@ -53,7 +87,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Checkboxes**: Use `accent-color: var(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
@@ -139,14 +173,15 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Main branch**: `main` — use as base for all PRs and feature branches
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
- **Always work in a worktree**: Never edit files in the main checkout. Before touching any file, create a dedicated worktree (`EnterWorktree` or `git worktree add .worktrees/<name> -b <branch>`) on a fresh branch from `origin/main`, do all work, commits, and PR creation from there. This keeps the main checkout clean and lets several tasks run side by side without clobbering each other's uncommitted changes.
- **Feature branches**: Use `feat/<name>` (or `fix/<name>`) branches, one per worktree
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
## CI/CD
GitHub Actions with two workflows:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`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)
@@ -186,6 +221,7 @@ Write tests first or alongside features. Tests serve as living documentation of
- Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173)
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- **Shared login state**: `tests/e2e/auth.setup.ts` (project `login-state`) logs in once and saves the admin session cookie to `data/test-storage-state.json`; the `app` project loads it into every context so tests start authenticated instead of submitting the login form each time. A spec that needs an anonymous baseline opts out with `test.use({ storageState: { cookies: [], origins: [] } })` (see `pwa.spec.ts`, `mcp.spec.ts`). Never write a test that revokes or logs out the admin's own session — it would break every test in other workers
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
+5 -3
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 ./
@@ -37,8 +37,10 @@ COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Use non-root user
USER node
# Runs as root only long enough to hand the data folder (often a host bind
# mount) to the node user, then drops privileges — see crumbs-entrypoint.sh
COPY crumbs-entrypoint.sh /usr/local/bin/crumbs-entrypoint.sh
ENTRYPOINT ["crumbs-entrypoint.sh"]
# Configuration
ENV NODE_ENV=production
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Starts as root so a bind-mounted data folder owned by the host user can be
# handed to the unprivileged `node` user, then drops privileges before running
# the app. Without this, a host folder mounted at /data is not writable by
# uid 1000 and SQLite fails with SQLITE_CANTOPEN.
set -e
if [ "$(id -u)" = "0" ]; then
for dir in "${DATA_DIR:-/data}" "$(dirname "${DATABASE_URL:-/data/crumbs.db}")"; do
mkdir -p "$dir"
# Only touch entries not already owned by node, so restarts on a large
# attachment folder stay fast.
find "$dir" \! -user node -exec chown node:node {} + ||
echo "warning: could not change ownership of $dir; it must be writable by uid 1000" >&2
done
exec setpriv --reuid=node --regid=node --init-groups "$@"
fi
# Started with an explicit non-root user (e.g. `user:` in compose): run as-is.
exec "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.28.2
image: ghcr.io/bretzel-app/crumbs:0.35.2
container_name: crumbs
ports:
- "3000:3000"
+8 -2
View File
@@ -82,6 +82,9 @@ For a single-user app with multiple devices:
- Built-in dark mode support (`dark:` variants)
- New v4 uses Vite plugin (faster builds)
### lowlight (highlight.js)
Code blocks are highlighted by one shared lowlight instance (`src/lib/utils/highlight.ts`) that feeds both the TipTap editor (`@tiptap/extension-code-block-lowlight`, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six `--hl-*` variables in `src/app.css`; a unit test asserts AA contrast against `--code-bg` in both themes.
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
@@ -91,7 +94,7 @@ For a single-user app with multiple devices:
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - User accounts (password hash, OAuth provider link, role)
- `users` - User accounts (password hash, `password_login_enabled`, OAuth provider link, role)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
@@ -159,7 +162,10 @@ Theme switching uses CSS variable overrides on the `<html>` element:
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
draws from a separate vivid `NOTE_CHIPS_DARK` map.
## File Organization
+22 -3
View File
@@ -10,7 +10,24 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
### Admin password resets
An admin can reset the password of any user other than themselves. Crumbs generates the new password and shows it once in that user's row — copy it and pass it to the user out of band, because it is not stored anywhere and cannot be shown again once dismissed. If SMTP is configured Crumbs also tries to notify the user that their password was reset, but the attempt is best-effort: a delivery failure is logged on the server and never surfaced in the UI, so a successful reset is not evidence the user was told. The notification never contains the password.
The control appears on every row, but is unavailable in two cases:
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
- **While a generated password is still on screen** — dismiss it first if you need to generate another.
A reset enables password login for that account if it was not already enabled — including OAuth-only accounts — without removing their SSO link. Both sign-in methods can coexist.
A reset **revokes all of the user's sessions** — they must sign in again with the new password (or via OAuth if linked). **Disable password login** is a separate action on rows where password login is enabled; it clears the stored hash, turns off password login, and revokes all sessions. It is not offered on your own row.
A displayed password is only good until that account's password changes again: any later reset — by another admin, or by the user themselves from **Settings > Profile** — silently invalidates one still on screen, and nothing marks it as stale. Separately, if Crumbs cannot confirm a reset it says so and shows the password anyway; keep that one, since it may or may not have been applied, and reset again if the user cannot sign in.
> **No password recovery.** Crumbs has no forgot-password flow. An admin who forgets their own password cannot reset it themselves: the admin control excludes their own row, and the profile flow requires the current password. Another admin can reset you, so keep a second admin account as cheap insurance. Failing that, if an OAuth/SSO provider is configured and your account's email matches your identity there, signing in with that provider gets you back in without a password. An admin can also enable password login on an OAuth-linked account via **Settings > Users**. With no other admin and no provider configured, there is no way back in through the app.
## OAuth / SSO
@@ -29,12 +46,14 @@ OAuth providers are **auto-enabled** when their environment variables are set
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
1. Go to **Settings > Users** and fill in the **Create User** form
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in. An admin can later enable password login via **Settings > Users** if the IdP becomes unavailable. If you set a password at invite time, it keeps working after the user's first OAuth sign-in — both methods coexist independently.
### Environment variables
#### Google OAuth
@@ -130,7 +149,7 @@ services:
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
#### Keycloak
+4
View File
@@ -26,6 +26,10 @@ services:
restart: unless-stopped
```
The app runs as the image's `node` user (uid 1000). On startup the container hands ownership of a
bind-mounted data folder to uid 1000, so a folder created by any host user works as-is. If you set
`user:` in compose, that step is skipped and the folder must already be writable by that user.
### Reverse Proxy (nginx)
```nginx
+8 -4
View File
@@ -23,7 +23,7 @@
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Code blocks (triple backtick) with syntax highlighting for 30+ languages — set the language in the fence (```ts) or from the toolbar picker while the cursor is inside the block; unlabelled blocks stay plain rather than guessing
- Inline code (single backtick)
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
@@ -71,7 +71,7 @@
### Dark Mode
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — retro aesthetic preserved in both themes
- Respects system `prefers-color-scheme` preference by default
- FOUC-free: theme applied before first paint via a blocking inline script
@@ -142,8 +142,12 @@
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
- Works for any account other than your own, including OAuth-only — the reset deliberately enables password login and revokes all sessions
- **Disable password login** turns off password auth, clears the hash, and revokes sessions (not offered on your own row)
- Your own password is changed from Settings > Profile, not here
- Use **Revoke all sessions** separately if you need to force a logout without changing credentials
- Revoke all sessions (force logout) for any user other than yourself
### PWA / Offline-First
- Installable as standalone app
@@ -0,0 +1,557 @@
# Dark Theme Palette Rework Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
**Architecture:** Purely a colour-data and CSS-variable change. `NOTE_COLORS_DARK` in `src/lib/utils/colors.ts` gets new values; a new `NOTE_CHIPS_DARK` map plus `getNoteChip()` serves the picker only. The `[data-theme="dark"]` block in `src/app.css` is rewritten. Consumers (`NoteCard`, `NoteEditor`, the public share page) already resolve colour through `getNoteColor()` and need no changes — only `ColorPicker.svelte` switches functions.
**Tech Stack:** SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
**Spec:** `docs/superpowers/specs/2026-07-31-dark-palette-design.md`
## Global Constraints
- Light mode must not change. Any diff touching `NOTE_COLORS` (the light map) or `:root` colour values other than the new `--accent-check` is out of scope.
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
- Run `pnpm check` before every commit — not just tests. (Project rule.)
- Run `pnpm build` before `pnpm test:e2e` locally, or e2e tests run against a stale bundle.
- Conventional commit prefixes: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
- Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
---
### Task 1: New dark note-card colours
**Files:**
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
- Test: `src/lib/utils/colors.test.ts`
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being unchanged; Task 2's chip values must differ from every value here.
- [ ] **Step 1: Write the failing invariant tests**
Add to `src/lib/utils/colors.test.ts`. These assert the *properties* that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
```ts
/** WCAG relative luminance. */
function relativeLuminance(hex: string): number {
const n = parseInt(hex.slice(1), 16);
const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps muted text above the AA threshold on every dark card', () => {
// This is the invariant the old palette broke: muted/struck-through text
// became unreadable on the lighter surfaces.
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => {
const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg));
expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm test:unit colors`
Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA
tests are forward guards, not regressions of the old palette — the old palette actually clears
both AA thresholds. The distinguishability test is the one that would fail on the old palette
(its closest pair, `fog`/`storm`, sits at RGB distance 7.5).
- [ ] **Step 3: Replace the dark palette**
In `src/lib/utils/colors.ts`, replace the whole `NOTE_COLORS_DARK` map with these values exactly:
```ts
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
```
Note for the implementer: `blossom` and `storm` intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with `coral`/`peach` and `fog`. This is deliberate — see the spec.
- [ ] **Step 4: Update the two hex assertions that pin the old values**
In `src/lib/utils/colors.test.ts`, the existing `getNoteColor` tests assert old hexes. Change them:
```ts
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
```
- [ ] **Step 5: Run the unit tests to verify they pass**
Run: `pnpm test:unit colors`
Expected: PASS, all tests in the file.
- [ ] **Step 6: Update the e2e test that asserts a dark note colour**
`tests/e2e/public-sharing.spec.ts` — the "Shared note follows a dark-mode visitor theme" scenario asserts dark `fog`. `#253946` is `rgb(37, 57, 70)`:
```ts
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
```
In `tests/e2e/dark-mode.spec.ts:84`, the assertion itself is a negative one (`not.toBe` light default) and needs no change, but its comment names the old hex. Update it:
```ts
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
```
- [ ] **Step 7: Verify types, then the full e2e suites that touch colour**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e public-sharing dark-mode organization`
Expected: PASS. (`organization.spec.ts` exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
- [ ] **Step 8: Commit**
```bash
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
```
---
### Task 2: Decouple picker swatches from card surfaces
**Files:**
- Modify: `src/lib/utils/colors.ts` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
- Modify: `src/lib/components/ColorPicker.svelte:2,19`
- Test: `src/lib/utils/colors.test.ts`
- Test: `tests/e2e/dark-mode.spec.ts`
**Interfaces:**
- Consumes: `NOTE_COLORS_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(color: NoteColor, isDark: boolean): string`. No later task depends on these.
- [ ] **Step 1: Write the failing tests**
Add to `src/lib/utils/colors.test.ts`:
```ts
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const toRgb = (hex: string) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
});
```
Extend the import at the top of the file:
```ts
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm test:unit colors`
Expected: FAIL — `getNoteChip is not a function` / `NOTE_CHIPS_DARK` undefined.
- [ ] **Step 3: Add the chip map and accessor**
Append to `src/lib/utils/colors.ts`, after `getNoteColor`:
```ts
/**
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
* reads as a grey dot — small patches lose apparent saturation — so the dark
* picker uses vivid mid-lightness chips that share the card's hue but not its
* lightness. Light mode needs no equivalent: there the pastels are the cards.
*/
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c',
coral: '#d04f43',
peach: '#cd7c42',
sand: '#c7b138',
mint: '#7bbd4c',
sage: '#47b89d',
fog: '#609abe',
storm: '#547bc0',
dusk: '#a965bd',
blossom: '#c05d78',
clay: '#b5954a',
chalk: '#c3c3c8'
};
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false);
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `pnpm test:unit colors`
Expected: PASS.
- [ ] **Step 5: Point the picker at the chips**
In `src/lib/components/ColorPicker.svelte`, change the import on line 2 and the style on line 19:
```svelte
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
```
```svelte
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
```
Leave everything else alone — the 2px `--border-subtle` ring stays, and it's what gives each swatch its boundary regardless of fill.
- [ ] **Step 6: Add the e2e scenario**
Append inside the `test.describe.serial('Dark mode', ...)` block in `tests/e2e/dark-mode.spec.ts`:
```ts
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user opens the colour picker while editing a note
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Swatch Test');
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
// Then a swatch shows its vivid chip rather than the muted card surface
await expect(page.getByTestId('color-coral')).toHaveCSS(
'background-color',
'rgb(208, 79, 67)'
);
await page.getByTestId('close-editor-btn').click();
});
```
- [ ] **Step 7: Verify**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e dark-mode organization`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
```
---
### Task 3: New dark CSS variables and checkbox accent
**Files:**
- Modify: `src/app.css:6-24` (`:root` — add `--accent-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
**Interfaces:**
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
- Produces: `--accent-check` custom property, available in both themes.
- [ ] **Step 1: Add `--accent-check` to `:root`**
In the `:root` block in `src/app.css`, after `--grid-dot`:
```css
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
```
- [ ] **Step 2: Replace the dark block**
Replace the entire `[data-theme="dark"]` block with:
```css
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
}
```
Two of these reverse earlier decisions on purpose: `--primary` is no longer shared with light mode (`#C8860A` reads mustard on ink), and `--border` is a warm mid-grey rather than the mirrored cream `#e8dcc8` (a bright 1px line on a dark background reads as glare, not structure).
- [ ] **Step 3: Point `accent-color` at the new variable**
In the `html, body` rule:
```css
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
```
- [ ] **Step 4: Verify light mode is untouched and dark mode renders**
Run: `pnpm check`
Expected: 0 errors.
Run: `pnpm build && pnpm test:e2e dark-mode public-sharing`
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
Then look at it: `pnpm preview`, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
- [ ] **Step 5: Commit**
```bash
git add src/app.css
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
```
---
### Task 4: Sync the browser theme-colour with the new base
**Files:**
- Modify: `src/lib/utils/theme.svelte.ts:4`
- Modify: `src/app.html:21`
- Test: `src/lib/utils/theme.test.ts:40`
**Interfaces:**
- Consumes: `--bg-base` `#141312` from Task 3. These two hardcoded copies must equal it.
- Produces: nothing.
- [ ] **Step 1: Update the failing test first**
In `src/lib/utils/theme.test.ts`:
```ts
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#141312');
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `pnpm test:unit theme`
Expected: FAIL — received `#1a1715`.
- [ ] **Step 3: Update both hardcoded copies**
`src/lib/utils/theme.svelte.ts` line 4:
```ts
const DARK_THEME_COLOR = '#141312';
```
`src/app.html` line 21, inside the FOUC script:
```js
if (m) m.content = '#141312';
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test:unit theme`
Expected: PASS.
- [ ] **Step 5: Verify and commit**
Run: `pnpm check && pnpm test:unit`
Expected: 0 errors, all unit tests pass.
```bash
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
```
---
### Task 5: Document the palette
**Files:**
- Modify: `CLAUDE.md` (the "Design system: Retro Parchment" section)
- Modify: `docs/ARCHITECTURE.md:155-165` (the dark mode section)
**Interfaces:**
- Consumes: everything from Tasks 1-4.
- Produces: nothing.
- [ ] **Step 1: Document the dark palette in `CLAUDE.md`**
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about `src/lib/utils/colors.ts`, add:
```markdown
### Dark theme
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
mechanical inversion of light mode — two differences are deliberate:
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
mustard on a dark background.
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
Note colours have two representations, and they are not interchangeable:
| Map | Function | Used by | Role |
|---|---|---|---|
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
A dark card sits at ~18% lightness, where a 28px picker circle would read as a grey dot — small
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
AA contrast against both `--text` and `--text-muted` on every surface, a narrow lightness band across
the twelve, and no two chips closer than an RGB distance of 25.
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
together.
```
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
In the dark mode section, the line describing note colours currently mentions only `getNoteColor()`. Replace it with:
```markdown
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
draws from a separate vivid `NOTE_CHIPS_DARK` map.
```
- [ ] **Step 3: Verify the docs match the code**
Re-read both edited sections against `src/lib/utils/colors.ts` and `src/app.css`. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md docs/ARCHITECTURE.md
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
```
---
## Final verification
- [ ] `pnpm check` — 0 errors
- [ ] `pnpm test:unit` — all pass
- [ ] `pnpm build && pnpm test:e2e` — full suite passes
- [ ] Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
# Dark Theme Palette Rework — Design Spec
**Date:** 2026-07-31
**Status:** Approved
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
## Summary
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
with a systematically generated one, and splits the picker swatch from the card surface — they are
different jobs and should not share a value.
Light mode is unchanged.
## Problems being fixed
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
no consistent relationship, so they don't read as a family.
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
percent of `--bg-base`, so cards lose their edges.
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
twelve swatches become twelve grey dots.
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
with gold and cream on top, the UI reads dim rather than crisp.
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
inverted border reads as a glowing rectangle, not as structure.
## Decisions
| # | Decision | Choice |
|---|---|---|
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
Only dark mode needs the split.
## 1. CSS variables (`src/app.css`)
Replace the `[data-theme="dark"]` block:
```css
[data-theme="dark"] {
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #ece3d3;
--accent-check: #c8a44a;
}
```
Three of these are notable changes rather than tweaks:
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
hard offset shadow still carries the retro structure.
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
### Checkbox accent
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
the checkbox can be dimmed independently of links and the brand:
```css
:root { --accent-check: var(--primary); } /* light: unchanged */
html, body { accent-color: var(--accent-check); }
```
## 2. Note card colours (`src/lib/utils/colors.ts`)
Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink
and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge.
The visible improvement in dark mode comes from the base surfaces, the picker chips, the
softened border, and the lifted gold — not from the card surfaces, which are close in chroma to
the previous dark values.
```ts
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
```
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
different hue in each theme. This is an accepted trade: distinguishable beats identical.
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
All clear WCAG AA.
## 3. Picker chips (`src/lib/utils/colors.ts`)
New map, used only by the picker, only in dark mode:
```ts
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c', coral: '#d04f43', peach: '#cd7c42', sand: '#c7b138',
mint: '#7bbd4c', sage: '#47b89d', fog: '#609abe', storm: '#547bc0',
dusk: '#a965bd', blossom: '#c05d78', clay: '#b5954a', chalk: '#c3c3c8'
};
/** Picker swatch colour: a vivid label, not the card surface. */
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
```
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
apart at 28px.
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
the editor and the public share page keep calling `getNoteColor`.
## 4. Theme-colour meta tag
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
Both become `#141312`.
## Files to modify
| File | Change |
|---|---|
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
through `getNoteColor`, so they inherit the new palette.
## Testing
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
- unknown colour falls back to `default` in both maps
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
before, so it gets asserted rather than eyeballed
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
picker swatch is *not* the card background, which is the bug in user-visible terms.
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
can't judge those.
## Out of scope
- Light mode, including its picker
- The 12-colour set itself (no colours added, removed, or renamed)
- Per-note custom colours
- `prefers-contrast` / high-contrast variants
@@ -0,0 +1,129 @@
# Note Linking — Design Spec
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
**Date**: 2026-08-03
## Overview
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
## Architecture
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
```typescript
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
```
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
```
[Grocery List](crumb-note://<note-id>)
```
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
## Components
### Client
| Component | Change |
|---|---|
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
### Server
| Function | Location | Description |
|---|---|---|
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
## Data Flow
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
4. **Click** — opens the target note in the editor overlay.
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
## Error Handling
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
- **Self-links** — the currently-open note is filtered out of its own picker results.
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
## Testing
**Unit (Vitest)**
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
- `syncNoteLinks` — clears + re-inserts correctly
- `fetchBacklinksForNote` — returns only notes the requesting user can access
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
**E2E (Playwright)**
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
- Renaming the linked note updates the displayed link text next time it's viewed
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
- The linked-to note shows a "Referenced by" backlink to the note that links to it
- A public share page never renders a note-link as clickable, even if the target is also shared
- The note-picker excludes the currently-open note from its own search results
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
## Out of Scope (v1)
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
- Note graph / visual map view
- Ranking or sorting backlinks beyond simple list order
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
## Acceptance Criteria
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
- [ ] Clicking a note-link opens the target note in the editor overlay
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
- [ ] Public share page never renders a note-link as clickable
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE `note_links` (
`source_note_id` text NOT NULL,
`target_note_id` text NOT NULL,
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `users` ADD `password_login_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint
UPDATE `users` SET `password_login_enabled` = 1 WHERE `auth_provider` = 'password';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -29,6 +29,20 @@
"when": 1774037379518,
"tag": "0003_uneven_tomorrow_man",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1785824271578,
"tag": "0004_violet_nicolaos",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1787064677945,
"tag": "0005_green_raider",
"breakpoints": true
}
]
}
+53 -46
View File
@@ -1,8 +1,9 @@
{
"name": "crumbs",
"private": true,
"version": "0.28.3",
"version": "0.35.2",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -33,64 +34,66 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@sveltejs/adapter-node": "^5.5.7",
"@tiptap/core": "^3.31.0",
"@tiptap/extension-code-block-lowlight": "^3.31.0",
"@tiptap/extension-link": "^3.31.0",
"@tiptap/extension-placeholder": "^3.31.0",
"@tiptap/extension-table": "^3.31.0",
"@tiptap/extension-table-cell": "^3.31.0",
"@tiptap/extension-table-header": "^3.31.0",
"@tiptap/extension-table-row": "^3.31.0",
"@tiptap/extension-task-item": "^3.31.0",
"@tiptap/extension-task-list": "^3.31.0",
"@tiptap/extension-text-align": "^3.31.0",
"@tiptap/extension-underline": "^3.31.0",
"@tiptap/pm": "^3.31.0",
"@tiptap/starter-kit": "^3.31.0",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"argon2": "^0.45.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lowlight": "^3.3.0",
"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",
"markdown-it": "^15.0.1",
"nanoid": "^5.1.16",
"node-diff3": "^3.2.1",
"nodemailer": "^9.1.0",
"sanitize-html": "^2.17.7",
"svelte-dnd-action": "^0.9.79",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
"zod": "^4.3.6",
"uuid": "^14.0.2",
"zod": "^4.5.4",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@playwright/test": "^1.62.1",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0",
"@types/nodemailer": "^8.0.1",
"@types/sanitize-html": "^2.16.1",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"@vitest/coverage-v8": "^3.2.7",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.13",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^7.3.6",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
"vite-plugin-pwa": "^1.3.0",
"vitest": "^3.2.7",
"workbox-window": "^7.4.1",
"yaml": "^2.9.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -101,8 +104,12 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
"cookie": ">=0.7.0 <1",
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2",
"postcss>nanoid": ">=3.3.18 <4"
}
}
}
+21 -3
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE_FILE } from './tests/e2e/global-setup';
export default defineConfig({
testDir: './tests/e2e',
@@ -22,14 +23,31 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: 'auth.spec.ts',
// Logs in once and saves the session cookie for the `app` project.
name: 'login-state',
testMatch: 'auth.setup.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: ['auth.spec.ts', 'auth.setup.ts'],
dependencies: ['login-state'],
// Every context starts with the admin already logged in. Specs that
// need an unauthenticated baseline opt out with
// `test.use({ storageState: { cookies: [], origins: [] } })`.
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_FILE }
}
],
webServer: {
command: 'pnpm preview --port 4173',
// The wipe runs here (before the server opens the database), not in
// globalSetup: Playwright starts the webServer before globalSetup, so
// deleting the SQLite files later would orphan the server's open
// database onto a deleted inode. Tests that read the database file
// directly (see linkUserToOAuth in admin-users.spec.ts) must see the
// server's real database file.
command:
'rm -f data/test-crumbs.db data/test-crumbs.db-wal data/test-crumbs.db-shm data/test-crumbs.db-journal && pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+2412 -2185
View File
File diff suppressed because it is too large Load Diff
+167 -18
View File
@@ -21,29 +21,64 @@
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
--accent-check: var(--primary);
/* Tint source for hover/active washes. Light mode reuses the ink border, so
light styling is byte-identical; dark mode keeps the cream that --border
used to supply, because a mid-grey wash is invisible on a dark surface. */
--hover-wash: var(--border);
/* Code surface: an inset one step below the page so it reads as a panel on
every note colour (the default card is --bg-surface itself, so neither
surface nor base would stand out on it). Token colours must keep AA
contrast against it — see src/lib/utils/highlight.test.ts. */
--code-bg: #dfd5c1;
--hl-keyword: #7d5306;
--hl-string: #325d28;
--hl-number: #1f5c7a;
--hl-comment: #5d5462;
--hl-title: #6a2c7a;
--hl-tag: #8b2e1f;
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--bg-base: #141312;
--bg-surface: #1e1c1b;
--primary: #e0a030;
--primary-hover: #f2b855;
--text: #ece3d3;
--text-muted: #b3a695;
--border: #57514b;
--border-subtle: #302d2b;
--destructive: #e2705c;
--error-bg: #3a2320;
--error-border: #5c3a34;
--error-text: #f0a898;
--success-bg: #1f2e1f;
--success-text: #8fc97a;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
--grid-dot: #ece3d3;
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
--accent-check: #c8a44a;
--hover-wash: #ece3d3;
/* Dark code sinks to page level (the ink behind the cards) rather than
lifting, so a snippet on a dark card reads as a cut-out, not a slab. */
--code-bg: #141312;
--hl-keyword: #e0a030;
--hl-string: #8fc97a;
--hl-number: #7fb8e0;
--hl-comment: #a89b8c;
--hl-title: #cfa3e6;
--hl-tag: #e2705c;
}
body {
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
accent-color: var(--accent-check);
overscroll-behavior-y: none;
}
body.editor-scroll-locked {
overflow: hidden;
}
body::before {
@@ -68,20 +103,134 @@ body::before {
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Prose overrides for parchment theme */
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Prose overrides for parchment theme.
The typography plugin gives every element type its own --tw-prose-* variable,
each defaulting to a fixed grey. Anything not remapped here stays that grey in
dark mode (bold text was near-black on ink — see issue #86), so map them all. */
.prose {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-lead: var(--text-muted);
--tw-prose-links: var(--primary);
--tw-prose-bold: var(--text);
--tw-prose-counters: var(--text-muted);
--tw-prose-bullets: var(--text-muted);
--tw-prose-hr: var(--border-subtle);
--tw-prose-quotes: var(--text);
--tw-prose-quote-borders: var(--border-subtle);
--tw-prose-captions: var(--text-muted);
--tw-prose-kbd: var(--text);
--tw-prose-kbd-shadows: color-mix(in oklab, var(--text) 10%, transparent);
--tw-prose-code: var(--text);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-pre-code: var(--text);
--tw-prose-pre-bg: var(--code-bg);
--tw-prose-th-borders: var(--border-subtle);
--tw-prose-td-borders: var(--border-subtle);
}
/* Code — the body font is already monospace, so the typography plugin's
"shrink code to 86%" default only makes snippets harder to read. Code keeps
the surrounding text size and gets a distinct surface instead. */
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--bg-surface);
background-color: var(--code-bg);
border-radius: 2px;
padding: 0.125rem 0.25rem;
font-size: inherit;
}
/* The chip is the signal now; the plugin's decorative backticks doubled it. */
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
border: 1px solid var(--border-subtle);
border-radius: 2px;
font-size: inherit;
line-height: 1.6;
padding: 0.625rem 0.75rem;
tab-size: 4;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-radius: 0;
padding: 0;
}
/* Card previews are a glance, not a workspace: clip long lines instead of
growing a scrollbar inside the card. The editor and share page keep the
typography default of scrolling horizontally. */
.card-preview :where(pre) {
overflow: hidden;
/* line-clamp is a legacy -webkit-box; without this a wide <pre> takes its
content width and runs out past the card edge instead of being clipped. */
max-width: 100%;
}
/* Syntax highlighting (highlight.js classes via lowlight). Six roles are
enough to tell structure apart without turning a snippet into confetti;
the colours live in --hl-* above so the dark theme can swap them. */
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-type,
.hljs-literal {
color: var(--hl-keyword);
}
.hljs-string,
.hljs-regexp,
.hljs-quote,
.hljs-addition {
color: var(--hl-string);
}
.hljs-number,
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-attr,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: var(--hl-number);
}
.hljs-comment,
.hljs-meta,
.hljs-doctag {
color: var(--hl-comment);
font-style: italic;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hl-title);
}
.hljs-tag,
.hljs-name,
.hljs-deletion {
color: var(--hl-tag);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
+3 -1
View File
@@ -15,7 +15,9 @@ declare global {
} | null;
}
// interface PageData {}
// interface PageState {}
interface PageState {
noteEditorOpen?: boolean;
}
// interface Platform {}
}
}
+6 -2
View File
@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="theme-color" content="#f0e6d3" />
<script>
(function() {
@@ -14,7 +18,7 @@
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
if (m) m.content = '#141312';
}
} catch(e) {}
})();
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
@@ -16,7 +16,7 @@
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+423 -226
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
import {
Undo2,
Redo2,
@@ -33,35 +34,120 @@
interface Props {
tick?: number;
editor: Editor | undefined;
currentNoteId?: string | null;
}
let { editor, tick }: Props = $props();
let { editor, tick, currentNoteId = null }: Props = $props();
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
// The fence info string may carry an alias (```js) or a language the picker
// doesn't list; surface it as-is rather than silently showing "Plain text".
function codeBlockLanguage(): string {
const lang = getAttrs('codeBlock').language;
return typeof lang === 'string' ? lang : '';
}
function setCodeBlockLanguage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
}
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) {
let noteSearchResults: { id: string; title: string }[] = $state([]);
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
async function searchNotesForLink(query: string) {
const requestId = ++noteSearchRequestId;
if (!query.trim()) {
noteSearchResults = [];
return;
}
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (requestId !== noteSearchRequestId) return;
if (!res.ok) return;
const results: { id: string; title: string }[] = await res.json();
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
} catch {
// Search failed silently
}
}
let noteSearchRequestId = 0;
function handleLinkInput() {
clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
}
function insertNoteLink(noteId: string) {
if (!editor) return;
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
noteSearchResults = [];
closeDropdowns();
}
const dropdownGap = 4;
const viewportMargin = 10;
function toggleDropdown(name: string, anchorEl: HTMLElement) {
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;
clearTimeout(noteSearchTimer);
noteSearchResults = [];
}
function closeDropdownsAfterPositionChange() {
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
closeDropdowns();
}
function getDropdownStyle(rect: DOMRect, width: number): string {
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
// coordinates, so the reference height must be innerHeight — visualViewport.height is
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
const bottom = window.innerHeight - rect.top + dropdownGap;
return `bottom: ${bottom}px; left: ${left}px;`;
}
function applyLink() {
@@ -88,15 +174,15 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -112,17 +198,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 +232,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 +246,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 +255,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 +264,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 +273,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 +285,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 +296,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 +310,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 +337,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 +359,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 +374,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,15 +383,36 @@
<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"
>
<CodeXml size={iconSize} />
</button>
{#if isActive('codeBlock')}
{@const currentLanguage = codeBlockLanguage()}
<select
value={currentLanguage}
onchange={setCodeBlockLanguage}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
title="Code block language"
aria-label="Code block language"
data-testid="format-code-language"
>
<option value="">Plain text</option>
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
<option value={currentLanguage}>{currentLanguage}</option>
{/if}
{#each CODE_LANGUAGES as lang (lang.id)}
<option value={lang.id}>{lang.label}</option>
{/each}
</select>
{/if}
<button
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 +420,260 @@
<Minus size={iconSize} />
</button>
</div>
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
{#if openDropdown && dropdownAnchorEl}
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
{#if openDropdown === 'heading'}
<div
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 170)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
{:else if openDropdown === 'list'}
<div
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 190)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{:else if openDropdown === 'link'}
<div
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
style={getDropdownStyle(rect, 220)}
data-dropdown="true"
>
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
oninput={handleLinkInput}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
onmousedown={preventToolbarMouseFocus}
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
title="Remove link"
data-testid="format-link-remove"
>
<Trash2 size={16} />
</button>
</form>
{#if noteSearchResults.length > 0}
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
{#each noteSearchResults as result (result.id)}
<button
type="button"
onmousedown={preventToolbarMouseFocus}
onclick={() => insertNoteLink(result.id)}
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="format-link-note-result"
>
{result.title || 'Untitled'}
</button>
{/each}
</div>
{/if}
</div>
{:else if openDropdown === 'table'}
<div
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 210)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-insert-table"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-column"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-column"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-delete-table"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{:else if openDropdown === 'align'}
<div
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 180)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
{/if}
<style>
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
display: none;
}
</style>
+2 -2
View File
@@ -21,7 +21,7 @@
{:else}
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
aria-label="Toggle sidebar"
>
<Menu class="h-6 w-6 text-[var(--text)]" />
@@ -40,7 +40,7 @@
<!-- Mobile search icon -->
<button
onclick={() => (mobileSearchOpen = true)}
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
aria-label="Search"
>
<Search class="h-5 w-5 text-[var(--text)]" />
+9 -9
View File
@@ -118,7 +118,7 @@
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
@@ -154,7 +154,7 @@
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="card-preview prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
@@ -164,7 +164,7 @@
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
@@ -172,7 +172,7 @@
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
@@ -182,7 +182,7 @@
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
@@ -192,7 +192,7 @@
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
@@ -201,7 +201,7 @@
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
@@ -211,7 +211,7 @@
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
@@ -220,7 +220,7 @@
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
+275 -51
View File
@@ -1,5 +1,11 @@
<script module lang="ts">
let bodyScrollLockCount = 0;
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } 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';
@@ -13,7 +19,7 @@
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';
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
@@ -28,6 +34,7 @@
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';
@@ -36,9 +43,10 @@
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
onOpenNote: (noteId: string) => void;
}
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
@@ -52,7 +60,7 @@
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
@@ -157,22 +165,85 @@
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
});
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
// component (rather than reusing the instance) whenever the user switches to a
// different note while the editor is open. That teardown skips the debounced
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
// could otherwise be silently dropped.
onDestroy(() => {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// svelte-ignore state_referenced_locally
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
let viewportHeight = $state('100dvh');
let viewportTop = $state('0px');
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
function updateViewport() {
if (window.visualViewport) {
viewportHeight = `${window.visualViewport.height}px`;
viewportTop = `${window.visualViewport.offsetTop}px`;
} else {
viewportHeight = `${window.innerHeight}px`;
viewportTop = '0px';
}
}
updateViewport();
const handleVisualViewportChange = () => {
requestAnimationFrame(updateViewport);
};
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
} else {
window.addEventListener('resize', handleVisualViewportChange);
}
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => clearTimeout(timer);
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 overflowBtnEl: HTMLButtonElement | undefined = $state();
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
@@ -180,7 +251,7 @@
if (!showOverflowMenu) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
showOverflowMenu = false;
}
// Use setTimeout to avoid the current click from closing the menu
@@ -269,15 +340,45 @@
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
// component on every note-switch (rather than reusing the instance), a fetch that
// resolves after teardown would otherwise write to $state on an already-destroyed
// instance racing the newly-mounted one.
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (destroyed) return;
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
// note.backlinks is only ever attached server-side by getNote(), which nothing
// else in the client calls when opening a note (the bulk notes store and
// IndexedDB never carry it), so this mirrors the attachments effect above to
// fill it in on open. Same post-teardown guard as above.
$effect(() => {
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
let destroyed = false;
fetch(`/api/notes/${noteId}`)
.then((res) => res.ok ? res.json() : null)
.then((data: Note | null) => {
if (destroyed) return;
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
})
.catch(() => {});
return () => {
destroyed = true;
};
}
});
@@ -350,8 +451,34 @@
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);
}
@@ -440,7 +567,8 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 {closing ? '' : '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}
@@ -449,29 +577,52 @@
{#if !closing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_150ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
<!-- Mobile Back Button -->
<button
onclick={saveAndClose}
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 flex items-center shrink-0"
aria-label="Back"
>
<ArrowLeft class="h-6 w-6" />
</button>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Mobile Overflow Trigger -->
<button
bind:this={mobileOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
class="md:hidden rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 text-[var(--text)] shrink-0"
data-testid="mobile-overflow-menu-btn"
>
<EllipsisVertical class="h-6 w-6" />
</button>
</div>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto">
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
@@ -481,7 +632,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>
@@ -491,6 +642,7 @@
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
{onOpenNote}
placeholder="Add a crumb..."
/>
{/if}
@@ -498,7 +650,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}
@@ -510,16 +662,35 @@
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
<!-- Backlinks -->
{#if backlinksList.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
<div class="flex flex-wrap gap-1.5">
{#each backlinksList as backlink (backlink.id)}
<button
type="button"
onclick={() => onOpenNote(backlink.id)}
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
data-testid="backlink-chip"
>
{backlink.title || 'Untitled'}
</button>
{/each}
</div>
</div>
{/if}
<!-- Toolbar -->
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
</div>
{/if}
<!-- 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">
@@ -527,7 +698,7 @@
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
@@ -543,7 +714,7 @@
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
@@ -554,7 +725,7 @@
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Share"}
data-testid="share-toggle"
>
@@ -576,7 +747,7 @@
{#if !currentlyNew}
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
@@ -586,9 +757,9 @@
<!-- Overflow menu trigger -->
<button
bind:this={overflowBtnEl}
onclick={() => (showOverflowMenu = !showOverflowMenu)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
bind:this={desktopOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
@@ -598,7 +769,7 @@
<button
onclick={saveAndClose}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="close-editor-btn"
>
Close
@@ -607,18 +778,71 @@
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowBtnEl}
{@const rect = overflowBtnEl.getBoundingClientRect()}
{#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="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
data-testid="overflow-menu"
>
<!-- Mobile-only actions -->
{#if isMobileTrigger}
<!-- Color picker -->
<div class="px-3 py-2 w-56 whitespace-normal">
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
</div>
<!-- Attachments -->
<button
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
Attachments
</button>
<!-- Share -->
{#if isOwner}
<button
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
{#if hasPublicLink}
<Globe class="h-4 w-4 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-4 w-4 text-[var(--primary)]" />
{:else}
<UserPlus class="h-4 w-4" />
{/if}
Share
</button>
{:else if isShared}
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
<Users class="h-4 w-4" />
Shared note
</div>
{/if}
<!-- Archive -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
<Archive class="h-4 w-4" />
Archive
</button>
{/if}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{/if}
<!-- Checklist mode toggle -->
<button
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="checklist-toggle"
>
{#if checklistMode}
@@ -634,7 +858,7 @@
{#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"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
@@ -651,7 +875,7 @@
{#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"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="history-toggle"
>
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
@@ -664,7 +888,7 @@
<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"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--hover-wash)]/10"
data-testid="trash-note-btn"
>
<Trash2 class="h-4 w-4" />
@@ -678,7 +902,7 @@
{#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"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="delete-checked-btn"
>
<Trash2 class="h-4 w-4" />
@@ -687,7 +911,7 @@
{/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"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
+3 -3
View File
@@ -116,7 +116,7 @@
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
title="Close history"
data-testid="close-history-btn"
>
@@ -140,7 +140,7 @@
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
@@ -195,7 +195,7 @@
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
>
Cancel
</button>
+31 -4
View File
@@ -4,10 +4,12 @@
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
import { replaceState } from '$app/navigation';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js';
import { getNote as getIdbNote } from '$lib/sync/idb.js';
interface Props {
filter: NoteFilter;
@@ -27,16 +29,33 @@
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;
newNoteChecklist = false;
history.replaceState(null, '', location.pathname);
replaceState(location.pathname, {});
}
// Reads from the local IndexedDB cache rather than fetching over the network —
// this keeps note-link navigation instant and offline-safe (matching how the
// rest of the app opens notes), and backlinks are filled in progressively by
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
// the same pattern already used there for attachments.
let openNoteRequestId = 0;
async function openNoteById(noteId: string) {
const requestId = ++openNoteRequestId;
const note = await getIdbNote(noteId);
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
if (note) openEditor(note);
}
function handleReorder(noteIds: string[]) {
@@ -133,9 +152,17 @@
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
while the editor is already open) instead of reusing the same instance
with a new `note` prop — a persisting instance's own back-navigation
effect misreads `openEditor`'s replaceState call as "browser back was
pressed" and closes the editor, and its once-only local state never
resyncs to the new note otherwise. -->
{#key editingNote.id}
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/key}
{/if}
+44 -20
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import { searchQuery, searchResults } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { onDestroy } from 'svelte';
interface Props {
onClose?: () => void;
@@ -12,41 +13,64 @@
let { onClose }: Props = $props();
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
let searchController: AbortController | undefined;
$effect(() => {
if (onClose) inputEl?.focus();
});
onDestroy(() => {
searchController?.abort();
searchQuery.set('');
searchResults.set([]);
});
async function handleSearch() {
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchController?.abort();
searchController = undefined;
const trimmedQuery = query.trim();
if (!trimmedQuery) {
searchQuery.set('');
searchResults.set([]);
return;
}
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
const controller = new AbortController();
searchController = controller;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
signal: controller.signal
});
if (res.ok) {
const results: Note[] = await res.json();
if (searchController !== controller || query.trim() !== trimmedQuery) return;
// Commit the query alongside its results so the view switches to the
// (possibly empty) result set only once it's ready — this avoids
// flashing an empty state over the current notes while the request is
// in flight, and keeps searchQuery consistent with searchResults on
// a failed request (neither updates).
searchResults.set(results);
searchQuery.set(trimmedQuery);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error('Search failed:', error);
}
} finally {
if (searchController === controller) searchController = undefined;
}
}
function clearSearch() {
searchController?.abort();
searchController = undefined;
query = '';
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchQuery.set('');
searchResults.set([]);
}
function close() {
+2 -2
View File
@@ -168,7 +168,7 @@
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
title="Close"
>
<X class="h-4 w-4" />
@@ -277,7 +277,7 @@
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
+118 -37
View File
@@ -6,65 +6,144 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { Table } from './tiptap/Table.js';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { NoteLink } from './tiptap/NoteLink.js';
import { editorLowlight } from '$lib/utils/highlight.js';
import { getAllNotes } from '$lib/sync/idb.js';
interface Props {
content: string;
onUpdate: (markdown: string) => void;
onEditor?: (editor: Editor) => void;
onTransaction?: () => void;
onOpenNote?: (noteId: string) => void;
placeholder?: string;
}
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
onMount(() => {
editor = new Editor({
element: element!,
extensions: [
StarterKit,
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Trigger Svelte reactivity for active state checks
editor = editor;
onTransaction?.();
}
});
const titleIndexRef = { index: new Map<string, string>() };
// Expose editor on DOM element for e2e testing
if (element) {
(element as any).__tiptapEditor = editor;
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;
}
onEditor?.(editor);
editor?.commands.focus();
}
function handleWrapperChangeCapture(event: Event) {
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
if (event.target.type !== 'checkbox' || !editor) return;
const taskItem = event.target.closest('li[data-checked]');
if (!taskItem) return;
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
if (taskItemNode?.type.name !== 'taskItem') return;
event.stopPropagation();
editor.view.dispatch(
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
...taskItemNode.attrs,
checked: event.target.checked
})
);
}
onMount(() => {
let destroyed = false;
let editorInstance: Editor | undefined;
(async () => {
const allNotes = await getAllNotes();
if (destroyed) return;
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
editorInstance = new Editor({
element: element!,
extensions: [
StarterKit.configure({ link: false, underline: false, codeBlock: false }),
CodeBlockLowlight.configure({ lowlight: editorLowlight }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
if (destroyed) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
},
onTransaction: () => {
// Removing this component's DOM node (e.g. a {#key}-driven remount
// in NotesView.svelte when switching to a different note) fires a
// native blur on the still-focused contenteditable, which
// ProseMirror's blur plugin turns into one last transaction —
// synchronously, from inside Svelte's own render/effect pass that's
// doing the removal. Mutating $state right there crashes with
// state_unsafe_mutation; the `destroyed` check alone isn't enough
// since this can fire before onMount's own cleanup runs. Defer to a
// microtask so the write lands after Svelte's current pass finishes.
queueMicrotask(() => {
if (destroyed) return;
editor = editorInstance;
onTransaction?.();
});
}
});
editor = editorInstance;
if (element && !destroyed) {
(element as any).__tiptapEditor = editorInstance;
}
onEditor?.(editorInstance);
})();
return () => {
editor?.destroy();
destroyed = true;
editorInstance?.destroy();
};
});
</script>
@@ -72,12 +151,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>
+101
View File
@@ -0,0 +1,101 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
export interface NoteLinkOptions {
titleIndexRef: { index: Map<string, string> };
onOpenNote: (noteId: string) => void;
}
export const NoteLink = Node.create<NoteLinkOptions>({
name: 'noteLink',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
titleIndexRef: { index: new Map<string, string>() },
onOpenNote: () => {}
};
},
addAttributes() {
return {
noteId: {
default: null,
parseHTML: (element) => element.getAttribute('data-note-id'),
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
}
};
},
parseHTML() {
return [
{
tag: 'a[href^="crumb-note://"]',
priority: 100,
getAttrs: (element) => {
const href = (element as HTMLElement).getAttribute('href') ?? '';
const noteId = parseNoteLinkHref(href);
return noteId ? { noteId } : false;
}
}
];
},
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
},
addStorage() {
return {
markdown: {
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
}
}
};
},
addNodeView() {
return ({ node, editor, getPos }) => {
const noteId = node.attrs.noteId as string;
const title = this.options.titleIndexRef.index.get(noteId);
const dom = document.createElement('span');
dom.setAttribute('data-note-id', noteId);
dom.className =
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
const label = document.createElement('span');
if (title !== undefined) {
label.textContent = title;
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
label.addEventListener('click', (event) => {
event.preventDefault();
this.options.onOpenNote(noteId);
});
} else {
label.textContent = 'Note not found';
label.className = 'text-[var(--text-muted)] opacity-60';
}
dom.appendChild(label);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = '×';
removeButton.setAttribute('aria-label', 'Remove note link');
removeButton.className =
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
removeButton.addEventListener('click', (event) => {
event.preventDefault();
const pos = getPos();
if (pos === undefined) return;
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
});
dom.appendChild(removeButton);
return { dom };
};
}
});
+164
View File
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { Editor, type JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import { Markdown } from 'tiptap-markdown';
import { Table } from './Table.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
function createEditor(content: string | object, markdownOptions?: { html?: boolean }) {
return new Editor({
extensions: [
StarterKit,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown.configure(markdownOptions ?? {})
],
content
});
}
function getMarkdown(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (editor.storage as Record<string, any>).markdown.getMarkdown();
}
function docWithListInCell(): JSONContent {
return {
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }]
},
{
type: 'tableHeader',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }]
}
]
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }
]
}
]
},
{
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }]
}
]
}
]
}
]
};
}
// crumbs#83: a bulleted list inside a table cell survives in the live editor
// (never touches markdown) but breaks once the note is saved and reopened,
// because saving/reopening round-trips the doc through markdown text.
describe('Table markdown round-trip with a bullet list in a cell', () => {
it('preserves a bullet list inside a table cell after saving and reopening', () => {
const editor = createEditor(docWithListInCell());
// "Save": serialize the crumb to markdown, the way onUpdate() does.
const savedMarkdown = getMarkdown(editor);
// "Reopen": load that markdown into a fresh editor, the way opening the
// crumb again does (content is passed in as a markdown string).
const reopened = createEditor(savedMarkdown);
const json = reopened.getJSON() as JSONContent;
const table = json.content?.find((node) => node.type === 'table');
expect(table).toBeDefined();
const rows = table!.content!;
expect(rows).toHaveLength(2);
const [headerRow, bodyRow] = rows;
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
const [listCell, plainCell] = bodyRow.content!;
expect(listCell.content?.[0]?.type).toBe('bulletList');
const items = listCell.content?.[0]?.content ?? [];
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell');
});
it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => {
const editor = createEditor(docWithListInCell());
const savedMarkdown = getMarkdown(editor);
const html = renderMarkdown(savedMarkdown);
expect(html).toContain('<table>');
expect(html).toContain('<ul');
expect(html).toContain('First');
expect(html).toContain('Second');
expect(html).not.toContain('&lt;table&gt;');
});
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] },
{ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] }
]
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] }
]
}
]
}
]
});
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('| A | B |');
expect(savedMarkdown).toContain('| 1 | 2 |');
});
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
const editor = createEditor(docWithListInCell(), { html: false });
const savedMarkdown = getMarkdown(editor);
expect(savedMarkdown).not.toContain('<table');
expect(savedMarkdown).toContain('[table]');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
import { Table as BaseTable } from '@tiptap/extension-table';
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
interface MarkdownSerializerState {
write(text: string): void;
ensureNewLine(): void;
closeBlock(node: ProseMirrorNode): void;
renderInline(node: ProseMirrorNode): void;
inTable: boolean;
}
interface SerializeContext {
editor: { storage: { markdown: { options: { html: boolean } } } };
}
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
node.forEach((child) => nodes.push(child));
return nodes;
}
function hasSpan(cell: ProseMirrorNode): boolean {
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
}
/**
* A cell can only be squeezed onto a single `| cell |` markdown line via
* `state.renderInline()` if its content is itself a textblock (a paragraph,
* whose children are inline text runs). A block child instead - e.g. a
* bullet list - has to be rejected here, or renderInline() misreads its
* block-level children as inline ones and corrupts the row (crumbs#83).
*/
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
const content = cell.firstChild;
return content === null || content.type.isTextblock;
}
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
const rows = childNodes(node);
const firstRow = rows[0];
const bodyRows = rows.slice(1);
if (
childNodes(firstRow).some(
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
) {
return false;
}
return !bodyRows.some((row) =>
childNodes(row).some(
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
)
);
}
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
function formatBlock(html: string): string {
const element = elementFromString(html).firstElementChild!;
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
return element.outerHTML;
}
/**
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
* option is off, other node types degrade to a `[nodename]` placeholder
* instead of emitting HTML. A table with non-inline cell content has to
* follow the same rule, or it'd be the only node type still leaking raw HTML
* with html mode disabled.
*/
function serializeAsHTML(
context: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!context.editor.storage.markdown.options.html) {
state.write(`[${node.type.name}]`);
state.closeBlock(node);
return;
}
const schema = node.type.schema;
const html = getHTMLFromFragment(Fragment.from(node), schema);
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
state.write(isTopLevel ? formatBlock(html) : html);
state.closeBlock(node);
}
/**
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
* a default for it, but only guards against multi-child/spanning cells, not
* a single block child - see hasOnlyInlineContent above).
*/
export const Table = BaseTable.extend({
addStorage() {
return {
markdown: {
serialize(
this: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!isMarkdownSerializable(node)) {
serializeAsHTML(this, state, node, parent);
return;
}
state.inTable = true;
node.forEach((row, _rowOffset, rowIndex) => {
state.write('| ');
row.forEach((cell, _cellOffset, cellIndex) => {
if (cellIndex) {
state.write(' | ');
}
const cellContent = cell.firstChild;
if (cellContent && cellContent.textContent.trim()) {
state.renderInline(cellContent);
}
});
state.write(' |');
state.ensureNewLine();
if (!rowIndex) {
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
state.write(`| ${delimiterRow} |`);
state.ensureNewLine();
}
});
state.closeBlock(node);
state.inTable = false;
},
parse: {
// handled by markdown-it, same as tiptap-markdown's default table spec
}
}
};
}
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
describe('parseNoteLinkHref', () => {
it('should extract the note ID from a crumb-note:// href', () => {
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
});
it('should return null for a non-crumb-note href', () => {
expect(parseNoteLinkHref('https://example.com')).toBeNull();
});
it('should return null for an empty string', () => {
expect(parseNoteLinkHref('')).toBeNull();
});
});
describe('serializeNoteLinkMarkdown', () => {
it('should produce a markdown link with the title from the index', () => {
const titleIndex = new Map([['abc-123', 'Grocery List']]);
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
});
it('should fall back to Untitled when the ID is not in the index', () => {
const titleIndex = new Map<string, string>();
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
});
it('should escape markdown-special characters in the title', () => {
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
});
});
@@ -0,0 +1,22 @@
const NOTE_LINK_SCHEME = 'crumb-note://';
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
export function parseNoteLinkHref(href: string): string | null {
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
return href.slice(NOTE_LINK_SCHEME.length) || null;
}
/**
* Escapes markdown-special characters the same way a plain link's visible
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
* the surrounding markdown syntax.
*/
function escapeMarkdownText(text: string): string {
return text.replace(/[[\]\\*_`]/g, '\\$&');
}
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
const title = titleIndex.get(noteId) ?? 'Untitled';
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
}
+119
View File
@@ -6,6 +6,13 @@ import * as argon2 from 'argon2';
import { randomBytes, randomUUID } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
import {
verifyPassword,
resetPassword,
findOrLinkOAuthUser,
createUser,
disablePasswordLogin
} from './auth.js';
let db: BetterSQLite3Database<typeof schema>;
@@ -125,3 +132,115 @@ describe('Auth - Sessions', () => {
expect(session).toBeUndefined();
});
});
describe('Auth - password login enabled', () => {
it('verifyPassword succeeds when password login is enabled', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'pw@test.com',
displayName: 'PW User',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const user = await verifyPassword('pw@test.com', password, db);
expect(user?.email).toBe('pw@test.com');
});
it('verifyPassword rejects when password login is disabled even with a hash', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'oauth@test.com',
displayName: 'OAuth User',
passwordHash: hash,
passwordLoginEnabled: false,
authProvider: 'google',
providerId: 'gid-1',
createdAt: new Date()
});
expect(await verifyPassword('oauth@test.com', password, db)).toBeNull();
});
it('resetPassword enables password login on an OAuth-only account', async () => {
const [row] = await db
.insert(users)
.values({
email: 'enable@test.com',
displayName: 'Enable',
passwordLoginEnabled: false,
authProvider: 'none',
createdAt: new Date()
})
.returning();
const newPassword = randomUUID();
await resetPassword(row.id, newPassword, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(true);
expect(await argon2.verify(updated!.passwordHash!, newPassword)).toBe(true);
expect(await verifyPassword('enable@test.com', newPassword, db)).not.toBeNull();
});
it('findOrLinkOAuthUser preserves password login when linking SSO', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'both@test.com',
displayName: 'Both',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
});
const linked = findOrLinkOAuthUser('google', 'google-id-1', 'both@test.com', 'Both', db);
expect(linked?.authProvider).toBe('google');
expect(linked?.passwordLoginEnabled).toBe(true);
expect(await verifyPassword('both@test.com', password, db)).not.toBeNull();
});
it('createUser sets passwordLoginEnabled from whether a password was given', async () => {
const withPassword = await createUser('a@test.com', 'A', 'password123', 'user', db);
expect(withPassword.passwordLoginEnabled).toBe(true);
const oauthOnly = await createUser('b@test.com', 'B', null, 'user', db);
expect(oauthOnly.passwordLoginEnabled).toBe(false);
});
it('disablePasswordLogin clears the hash, disables login, and removes sessions', async () => {
const password = randomUUID();
const hash = await argon2.hash(password);
const [row] = await db
.insert(users)
.values({
email: 'disable@test.com',
displayName: 'Disable',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: row.id,
expiresAt: new Date(Date.now() + 86400000)
});
disablePasswordLogin(row.id, db);
const updated = await db.select().from(users).where(eq(users.id, row.id)).get();
expect(updated?.passwordLoginEnabled).toBe(false);
expect(updated?.passwordHash).toBeNull();
expect(await verifyPassword('disable@test.com', password, db)).toBeNull();
expect(await db.select().from(sessions).where(eq(sessions.userId, row.id)).all()).toHaveLength(0);
});
});
+43 -17
View File
@@ -1,4 +1,4 @@
import { db, sqlite } from './db/index.js';
import { db, sqlite, type Db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
@@ -16,6 +16,7 @@ function toUser(row: typeof users.$inferSelect): User {
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
passwordLoginEnabled: row.passwordLoginEnabled,
providerId: row.providerId,
createdAt: row.createdAt
};
@@ -29,19 +30,21 @@ export async function isSetupComplete(): Promise<boolean> {
export async function setupUser(
email: string,
password: string,
displayName?: string
displayName?: string,
dbInstance: Db = db
): Promise<User | null> {
const existing = db.select().from(users).get();
const existing = dbInstance.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
const result = db
const result = dbInstance
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
passwordLoginEnabled: true,
authProvider: 'password',
createdAt: new Date()
})
@@ -52,12 +55,13 @@ export async function setupUser(
export async function verifyPassword(
email: string,
password: string
password: string,
dbInstance: Db = db
): Promise<User | null> {
const user = db
const user = dbInstance
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.where(and(eq(users.email, email), eq(users.passwordLoginEnabled, true)))
.get();
if (!user || !user.passwordHash) return null;
@@ -195,16 +199,18 @@ export async function createUser(
email: string,
displayName: string,
password: string | null,
role: 'admin' | 'user' = 'user'
role: 'admin' | 'user' = 'user',
dbInstance: Db = db
): Promise<User> {
const hash = password ? await argon2.hash(password) : null;
const result = db
const result = dbInstance
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
passwordLoginEnabled: !!password,
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
@@ -282,7 +288,7 @@ export async function changePassword(
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash) return false;
if (!user || !user.passwordHash || !user.passwordLoginEnabled) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
@@ -292,9 +298,26 @@ export async function changePassword(
return true;
}
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
export async function resetPassword(
userId: number,
newPassword: string,
dbInstance: Db = db
): Promise<void> {
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
dbInstance
.update(users)
.set({ passwordHash: hash, passwordLoginEnabled: true })
.where(eq(users.id, userId))
.run();
}
export function disablePasswordLogin(userId: number, dbInstance: Db = db): void {
dbInstance
.update(users)
.set({ passwordHash: null, passwordLoginEnabled: false })
.where(eq(users.id, userId))
.run();
dbInstance.delete(sessions).where(eq(sessions.userId, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
@@ -303,15 +326,17 @@ export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
* Linking OAuth updates the provider fields only — password login stays as configured.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string
name: string,
dbInstance: Db = db
): User | null {
// First try exact provider+providerId match
const byProvider = db
const byProvider = dbInstance
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
@@ -319,11 +344,12 @@ export function findOrLinkOAuthUser(
if (byProvider) return toUser(byProvider);
// Try matching by email (invite-only: user must already exist)
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
const byEmail = dbInstance.select().from(users).where(eq(users.email, email)).get();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user
db.update(users)
// Link this OAuth provider to the existing user without touching password login.
dbInstance
.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
+25 -2
View File
@@ -28,6 +28,7 @@ sqlite.exec(`
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -226,6 +227,13 @@ sqlite.exec(`
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
`);
// Replace old tags unique index with compound index including user_id
@@ -262,17 +270,32 @@ if (pwCol && pwCol.notnull) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
INSERT INTO __new_users(id, email, display_name, role, password_hash, password_login_enabled, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash,
CASE WHEN auth_provider = 'password' THEN 1 ELSE 0 END,
auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Migration: add password_login_enabled (independent of OAuth provider)
const userColumnsAfterPw = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
if (!userColumnsAfterPw.some((col) => col.name === 'password_login_enabled')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN password_login_enabled INTEGER NOT NULL DEFAULT 0;`);
// Backfill: only accounts whose sign-in method was password keep it enabled.
// OAuth-linked rows may carry a legacy hash; leaving them disabled avoids
// silently widening access — an admin reset is the deliberate enable act.
sqlite.exec(`UPDATE users SET password_login_enabled = 1 WHERE auth_provider = 'password';`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm';
import { createTestDb } from './test-helpers.js';
import { notes, noteLinks, users } from './schema.js';
describe('noteLinks schema', () => {
it('should store a source/target link row', () => {
const { db } = createTestDb();
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
});
it('should cascade-delete link rows when the source note is deleted', () => {
const { db, sqlite } = createTestDb();
sqlite.pragma('foreign_keys = ON');
db.insert(users)
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(notes)
.values([
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
])
.run();
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
db.delete(notes).where(eq(notes.id, 'source-note')).run();
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
+19
View File
@@ -6,6 +6,9 @@ export const users = sqliteTable('users', {
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
passwordLoginEnabled: integer('password_login_enabled', { mode: 'boolean' })
.notNull()
.default(false),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
@@ -80,6 +83,22 @@ export const noteTags = sqliteTable(
]
);
export const noteLinks = sqliteTable(
'note_links',
{
sourceNoteId: text('source_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
targetNoteId: text('target_note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_links_source_idx').on(table.sourceNoteId),
index('note_links_target_idx').on(table.targetNoteId)
]
);
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
+9
View File
@@ -19,6 +19,7 @@ export function createTestDb(options?: { seedUser?: boolean }) {
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
password_login_enabled INTEGER NOT NULL DEFAULT 0,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
@@ -68,6 +69,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -166,6 +174,7 @@ export function createTestDb(options?: { seedUser?: boolean }) {
displayName: 'Test User',
role: 'admin',
authProvider: 'password',
passwordLoginEnabled: true,
createdAt: new Date()
}).run();
}
+155 -2
View File
@@ -1,5 +1,8 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { lookup } from 'node:dns/promises';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { NoteFilter } from '$lib/types/index.js';
import {
listNotes,
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
'chalk'
] as const;
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
const MAX_IMAGE_REDIRECTS = 5;
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
// upload_image can't be used to reach internal network services.
function isPrivateOrReservedIp(address: string): boolean {
if (address.includes(':')) {
const normalized = address.toLowerCase();
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fe80:')) return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
return false;
}
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
return false;
}
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
// (not just the first), so a validated hostname can't have a private record hiding
// behind a public one in the same DNS answer.
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
const records = await lookup(hostname, { all: true });
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
for (const record of records) {
if (isPrivateOrReservedIp(record.address)) {
throw new Error('URL resolves to a private or reserved address');
}
}
return records[0];
}
interface PinnedResponse {
ok: boolean;
status: number;
statusText: string;
headers: { get(name: string): string | null };
arrayBuffer(): Promise<ArrayBuffer>;
}
// Issues the request against the exact address validated by resolveValidatedAddress,
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
// could serve a public address to the validator and a private one to the real connect.
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
return new Promise((resolve, reject) => {
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = requestFn(
{
hostname: url.hostname,
servername: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
timeout: 15000,
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
callback(null, address, family);
}
},
(res) => {
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_IMAGE_BYTES) {
req.destroy(new Error('Image too large (max 10MB)'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const buffer = Buffer.concat(chunks);
const status = res.statusCode ?? 0;
resolve({
ok: status >= 200 && status < 300,
status,
statusText: res.statusMessage ?? '',
headers: {
get: (name: string) => {
const value = res.headers[name.toLowerCase()];
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
}
},
arrayBuffer: async () =>
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
});
});
res.on('error', reject);
}
);
req.on('error', reject);
req.end();
});
}
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
let currentUrl = new URL(imageUrl);
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
}
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
const response = await requestPinnedAddress(currentUrl, address, family);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) return response;
currentUrl = new URL(location, currentUrl);
continue;
}
return response;
}
throw new Error('Too many redirects');
}
export function createMcpServer(userId: number): McpServer {
const server = new McpServer({
name: 'crumbs',
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
}
try {
const response = await fetch(imageUrl);
const response = await fetchPublicImage(imageUrl);
if (!response.ok) {
return {
content: [
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
};
}
const contentType = response.headers.get('content-type') || 'image/png';
const contentType = response.headers.get('content-type') || '';
if (!contentType.startsWith('image/')) {
return {
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
isError: true
};
}
const contentLength = Number(response.headers.get('content-length'));
if (contentLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
return {
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
isError: true
};
}
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
const filename = `upload_${Date.now()}.${ext}`;
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
let db: Db;
const OWNER_ID = 1;
const OTHER_USER_ID = 2;
function seedUser(id: number, email: string) {
db.insert(users)
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string, userId: number, title = '') {
db.insert(notes)
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser(OWNER_ID, 'owner@test.com');
seedUser(OTHER_USER_ID, 'other@test.com');
});
describe('syncNoteLinks', () => {
it('should create link rows for each target', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toHaveLength(2);
});
it('should remove old links and add new ones on re-sync', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
seedNote('target-2', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', ['target-2']);
const rows = db.select().from(noteLinks).all();
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
});
it('should handle an empty target list (removes all links)', () => {
seedNote('source', OWNER_ID);
seedNote('target-1', OWNER_ID);
syncNoteLinks(db, 'source', ['target-1']);
syncNoteLinks(db, 'source', []);
expect(db.select().from(noteLinks).all()).toEqual([]);
});
});
describe('fetchBacklinksForNote', () => {
it('should return notes that link to the given note', () => {
seedNote('source', OWNER_ID, 'Source Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
});
it('should return an empty array when nothing links to the note', () => {
seedNote('target', OWNER_ID, 'Target Note');
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should exclude a backlink from a source note the requesting user cannot access', () => {
seedNote('source', OTHER_USER_ID, 'Other Users Note');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
});
it('should include a backlink from a source note shared with the requesting user', () => {
seedNote('source', OTHER_USER_ID, 'Shared Source');
seedNote('target', OWNER_ID, 'Target Note');
syncNoteLinks(db, 'source', ['target']);
db.insert(noteCollaborators)
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
.run();
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Db } from './db/index.js';
import { noteLinks, notes } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm';
import { canAccessNote } from './api-utils.js';
/**
* Sync link rows for a note: removes old associations, inserts the current set.
* Mirrors syncNoteTags — called right after a note's content is saved.
*/
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
for (const targetId of targetIds) {
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
}
}
/**
* Find notes that link to the given note, scoped to what the requesting user
* can access — this both respects normal sharing boundaries and, as a side
* effect, prevents an unrelated note from appearing in someone's backlinks
* just because it happens to reference a note ID it was never shared to see.
*/
export function fetchBacklinksForNote(
db: Db,
noteId: string,
userId: number
): { id: string; title: string }[] {
const linkRows = db
.select({ sourceNoteId: noteLinks.sourceNoteId })
.from(noteLinks)
.where(eq(noteLinks.targetNoteId, noteId))
.all();
if (linkRows.length === 0) return [];
const sourceIds = linkRows.map((r) => r.sourceNoteId);
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
if (accessibleIds.length === 0) return [];
const sourceNotes = db
.select({ id: notes.id, title: notes.title })
.from(notes)
.where(inArray(notes.id, accessibleIds))
.all();
return sourceNotes;
}
+82
View File
@@ -263,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', () => {
@@ -334,3 +374,45 @@ describe('reorderNotes', () => {
expect(collabView!.sortOrder).toBe(5);
});
});
describe('note links', () => {
it('should sync note-links when a note is created with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should sync note-links when a note is updated with a reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
});
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
const target = createNote(db, OWNER_ID, { title: 'Target' });
const source = createNote(db, OWNER_ID, {
title: 'Source',
content: `See [Target](crumb-note://${target.id})`
});
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
const reopened = getNote(db, OWNER_ID, target.id);
expect(reopened?.backlinks).toEqual([]);
});
it('should return an empty backlinks array for a note nothing links to', () => {
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
const reopened = getNote(db, OWNER_ID, note.id);
expect(reopened?.backlinks).toEqual([]);
});
});
+40 -42
View File
@@ -1,15 +1,17 @@
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 { extractNoteLinks } from '$lib/utils/note-links.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot } 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 {
@@ -155,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) {
}
const result = hydrateNotes(db, [effectiveNote], userId);
return result[0] ?? null;
const hydrated = result[0];
if (!hydrated) return null;
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
}
export interface CreateNoteInput {
@@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
});
syncNoteTags(db, id, extractedTags, userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
return { ...newNote, tags: extractedTags };
}
@@ -238,29 +246,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.
// Only attempt merge when there's a concurrent edit: the client's
// baseVersion is behind the current version, meaning someone else
// saved between the client's load and this save.
// 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 (hasConcurrentEdit && input.content !== existing.content) {
const baseSnapshot = 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();
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; }
@@ -278,6 +275,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();
}
@@ -321,26 +334,11 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(db, id, extractedTags, existing.userId);
const extractedLinks = extractNoteLinks(content);
syncNoteLinks(db, id, extractedLinks);
}
}
// Create a version snapshot only when at least one content field actually changed
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(input.content !== undefined && input.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
return getNote(db, userId, id);
}
+27 -1
View File
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
import { users, notes, noteVersions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
let db: Db;
@@ -44,6 +44,32 @@ beforeEach(() => {
seedUser();
});
describe('getBaseContent', () => {
it('returns null when the base version is unknown', () => {
seedNote();
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
});
it('returns the snapshot content at or before the base version', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
});
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
seedNote();
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
// pruned). Using it as the merge base would treat unseen lines as deletions,
// so getBaseContent must return null rather than fall back to it.
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
});
});
describe('createSnapshot', () => {
it('should store a snapshot for a note', () => {
seedNote();
+30 -2
View File
@@ -1,18 +1,46 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { eq, and, desc, lte } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
const MAX_VERSIONS_PER_NOTE = 50;
const CONTENT_PREVIEW_LENGTH = 80;
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
export type SnapshotDb = Db | Transaction;
/**
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
* at or before the client's base version. Returns null when the base version is
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
* snapshot as the base — a base newer than what the client actually saw makes the
* merge treat other users' lines as deletions and silently drop them.
*/
export function getBaseContent(
db: SnapshotDb,
noteId: string,
baseVersion: number | undefined
): string | null {
if (baseVersion === undefined) return null;
const snapshot = db
.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return snapshot?.content ?? null;
}
/**
* Create a snapshot of a note's current state.
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
*/
export function createSnapshot(
db: Db,
db: SnapshotDb,
noteId: string,
snapshot: {
version: number;
+47 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeNotesByVersion } from './notes.js';
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
import type { Note } from '$lib/types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
@@ -97,3 +97,49 @@ describe('mergeNotesByVersion', () => {
expect(result[1].content).toBe('updated on server');
});
});
describe('reconcileSearchResults', () => {
it('replaces a stale result with the canonical note', () => {
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
const result = reconcileSearchResults(
[canonical],
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
);
expect(result).toEqual([canonical]);
});
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
expect(result).toEqual([serverHit]);
});
it('drops a result that is trashed in the canonical store', () => {
const trashed = makeNote({ id: 'n1', trashed: true });
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
expect(result).toEqual([]);
});
it('preserves result order while reconciling a mix of cases', () => {
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
const trashed = makeNote({ id: 'n2', trashed: true });
const absent = makeNote({ id: 'n3', title: 'Absent' });
const result = reconcileSearchResults(
[canonical, trashed],
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
);
expect(result).toEqual([canonical, absent]);
});
it('returns an empty array when there are no results', () => {
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
});
});
+36 -3
View File
@@ -8,11 +8,41 @@ export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
export const selectedTag = writable<string | null>(null);
export const notesLoaded = writable(false);
export const searchQuery = writable<string>('');
export const searchResults = writable<Note[]>([]);
/**
* Reconcile search results against the canonical notes store so local edits and
* trashing propagate into the active result set without re-querying the server.
*
* A result absent from the canonical store is kept as-is — it's a server hit not
* yet loaded locally (e.g. created on another device), and dropping it would make
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
* pruned explicitly at their call sites; trashed notes are dropped here.
*/
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
return results.flatMap((result) => {
const canonical = notesMap.get(result.id);
if (!canonical) return [result];
return canonical.trashed ? [] : [canonical];
});
}
// Keep searchResults in sync with edits/trashing in the notes store. These search
// stores (like the others in this module) are client-only state; the subscription
// lives for the app's lifetime and is intentionally never unsubscribed.
notes.subscribe(($notes) => {
searchResults.update(($results) => {
if ($results.length === 0) return $results;
return reconcileSearchResults($notes, $results);
});
});
export const filteredNotes = derived(
[notes, selectedTag, currentFilter],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
[notes, selectedTag, currentFilter, searchQuery, searchResults],
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
let result = $searchQuery.trim() ? $searchResults : $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
@@ -218,6 +248,7 @@ export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
@@ -225,6 +256,7 @@ export async function deleteNote(id: string): Promise<boolean> {
} catch (err) {
if (isNetworkError(err)) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
@@ -263,6 +295,7 @@ export async function leaveNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
+59 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from '../server/db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from '../server/db/schema.js';
@@ -289,6 +289,64 @@ describe('processSyncPush — shared notes', () => {
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
});
it('should create a base snapshot during sync so later queued edits can merge', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: t1,
createdAt: t1,
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
version: 1
});
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 1000,
data: { content: '- [ ] Milk\n- [x] Bread' },
baseVersion: 1
}], OWNER_ID);
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 2000,
data: { content: '- [x] Milk\n- [ ] Bread' },
baseVersion: 1
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
});
it('should retain at most 50 snapshots created by sync updates', async () => {
const createdAt = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: createdAt,
createdAt,
content: 'version 1',
version: 1
});
for (let version = 2; version <= 52; version++) {
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: createdAt.getTime() + version,
data: { content: `version ${version}` },
baseVersion: version - 1
}], OWNER_ID);
}
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(50);
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
});
it('should not allow non-collaborator to update the note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
// Create note without sharing with user 3
+30 -38
View File
@@ -1,37 +1,12 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import type { SyncQueueItem } from './idb.js';
/**
* Find the base content for 3-way merge from version snapshots.
* Returns the content from the snapshot at or before the given version,
* or the current note content if no snapshot is found.
*/
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
if (baseVersion !== undefined) {
const snapshot = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (snapshot) return snapshot.content;
}
// Fallback: get the most recent snapshot before the current version
const latest = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return latest?.content ?? null;
}
/**
* Process incoming sync changes from client.
*/
@@ -69,16 +44,17 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
// Content: 3-way merge to preserve concurrent edits from other users
// Content: merge only when the client saved from an older note version.
if (change.data.content !== undefined && change.data.content !== note.content) {
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
if (baseContent !== null && baseContent !== note.content) {
// 3-way merge: base, incoming (local), server current (remote)
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
} else {
// No base or base matches server — take incoming content
sharedData.content = change.data.content;
}
const hasConcurrentEdit = change.baseVersion !== undefined
&& change.baseVersion < note.version;
sharedData.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
incomingContent: change.data.content,
currentContent: note.content
})
: change.data.content;
}
if (isOwner) {
@@ -92,6 +68,22 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
const hasActualContentChange =
(change.data.title !== undefined && change.data.title !== note.title) ||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
(change.data.color !== undefined && change.data.color !== note.color) ||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
if (hasActualContentChange) {
createSnapshot(tx, change.noteId, {
version: note.version,
title: note.title,
content: note.content,
checklistMode: note.checklistMode,
color: note.color
});
}
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
+7
View File
@@ -18,6 +18,12 @@ export interface Note {
isOwner?: boolean;
isShared?: boolean;
shareToken?: string;
backlinks?: NoteBacklink[];
}
export interface NoteBacklink {
id: string;
title: string;
}
export interface Collaborator {
@@ -69,6 +75,7 @@ export interface User {
displayName: string;
role: 'admin' | 'user';
authProvider: string;
passwordLoginEnabled: boolean;
providerId: string | null;
createdAt: Date;
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import {
canResetPassword,
passwordResetBlockedReason,
requiresPasswordForAccountDeletion,
canDisablePasswordLogin
} from './auth-methods.js';
describe('canResetPassword', () => {
it('allows a reset for a password account', () => {
expect(canResetPassword({ authProvider: 'password', passwordLoginEnabled: true })).toBe(true);
});
it('allows a reset for an OAuth-only account', () => {
expect(canResetPassword({ authProvider: 'none', passwordLoginEnabled: false })).toBe(true);
});
it('allows a reset for an OAuth-linked account', () => {
expect(canResetPassword({ authProvider: 'google', passwordLoginEnabled: true })).toBe(true);
});
});
describe('passwordResetBlockedReason', () => {
it('returns null for every account type', () => {
for (const user of [
{ authProvider: 'password', passwordLoginEnabled: true },
{ authProvider: 'none', passwordLoginEnabled: false },
{ authProvider: 'google', passwordLoginEnabled: true },
{ authProvider: 'oidc', passwordLoginEnabled: false }
]) {
expect(passwordResetBlockedReason(user)).toBeNull();
}
});
});
describe('requiresPasswordForAccountDeletion', () => {
it('requires password for a pure password account', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'password', passwordLoginEnabled: true })
).toBe(true);
});
it('requires password for OAuth-only with admin-enabled password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: true })
).toBe(true);
});
it('does not require password for OAuth-linked accounts even when password login is enabled', () => {
for (const authProvider of ['google', 'github', 'oidc']) {
expect(
requiresPasswordForAccountDeletion({ authProvider, passwordLoginEnabled: true }),
authProvider
).toBe(false);
}
});
it('does not require password for OAuth-only without password login', () => {
expect(
requiresPasswordForAccountDeletion({ authProvider: 'none', passwordLoginEnabled: false })
).toBe(false);
});
});
describe('canDisablePasswordLogin', () => {
it('allows disabling for an OAuth-linked user with password login enabled', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(true);
});
it('blocks disabling on the admin own row', () => {
expect(
canDisablePasswordLogin({ id: 1, passwordLoginEnabled: true, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling when password login is already off', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: false, authProvider: 'google' }, 1)
).toBe(false);
});
it('blocks disabling on a password-only account', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'password' }, 1)
).toBe(false);
});
it('blocks disabling on OAuth-only with admin-enabled password but no provider link', () => {
expect(
canDisablePasswordLogin({ id: 2, passwordLoginEnabled: true, authProvider: 'none' }, 1)
).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { User } from '$lib/types/index.js';
/**
* Whether an admin may set a new password for this account.
*
* Password login is gated on `passwordLoginEnabled`, which resetPassword() sets
* deliberately — so an admin can enable password auth on any account, including
* OAuth-only ones, without clearing their SSO link.
*/
export function canResetPassword(_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>): boolean {
return true;
}
/**
* Why a password reset is blocked, or null when it is allowed.
*
* Eligibility is checked in the UI for the signed-in admin's own row; every
* other account may receive a reset that enables password login.
*/
export function passwordResetBlockedReason(
_user: Pick<User, 'passwordLoginEnabled' | 'authProvider'>
): string | null {
return null;
}
/**
* Whether self-service account deletion must verify the user's password.
*
* OAuth-linked accounts may delete with their active SSO session alone, even
* when an admin has enabled password login. Pure password accounts, and
* OAuth-only rows with password login enabled but no provider link, still
* require the password.
*/
export function requiresPasswordForAccountDeletion(
user: Pick<User, 'authProvider' | 'passwordLoginEnabled'>
): boolean {
if (user.authProvider === 'password') return true;
if (user.authProvider === 'none' && user.passwordLoginEnabled) return true;
return false;
}
/** Whether an admin may turn off password login for this account. */
export function canDisablePasswordLogin(
user: Pick<User, 'id' | 'passwordLoginEnabled' | 'authProvider'>,
adminId: number
): boolean {
if (user.id === adminId || !user.passwordLoginEnabled) return false;
// Only when another sign-in method exists — disabling on a password-only row
// would lock the user out with no OAuth fallback.
return user.authProvider !== 'password' && user.authProvider !== 'none';
}
+123 -4
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
@@ -8,13 +15,13 @@ describe('getNoteColor', () => {
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
it('has matching keys in light and dark color maps', () => {
@@ -23,3 +30,115 @@ describe('getNoteColor', () => {
expect(lightKeys).toEqual(darkKeys);
});
});
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** WCAG relative luminance. */
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps muted text above the AA threshold on every dark card', () => {
// The shipped dark theme failed this at 3.77:1 with its previous muted
// colour (#9a8e7e). This guard exists so a future palette edit cannot
// reintroduce it — it does not discriminate the old surfaces, which were
// darker and so scored higher against the lifted muted colour.
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps every pair of dark surfaces distinguishable', () => {
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
// colours read as one dark smudge. Threshold 8 clears that while leaving
// headroom above the current closest pair, coral/peach at 12.0.
const entries = Object.entries(NOTE_COLORS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a.bg, b.bg);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
}
}
});
});
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a, b);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
// A chip set to card-lightness values could still pass the pairwise
// distance test above (25 apart) while being just as dark as the cards,
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
// side of the 0.1 threshold below.
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
const maxCardLuminance = Math.max(...cardLuminances);
expect(maxCardLuminance).toBeLessThan(0.1);
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
}
});
});
+38 -12
View File
@@ -16,18 +16,18 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
};
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#2a2520', label: 'Default' },
coral: { bg: '#4a2522', label: 'Coral' },
peach: { bg: '#4a3020', label: 'Peach' },
sand: { bg: '#3a3520', label: 'Sand' },
mint: { bg: '#2a3a22', label: 'Mint' },
sage: { bg: '#223a32', label: 'Sage' },
fog: { bg: '#222e3a', label: 'Fog' },
storm: { bg: '#1e2a35', label: 'Storm' },
dusk: { bg: '#352540', label: 'Dusk' },
blossom: { bg: '#3a2830', label: 'Blossom' },
clay: { bg: '#302e28', label: 'Clay' },
chalk: { bg: '#2e2e30', label: 'Chalk' }
default: { bg: '#21201f', label: 'Default' },
coral: { bg: '#3f1f1c', label: 'Coral' },
peach: { bg: '#3f2b1c', label: 'Peach' },
sand: { bg: '#3c371b', label: 'Sand' },
mint: { bg: '#2a3d1d', label: 'Mint' },
sage: { bg: '#1f3d36', label: 'Sage' },
fog: { bg: '#253946', label: 'Fog' },
storm: { bg: '#1d283d', label: 'Storm' },
dusk: { bg: '#3d2843', label: 'Dusk' },
blossom: { bg: '#3d2129', label: 'Blossom' },
clay: { bg: '#363126', label: 'Clay' },
chalk: { bg: '#303036', label: 'Chalk' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
@@ -39,3 +39,29 @@ export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]
value: value as NoteColor,
label
}));
/**
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
* reads as a grey dot — small patches lose apparent saturation — so the dark
* picker uses vivid mid-lightness chips that share the card's hue but not its
* lightness. Light mode needs no equivalent: there the pastels are the cards.
*/
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
default: '#8d857c',
coral: '#d04f43',
peach: '#cd7c42',
sand: '#c7b138',
mint: '#7bbd4c',
sage: '#47b89d',
fog: '#609abe',
storm: '#547bc0',
dusk: '#a965bd',
blossom: '#c05d78',
clay: '#b5954a',
chalk: '#c3c3c8'
};
export function getNoteChip(color: NoteColor, isDark: boolean): string {
if (!isDark) return getNoteColor(color, false);
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
}
+49 -10
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
import { mergeContent, mergeContentUpdate } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
});
});
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
describe('mergeContentUpdate', () => {
it('should merge an incoming update against current content when a base is available', () => {
const result = mergeContentUpdate({
baseContent: '- [ ] Milk\n- [ ] Bread',
incomingContent: '- [x] Milk\n- [ ] Bread',
currentContent: '- [ ] Milk\n- [x] Bread'
});
expect(result).toBe('- [x] Milk\n- [x] Bread');
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
it('should keep the incoming edit when no base snapshot is available', () => {
// Without a common ancestor a 3-way merge is impossible; dropping the
// incoming edit would silently lose the user's changes.
const result = mergeContentUpdate({
baseContent: null,
incomingContent: '- [x] Milk',
currentContent: '- [ ] Bread'
});
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
expect(result).toBe('- [x] Milk');
});
it('should keep current content when the incoming edit equals it', () => {
const result = mergeContentUpdate({
baseContent: null,
incomingContent: 'same',
currentContent: 'same'
});
expect(result).toBe('same');
});
it('should keep current content when the incoming edit matches the base (no local change)', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'base',
currentContent: 'server changed'
});
expect(result).toBe('server changed');
});
it('should take the incoming edit when the server is unchanged from the base', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'incoming change',
currentContent: 'base'
});
expect(result).toBe('incoming change');
});
});
+23 -6
View File
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
}
/**
* 2-way merge fallback when no base is available.
* Uses an empty string as the base, so all lines from both sides appear
* as additions. For conflicts (same region), prefers remote.
* Merge an incoming content snapshot into the current server content.
*
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
* change that created no snapshot — a 3-way merge is impossible, so keep the
* incoming edit rather than silently discarding the user's changes.
*/
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, remote);
export function mergeContentUpdate(input: {
baseContent: string | null;
incomingContent: string;
currentContent: string;
}): string {
const { baseContent, incomingContent, currentContent } = input;
if (incomingContent === currentContent) return currentContent;
if (baseContent === incomingContent) return currentContent;
if (baseContent === currentContent) return incomingContent;
if (baseContent === null) {
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
return incomingContent;
}
return mergeContent(baseContent, incomingContent, currentContent);
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { CODE_LANGUAGES, editorLowlight, highlightCode, isKnownLanguage, lowlight } from './highlight.js';
describe('highlightCode', () => {
it('wraps tokens in hljs-* spans for a known language', () => {
const html = highlightCode('const x = 1;', 'javascript');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('accepts highlight.js aliases the way a fence info string carries them', () => {
expect(highlightCode('let a = 1', 'js')).toContain('hljs-keyword');
expect(highlightCode('print(1)', 'py')).toContain('hljs-built_in');
expect(highlightCode('<div></div>', 'html')).toContain('hljs-tag');
});
it('returns null for an absent or unknown language so the caller falls back to plain text', () => {
expect(highlightCode('SELECT 1', null)).toBeNull();
expect(highlightCode('SELECT 1', '')).toBeNull();
expect(highlightCode('SELECT 1', 'not-a-language')).toBeNull();
});
it('escapes HTML in the source so a snippet cannot inject markup', () => {
const html = highlightCode('const s = "<img src=x onerror=alert(1)>";', 'javascript');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});
it('keeps highlight.js sub-scope classes alongside the hljs-* class', () => {
const html = highlightCode('function foo() {}', 'javascript');
expect(html).toContain('<span class="hljs-title function_">foo</span>');
});
it('round-trips the source text exactly once tags are stripped', () => {
const source = 'if (a < b && c > d) {\n\treturn "x&y";\n}';
const html = highlightCode(source, 'typescript')!;
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(text).toBe(source);
});
});
describe('CODE_LANGUAGES', () => {
it('lists only languages the shared lowlight instance can actually highlight', () => {
for (const { id } of CODE_LANGUAGES) {
expect(isKnownLanguage(id), id).toBe(true);
}
});
it('has no duplicate ids', () => {
const ids = CODE_LANGUAGES.map((l) => l.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('editorLowlight', () => {
it('never auto-detects: an unlabelled block comes back as a single plain text node', () => {
const result = editorLowlight.highlightAuto('SELECT * FROM users;');
expect(result.children).toEqual([{ type: 'text', value: 'SELECT * FROM users;' }]);
});
it('highlights a labelled block with the same grammars as the renderer', () => {
const viaEditor = editorLowlight.highlight('python', 'def f(): pass');
const viaRenderer = lowlight.highlight('python', 'def f(): pass');
expect(viaEditor.children).toEqual(viaRenderer.children);
});
it('reports the languages the picker offers as registered', () => {
for (const { id } of CODE_LANGUAGES) {
expect(editorLowlight.registered(id), id).toBe(true);
}
expect(editorLowlight.listLanguages()).toContain('javascript');
});
});
// The palette lives in CSS, so the contrast guarantees are checked against the
// stylesheet itself rather than a TypeScript mirror that could drift.
describe('code palette in src/app.css', () => {
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf8');
const light = readVars(css.slice(css.indexOf(':root {'), css.indexOf('[data-theme="dark"] {')));
const dark = readVars(css.slice(css.indexOf('[data-theme="dark"] {')));
const tokenNames = ['--hl-keyword', '--hl-string', '--hl-number', '--hl-comment', '--hl-title', '--hl-tag'];
it('defines the code surface and every token colour in both themes', () => {
for (const name of ['--code-bg', ...tokenNames]) {
expect(light[name], `${name} light`).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark[name], `${name} dark`).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('keeps AA contrast (4.5:1) between every token and the code surface', () => {
for (const [theme, vars] of [
['light', light],
['dark', dark]
] as const) {
for (const name of tokenNames) {
const ratio = contrastRatio(vars[name], vars['--code-bg']);
expect(ratio, `${theme} ${name} ${vars[name]} on ${vars['--code-bg']}`).toBeGreaterThanOrEqual(4.5);
}
}
});
it('keeps AA contrast between body text and the code surface', () => {
expect(contrastRatio(light['--text'], light['--code-bg'])).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(dark['--text'], dark['--code-bg'])).toBeGreaterThanOrEqual(4.5);
});
it('keeps token colours distinguishable from each other', () => {
for (const vars of [light, dark]) {
for (let i = 0; i < tokenNames.length; i++) {
for (let j = i + 1; j < tokenNames.length; j++) {
const distance = rgbDistance(vars[tokenNames[i]], vars[tokenNames[j]]);
expect(distance, `${tokenNames[i]} vs ${tokenNames[j]}`).toBeGreaterThanOrEqual(40);
}
}
}
});
});
function readVars(block: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const match of block.matchAll(/(--[\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) {
vars[match[1]] = match[2].toLowerCase();
}
return vars;
}
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
+108
View File
@@ -0,0 +1,108 @@
import { common, createLowlight } from 'lowlight';
/**
* One lowlight instance shared by the TipTap editor (live decorations) and the
* markdown-it renderer (card previews, share page), so a fence is coloured
* identically wherever it shows up.
*
* There is deliberately no language auto-detection. highlight.js guesses
* wrongly on note-sized snippets often enough (JavaScript read as CSS, SQL as
* VB.NET) that a wrong palette is worse than none. An unlabelled fence renders
* as plain text on the code surface; the language comes from the fence info
* string (```ts) or the toolbar picker.
*/
export const lowlight = createLowlight(common);
type HighlightRoot = ReturnType<typeof lowlight.highlight>;
type HighlightNode = HighlightRoot['children'][number];
/** Languages offered by the editor's picker. Aliases (js, py, sh…) still highlight. */
export const CODE_LANGUAGES: readonly { id: string; label: string }[] = [
{ id: 'bash', label: 'Bash' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'css', label: 'CSS' },
{ id: 'diff', label: 'Diff' },
{ id: 'go', label: 'Go' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'xml', label: 'HTML / XML' },
{ id: 'ini', label: 'INI / TOML' },
{ id: 'java', label: 'Java' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'json', label: 'JSON' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'less', label: 'Less' },
{ id: 'lua', label: 'Lua' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'objectivec', label: 'Objective-C' },
{ id: 'perl', label: 'Perl' },
{ id: 'php', label: 'PHP' },
{ id: 'python', label: 'Python' },
{ id: 'r', label: 'R' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'rust', label: 'Rust' },
{ id: 'scss', label: 'SCSS' },
{ id: 'shell', label: 'Shell session' },
{ id: 'sql', label: 'SQL' },
{ id: 'swift', label: 'Swift' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'vbnet', label: 'VB.NET' },
{ id: 'wasm', label: 'WebAssembly' },
{ id: 'yaml', label: 'YAML' }
];
export function isKnownLanguage(lang: string | null | undefined): lang is string {
return typeof lang === 'string' && lang.length > 0 && lowlight.registered(lang);
}
/**
* Highlighted HTML (spans with `hljs-*` classes, text escaped) for a code
* block, or `null` when the language is absent or unknown so the caller can
* fall back to plain escaped text.
*/
export function highlightCode(code: string, lang: string | null | undefined): string | null {
if (!isKnownLanguage(lang)) return null;
return toHtml(lowlight.highlight(lang, code).children);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// lowlight emits only `element` (span with a className list) and `text`
// nodes, so a full hast serializer would be dead weight here.
function toHtml(nodes: HighlightNode[]): string {
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += escapeHtml(node.value);
} else if (node.type === 'element') {
const classes = node.properties?.className;
const classAttr = Array.isArray(classes) && classes.length ? ` class="${classes.join(' ')}"` : '';
html += `<span${classAttr}>${toHtml(node.children as HighlightNode[])}</span>`;
}
}
return html;
}
/**
* What `@tiptap/extension-code-block-lowlight` needs from a lowlight instance,
* with auto-detection stubbed out so an unlabelled block stays plain (see the
* note at the top of this file).
*/
export const editorLowlight = {
highlight: (language: string, value: string) => lowlight.highlight(language, value),
highlightAuto: (value: string): HighlightRoot => ({
type: 'root',
children: [{ type: 'text', value }],
data: { language: undefined, relevance: 0 }
}),
listLanguages: () => lowlight.listLanguages(),
registered: (aliasOrLanguage: string) => lowlight.registered(aliasOrLanguage)
};
+99
View File
@@ -35,6 +35,40 @@ describe('renderMarkdown', () => {
expect(html).toContain('const x = 1;');
});
it('should syntax-highlight a fenced block that names its language', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('<code class="language-ts">');
expect(html).toContain('<span class="hljs-keyword">const</span>');
expect(html).toContain('<span class="hljs-number">1</span>');
});
it('should leave an unlabelled fence as plain text rather than guessing a language', () => {
const html = renderMarkdown('```\nSELECT * FROM users;\n```');
expect(html).toContain('<pre><code>SELECT * FROM users;');
expect(html).not.toContain('hljs-');
});
it('should leave a fence with an unknown language as plain text but keep its label', () => {
const html = renderMarkdown('```brainfuck\n+++\n```');
expect(html).toContain('<code class="language-brainfuck">+++');
expect(html).not.toContain('hljs-');
});
it('should keep code in a highlighted fence escaped', () => {
const html = renderMarkdown('```html\n<script>alert(1)</script>\n```');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;<span class="hljs-name">script</span>&gt;');
});
it('should strip classes other than highlighting classes from raw HTML spans and code', () => {
const html = renderMarkdown(
'<p><span class="hljs-keyword">kept</span> <span class="evil">dropped</span> <code class="language-js other">c</code></p>'
);
expect(html).toContain('<span class="hljs-keyword">kept</span>');
expect(html).toContain('<span>dropped</span>');
expect(html).toContain('<code class="language-js">c</code>');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
@@ -62,6 +96,58 @@ describe('renderMarkdown', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => {
// crumbs#83: a bulleted list inside a table cell can't be expressed in
// plain GFM, so the editor falls back to embedding the whole table as
// raw HTML in the saved markdown. The preview/share renderer must
// actually render that block, not show it as literal escaped text.
const html = renderMarkdown(
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
);
expect(html).toContain('<table>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('<li>Second</li>');
expect(html).not.toContain('&lt;table&gt;');
});
it('should strip a script tag from raw HTML content', () => {
// Note content isn't only ever written through the Tiptap editor - the
// MCP server and the notes API accept a raw markdown string directly,
// so this renderer can't trust that any HTML it's asked to render is
// safe just because rendering HTML at all is now allowed.
const html = renderMarkdown('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).toContain('hello');
});
it('should still render task-list checkboxes after sanitization', () => {
const html = renderMarkdown('[ ] todo\n\n[x] done');
expect(html).toContain('type="checkbox"');
expect(html).toContain('checked');
expect(html).toContain('disabled');
expect(html).toContain('class="task-checkbox"');
});
it('should render an empty task item as a checkbox, not literal brackets', () => {
const html = renderMarkdown('- [ ] Train tickets\n- [ ]');
expect(html).not.toContain('[ ]');
expect(html.match(/type="checkbox"/g)).toHaveLength(2);
});
it('should render an empty checked task item as a checked checkbox', () => {
const html = renderMarkdown('- [x]');
expect(html).not.toContain('[x]');
expect(html).toContain('<input type="checkbox" checked disabled class="task-checkbox">');
expect(html).toContain('class="task-list"');
});
it('should not treat brackets glued to text as a task marker', () => {
const html = renderMarkdown('- [ ]x');
expect(html).not.toContain('type="checkbox"');
expect(html).toContain('[ ]x');
});
});
describe('stripMarkdown', () => {
@@ -85,3 +171,16 @@ describe('stripMarkdown', () => {
expect(stripMarkdown('')).toBe('');
});
});
describe('renderMarkdown note-link handling', () => {
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
expect(html).not.toContain('<a');
expect(html).toContain('Grocery List');
});
it('should still render a normal external link as a real anchor', () => {
const html = renderMarkdown('See [Example](https://example.com) for details');
expect(html).toContain('<a href="https://example.com"');
});
});
+61 -7
View File
@@ -1,24 +1,59 @@
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { highlightCode } from './highlight.js';
const md = new MarkdownIt({
html: false,
html: true,
linkify: true,
typographer: true,
breaks: true
breaks: true,
// Returning '' makes markdown-it escape and wrap the block itself, so an
// unlabelled or unknown-language fence still renders as plain <pre><code>.
highlight: (code, lang) => highlightCode(code, lang) ?? ''
});
// Raw HTML has to render (not just be escaped as visible text): a table with
// a list in a cell can't be expressed in plain GFM, so the editor falls back
// to embedding it as an HTML block (see tiptap/Table.ts). Note content also
// isn't only ever written through that editor - the MCP server and the notes
// API accept a raw markdown string directly - so any HTML this renders has to
// be sanitized regardless of where it came from.
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'],
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
input: ['type', 'checked', 'disabled', 'class'],
ul: ['class']
},
// Syntax highlighting is carried by classes: `language-*` on the <code>
// of a fence and `hljs-*` (plus highlight.js sub-scopes like `function_`)
// on the token spans. Nothing else may put a class on these tags.
allowedClasses: {
code: [/^language-[\w+#.-]+$/],
span: [/^hljs-[\w-]+$/, /^language-[\w+#.-]+$/, /^[a-z]+_{1,2}$/]
},
// Keep markdown-it's bare void-element style (<br>, not <br />) so
// sanitizing doesn't change output for content that had no raw HTML to
// begin with.
selfClosing: []
};
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline') continue;
const content = tokens[i].content;
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
const checked = content.startsWith('[x] ');
// markdown-it trims trailing whitespace from inline content, so an item
// with no text after the marker arrives as exactly "[ ]" — match the
// marker followed by a space or end-of-content.
const marker = /^\[( |x)\](?: |$)/.exec(content);
if (marker) {
const checked = marker[1] === 'x';
const checkbox = checked
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
: '<input type="checkbox" disabled class="task-checkbox" /> ';
tokens[i].content = content.slice(4);
tokens[i].content = content.slice(marker[0].length);
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
// Prepend checkbox
const token = new state.Token('html_inline', '', 0);
@@ -28,7 +63,7 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
// Add task-list class to parent <ul> (once)
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'bullet_list_open') {
const cls = tokens[j].attrGet('class') ?? '';
const cls = String(tokens[j].attrGet('class') ?? '');
if (!cls.includes('task-list')) {
tokens[j].attrJoin('class', 'task-list');
}
@@ -39,8 +74,27 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
}
});
md.core.ruler.after('task-lists', 'note-links', (state) => {
for (const token of state.tokens) {
if (token.type !== 'inline' || !token.children) continue;
const children = token.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type !== 'link_open') continue;
const href = String(children[i].attrGet('href') ?? '');
if (!href.startsWith('crumb-note://')) continue;
let closeIndex = i + 1;
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
children.splice(closeIndex, 1);
children.splice(i, 1);
i -= 1;
}
}
});
export function renderMarkdown(content: string): string {
return md.render(content);
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
}
export function stripMarkdown(content: string): string {
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { extractNoteLinks } from './note-links.js';
describe('extractNoteLinks', () => {
it('should extract a single note-link reference', () => {
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
});
it('should extract multiple distinct references', () => {
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
});
it('should deduplicate repeated references', () => {
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
expect(extractNoteLinks(content)).toEqual(['id-1']);
});
it('should return an empty array for content with no note-links', () => {
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
});
it('should return an empty array for empty content', () => {
expect(extractNoteLinks('')).toEqual([]);
});
it('should not match crumb-note:// occurring inside a code block', () => {
const content = '```\n[A](crumb-note://id-1)\n```';
expect(extractNoteLinks(content)).toEqual([]);
});
it('should not match crumb-note:// occurring inside inline code', () => {
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Extract crumb-note://<id> references from markdown content.
* Mirrors extractTags' code-block/inline-code exclusion so a reference
* mentioned as an example inside a code sample isn't treated as a real link.
*/
export function extractNoteLinks(content: string): string[] {
if (!content) return [];
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
if (!matches) return [];
const ids = matches.map((m) => m.replace('crumb-note://', ''));
return [...new Set(ids)];
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
const DIGIT = /[23456789]/;
const SYMBOL = /[!@#$%^&*]/;
/** Visually ambiguous characters the charset deliberately excludes. */
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
describe('PASSWORD_ALPHABET', () => {
it('is exactly 64 distinct characters', () => {
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
// A charset edit that breaks this silently biases every password.
expect(PASSWORD_ALPHABET).toHaveLength(64);
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
});
it('excludes every visually ambiguous character', () => {
for (const char of AMBIGUOUS) {
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
}
});
});
describe('generatePassword', () => {
it('returns a 20-character password by default', () => {
expect(generatePassword()).toHaveLength(20);
});
it('honours an explicit length', () => {
expect(generatePassword(8)).toHaveLength(8);
expect(generatePassword(32)).toHaveLength(32);
expect(generatePassword(64)).toHaveLength(64);
});
it('always includes at least one character from each of the four groups', () => {
// Not a statistical bet: the function only ever returns a class-complete
// password or throws, so every sample must pass.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
expect(password, `sample ${i}`).toMatch(LOWERCASE);
expect(password, `sample ${i}`).toMatch(UPPERCASE);
expect(password, `sample ${i}`).toMatch(DIGIT);
expect(password, `sample ${i}`).toMatch(SYMBOL);
}
});
it('never emits a visually ambiguous character', () => {
// 0 O o 1 l I survive neither being read aloud nor retyped.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
for (const char of AMBIGUOUS) {
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
}
}
});
it('draws only from the pinned 64-character charset', () => {
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
for (let i = 0; i < 20; i++) {
expect(generatePassword(), `sample ${i}`).toMatch(charset);
}
});
it('produces a different password on each call', () => {
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
expect(passwords.size).toBe(20);
});
it('throws below the 8-character server minimum', () => {
expect(() => generatePassword(7)).toThrow(/8/);
expect(() => generatePassword(0)).toThrow(/8/);
expect(() => generatePassword(-1)).toThrow(/8/);
});
it('does not use Math.random anywhere in the module', () => {
// Randomness must come from nanoid's CSPRNG, not Math.random.
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
expect(source).not.toContain('Math.random');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { customAlphabet } from 'nanoid';
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
// password survives being read aloud or retyped from a screenshot.
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
const DIGITS = '23456789'; // no 0, no 1
const SYMBOLS = '!@#$%^&*';
/**
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
* favoured over another. At 64 symbols a 20-character password carries 120 bits
* of entropy — 119.79 once conditioned on the class-completeness retry below.
* Do not change the size without understanding both consequences.
*/
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
/** The server rejects anything shorter. */
const MIN_LENGTH = 8;
/**
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
* respectively — unreachable in practice. The bound exists so a future charset
* edit cannot turn the retry into an infinite loop.
*/
const MAX_ATTEMPTS = 100;
const randomString = customAlphabet(PASSWORD_ALPHABET);
function containsAnyOf(password: string, group: string): boolean {
return [...password].some((char) => group.includes(char));
}
/**
* Generates a random password containing at least one lowercase letter, one
* uppercase letter, one digit and one symbol.
*
* A password missing a group is discarded whole and regenerated, which keeps the
* result uniform over exactly the set of valid passwords. Forcing characters
* into chosen positions and shuffling would need its own unbiased index source.
*/
export function generatePassword(length = 20): string {
if (length < MIN_LENGTH) {
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
}
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const password = randomString(length);
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
}
throw new Error(
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
const DARK_THEME_COLOR = '#141312';
let darkMode = $state(false);
+1 -1
View File
@@ -37,7 +37,7 @@ describe('applyTheme', () => {
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
expect(meta?.getAttribute('content')).toBe('#141312');
});
it('updates meta theme-color for light mode', () => {
+419 -33
View File
@@ -1,6 +1,16 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { canResetPassword, passwordResetBlockedReason, canDisablePasswordLogin } from '$lib/utils/auth-methods.js';
import { generatePassword } from '$lib/utils/password.js';
import { tooltip } from '$lib/utils/tooltip.js';
import ShieldCheck from 'lucide-svelte/icons/shield-check';
import ShieldOff from 'lucide-svelte/icons/shield-off';
import KeyRound from 'lucide-svelte/icons/key-round';
import LockKeyhole from 'lucide-svelte/icons/lock-keyhole';
import LogOut from 'lucide-svelte/icons/log-out';
import Trash2 from 'lucide-svelte/icons/trash-2';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
let { data } = $props();
// svelte-ignore state_referenced_locally
@@ -15,6 +25,213 @@
let createMsg = $state('');
let createError = $state(false);
/**
* One row's password-reset state.
*
* The generated plaintext lives here and nowhere else — never localStorage,
* sessionStorage, IndexedDB, a sync payload or a log — so navigating away is
* enough to lose it. State is per row rather than shared because two rows'
* outcomes would otherwise overwrite each other, and a password destroyed that
* way is unrecoverable.
*/
type ResetState = {
inFlight: boolean;
/** Revealed plaintext. Set on success and on an unconfirmed outcome. */
password: string | null;
/** Row message. Always rendered *alongside* `password`, never instead of it. */
message: string | null;
copyStatus: string | null;
/**
* Reason the server refused, kept even after the message is dismissed. This
* list snapshots `authProvider` at page load and never refetches, so a row can
* still look eligible after the account moved to OAuth; without this the
* control would stay available and invite the same failure forever.
*/
serverReason: string | null;
};
const IDLE_RESET: ResetState = {
inFlight: false,
password: null,
message: null,
copyStatus: null,
serverReason: null
};
const SELF_RESET_REASON = 'Change your own password in Profile.';
/**
* The request left the device but no answer came back, so the server may well
* have stored this password. Revealing it anyway is deliberate: it is the only
* copy that exists, and dropping it could leave the account holding a credential
* nobody knows.
*/
const UNCONFIRMED_MESSAGE =
'Could not confirm the reset. This password may or may not have been applied. Keep it — if the user cannot sign in, reset again once you are back online.';
/**
* Definite, not hedged: the service worker caches only `GET /api/*`, so a PATCH
* issued while offline never leaves the browser and nothing can have changed.
*/
const OFFLINE_MESSAGE = "You're offline — nothing was changed. Try again when reconnected.";
let resets = $state<Record<number, ResetState>>({});
let announcement = $state('');
const resetButtons: Record<number, HTMLButtonElement | null> = {};
function userLabel(user: User): string {
return user.displayName || user.email;
}
/**
* Updates the live region's text. The region itself is rendered empty from the
* first paint and never inserted alongside its content — a region added to the
* DOM together with its text is not reliably announced, least of all by
* TalkBack.
*/
function announce(text: string) {
announcement = text;
}
/** Why this row's password cannot be reset, or null when it can. */
function resetBlockedReason(user: User): string | null {
// Takes precedence over eligibility: an admin changes their own password in
// the profile flow, which verifies the current one.
if (user.id === data.user?.id) return SELF_RESET_REASON;
const serverReason = resets[user.id]?.serverReason;
if (serverReason) return serverReason;
if (!canResetPassword(user)) {
// auth-methods guarantees a non-empty reason whenever the check fails; the
// ?? only satisfies the nullable return type, as on the API route.
return passwordResetBlockedReason(user) ?? 'Password login is not available for this account.';
}
return null;
}
/**
* How one row's control presents itself. `unavailable` is expressed with
* `aria-disabled`, never the `disabled` attribute: a disabled button leaves the
* tab order, so a keyboard or screen-reader user loses the element they just
* activated, and a `title` tooltip explains nothing on a touch screen.
*/
function resetControl(user: User): {
reason: string | null;
inFlight: boolean;
unavailable: boolean;
} {
const state = resets[user.id];
const reason = resetBlockedReason(user);
const inFlight = state?.inFlight === true;
return {
reason,
inFlight,
unavailable: reason !== null || inFlight || state?.password != null
};
}
/** Merges a partial update into one row's state, leaving untouched fields alone. */
function patchReset(userId: number, patch: Partial<ResetState>) {
resets[userId] = { ...IDLE_RESET, ...resets[userId], ...patch };
}
function revealUnconfirmed(user: User, password: string) {
patchReset(user.id, { inFlight: false, password, message: UNCONFIRMED_MESSAGE });
announce(
`Could not confirm the password reset for ${userLabel(user)}. The generated password is shown in their row — keep it.`
);
}
async function resetUserPassword(user: User) {
// The control stays focusable while unavailable, so activation is refused
// here instead of by the browser.
if (resetControl(user).unavailable) return;
// Checked before the request, so the offline outcome is reported as the
// certainty it is rather than as a lost response.
if (navigator.onLine === false) {
patchReset(user.id, { message: OFFLINE_MESSAGE, copyStatus: null });
announce(`${userLabel(user)}: ${OFFLINE_MESSAGE}`);
return;
}
const password = generatePassword();
patchReset(user.id, { inFlight: true, message: null, copyStatus: null });
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: password })
});
if (res.ok) {
patchReset(user.id, { inFlight: false, password, message: null });
announce(
`New password generated for ${userLabel(user)}. It is shown in their row — copy it now, it cannot be shown again.`
);
return;
}
if (res.status === 404) {
users = users.filter((u) => u.id !== user.id);
// No account, no credential: drop the row's plaintext with the row.
delete resets[user.id];
announce(
`${userLabel(user)} no longer exists. The row was removed and no password was set.`
);
return;
}
const body = await res.json().catch(() => ({}));
const message = typeof body.message === 'string' && body.message ? body.message : null;
if (message !== null && res.status < 500) {
// A refusal the server states outright: nothing was stored, so no
// password is revealed and the reason sticks to the control.
patchReset(user.id, { inFlight: false, message, serverReason: message });
announce(`Password reset failed for ${userLabel(user)}. ${message}`);
return;
}
// A status without a readable reason says nothing about what the server did.
revealUnconfirmed(user, password);
} catch {
// The response was lost, not refused — see UNCONFIRMED_MESSAGE.
revealUnconfirmed(user, password);
}
}
async function copyPassword(user: User) {
const password = resets[user.id]?.password;
if (!password) return;
try {
// Absent outside a secure context, which a LAN-IP HTTP deployment is not,
// so the failure below has to be a working fallback rather than a dead end.
await navigator.clipboard.writeText(password);
patchReset(user.id, { copyStatus: 'Copied to clipboard.' });
announce(`Password for ${userLabel(user)} copied to clipboard.`);
} catch {
patchReset(user.id, {
copyStatus: 'Could not copy automatically — select the password and copy it by hand.'
});
announce(
`Could not copy the password for ${userLabel(user)}. Select it in their row and copy it by hand.`
);
}
}
function dismissReset(user: User) {
const serverReason = resets[user.id]?.serverReason ?? null;
// A server refusal explains the control's state, which outlives the message
// being dismissed here.
if (serverReason) resets[user.id] = { ...IDLE_RESET, serverReason };
else delete resets[user.id];
announce(`Password reset message for ${userLabel(user)} cleared.`);
// Never drop the admin on <body>: give the row's control the focus back.
resetButtons[user.id]?.focus();
}
async function createUser() {
createMsg = '';
if (!newEmail) {
@@ -70,6 +287,7 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
delete resets[userId];
createMsg = 'User deleted';
createError = false;
} else {
@@ -114,6 +332,37 @@
// ignore
}
}
async function disablePasswordLogin(user: User) {
if (!canDisablePasswordLogin(user, data.user?.id ?? -1)) return;
if (
!confirm(
`Disable password login for ${userLabel(user)}? They will need to sign in via their linked OAuth provider. All sessions will be revoked.`
)
) {
return;
}
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disablePasswordLogin: true })
});
if (res.ok) {
const updated = await res.json();
users = users.map((u) => (u.id === user.id ? updated : u));
createMsg = `Password login disabled for ${userLabel(user)}`;
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to disable password login';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
</script>
<!-- Create User -->
@@ -172,46 +421,183 @@
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
<!-- Rendered empty on first paint; only its text is ever replaced, so both
reveals and per-row failures are announced from a region assistive tech
has already been watching. -->
<p
role="status"
aria-live="polite"
data-testid="reset-password-live"
class="sr-only"
>{announcement}</p>
<div class="space-y-3">
{#each users as user (user.id)}
{@const reset = resets[user.id]}
{@const control = resetControl(user)}
{@const roleAction = user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
{@const resetLabel = control.inFlight ? 'Resetting…' : 'Reset password'}
<div
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
class="flex flex-col gap-3 rounded-sm border border-[var(--border-subtle)] p-4"
data-testid="user-row-{user.id}"
>
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
{/if}
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<!-- One uniform 44x44 square per action, replacing four text labels that
wrapped onto three lines at phone width. `flex-wrap` is what keeps the
squares square: flex compresses items only on a line that overflows, so
breaking the line means no button is ever asked to shrink. Drop it and
the four flatten to 40x44 at 320px — under the minimum, on a row whose
Delete is irreversible. They hold one line down to 336px, which is
exactly 4x44 plus the gaps, and take a second line below that: correctly
sized targets over two lines beat cramped ones on one. The container's
own `shrink-0` is a different mechanism and does carry weight — from
640px the row turns `sm:flex-row`, where the truncating name block would
otherwise squeeze the strip. On the buttons themselves that same token
would do nothing, `flex-wrap` having already spared them. Each icon
carries both a tooltip, for a mouse, and an aria-label, since an icon
alone has no accessible name. -->
<div class="flex shrink-0 flex-wrap items-center gap-1">
<button
onclick={() => toggleRole(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={roleAction}
aria-label={roleAction}
>
{#if user.role === 'admin'}
<ShieldOff class="h-4 w-4" />
{:else}
<ShieldCheck class="h-4 w-4" />
{/if}
</button>
<!-- Offered on every row, the admin's own included: a control that
vanishes is the confusion this replaces. -->
<button
bind:this={resetButtons[user.id]}
onclick={() => resetUserPassword(user)}
data-testid="reset-password-btn-{user.id}"
aria-disabled={control.unavailable}
aria-describedby={control.reason ? `reset-password-reason-${user.id}` : undefined}
use:tooltip={resetLabel}
aria-label={resetLabel}
class="inline-flex size-11 items-center justify-center rounded-sm transition-colors duration-150 ease-out {control.unavailable
? 'cursor-not-allowed border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)]'
: 'text-[var(--text)] hover:bg-[var(--hover-wash)]/10'}"
>
{#if control.inFlight}
<!-- The label went with the text, so in-flight has to be carried by
the icon and by the accessible name above. -->
<LoaderCircle class="h-4 w-4 animate-spin" />
{:else}
<KeyRound class="h-4 w-4" />
{/if}
</button>
{#if canDisablePasswordLogin(user, data.user?.id ?? -1)}
<button
onclick={() => disablePasswordLogin(user)}
data-testid="disable-password-btn-{user.id}"
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={'Disable password login'}
aria-label="Disable password login"
>
<LockKeyhole class="h-4 w-4" />
</button>
{/if}
</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
title="Force logout"
>
Revoke
</button>
<button
onclick={() => deleteUser(user.id)}
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
</button>
{/if}
{#if user.id !== data.user?.id}
<button
onclick={() => revokeSessions(user)}
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors duration-150 ease-out hover:bg-[var(--hover-wash)]/10"
use:tooltip={'Revoke all sessions'}
aria-label="Revoke all sessions"
>
<LogOut class="h-4 w-4" />
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="inline-flex size-11 items-center justify-center rounded-sm text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
use:tooltip={'Delete user'}
aria-label="Delete user"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</div>
</div>
{#if control.reason || reset?.message || reset?.password}
<div class="flex flex-col gap-2">
{#if control.reason}
<!-- Visible text, not a tooltip: a title attribute is unreachable
by touch, and colour alone explains nothing. -->
<p
id="reset-password-reason-{user.id}"
data-testid="reset-password-reason-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{control.reason}</p>
{/if}
{#if reset?.message}
<p
data-testid="reset-password-error-{user.id}"
class="rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-2 text-xs text-[var(--error-text)]"
>{reset.message}</p>
{/if}
{#if reset?.password}
<!-- Rendered independently of the message above, so a failure can
never replace a password that is already the account's only
credential. -->
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border)] bg-[var(--bg-base)] p-3 shadow-[var(--card-shadow)]">
<p class="text-xs text-[var(--text-muted)]">
New password for {userLabel(user)} — shown once, not stored anywhere.
</p>
<code
data-testid="generated-password-{user.id}"
class="block break-all select-all text-sm text-[var(--text)]"
>{reset.password}</code>
</div>
{/if}
{#if reset?.message || reset?.password}
<!-- 44px targets with a wide gap: dismissing is irreversible, so a
mis-tap next to Copy would destroy a live credential. -->
<div class="flex flex-wrap items-center gap-3">
{#if reset?.password}
<button
onclick={() => copyPassword(user)}
data-testid="copy-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--border-subtle)] px-3 text-xs text-[var(--text)] transition-colors duration-150 ease-out hover:border-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
>
Copy
</button>
{/if}
<button
onclick={() => dismissReset(user)}
data-testid="dismiss-password-btn-{user.id}"
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm border border-[var(--destructive)] px-3 text-xs text-[var(--destructive)] transition-colors duration-150 ease-out hover:bg-[var(--error-bg)]"
>
Dismiss
</button>
</div>
{/if}
{#if reset?.copyStatus}
<p
data-testid="copy-status-{user.id}"
class="text-xs text-[var(--text-muted)]"
>{reset.copyStatus}</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
+18 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { linkifyText } from '$lib/utils/checklist.js';
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
@@ -12,8 +12,12 @@
const { data } = $props();
// Both palettes are emitted as custom properties so the card follows the visitor's theme on the
// first paint — the CSS variables in app.css switch on [data-theme], which app.html sets before
// hydration, so a JS-resolved single colour would flash the wrong palette on this SSR page.
const noteColor = data.color as NoteColor;
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const bgLight = getNoteColor(noteColor, false);
const bgDark = getNoteColor(noteColor, true);
const renderedContent = renderMarkdown(data.content);
@@ -55,8 +59,8 @@
<div class="mx-4 w-full max-w-2xl py-10">
<article
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="background-color: {bgColor}"
class="note-surface rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="--note-bg: {bgLight}; --note-bg-dark: {bgDark}"
data-testid="shared-note"
>
<div class="px-6 py-5">
@@ -128,3 +132,13 @@
</a>
</div>
</div>
<style>
.note-surface {
background-color: var(--note-bg);
}
:global([data-theme='dark']) .note-surface {
background-color: var(--note-bg-dark);
}
</style>
+26 -1
View File
@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions, disablePasswordLogin } from '$lib/server/auth.js';
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
@@ -15,6 +15,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (body.role !== undefined) {
if (body.role !== 'admin' && body.role !== 'user') {
throw error(400, 'Invalid role');
}
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
@@ -31,7 +34,13 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
if (body.newPassword.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
// Prevent admin from resetting their own password — changing your own
// password goes through the profile flow, which verifies the current one.
if (userId === admin.id) {
throw error(400, 'Cannot reset your own password — change it from your profile instead');
}
await resetPassword(userId, body.newPassword);
revokeAllSessions(userId);
// Notify user of password reset (fire-and-forget)
if (isEmailConfigured() && user.email) {
@@ -39,6 +48,22 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
}
}
if (body.disablePasswordLogin) {
if (userId === admin.id) {
throw error(400, 'Cannot disable password login on your own account');
}
if (!user.passwordLoginEnabled) {
throw error(400, 'Password login is not enabled for this account');
}
if (user.authProvider === 'password' || user.authProvider === 'none') {
throw error(
400,
'Password login can only be disabled when the account is linked to OAuth'
);
}
disablePasswordLogin(userId);
}
if (body.revokeSessions) {
revokeAllSessions(userId);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
import { requiresPasswordForAccountDeletion } from '$lib/utils/auth-methods.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
@@ -11,8 +12,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
const body = await request.json();
// OAuth users don't have passwords — allow deletion without password
if (user.authProvider === 'password') {
// Password required for pure password accounts; OAuth-linked accounts may delete
// with their active SSO session even when password login was admin-enabled.
if (requiresPasswordForAccountDeletion(user)) {
if (!body.password) throw error(400, 'Password is required');
const verified = await verifyPassword(user.email, body.password);
if (!verified) throw error(401, 'Invalid password');
@@ -23,6 +23,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
@@ -31,6 +31,7 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
return new Response(buffer, {
headers: {
'Content-Type': mimeType,
'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=3600'
}
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.27.1",
"version": "0.33.0",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+874 -1
View File
@@ -1,13 +1,27 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, TEST_EMAIL } from './helpers/fixtures.js';
import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import Database from 'better-sqlite3';
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');
const TEST_DB = './data/test-crumbs.db';
/** Link a user row to an OAuth provider in the test database (simulates first SSO sign-in). */
function linkUserToOAuth(userId: number, provider = 'google', providerId?: string): void {
const db = new Database(TEST_DB);
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?').run(
provider,
providerId ?? `test-${userId}`,
userId
);
db.close();
}
/** Create a victim user via the admin API, returning the user's id. */
async function createVictim(
@@ -23,6 +37,36 @@ async function createVictim(
return user.id;
}
/**
* Create a victim user with no password via the admin API, returning the user's id.
* Omitting the password is the OAuth-only path: createUser() stores authProvider
* 'none' with passwordLoginEnabled false until an admin reset enables it.
*/
async function createOAuthOnlyVictim(
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, role: 'user' }
});
expect(res.status()).toBe(201);
const user = await res.json();
expect(user.authProvider).toBe('none');
expect(user.passwordLoginEnabled).toBe(false);
return user.id;
}
/** The id of the admin account the authenticated page is signed in as. */
async function currentAdminId(page: import('@playwright/test').Page): Promise<number> {
const res = await page.request.get('/api/admin/users');
expect(res.status()).toBe(200);
const users = (await res.json()) as Array<{ id: number; email: string }>;
const admin = users.find((u) => u.email === TEST_EMAIL);
if (!admin) throw new Error(`admin ${TEST_EMAIL} is missing from the users list`);
return admin.id;
}
test.describe.serial('Admin — User Deletion', () => {
test('Scenario: Admin deletes a user from the users list', async ({
authenticatedPage: page
@@ -151,3 +195,832 @@ test.describe.serial('Admin — User Deletion', () => {
expect(loginRes.status()).toBe(401);
});
});
test.describe.serial('Admin — Password Reset', () => {
test('Scenario: An account can sign in with a password the admin set', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user exists
const email = 'reset-target@test.com';
const victimId = await createVictim(page, email, 'Reset Target');
// When the admin sets a new password for that account
const newPassword = `reset-${randomUUID()}`;
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword }
});
// Then the reset succeeds
expect(res.status()).toBe(200);
// And the account can sign in with the new password. A separate browser
// context keeps the resulting session cookie out of the admin's jar.
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: newPassword }
});
expect(loginRes.status()).toBe(200);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An OAuth-only account can be given a password by an admin', async ({
authenticatedPage: page,
browser
}) => {
// Given an OAuth-only user exists
const email = 'oauth-only@test.com';
const victimId = await createOAuthOnlyVictim(page, email, 'OAuth Only');
// When the admin sets a password for that account
const newPassword = `oauth-pw-${randomUUID()}`;
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword }
});
// Then the reset succeeds and enables password login
expect(res.status()).toBe(200);
const updated = await res.json();
expect(updated.passwordLoginEnabled).toBe(true);
// And the account can sign in with the new password while keeping OAuth
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: newPassword }
});
expect(loginRes.status()).toBe(200);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A password reset revokes all existing sessions', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user with an active session
const email = 'revoke-on-reset@test.com';
const victimId = await createVictim(page, email, 'Revoke Reset');
const ctx = await browser.newContext();
const loginRes = await ctx.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(loginRes.status()).toBe(200);
// When the admin sets a new password for that account
const newPassword = `revoke-${randomUUID()}`;
const resetRes = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword }
});
expect(resetRes.status()).toBe(200);
// Then the old session is no longer valid
expect((await ctx.request.get('/api/notes')).status()).toBe(401);
await ctx.close();
// And the new password still works
const fresh = await browser.newContext();
expect(
(
await fresh.request.post('/api/auth/login', {
data: { email, password: newPassword }
})
).status()
).toBe(200);
await fresh.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An admin can disable password login on an OAuth-linked user', async ({
authenticatedPage: page
}) => {
// Given an OAuth-linked user with password login enabled
const email = 'disable-pw@test.com';
const victimId = await createVictim(page, email, 'Disable Pw');
linkUserToOAuth(victimId);
// When the admin disables password login for that account
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { disablePasswordLogin: true }
});
// Then password login is off and the old password no longer works
expect(res.status()).toBe(200);
expect((await res.json()).passwordLoginEnabled).toBe(false);
expect(
(await page.request.post('/api/auth/login', { data: { email, password: userPassword } }))
.status()
).toBe(401);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: An admin cannot reset their own password', async ({
authenticatedPage: page
}) => {
// Given the admin's own account
const adminId = await currentAdminId(page);
// When the admin targets their own account for a password reset
const res = await page.request.patch(`/api/admin/users/${adminId}`, {
data: { newPassword: `self-${randomUUID()}` }
});
// Then the attempt is rejected and the response says why
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/your own password/i);
});
test('Scenario: A password below the minimum length is rejected for its length, whatever the account', async ({
authenticatedPage: page
}) => {
// Given an OAuth-only user exists, who is also ineligible for a reset
const victimId = await createOAuthOnlyVictim(page, 'short-pw-oauth@test.com', 'Short Pw');
// When the admin attempts to set a password shorter than the minimum
const res = await page.request.patch(`/api/admin/users/${victimId}`, {
data: { newPassword: 'short' }
});
// Then the length rule is what the response reports, taking precedence
// over the eligibility rule
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message).toMatch(/at least 8 characters/i);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
});
/** Open the users settings page and wait for it to hydrate. */
async function openUsersPage(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
}
/**
* Reset a row's password through the UI and return the revealed plaintext.
* Trimmed because the reveal is markup-formatted text, not a form value.
*/
async function resetViaUi(
page: import('@playwright/test').Page,
victimId: number
): Promise<string> {
await page.getByTestId(`reset-password-btn-${victimId}`).click();
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
const revealed = (await reveal.textContent())?.trim() ?? '';
expect(revealed.length).toBeGreaterThanOrEqual(8);
return revealed;
}
/**
* The four row action buttons, in DOM order, named by their accessible name. An
* eligible row that is not the signed-in admin's own offers all four.
*/
const ROW_ACTION_LABELS = [
'Promote to admin',
'Reset password',
'Disable password login',
'Revoke all sessions',
'Delete user'
] as const;
test.describe('Admin — Password Reset UI', () => {
test('Scenario: Every user row offers a reset control, and an unavailable one says why', async ({
authenticatedPage: page
}) => {
// Given a password-auth user and an OAuth-only user exist
const eligibleId = await createVictim(page, 'ui-reason-pw@test.com', 'UI Reason Pw');
const oauthId = await createOAuthOnlyVictim(page, 'ui-reason-oauth@test.com', 'UI Reason OAuth');
const adminId = await currentAdminId(page);
// When the admin views the users list
const patchedIds: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH' && req.url().includes('/api/admin/users/')) {
patchedIds.push(req.url());
}
});
await openUsersPage(page);
// Then the control is offered on every row, including the admin's own
for (const id of [eligibleId, oauthId, adminId]) {
await expect(page.getByTestId(`reset-password-btn-${id}`)).toBeVisible();
}
// And it is available for both password and OAuth-only accounts
for (const id of [eligibleId, oauthId]) {
await expect(page.getByTestId(`reset-password-btn-${id}`)).toHaveAttribute(
'aria-disabled',
'false'
);
await expect(page.getByTestId(`reset-password-reason-${id}`)).toHaveCount(0);
}
// And the admin's own row points them at their profile instead
const ownBtn = page.getByTestId(`reset-password-btn-${adminId}`);
await expect(ownBtn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${adminId}`)).toContainText(/Profile/i);
// And an unavailable control stays reachable by keyboard
await expect(ownBtn).not.toHaveAttribute('disabled', /.*/);
await ownBtn.focus();
await expect(ownBtn).toBeFocused();
// And activating the admin's own control changes nothing
await ownBtn.click({ force: true });
await expect(page.getByTestId(`generated-password-${adminId}`)).toHaveCount(0);
expect(patchedIds).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${eligibleId}`);
await page.request.delete(`/api/admin/users/${oauthId}`);
});
test('Scenario: An account signs in with the password the admin generated, and its old one stops working', async ({
authenticatedPage: page,
browser
}) => {
// Given a password-auth user exists
const email = 'ui-roundtrip@test.com';
const victimId = await createVictim(page, email, 'UI Roundtrip');
await openUsersPage(page);
// And the announcement region is present and silent
await expect(page.getByTestId('reset-password-live')).toHaveText('');
// When the admin resets that account's password
const patch = page.waitForResponse(
(res) =>
res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'PATCH'
);
const revealed = await resetViaUi(page, victimId);
expect((await patch).status()).toBe(200);
// Then the reveal is announced
await expect(page.getByTestId('reset-password-live')).toContainText(/password/i);
// And that row's control is unavailable until the reveal is dismissed
await expect(page.getByTestId(`reset-password-btn-${victimId}`)).toHaveAttribute(
'aria-disabled',
'true'
);
// And the account signs in with the generated password, from a context whose
// cookie jar is separate from the admin's
const ctx = await browser.newContext();
const newLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: revealed }
});
expect(newLogin.status()).toBe(200);
// And the password it had before is refused. 401 exactly: a 429 would mean the
// shared test IP is rate-limited, which proves nothing about the credential.
const oldLogin = await ctx.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(oldLogin.status()).toBe(401);
await ctx.close();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is never persisted or logged', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists
const victimId = await createVictim(page, 'ui-nostore@test.com', 'UI No Store');
const consoleMessages: string[] = [];
page.on('console', (msg) => consoleMessages.push(msg.text()));
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then the plaintext reaches no persistent client storage
const leaks = await page.evaluate(async (secret: string) => {
const hits: string[] = [];
const scanWebStorage = (store: Storage, label: string) => {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (key === null) continue;
if (key.includes(secret) || (store.getItem(key) ?? '').includes(secret)) {
hits.push(`${label}:${key}`);
}
}
};
scanWebStorage(localStorage, 'localStorage');
scanWebStorage(sessionStorage, 'sessionStorage');
const databases = (await indexedDB.databases?.()) ?? [];
for (const info of databases) {
if (!info.name) continue;
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(info.name as string);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const storeName of Array.from(db.objectStoreNames)) {
const records = await new Promise<unknown[]>((resolve, reject) => {
const request = db.transaction(storeName, 'readonly').objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
let serialised = '';
try {
serialised = JSON.stringify(records) ?? '';
} catch {
serialised = '';
}
if (serialised.includes(secret)) hits.push(`indexedDB:${info.name}/${storeName}`);
}
db.close();
}
return hits;
}, revealed);
expect(leaks).toEqual([]);
// And it appears in no console output
expect(consoleMessages.filter((m) => m.includes(revealed))).toEqual([]);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A revealed password is shown whole on a phone-sized screen', async ({
authenticatedPage: page
}) => {
// Given a password-auth user seen on a 360px-wide screen
const victimId = await createVictim(page, 'ui-narrow@test.com', 'UI Narrow');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
const revealed = await resetViaUi(page, victimId);
// Then none of it is clipped away — truncating it would quietly corrupt the
// fallback of copying it by hand
const shown = await page.getByTestId(`generated-password-${victimId}`).evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
text: (el.textContent ?? '').trim()
}));
expect(shown.text).toBe(revealed);
expect(shown.clientWidth).toBeGreaterThan(0);
expect(shown.scrollWidth).toBeLessThanOrEqual(shown.clientWidth);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Copying a revealed password confirms that it was copied', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard is available
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const victimId = await createVictim(page, 'ui-copy-ok@test.com', 'UI Copy Ok');
await openUsersPage(page);
// When the admin resets the password and copies it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the copy is confirmed and the clipboard holds the password
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/copied/i);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(revealed);
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused clipboard leaves the password copyable by hand', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the clipboard refuses writes.
// defineProperty, not assignment: navigator.clipboard is a getter-only
// accessor, so assigning to it silently leaves the real API in place.
const victimId = await createVictim(page, 'ui-copy-denied@test.com', 'UI Copy Denied');
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
get: () => ({
writeText: () => Promise.reject(new DOMException('denied', 'NotAllowedError'))
})
});
});
await openUsersPage(page);
// When the admin resets the password and tries to copy it
const revealed = await resetViaUi(page, victimId);
await page.getByTestId(`copy-password-btn-${victimId}`).click();
// Then the failure is reported as a manual-copy fallback
await expect(page.getByTestId(`copy-status-${victimId}`)).toContainText(/by hand|manually/i);
// And the password is still shown, selectable, for copying by hand
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect((await reveal.textContent())?.trim()).toBe(revealed);
expect(await reveal.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Dismissing a reveal clears the password and leaves focus on that row', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose password was just reset
const victimId = await createVictim(page, 'ui-dismiss@test.com', 'UI Dismiss');
await openUsersPage(page);
await resetViaUi(page, victimId);
// When the admin dismisses the reveal
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
// Then the password is gone from the page
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toHaveCount(0);
// And focus is back on that row's control, which is available again
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'false');
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Each of the three controls is large enough to hit on a phone', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists, seen on a phone-sized screen
const victimId = await createVictim(page, 'ui-target@test.com', 'UI Target');
await page.setViewportSize({ width: 360, height: 800 });
await openUsersPage(page);
// When the admin resets that account's password
await resetViaUi(page, victimId);
// Then every control in the flow meets the 44px minimum. Dismiss destroys the
// only copy of the credential, so a cramped target is a data-loss risk.
for (const testId of [
`reset-password-btn-${victimId}`,
`copy-password-btn-${victimId}`,
`dismiss-password-btn-${victimId}`
]) {
const box = await page.getByTestId(testId).boundingBox();
expect(box, testId).not.toBeNull();
expect(Math.min(box!.width, box!.height), testId).toBeGreaterThanOrEqual(44);
}
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Row actions stay full-size square targets at every phone width', async ({
authenticatedPage: page
}) => {
// Given an OAuth-linked user, whose row therefore offers all five actions
// (the disable-password control is only offered where another sign-in
// method exists)
const victimId = await createVictim(page, 'ui-strip@test.com', 'UI Strip');
linkUserToOAuth(victimId);
// The strip fits four of its five 44px squares on one line and wraps the
// fifth below it at every phone width, so all three widths exercise the
// wrap case; 300px is narrow enough that a strip which refused to wrap
// would be pushed clear of its row, which the containment check below
// then catches.
for (const [width, fitsOneLine] of [
[360, false],
[320, false],
[300, false]
] as const) {
// When the admin views the users list on a screen that wide
await page.setViewportSize({ width, height: 800 });
await openUsersPage(page);
const row = page.getByTestId(`user-row-${victimId}`);
const boxes = [];
for (const label of ROW_ACTION_LABELS) {
const box = await row.getByRole('button', { name: label, exact: true }).boundingBox();
expect(box, `${label} at ${width}px`).not.toBeNull();
boxes.push(box!);
}
// Then every action is a square that clears the 44px minimum. One of them
// deletes an account irreversibly, so an action that shrank to fit its row
// would put a data-losing target under a thumb.
for (const [i, box] of boxes.entries()) {
const at = `${ROW_ACTION_LABELS[i]} at ${width}px`;
expect(Math.min(box.width, box.height), at).toBeGreaterThanOrEqual(44);
expect(Math.abs(box.width - box.height), at).toBeLessThan(1);
}
// And they sit on one line wherever there is room for one
const lines = new Set(boxes.map((box) => Math.round(box.y)));
if (fitsOneLine) expect(lines.size, `lines at ${width}px`).toBe(1);
// And where there is not, they wrap rather than spill out of their row or
// push the page sideways
const rowBox = await row.boundingBox();
expect(rowBox, `row at ${width}px`).not.toBeNull();
for (const [i, box] of boxes.entries()) {
expect(box.x + box.width, `${ROW_ACTION_LABELS[i]} at ${width}px`).toBeLessThanOrEqual(
rowBox!.x + rowBox!.width
);
}
const overflows = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(overflows, `horizontal overflow at ${width}px`).toBe(false);
}
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A refused reset reports the server reason and reveals no password', async ({
authenticatedPage: page
}) => {
// Given a user the server refuses to reset — an account whose sign-in method
// changed after the list was loaded, so the row still looks eligible
const victimId = await createVictim(page, 'ui-rejected@test.com', 'UI Rejected');
const serverReason = 'Managed by SSO — password login is disabled for this account.';
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: serverReason })
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the refusal is shown in that row, and announced
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(serverReason);
await expect(page.getByTestId('reset-password-live')).toContainText(serverReason);
// And no password is revealed
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// And the control no longer invites a retry that would fail the same way
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toContainText(serverReason);
// And dismissing the message keeps that explanation in place
await page.getByTestId(`dismiss-password-btn-${victimId}`).click();
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toHaveCount(0);
await expect(btn).toBeFocused();
await expect(btn).toHaveAttribute('aria-disabled', 'true');
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset whose outcome is unknown keeps the generated password', async ({
authenticatedPage: page
}) => {
// Given a user whose reset response never arrives, so the server may or may
// not have stored the new password
const victimId = await createVictim(page, 'ui-unknown@test.com', 'UI Unknown');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.abort('connectionreset');
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because it may be the account's only
// working credential and nothing else holds a copy of it
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
await expect(page.getByTestId(`copy-password-btn-${victimId}`)).toBeVisible();
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A server-side failure keeps the generated password even when it gives a reason', async ({
authenticatedPage: page
}) => {
// Given a user whose reset fails inside the server, with a reason attached —
// a reason says why the server is unhappy, never whether it already stored
// the password, so the outcome is unknown however readable the body is
const victimId = await createVictim(page, 'ui-5xx-reason@test.com', 'UI 5xx Reason');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ message: 'Database is temporarily read-only.' })
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated rather than the server's reason being taken
// as a refusal
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because the server may already hold it
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A rejection nobody can read keeps the generated password', async ({
authenticatedPage: page
}) => {
// Given a user whose reset comes back as an unreadable error page — what a
// reverse proxy returns when it answers instead of the app, so the request may
// have reached the server or may not have
const victimId = await createVictim(page, 'ui-html-error@test.com', 'UI Html Error');
await openUsersPage(page);
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'text/html',
body: '<html><body><h1>400 Bad Request</h1></body></html>'
});
});
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the uncertainty is stated rather than the status alone being read as a
// refusal
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/may or may not/i
);
// And the generated password is kept, because nothing said it was not stored
const reveal = page.getByTestId(`generated-password-${victimId}`);
await expect(reveal).toBeVisible();
expect(((await reveal.textContent()) ?? '').trim().length).toBeGreaterThanOrEqual(8);
// And the control is not left explaining a reason it never received
await expect(page.getByTestId(`reset-password-reason-${victimId}`)).toHaveCount(0);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: A reset attempted offline reports that nothing was changed', async ({
authenticatedPage: page
}) => {
// Given a password-auth user exists and the browser has no connection
const victimId = await createVictim(page, 'ui-offline@test.com', 'UI Offline');
await openUsersPage(page);
const patchRequests: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PATCH') patchRequests.push(req.url());
});
await page.context().setOffline(true);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the outcome is stated as definite: nothing left the device
await expect(page.getByTestId(`reset-password-error-${victimId}`)).toContainText(
/offline.*nothing was changed/i
);
expect(patchRequests).toEqual([]);
// And no password is revealed, since none was set
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
// Restore connectivity — Playwright does not reliably emit the online event
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: One row\'s reset never touches another row', async ({
authenticatedPage: page
}) => {
// Given two password-auth users exist
const firstId = await createVictim(page, 'ui-isolation-a@test.com', 'UI Isolation A');
const secondId = await createVictim(page, 'ui-isolation-b@test.com', 'UI Isolation B');
await openUsersPage(page);
// When the first account's password is reset
const revealed = await resetViaUi(page, firstId);
// Then the second row shows neither a password nor a message
await expect(page.getByTestId(`generated-password-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toHaveCount(0);
await expect(page.getByTestId(`reset-password-btn-${secondId}`)).toHaveAttribute(
'aria-disabled',
'false'
);
// And when the second account's reset is refused
await page.route(`**/api/admin/users/${secondId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: 'Password login is not available for this account.' })
});
});
await page.getByTestId(`reset-password-btn-${secondId}`).click();
await expect(page.getByTestId(`reset-password-error-${secondId}`)).toBeVisible();
// Then the first row keeps its password and stays free of that message
expect((await page.getByTestId(`generated-password-${firstId}`).textContent())?.trim()).toBe(
revealed
);
await expect(page.getByTestId(`reset-password-error-${firstId}`)).toHaveCount(0);
// Clean up
await page.unroute(`**/api/admin/users/${secondId}`);
await page.request.delete(`/api/admin/users/${firstId}`);
await page.request.delete(`/api/admin/users/${secondId}`);
});
test('Scenario: Resetting an account that no longer exists drops it from the list', async ({
authenticatedPage: page
}) => {
// Given a user who is deleted after the list was loaded
const victimId = await createVictim(page, 'ui-missing@test.com', 'UI Missing');
await openUsersPage(page);
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
expect((await page.request.delete(`/api/admin/users/${victimId}`)).status()).toBe(200);
// When the admin resets that account's password
await page.getByTestId(`reset-password-btn-${victimId}`).click();
// Then the row disappears and no password is left behind
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
await expect(page.getByTestId(`generated-password-${victimId}`)).toHaveCount(0);
});
test('Scenario: A reset in flight cannot be issued twice', async ({
authenticatedPage: page
}) => {
// Given a password-auth user whose reset response is slow to arrive
const victimId = await createVictim(page, 'ui-inflight@test.com', 'UI In Flight');
await openUsersPage(page);
let patchCount = 0;
await page.route(`**/api/admin/users/${victimId}`, async (route) => {
if (route.request().method() !== 'PATCH') return route.continue();
patchCount++;
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});
// When the admin activates the control twice in a row
const btn = page.getByTestId(`reset-password-btn-${victimId}`);
await btn.click();
await expect(btn).toHaveAccessibleName(/Resetting/);
await expect(btn).toHaveAttribute('aria-disabled', 'true');
await btn.click({ force: true });
// Then only one reset is issued
await expect(page.getByTestId(`generated-password-${victimId}`)).toBeVisible();
expect(patchCount).toBe(1);
// Clean up
await page.unroute(`**/api/admin/users/${victimId}`);
await page.request.delete(`/api/admin/users/${victimId}`);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { test as setup } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures';
import { STORAGE_STATE_FILE } from './global-setup';
/**
* Log in as the admin once and persist the session cookie. The `app` project
* loads this storage state into every browser context, so tests start already
* authenticated instead of each one submitting the login form (~0.8s per test,
* roughly half of a typical test's duration).
*
* The admin's own session is never revoked by any test: admin-users only
* resets/revokes victim accounts, and auth.spec's logout scenario runs in the
* auth-setup project with its own context before this setup executes.
*/
setup('authenticate as admin and save storage state', async ({ page }) => {
await setupAndLogin(page);
await page.context().storageState({ path: STORAGE_STATE_FILE });
});
+164
View File
@@ -0,0 +1,164 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
async function runTiptapCommand(page: Page, commandFn: string) {
await page.getByTestId('tiptap-editor').evaluate(
(el, fn) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
new Function('editor', fn)(editor);
},
commandFn
);
}
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Write note content as markdown, then return to the rich editor. */
async function typeViaMarkdown(page: Page, content: string) {
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await toggleMarkdownMode(page);
}
test.describe('Code snippets', () => {
test('Scenario: A fenced block with a language is syntax highlighted in the editor and on the card', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a JavaScript code block
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('JS Snippet');
await typeViaMarkdown(page, '```js\nconst answer = 42;\n```');
await waitForTiptapEditor(page);
// Then the editor colours the keyword and number as separate tokens
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre .hljs-keyword')).toHaveText('const');
await expect(editor.locator('pre .hljs-number')).toHaveText('42');
// When the note is closed
await page.getByTestId('close-editor-btn').click();
// Then the card preview shows the same highlighting
const preview = noteCard(page, 'JS Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre .hljs-keyword')).toHaveText('const');
await expect(preview.locator('pre .hljs-number')).toHaveText('42');
});
test('Scenario: An unlabelled code block stays plain instead of guessing a language', async ({
authenticatedPage: page
}) => {
// Given a note whose content is a code block with no language
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Plain Snippet');
await typeViaMarkdown(page, '```\nSELECT * FROM users;\n```');
await waitForTiptapEditor(page);
// Then the editor shows the block without any token colouring
const editor = page.getByTestId('tiptap-editor');
await expect(editor.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(editor.locator('pre [class*="hljs-"]')).toHaveCount(0);
// And so does the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Plain Snippet').getByTestId('note-content-preview');
await expect(preview.locator('pre code')).toHaveText('SELECT * FROM users;');
await expect(preview.locator('pre [class*="hljs-"]')).toHaveCount(0);
});
test('Scenario: Choosing a language for a code block is saved in the markdown fence', async ({
authenticatedPage: page
}) => {
// Given the user is editing a note with a plain code block
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, '```\ndef greet():\n return "hi"\n```');
await waitForTiptapEditor(page);
// And the language picker is offered only while the cursor is in the block
await runTiptapCommand(page, 'editor.chain().focus("start").run()');
const picker = page.getByTestId('format-code-language');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('');
// When the user picks Python
await picker.selectOption('python');
// Then the block is highlighted as Python
await expect(page.getByTestId('tiptap-editor').locator('pre .hljs-keyword').first()).toHaveText('def');
await expect(picker).toHaveValue('python');
// And the markdown fence carries the language
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('```python\ndef greet():\n return "hi"\n```');
});
test('Scenario: The language picker is hidden outside a code block', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with a paragraph
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'just prose');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus("end").run()');
// Then no language picker is shown
await expect(page.getByTestId('format-code-language')).toHaveCount(0);
});
test('Scenario: Code keeps the body text size instead of shrinking', async ({ authenticatedPage: page }) => {
// Given a note with a code block and a paragraph
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sized Snippet');
await typeViaMarkdown(page, 'Some prose\n\n```js\nconst a = 1;\n```');
await waitForTiptapEditor(page);
// Then the code block is set at the same size as the prose around it
const editor = page.getByTestId('tiptap-editor');
const fontSize = (locator: ReturnType<Page['locator']>) =>
locator.evaluate((el) => getComputedStyle(el).fontSize);
expect(await fontSize(editor.locator('pre code'))).toBe(await fontSize(editor.locator('p').first()));
// And the same holds on the card preview
await page.getByTestId('close-editor-btn').click();
const preview = noteCard(page, 'Sized Snippet').getByTestId('note-content-preview');
expect(await fontSize(preview.locator('pre code'))).toBe(await fontSize(preview.locator('p').first()));
});
test('Scenario: A long code line is clipped on the card instead of adding a scrollbar', async ({
authenticatedPage: page
}) => {
// Given a note with a code line far wider than a card
const longLine = 'const url = "https://example.com/' + 'segment/'.repeat(30) + '";';
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Wide Snippet');
await typeViaMarkdown(page, '```js\n' + longLine + '\n```');
await page.getByTestId('close-editor-btn').click();
// Then the card's code block stays inside the card and does not scroll horizontally
const preview = noteCard(page, 'Wide Snippet').getByTestId('note-content-preview');
const pre = preview.locator('pre');
await expect(pre).toBeVisible();
const overflow = await pre.evaluate((el) => {
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
const container = el.parentElement!.getBoundingClientRect();
return {
x: style.overflowX,
wider: el.scrollWidth > el.clientWidth,
spill: box.right - container.right
};
});
expect(overflow.wider, 'the line must actually be wider than the card for this test to mean anything').toBe(true);
expect(overflow.x).toBe('hidden');
expect(overflow.spill).toBeLessThanOrEqual(1);
});
});
+62 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, createNote } from './helpers/fixtures.js';
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
test.describe.serial('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
@@ -81,7 +81,67 @@ test.describe.serial('Dark mode', () => {
const noteCard = page.getByTestId('note-card').first();
await expect(noteCard).toBeVisible();
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
// Dark default color is #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
expect(bg).not.toBe('rgb(250, 245, 235)');
});
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user opens the colour picker while editing a note
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Swatch Test');
await page.getByTestId('color-picker-toggle').click();
await expect(page.getByTestId('color-picker')).toBeVisible();
// Then a swatch shows its vivid chip rather than the muted card surface
await expect(page.getByTestId('color-coral')).toHaveCSS(
'background-color',
'rgb(208, 79, 67)'
);
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Bold text in notes uses the theme text colour in dark mode', async ({
authenticatedPage: page
}) => {
// Given a note with bold content exists
await page.goto('/');
await page.waitForLoadState('networkidle');
await createNote(page, 'Bold Contrast Note', 'Plain then **bold words** here');
// And dark theme is active
await page.goto('/settings/preferences');
await page.waitForLoadState('networkidle');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When viewing the note on the notes page
await page.goto('/');
await page.waitForLoadState('networkidle');
const preview = noteCard(page, 'Bold Contrast Note').getByTestId('note-content-preview');
const boldInCard = preview.locator('strong');
await expect(boldInCard).toHaveText('bold words');
// Then the bold text is rendered in the dark theme's text colour (--text = #ece3d3),
// not the typography plugin's hardcoded near-black
await expect(boldInCard).toHaveCSS('color', 'rgb(236, 227, 211)');
// And the same holds inside the editor
await noteCard(page, 'Bold Contrast Note').click();
const boldInEditor = page.getByTestId('tiptap-editor').locator('.tiptap strong');
await expect(boldInEditor).toHaveText('bold words');
await expect(boldInEditor).toHaveCSS('color', 'rgb(236, 227, 211)');
await page.getByTestId('close-editor-btn').click();
});
});
+83 -1
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,6 +13,37 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
async function waitForTiptapEditor(page: Page) {
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
await page.waitForFunction(() => {
const el = document.querySelector('[data-testid="tiptap-editor"]');
return el && (el as any).__tiptapEditor != null;
});
}
/**
* Read the `checked` attribute of the first task item from the live ProseMirror
* document. This reflects the editor's document state, not just the native
* checkbox widget — so it catches a widget/document desync where the DOM
* checkbox toggles but the transaction never updates the node.
*/
async function firstTaskItemChecked(page: Page): Promise<boolean> {
return page.getByTestId('tiptap-editor').evaluate((el) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
let checked: boolean | undefined;
editor.state.doc.descendants((node: any) => {
if (checked === undefined && node.type.name === 'taskItem') {
checked = Boolean(node.attrs.checked);
return false;
}
return true;
});
if (checked === undefined) throw new Error('No task item found in document');
return checked;
});
}
/** Toggle markdown mode via the overflow menu. */
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
@@ -34,6 +65,7 @@ test.describe('Rich text formatting', () => {
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies bold formatting
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleBold().run()');
// Then the selected text appears bold in the editor
@@ -76,6 +108,7 @@ test.describe('Rich text formatting', () => {
// Given the user is editing a note with selected text
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'visit example');
await waitForTiptapEditor(page);
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
// When the user opens the link popover and enters a URL
@@ -109,3 +142,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');
});
});
+8 -9
View File
@@ -1,17 +1,16 @@
import { existsSync, unlinkSync, writeFileSync } from 'fs';
import { writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
const TEST_DB = './data/test-crumbs.db';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
/** Admin session cookie captured by auth.setup.ts and shared by the `app` project. */
export const STORAGE_STATE_FILE = './data/test-storage-state.json';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${TEST_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
}
}
// The test database is wiped before the webServer starts (see the
// webServer.command in playwright.config.ts), NOT here: Playwright brings
// the webServer up before globalSetup runs, so deleting the SQLite files
// here would orphan the server's open database onto a deleted inode and
// tests that read the database file directly would see an empty schema.
// Generate random passwords for this test run, shared across all workers
writeFileSync(
+16 -2
View File
@@ -13,9 +13,18 @@ const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
/**
* Find a specific note card by its exact title text. Matches only the
* card's <h3> title, not its full text content — with note-linking, a
* card's content preview can render another note's title as plain text
* (e.g. a stripped note-link), so a substring match against the whole
* card can ambiguously resolve to more than one card.
*/
export function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { hasText: title });
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return page.locator('[data-testid="note-card"]').filter({
has: page.locator('h3', { hasText: new RegExp(`^${escaped}$`) })
});
}
/**
@@ -104,6 +113,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();
}
+4
View File
@@ -9,6 +9,10 @@ import { setupAndLogin } from './helpers/fixtures.js';
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
*/
// Start unauthenticated: the "rejects requests without API key" scenario must
// not carry the shared admin session cookie, and the apiKey fixture logs in itself.
base.use({ storageState: { cookies: [], origins: [] } });
const test = base.extend<{ apiKey: string }>({
apiKey: async ({ page }, use) => {
await setupAndLogin(page);
+110
View File
@@ -0,0 +1,110 @@
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
test.describe('Note Linking', () => {
test('Scenario: Linking to a note renders a live chip and clicking it opens that note', async ({ authenticatedPage: page }) => {
await createNote(page, 'Target Note', 'target content');
await createNote(page, 'Source Note', 'source content');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Target');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Target Note' }).click();
await expect(page.getByTestId('tiptap-editor').locator('[data-note-id]')).toBeVisible();
await page.getByTestId('tiptap-editor').locator('[data-note-id]').click();
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Target Note');
});
test('Scenario: Renaming a linked note updates the displayed link text', async ({ authenticatedPage: page }) => {
await createNote(page, 'Original Title', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Original');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Original Title' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Original Title').click();
await page.getByTestId('note-title-input').fill('Renamed Title');
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Linker Note').click();
await expect(editor.getByText('Renamed Title')).toBeVisible();
await expect(editor.getByText('Original Title')).not.toBeVisible();
});
test('Scenario: A link to a trashed note renders inert and does not navigate', async ({ authenticatedPage: page }) => {
await createNote(page, 'Doomed Note', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Linker Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Doomed');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Doomed Note' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Doomed Note').click();
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('trash-note-btn').click();
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
await noteCard(page, 'Linker Note').click();
await expect(editor.getByText('Note not found')).toBeVisible();
await editor.getByText('Note not found').click();
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Linker Note');
});
test('Scenario: The linked-to note shows a Referenced by backlink', async ({ authenticatedPage: page }) => {
await createNote(page, 'Popular Note', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Referencing Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Popular');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Popular Note' }).click();
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Popular Note').click();
await expect(page.getByTestId('backlink-chip').filter({ hasText: 'Referencing Note' })).toBeVisible();
});
test('Scenario: The note-picker excludes the currently-open note from its own search results', async ({ authenticatedPage: page }) => {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Self Reference Test');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Self Reference');
await expect(page.getByTestId('format-link-note-result').filter({ hasText: 'Self Reference Test' })).toHaveCount(0);
});
test('Scenario: Hovering a note-link chip reveals a remove button that deletes it without navigating', async ({ authenticatedPage: page }) => {
await createNote(page, 'Removable Target', '');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Has A Link');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await page.getByTestId('format-link').click();
await page.getByTestId('format-link-input').fill('Removable');
await page.getByTestId('format-link-note-result').filter({ hasText: 'Removable Target' }).click();
const chip = page.getByTestId('tiptap-editor').locator('[data-note-id]');
await expect(chip).toBeVisible();
await chip.hover();
await page.getByRole('button', { name: 'Remove note link' }).click();
await expect(chip).toHaveCount(0);
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Has A Link');
});
});
+34
View File
@@ -23,6 +23,40 @@ test.describe('Notes CRUD', () => {
await expect(page.getByText('Updated Title')).toBeVisible();
});
test('Scenario: Browser back closes the open editor instead of leaving the page', async ({ authenticatedPage: page }) => {
// Given a note titled "Back Closes Me" exists
await createNote(page, 'Back Closes Me', 'Some content');
// Given the note is open in the editor
await noteCard(page, 'Back Closes Me').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor is closed and the notes list is still shown
await expect(page.getByTestId('note-editor')).not.toBeVisible();
await expect(noteCard(page, 'Back Closes Me')).toBeVisible();
});
test('Scenario: Closing the editor leaves browser history clean', async ({ authenticatedPage: page }) => {
// Given a note titled "Clean History" exists
await createNote(page, 'Clean History');
// Given the user opened and closed the note
await noteCard(page, 'Clean History').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
await page.getByTestId('close-editor-btn').click();
await expect(page.getByTestId('note-editor')).not.toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor does not reopen and the note's URL is gone from history
await expect(page.getByTestId('note-editor')).not.toBeVisible();
expect(page.url()).not.toContain('#');
});
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists
await createNote(page, 'Delete Me');
+48
View File
@@ -121,6 +121,54 @@ test.describe('Public Note Sharing', () => {
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
});
test('Scenario: Shared note follows a dark-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Dark Visitor Note', 'Readable in dark mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers dark mode opens the share URL
const context = await browser.newContext({ colorScheme: 'dark' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the dark "fog" surface, so its text stays legible
await expect(publicPage.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(37, 57, 70)'
);
await context.close();
});
test('Scenario: Shared note follows a light-mode visitor theme', async ({
authenticatedPage: page,
browser
}) => {
// Given a publicly shared note colored "fog"
const note = await createNoteViaApi(page, 'Light Visitor Note', 'Readable in light mode');
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
const token = await createShareViaApi(page, note.id);
// When a visitor whose system prefers light mode opens the share URL
const context = await browser.newContext({ colorScheme: 'light' });
const publicPage = await context.newPage();
await publicPage.goto(`/s/${token}`);
// Then the note is rendered on the light "fog" surface
await expect(publicPage.locator('html')).not.toHaveAttribute('data-theme', 'dark');
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
'background-color',
'rgb(212, 228, 237)'
);
await context.close();
});
test('Scenario: Copy link button copies to clipboard', async ({
authenticatedPage: page
}) => {
+4
View File
@@ -1,5 +1,9 @@
import { test, expect } from '@playwright/test';
// These scenarios describe what an anonymous visitor receives, so drop the
// shared admin session the `app` project injects.
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('PWA', () => {
test('Scenario: Web manifest provides correct app metadata', async ({ page }) => {
// When the browser requests the web manifest
+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();
});
});
+25 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
const DEFAULT_PREFS = {
@@ -81,6 +81,30 @@ test.describe('Settings — Preferences', () => {
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Default note mode applies when reopening an existing note', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given a note titled "Existing Markdown Note" exists
const title = `Existing Markdown Note ${Date.now()}`;
await page.request.post('/api/notes', { data: { title, content: 'Some **bold** text' } });
// And the user's default note mode is Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user opens the note
await gotoWithStablePrefs(page, '/');
await noteCard(page, title).click();
// Then the note editor opens in markdown mode showing the raw source
await expect(page.getByTestId('note-content-input')).toBeVisible();
await expect(page.getByTestId('note-content-input')).toHaveValue('Some **bold** text');
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
+24 -1
View File
@@ -2,13 +2,36 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
/**
* The e2e suite drives `vite preview` and its API helpers reuse keep-alive
* sockets. Node's http server drops an idle keep-alive socket after 5s by
* default, and a request that lands on a socket the server is closing at that
* instant fails client-side with "socket hang up" (seen on main CI in
* public-sharing.spec). Keeping the server's idle timeout well above any
* client's means the client always closes first, so the race cannot happen.
*/
function previewKeepAlive(): Plugin {
return {
name: 'crumbs:preview-keep-alive',
configurePreviewServer({ httpServer }) {
// HTTP/2 servers have no keep-alive timeout; preview is HTTP/1 here.
if ('keepAliveTimeout' in httpServer) {
httpServer.keepAliveTimeout = 65_000;
// Node requires headersTimeout to exceed keepAliveTimeout.
httpServer.headersTimeout = 66_000;
}
}
};
}
export default defineConfig({
build: {
chunkSizeWarningLimit: 850
},
plugins: [
previewKeepAlive(),
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.28.3</span>
<span class="footer-version">v0.35.2</span>
</div>
</footer>