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
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
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
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
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
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
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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>
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>
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
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>
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>
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>
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>