@openparachute/app 0.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +3473 -0
  2. package/LICENSE +661 -0
  3. package/README.md +68 -0
  4. package/dist/.parachute/info +8 -0
  5. package/dist/apple-touch-icon-180x180.png +0 -0
  6. package/dist/assets/Account-BhmA30V5.js +1 -0
  7. package/dist/assets/Activity-BgJa7Woi.js +1 -0
  8. package/dist/assets/AddVault-BWa_-QjM.js +1 -0
  9. package/dist/assets/AddVaultChooser-B3ElIlza.js +1 -0
  10. package/dist/assets/AddVaultCreate-DKJfYFJ1.js +1 -0
  11. package/dist/assets/Calendar-DUwSvnCY.js +1 -0
  12. package/dist/assets/CheckEmail-BQYQLTLL.js +1 -0
  13. package/dist/assets/ConnectAI-DCtARp-w.js +1 -0
  14. package/dist/assets/DayView-nficJMEc.js +1 -0
  15. package/dist/assets/Export-DNAoKjAn.js +1 -0
  16. package/dist/assets/Import-CoFeXNMx.js +20 -0
  17. package/dist/assets/NoteEditor-DtrdRbqx.js +1 -0
  18. package/dist/assets/NoteNew-C0DMe5Q8.js +8 -0
  19. package/dist/assets/NoteRenderer-Dl9X9uRt.js +37 -0
  20. package/dist/assets/NoteView-B2QH25fP.js +3 -0
  21. package/dist/assets/OAuthCallback-Dj26N8oJ.js +1 -0
  22. package/dist/assets/PinArchiveButtons-BXZFiW-i.js +1 -0
  23. package/dist/assets/Settings-BuXl91ig.js +1 -0
  24. package/dist/assets/TagPage-q7PgbEHn.js +1 -0
  25. package/dist/assets/TagSchemaEditor-DIHppYmj.js +1 -0
  26. package/dist/assets/Tags-BqJ9vWM5.js +1 -0
  27. package/dist/assets/VaultGraph-CaiNmEtX.js +3 -0
  28. package/dist/assets/Vaults-CTsUsDH8.js +1 -0
  29. package/dist/assets/ViewNew-BegTqdOq.js +1 -0
  30. package/dist/assets/ViewSurface-ClCtZW0N.js +1 -0
  31. package/dist/assets/Welcome-CN4Zh9jP.js +1 -0
  32. package/dist/assets/WizardShell-ZqrzQKxp.js +1 -0
  33. package/dist/assets/attachment-upload-l7qA8_xw.js +2 -0
  34. package/dist/assets/editor-mode-D9fveTEx.js +1 -0
  35. package/dist/assets/figtree-latin-ext-wght-normal-DCwSJGxG.woff2 +0 -0
  36. package/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  37. package/dist/assets/fraunces-latin-ext-opsz-italic-CGbN9UgK.woff2 +0 -0
  38. package/dist/assets/fraunces-latin-ext-opsz-normal-CJcjJNj7.woff2 +0 -0
  39. package/dist/assets/fraunces-latin-opsz-italic-lSdLDfvT.woff2 +0 -0
  40. package/dist/assets/fraunces-latin-opsz-normal-DihXLNYH.woff2 +0 -0
  41. package/dist/assets/fraunces-vietnamese-opsz-italic-DxWqP7Ku.woff2 +0 -0
  42. package/dist/assets/fraunces-vietnamese-opsz-normal-Czevyj-6.woff2 +0 -0
  43. package/dist/assets/history-ByMClxJV.js +1 -0
  44. package/dist/assets/index-CaleIHD8.css +1 -0
  45. package/dist/assets/index-Dwo2xe8_.js +66 -0
  46. package/dist/assets/provenance-B6WFYTWW.js +1 -0
  47. package/dist/assets/react-force-graph-2d-BMU3lxLC.js +44 -0
  48. package/dist/assets/transcribe-default-LVAxyzpn.js +1 -0
  49. package/dist/assets/types-BaJkUfo6.js +1 -0
  50. package/dist/assets/url-QSQwrjSV.js +1 -0
  51. package/dist/assets/useAttachmentUploader-C3UnTg01.js +30 -0
  52. package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
  53. package/dist/favicon.ico +0 -0
  54. package/dist/icon.svg +21 -0
  55. package/dist/index.html +31 -0
  56. package/dist/manifest.webmanifest +1 -0
  57. package/dist/maskable-icon-512x512.png +0 -0
  58. package/dist/pwa-192x192.png +0 -0
  59. package/dist/pwa-512x512.png +0 -0
  60. package/dist/pwa-64x64.png +0 -0
  61. package/dist/sw.js +1 -0
  62. package/dist/workbox-9c191d2f.js +1 -0
  63. package/meta.json +20 -0
  64. package/package.json +83 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,3473 @@
1
+ ## [0.22.5] - 2026-07-26
2
+
3
+ **A `%` in a name no longer breaks the page — and a subtler bug that nobody
4
+ could see is gone with it.**
5
+
6
+ Route params were decoded twice: the router decodes `:name` / `:id`, then the
7
+ component decoded again. For every name we actually see — spaces, unicode,
8
+ slashes like `capture/voice` — the second decode was a harmless no-op, which is
9
+ why it survived. Two names it did not survive:
10
+
11
+ - **A literal `%`** threw `URIError` and crashed the page. A tag called `100%`
12
+ had no page at all.
13
+ - **A name merely *containing* a valid escape sequence** was silently rewritten
14
+ — `50%20off` became `50 off`, quietly loading the wrong page with no error at
15
+ all. Nobody filed that one, because nobody could see it happen.
16
+
17
+ Fixed at four route components together — `/tags/:name`, `/views/:id`, `/n/:id`
18
+ and `/n/:id/edit` — rather than one at a time, so none of them becomes the odd
19
+ one out. They now receive an already-decoded value and trust it, which is the
20
+ pattern `NoteIdRedirect` was already following. The two remaining
21
+ `decodeURIComponent` calls in `src/` are correct boundaries (each decodes once
22
+ from a still-encoded source) and are left alone.
23
+
24
+ Every test was written and watched failing before the fix. The slash and
25
+ unicode pins were green on both sides — they survived the double decode by
26
+ accident and survive the single one by design.
27
+
28
+ # Changelog — @openparachute/app
29
+
30
+ ## [0.22.4] - 2026-07-26
31
+
32
+ **A yes/no question no longer holds the whole vault open.**
33
+
34
+ Every route — every route, forever — kept a full-vault live stream open to
35
+ answer one boolean: *has this person written a note yet?* The setup shelf's
36
+ "write your first note" step is a `.some()` over the vault, and the nav
37
+ model fed it by subscribing to everything (`limit=5000`, 1.29 MiB at 2,606
38
+ notes, live for the life of the app). It's now a bounded fetch: one
39
+ 25-note page, ~12 KiB, no socket — paging deeper only in the rare vault
40
+ whose newest pages are all system/seed notes. Measured before → after:
41
+
42
+ | | before | after |
43
+ | --- | --- | --- |
44
+ | Answering "any note yet?" | full-vault live stream, 1.29 MiB | one 25-note page, ~12 KiB, no socket |
45
+ | `/notes` (any non-date route) | 3 sockets | **2** — zero full-vault dateviews streams |
46
+ | `/` Recent | 4 sockets (dateviews ×2) | **3** (dateviews ×1) |
47
+
48
+ - **On date routes the deleted stream was a pure duplicate.** RecentLens,
49
+ Activity, Calendar, and DayView each already open their own dateviews
50
+ subscription on mount — the nav model's was a second copy of a stream
51
+ the route already owned. Removing it takes date routes to exactly one
52
+ (owned by the view that actually reads it) and non-date routes to zero.
53
+ Live-ness on the watch-your-AI-write surfaces is preserved by
54
+ construction, not by care.
55
+ - **Why not `limit=1`?** The obvious cheap check —
56
+ `sort=desc&limit=1`, is there a non-guide note? — returns the app's own
57
+ settings note (`.parachute/notes/settings`), which is rewritten on every
58
+ settings change. A fresh vault where someone only opened Settings would
59
+ report "onboarded": wrong on exactly the population the check serves.
60
+ The paged walk applies the *same* predicate as the checklist
61
+ (`hasUserAuthoredNote`) — agent-written and imported notes still count,
62
+ nothing about "authored" quietly changes. vault#628 (server-side path
63
+ exclusion) would collapse the walk to a single 403-byte request.
64
+ - **Offline stays honest.** Pending/offline is *unknown*, not
65
+ un-onboarded — the setup band renders only on a resolved "no notes yet,"
66
+ so an onboarded vault never flashes "Set up" during a cold offline
67
+ launch. The check has no live layer by design; creating or deleting a
68
+ note invalidates it, so an in-app first note still completes the step
69
+ immediately.
70
+ - The nav pins in `navigation-breakpoint-contract.test.tsx` tightened
71
+ from "exactly one dateviews subscription" to **zero**: nav projections
72
+ now open no full-vault streams at all, an invariant that can't quietly
73
+ regress back to one.
74
+
75
+ ## [0.22.3] - 2026-07-26
76
+
77
+ **Every tag gets a page.** Clicking a tag used to drop into `/notes?tag=X` — a
78
+ flat filter over the All-notes list that looked identical whether the tag
79
+ carried a rich field schema or none. Now a tag opens its own page at
80
+ `/tags/:name`, rendering a view **derived from the tag's own schema**: a typed
81
+ tag (one that declares ≥1 field) opens as a **table** with its schema fields as
82
+ columns; a plain tag opens as the familiar **list**. Tagging something now has a
83
+ consequence — and that consequence costs no second act. Nothing is stored: the
84
+ view is computed from the live tag record on every visit, so unlike a saved
85
+ `#view` note it can never dangle or drift.
86
+
87
+ - **New route `/tags/:name`** (`TagPage`) — reuses the exact `ViewSurface`
88
+ rendering machinery (extracted as the shared `ViewCanvas`), so a derived tag
89
+ page gets the lens switcher, refinement bar, problems banner, and configurable
90
+ fields for free. Deep-linkable and refresh-safe; handles names needing URL
91
+ encoding (spaces, slashes like `capture/voice`, unicode).
92
+ - **`deriveTagViewDef`** (`src/lib/views/derive.ts`) — the pure def builder. Its
93
+ query excludes the archived role, except on the archived tag's own page (a
94
+ page must not exclude itself). Its `noteId` is a synthetic `derived:tag:<name>`
95
+ marker, following the `builtin:` convention `defaults.ts` already uses for the
96
+ Pinned/Archive built-ins — there is no backing note, so nothing fetches or
97
+ writes it.
98
+ - **No backing note, no accidental writes.** `ViewCanvas` gates the "Edit view
99
+ note" link and the entire Save path off a real note. A derived page can be
100
+ explored (peek at Board/Table/Calendar, add refinement chips) but never
101
+ surfaces a Save that would PATCH the synthetic id — the real Save-as-view flow
102
+ for a derived page is a later wave. Exploration stays fully reversible through
103
+ the controls themselves.
104
+ - **Tag chips point at the new page** — the Tags directory rows, a note's tag
105
+ chips (`NoteView`), and the quick-switch tag entries now open `/tags/:name`.
106
+ `/notes?tag=X` keeps working unchanged for anyone who lands there directly.
107
+ - **A tag page is paged, never a wall.** A tag on hundreds of notes (a capture
108
+ stream, a journal) opens as ONE page — the tag's size on the header
109
+ ("622 notes"), the newest 50 in view, and the same offset pager the All-notes
110
+ list uses (Previous/Next). `deriveTagViewDef` pins the scale floor into the
111
+ def — `sort: "desc"` (newest-first) + a 50-row `limit` — so a page can never
112
+ inherit the vault's oldest-first, unbounded default. Paging is poll-only by
113
+ design: a live subscription's snapshot is always the *complete* matching set
114
+ (vault streams every match), so the tag page opts out of the live layer to
115
+ keep the server-side page window authoritative — the same reason a saved
116
+ `#view` (unpaged, live) is untouched.
117
+ - **The pager tells the truth about how far you can go.** "Next" is a FACT, not
118
+ a guess: the canvas fetches one row beyond the page (a 51-row peek) and offers
119
+ a next page only when that row exists — so Next can never run into empty
120
+ trailing pages when the archived exclusion or a live refinement has narrowed
121
+ the set below the tag's own count (the same window mechanism the All-notes list
122
+ uses, app#109/#114). The pager shows "of N" only once the peek has proven the
123
+ end (`offset + shown`); before that it's a bare range ("Showing 1–50"), because
124
+ the true result total isn't knowable until a real count endpoint lands
125
+ (vault#626). The header keeps the tag's own size ("N notes") while nothing has
126
+ narrowed the view.
127
+
128
+ ## [0.22.2] - 2026-07-26
129
+
130
+ **Opening the menu no longer downloads the entire vault.**
131
+
132
+ On a phone, one tap of ☰ — just opening the navigation sheet — silently
133
+ streamed every note in the vault: a third full copy, on top of the two the
134
+ app had already opened at boot. The nav model (the bands in the rail, the
135
+ lens strip, the sheet) derives from a full-vault live subscription, and
136
+ every component that derived it opened its own socket. The breakpoint
137
+ contract — "exactly one nav projection per viewport" — is enforced in CSS
138
+ only: `hidden lg:flex` / `lg:hidden` hide a projection without unmounting
139
+ it, so Rail and LensStrip both derived at every width and the sheet added
140
+ a third on open. Measured at 2,606 notes, before → after:
141
+
142
+ | | before | after |
143
+ | --- | --- | --- |
144
+ | Nav full-vault streams at boot | 2 × 1.25 MiB | **1** |
145
+ | Opening ☰ | +1 socket, +1.25 MiB, every open | **0 new sockets** |
146
+ | Closing the sheet | socket teardown + reopen churn | nothing — pure UI |
147
+ | Writers on the nav cache key | 2, racing | **1** |
148
+
149
+ The model now derives in exactly one place — `NavBandsProvider`, mounted
150
+ once in the app shell — and Rail, LensStrip, and NavSheet read the result
151
+ from context.
152
+
153
+ - **The correctness half, easy to under-sell next to the bytes:** the twin
154
+ subscriptions were two *writers* on one react-query cache key,
155
+ last-writer-wins, under `staleTime: Infinity` — so a stale overwrite
156
+ never self-healed, because nothing ever refetched. One deriver, one
157
+ writer; the race is structurally gone.
158
+ - **Enforced by construction, not convention.** The original bug was an
159
+ invariant asserted in a layer that can't enforce it (CSS). The
160
+ derivation is now module-private; `useNavBands()` throws outside the
161
+ provider, so the next component that tries to derive privately fails at
162
+ first render instead of quietly opening its own vault stream. Regression
163
+ pins sit beside the CSS half of the contract in
164
+ `navigation-breakpoint-contract.test.tsx`.
165
+ - **Composed with 0.22.1:** a cold `/notes` now opens 3 sockets where two
166
+ releases ago it opened 6. The offline mirror is now the *last* full-vault
167
+ stream on non-date routes — deliberate, not a leak: the mirror's whole job
168
+ is to hold a complete local copy. Named here so nobody rediscovers it as
169
+ a regression.
170
+ - The provider mounts unconditionally rather than behind the focus-mode
171
+ gate: toggling focus used to tear down and reopen the nav sockets every
172
+ time (a full re-stream per toggle); one idle socket during focus mode is
173
+ the cheaper side of that trade.
174
+
175
+ ## [0.22.1] - 2026-07-26
176
+
177
+ **All notes actually paginates — the page that looked finished and then
178
+ detonated is fixed.**
179
+
180
+ On a real-sized vault, All notes looked done half a second in — 50 rows, a
181
+ pager — and then, nine seconds later on an ordinary connection, silently
182
+ exploded into every note in the vault: one page four thousand rows long, both
183
+ pager buttons dead, reading "Showing 1–2606". Every visit moved megabytes to
184
+ display fifty rows. The cause: a live subscription's snapshot is always the
185
+ *complete* matching set, so the full-vault stream clobbered the bounded page
186
+ the moment it arrived — pagination wasn't leaky, it was absent.
187
+
188
+ The list now opts out of the live layer (`useNotes({ live: false })` — the
189
+ same contract the derived tag page ratified), so the server-side
190
+ limit/offset window is authoritative. Measured on the same 2,606-note
191
+ sandbox, before → after:
192
+
193
+ | | before | after |
194
+ | --- | --- | --- |
195
+ | Rows on page 1 | 2,606 | **50** |
196
+ | Page height | 330,339px | **6,896px** |
197
+ | Pager | both buttons dead | works |
198
+ | Turning a page | impossible | one 25 KiB poll |
199
+ | Toggling a filter | new socket, 431 notes, 203 KiB | one 24 KiB poll |
200
+ | List's own wire, cold | ~1.29 MiB | **25 KiB** |
201
+ | Cold load @ 10 Mbps | correct at 0.49s, detonates at +8.9s | correct and stays correct |
202
+
203
+ What it costs: without the stream, a note written from *outside* the tab
204
+ reaches the open list on the polling floor — measured at 18.3s / 29.7s /
205
+ 30.1s (30s interval; every poll that fired already carried the note), or
206
+ within 10s of refocusing the tab. Your own edits still appear immediately,
207
+ and Recent / Activity / Calendar keep their live stream untouched — watching
208
+ an agent write into the vault still works where watching happens.
209
+
210
+ - `hasNext` is now a fact, not an inference: the list fetches one row beyond
211
+ the page (`limit+1`, rendered `limit`), so a vault holding an exact
212
+ multiple of the page size ends with Next disabled instead of one extra
213
+ click onto a page wearing the empty-vault copy.
214
+ - The pager shows an honest interim total: exact ("of 23") the moment the
215
+ set ends, a lower bound ("of 50+") while it can't know more — a bare-array
216
+ list response carries no total anywhere. The real count is a wire-contract
217
+ addition (vault#626); the interim is marked as interim in the code.
218
+ - Regression pins: a fake vault socket that answers any subscription with a
219
+ 3,000-note snapshot proves the page stays at 50 rows, the windowed query
220
+ never subscribes, and both boundary shapes (short page, exact-multiple
221
+ page) end the set correctly.
222
+
223
+ ## [0.22.0] - 2026-07-26
224
+
225
+ **Renamed the npm package: `@openparachute/parachute-app` → `@openparachute/app`.**
226
+ Every other package in the `@openparachute` scope is a bare noun (`hub`, `vault`,
227
+ `surface`); `parachute-app` inside the `@openparachute` scope stuttered, and `app`
228
+ is the durable name as this package grows toward a full native (Tauri) shell.
229
+ Minor bump rather than patch: the published package identity changes — a fresh
230
+ name on the registry — even though no app code moved.
231
+
232
+ - **Only the npm package name moves.** The GitHub repo stays `parachute-app`, the
233
+ module's short name stays `app`, and the `dist/.parachute/info` service
234
+ identity stays `parachute-app` (hub's `manifestName` discovery key) — so hub's
235
+ well-known discovery is untouched.
236
+ - **Transparent to self-hosters.** They reach the package through
237
+ `parachute install app` (hub resolves the concrete package name), not by typing
238
+ the scoped name, so the rename is invisible on a current hub. The old
239
+ `@openparachute/parachute-app` stays published (deprecated) so any existing
240
+ install keeps resolving.
241
+ - **Tarball-name derivation bumped** in `ci.yml` and `release.yml` from
242
+ `openparachute-parachute-app-*.tgz` to `openparachute-app-*.tgz` — npm derives
243
+ the pack filename from the package name, and the pack-verify step asserts it.
244
+
245
+ ## [0.21.2] - 2026-07-26
246
+
247
+ **bigvault — a realistic-scale sandbox vault, one command.**
248
+
249
+ Every test vault and walkthrough sandbox we build has ~15 notes and 3–4 tags.
250
+ A real vault has dozens of tags and thousands of notes. Code that looks fine
251
+ at the first scale falls apart at the second, and two bugs shipped in one
252
+ night for exactly that reason:
253
+
254
+ - The filter panel fills the whole viewport at 49 tags, so toggling a tag
255
+ changes nothing you can see — zero results visible at 1440×900, the note
256
+ list entirely off-screen on a phone.
257
+ - A derived tag page on a 622-note tag rendered all 622 rows into a
258
+ 64,231px page, because a live subscription always delivers the complete
259
+ matching set and silently overwrites the bounded poll.
260
+
261
+ Neither was catchable against a vault small enough to flatter it.
262
+
263
+ `bun run bigvault up` now stands up a sandboxed vault with ~47 tags and
264
+ ~2,600 notes — power-law distribution, slash-namespaced families, schemas
265
+ from zero to nine fields, touches spread over two years. `--tags 500
266
+ --notes 10000` goes looking for the next ceiling. `status` and
267
+ `down [--wipe]` complete the lifecycle; `probe.ts` drives any running app and
268
+ captures screenshots.
269
+
270
+ Deterministic by construction: the same seed and anchor date produce a
271
+ byte-identical vault, so a screenshot comparison means something. Getting
272
+ there fixed two real bugs in the generator — a multiply that overflowed into
273
+ float rounding, and backdating that keyed off database insert order, which is
274
+ racy under parallel seeding.
275
+
276
+ Sandboxed by construction rather than by convention: it only ever wipes a
277
+ directory it created and marked, refuses anything that is, contains, or lives
278
+ inside a real `PARACHUTE_HOME`, refuses the live stack's ports, binds
279
+ 127.0.0.1 only, and kills by pid rather than by port. Every refusal is
280
+ exercised in tests.
281
+
282
+ The standing expectation now lives in `CLAUDE.md`: UI work touching lists,
283
+ filters, or tag surfaces gets checked at this scale before a PR.
284
+
285
+ ## [0.21.1] - 2026-07-25
286
+
287
+ **Field on-ramp — a board or calendar on a field-less tag helps you make your
288
+ own field instead of dead-ending.**
289
+
290
+ The worst onboarding dead-end in views. A fresh vault seeds exactly two tags
291
+ (`capture` + `guide`) and NEITHER carries a field schema — the schema-carrying
292
+ `starter-ontology` pack is deliberately opt-in. View fields resolve from tag
293
+ schemas alone, so for almost every real user a Board's Group-by pill had
294
+ nothing to offer and could only explain the absence ("No fields to group by —
295
+ this view's tag has no schema fields"), while a Calendar had no date field to
296
+ plot by. The only recovery — Tags → filter → +Schema → add field → Save → back
297
+ to the view — is a path nobody discovers.
298
+
299
+ - **The empty control NOTICES the absence and hands you the editor.** No
300
+ hardcoded field vocabulary: the app doesn't propose `status`, `priority`, or
301
+ `due` (Aaron, 2026-07-25 — "we don't really need to be opinionated on this").
302
+ Calendars in the wild key off `date`, `meeting date`, `published`, `when`;
303
+ shipping `due` as _the_ offer would bake a vocabulary into a product that
304
+ shouldn't have one. The empty Group-by / By-date pill now carries a single
305
+ invitation — "Add a field to group by…" / "Add a date field…" — that opens
306
+ the shipped tag-schema editor in place, oriented for the job, where **you**
307
+ name the field. On save the view organizes by it automatically.
308
+ - **The app already reads any field by TYPE, not by name.** A tag that declares
309
+ `meeting date` (or any date-typed field) already gets a working calendar;
310
+ any field with declared values already lanes a board. The presets only ever
311
+ answered the _zero-fields_ case — and answering it with Parachute's own
312
+ vocabulary was the mistake. Removed `GROUP_BY_PRESETS` / `DATE_PRESETS`
313
+ entirely.
314
+ - **The editor is a decent place to land cold.** Opened from a board, it lands
315
+ on a ready-to-name string row with a values input — because a board needs
316
+ declared values (an `enum`) or the columns won't render, and that's now stated
317
+ in the editor rather than discovered on an empty board. Opened from a
318
+ calendar, it lands on a date row. The schema editor gained enum-value
319
+ declaration (comma-separated → the board's columns) for exactly this.
320
+ - **A whole bug class dissolved.** The name-collision hazard existed only
321
+ because _we_ proposed a name that might already be taken; a merge-on-write tag
322
+ PUT would then silently REPLACE the user's own `due` (string) with a date.
323
+ Now the app proposes no name — there is nothing to clobber — and the user
324
+ names the field in an editor that shows the existing fields, so any collision
325
+ is visible, not silent. The old `existingFieldNames` suppression + write-
326
+ boundary guard are gone with the presets that made them necessary; the same
327
+ reasoning closed the schema-still-loading hole (the invitation writes nothing;
328
+ it opens an editor that reads the live schema itself). The empty controls also
329
+ wait for the tag schema to actually answer before inviting, so they never cry
330
+ "no fields yet" on a tag mid-fetch.
331
+ - **The calendar's undated notes stay actionable.** After adding a date field to
332
+ a fresh vault the grid is empty (nothing carries the field yet) — where it
333
+ used to state a bare count and offer nothing. Those notes now render below the
334
+ grid as cards with an in-place date picker — the calendar's answer to the
335
+ board's uncategorized lane: set a date and the note lands on its day, no
336
+ refetch. The section's copy reads well for any field name now: "no `meeting
337
+ date`" instead of the doubled "no `meeting date` date" (append " date" only
338
+ when the user's field name doesn't already carry it).
339
+ - **A board is not prose — it gets room to breathe.** A `status`-style board
340
+ mints four lanes (three declared values plus the uncategorized lane, which on
341
+ a fresh vault holds every note), and four `w-72` lanes overflow the reading-
342
+ width page cap — clipping the uncategorized lane. Board and table now use a
343
+ wider ceiling (`--w-board`) than the reading column list/calendar/note keep;
344
+ the board's own overflow-x still handles the genuinely-many-lane case. (Honest
345
+ limit: on a 1280–1440 laptop with the rail expanded a wide board still scrolls
346
+ horizontally — a kanban convention, and no lane width that keeps cards
347
+ readable fits there.)
348
+ - **The field the user made rides the DRAFT, not the note.** The schema write is
349
+ immediate and permanent (it's the thing you asked for); the view's
350
+ `group_by`/`date_field` goes into the URL DRAFT, so the board lanes up
351
+ instantly but the view note itself changes only on Save — consistent with the
352
+ explore-then-save model every other config control follows. Reverting keeps
353
+ the field and leaves the view untouched. The editor writes through
354
+ `useUpdateTag` (`PUT /api/tags/:name`, merge-on-write) — no second schema
355
+ writer, and a tag that already declares other fields keeps every one of them.
356
+ - **An honest message where the invitation can't apply.** A view whose query
357
+ isn't scoped to a single tag has no one schema to write to; that control now
358
+ says so AND says what to do ("add a single tag to the query, or open a tag's
359
+ view") instead of implying the view is broken.
360
+ - **Companion fix — the lane field's chip.** A board omits its lane field from
361
+ the card's chip band because the Move control already owns it. But Move
362
+ renders only when there's somewhere to move TO, so a lane field with no
363
+ declared enum, on a board where no note carries a value yet, produced a
364
+ single uncategorized column with neither Move nor chip — no way to set the
365
+ first value from the board at all. The chip now stays whenever Move is
366
+ absent. (Reachable through the on-ramp: its "add a field" invitation can mint
367
+ a plain string lane field with no declared values.)
368
+
369
+ ## [0.21.0] - 2026-07-25
370
+
371
+ **The app becomes installable.** Until now `@openparachute/parachute-app` was
372
+ `private: true` and had never been published, so `parachute install app` 404'd
373
+ at the registry and the only way onto a self-hosted box was building from a git
374
+ checkout — which meant everything shipped through 0.20.x (the views, the polish
375
+ wave) was unreachable for self-hosters. Minor bump rather than patch: the
376
+ package gains a real capability (it is now public and installable), even though
377
+ no app code changed.
378
+
379
+ - **Public package** — dropped `private: true`; added
380
+ `publishConfig.access: "public"`, `homepage`, `bugs`, `keywords`, and the
381
+ npm-canonical `git+https://` form of the `repository` URL.
382
+ - **The tarball always carries a built `dist/`** — the build hook moved from
383
+ `prepublishOnly` to `prepack`, so `npm pack` and `bun pm pack` produce the
384
+ same bytes `npm publish` would. This package ships a PREBUILT bundle:
385
+ consumers never build it. `parachute start app` runs hub's static-serve shim
386
+ (`parachute-hub` `src/notes-serve.ts --package @openparachute/parachute-app`),
387
+ which resolves `<package root>/dist` and serves files straight out of it —
388
+ and hard-errors if `dist/` is absent.
389
+ - **CI, for the first time** — this repo had no `.github/` at all, so every
390
+ gate verdict in a PR body was a builder self-report. `ci.yml` now runs lint →
391
+ typecheck → vitest → build → dist smoke → pack-and-verify on every PR.
392
+ (Making it a required check is a branch-protection step in repo settings.)
393
+ - **Release workflow** — `release.yml` publishes on a `v*` tag with
394
+ `--provenance` via npm Trusted Publishing (OIDC, no NPM_TOKEN), dist-tag `rc`
395
+ for `-rc.N` tags and `latest` otherwise, gated on a tag-vs-package.json
396
+ version match and on the tarball actually containing `dist/index.html` +
397
+ `dist/assets/*`.
398
+ - **`test:smoke` does something now** — it pointed at `vitest run dist-smoke`,
399
+ a filter matching no file, so it had only ever exited 1. It now runs
400
+ `scripts/verify-dist.ts`, which asserts the built bundle is the shape hub's
401
+ server expects: an `index.html` that references real, present hashed assets,
402
+ a non-empty `assets/`, the service worker + PWA manifest, and a
403
+ `.parachute/info` whose version matches `package.json` (that last check
404
+ caught a stale `dist/` during this very change).
405
+
406
+ ## [0.20.52] - 2026-07-25
407
+
408
+ **Three months free — the claim visitors actually read.**
409
+
410
+ - **The front door said 30 days while the backend granted 90.** Aaron ratified
411
+ a free-for-three-months campaign; the cloud worker's copy moved with it, but
412
+ `GET /signup` 302s to this SPA, so the promise a real visitor reads is the
413
+ landing page — and it still said "Free for 30 days, no card." The product was
414
+ advertising one trial and granting another. Now: "Three months free, no card."
415
+
416
+ - **And the number stopped being a literal.** The drift wasn't a typo, it was
417
+ structural: the trial length was hardcoded in two repos, so it was always
418
+ going to skew the next time the campaign moved. The landing claim now renders
419
+ `trial_length_label` from the door descriptor
420
+ (`GET /.well-known/parachute-account`) — the same descriptor-driven treatment
421
+ the price line already got — with the campaign phrase as the fallback when the
422
+ door publishes nothing (pre-fetch, offline, or an older door). The door owns
423
+ the number; the app renders what it's told. A hub, which grants no trial at
424
+ all, never renders the claim: it lives only on the confirmed-cloud branch.
425
+
426
+ - **The label is untrusted input.** A door serving an object there would white-
427
+ screen the app (no ErrorBoundary); a door serving a paragraph would blow out
428
+ the layout. Anything that isn't a short, non-empty string falls back.
429
+
430
+ ## [0.20.51] - 2026-07-25
431
+
432
+ **Gauntlet fixes 1 — Aaron's three live dogfood complaints (7/24).**
433
+
434
+ - **Quiet offline banner** — "Saving your vault for offline" no longer shows
435
+ on every open of an already-synced vault. Root cause: the mirror engine
436
+ persisted `phase: "hydrating"` into IndexedDB at the start of EVERY drain
437
+ (warm catch-up polls included) while only the in-memory callbacks were
438
+ cold-gated — so the provider's mount-time persisted-state read repainted the
439
+ one-time hydration banner on each app open (and a poll killed mid-drain
440
+ stranded "hydrating" there until the next completed drain). Warm drains now
441
+ leave the persisted "live" phase untouched; "hydrating" is persisted +
442
+ announced only while the vault's FIRST fill is incomplete (including
443
+ resuming an interrupted first fill). Belt: the chrome line renders only
444
+ while cold-hydration progress is actually ticking, so the mount-time flash
445
+ (state not yet read from IDB ⇒ derived "hydrating") stays invisible too.
446
+ First hydration still announces with its `n of ~total` progress line.
447
+ - **The editor's path recedes** — the edit header's note-path input dropped
448
+ its title-scale serif treatment (`text-xl md:text-2xl`, unchanged since
449
+ PR #1 and predating first-line-as-title, which made it a duplicate
450
+ headline; #59 quieted only the READ header) for a quiet single mono
451
+ `text-xs text-fg-dim` line at the header card's foot, after the tags —
452
+ mirroring NoteView's HeaderPath voice. Still directly editable (focus
453
+ brightens it; the rename-moves-the-note hint is unchanged).
454
+ - **A visible Sign out** — Settings' Manage card now shows the door session:
455
+ a "Signed in as {email}" row with a Sign out button whenever
456
+ `GET /account/session` reports one (both doors serve it; both serve
457
+ `POST /logout` — cloud console session / hub admin session). Sign-out
458
+ best-effort-POSTs `/logout`, drops the cached account bearer, and lands
459
+ home — honest about scope: vaults already connected on the device keep
460
+ their own tokens until removed. `/account`'s own sign-out is unchanged.
461
+
462
+ ## [0.20.50] - 2026-07-25
463
+
464
+ **Views polish wave complete: calendar chips + the save bar's voice (V6).**
465
+ The last of the five-lens polish PRs.
466
+
467
+ - **Calendar** — the weekday header takes the V5 table-header band register
468
+ (`bg-bg-soft` + 11px `text-2xs` micro-labels); day numbers set in the
469
+ display serif (the accent-filled today circle stays); and day chips trade
470
+ the flat coral `bg-accent/10 text-accent` tint — which made every note read
471
+ as a link — for a raised neutral mini (`rounded-md border bg-card shadow-sm
472
+ text-fg`, the prototype's `.mini`). Class-only through the one shared chip
473
+ constant; the drag/read-only gating and StaticDayChip/DayChip split are
474
+ untouched.
475
+ - **Save bar** — the "View modified" strip gains a gently pulsing accent dot
476
+ and says what it means: "View modified — you're exploring. Save to keep
477
+ this layout." The pulse keyframe lives in the consolidated reduced-motion
478
+ gate (stilled, dot still shown); position/glass/safe-area untouched.
479
+
480
+ ## [0.20.49] - 2026-07-25
481
+
482
+ **Views: contained table (polish V5).** The table lens sits in a raised card
483
+ now — `rounded-xl border bg-card shadow-sm` around the horizontal-scroll
484
+ container (the prototype's `.tbl-wrap`), with a soft `bg-bg-soft` header band
485
+ (uppercase 11px micro-labels, `tracking-[0.08em]`), light `border-border-light`
486
+ row hairlines, a `bg-bg-soft/60` row hover, and the last row running borderless
487
+ into the card's own edge.
488
+
489
+ - **The sticky title column's backgrounds now match the card** — `bg-card` in
490
+ the body and `bg-bg-soft` in the header (they carried the page's `bg-bg`
491
+ before, which would seam visibly against the new card when rows slide under
492
+ the sticky column on horizontal scroll). The row-hover tint is mirrored onto
493
+ the sticky cell by an opaque-composited overlay, so hover reads uniformly
494
+ across the row in both themes without the cell going transparent under the
495
+ scroll.
496
+ - Class-only containment: number-column right-alignment, V1 typed rendering,
497
+ click-to-edit cells, and the write/flash machinery are untouched.
498
+
499
+ ## [0.20.48] - 2026-07-25
500
+
501
+ **Views: recessed board lanes + card rhythm (polish V4).** The board reads as
502
+ columns now: each lane is a soft recessed well (`rounded-2xl bg-bg-soft` +
503
+ light border) the `bg-card` tiles pop out of. Lane heads drop their underline
504
+ for the prototype's composition — tint dot + semibold name + a pill count
505
+ (`rounded-full bg-bg`, tabular). Cards tighten from `.card`'s 24px radius to
506
+ 16px at tile width (both variants) and gain a resting `shadow-sm` that lifts
507
+ to `shadow-md` on hover; `hover:border-accent` stays.
508
+
509
+ - **Schema-declared lanes render even when empty** — a schema authoring
510
+ `status: [todo, doing, done]` shows all three columns, the empty ones
511
+ carrying a centered affordance ("Drop here" on fine pointers, "No notes" on
512
+ touch). Before, an emptied column vanished — an invisible drop target you
513
+ could never drag a card back into. Only the tag schema's authored enum earns
514
+ empty columns; the built-in fallback order stays presence-only.
515
+ - Card change is class-only: drag/flash/anchor machinery untouched.
516
+
517
+ ## [0.20.47] - 2026-07-24
518
+
519
+ **Views: a calm controls row — lens dropdown, labeled Group-by/By-date, Fields count
520
+ (polish V3).** The view header's controls now share one pill primitive
521
+ (`ControlPill`): `[icon?] [LABEL] [value] ▾`, opening as an anchored popover on
522
+ sm+ and as the established bottom sheet on phones (z-30 scrim / z-40 panel —
523
+ clear of the save bar and tab bar by construction).
524
+
525
+ - **Lens is a dropdown** — the five-way segmented switcher becomes one pill,
526
+ `[glyph] Board ▾` (no written label: the lens is the view's identity). The menu
527
+ lists the five kinds, glyph + name, ✓ on the current one. Same `onSwitch` seam,
528
+ same lossless draft path — switching never refetches.
529
+ - **Organize-by is first-class** — the bare native selects become labeled pills:
530
+ Board `[GROUP BY status ▾]` (every resolved field, each option wearing its V2
531
+ stable-hue dot), Calendar `[BY DATE meeting_date ▾]` (date-typed fields only).
532
+ Both RENDER EVEN EMPTY: a calendar with no date field honestly reads
533
+ `[BY DATE created ▾]` (that IS the read-only axis) with a "Showing by created
534
+ date" line and the graduation menu; a board over a schema-less tag reads
535
+ `[GROUP BY — ▾]` with one explanatory line.
536
+ - **Fields trigger joins the family** — `[FIELDS 4 ▾]` (count = shown fields);
537
+ its checkbox panel is unchanged.
538
+ - Each pill tints its border toward accent when ITS key diverges from the saved
539
+ view — a per-control echo of the View-modified bar.
540
+ - Render-only: draft semantics (`withLens`/normalize/save/revert), writes, and
541
+ wire shapes untouched.
542
+
543
+ ## [0.20.46] - 2026-07-24
544
+
545
+ **Views: enum values carry a stable color — tinted chips, menu dots, lane swatches
546
+ (polish V2).** Every enum value now resolves to a stable hue via the app's own hue
547
+ machinery (`hueForEnumValue` beside `hueForTag`): normalize (case/hyphen/underscore),
548
+ then hand-assigned state semantics — done/complete/completed/shipped/closed → grass,
549
+ in progress/active/doing → sun, blocked/urgent/critical → the semantic danger token —
550
+ else the existing djb2 hash into the 8-hue garden palette. Deliberately unsalted by the
551
+ field name, so "High" is the same hue on every priority-ish field, everywhere, forever,
552
+ with zero storage.
553
+
554
+ - **Chips** (`NoteFieldChips`) — a set enum value tints its chip: a `color-mix` wash +
555
+ border over the card/border tokens (dark mode flips for free) and a small leading
556
+ swatch dot. Text stays `--color-fg` — hue never carries the meaning alone.
557
+ - **Menus** (`FieldValueControl`) — each enum option carries its value's dot; the muted
558
+ "No {field}" / Clear options stay dotless.
559
+ - **Boards** (`BoardView`) — lane heads get the lane value's swatch before the name;
560
+ the uncategorized lane stays dotless. Just the swatch — lane containers unchanged.
561
+ - Render/CSS only — writes, toasts, and wire shapes untouched. New `.chip-tinted` /
562
+ `.tint-*` classes live beside `.hue-dot-*` in `styles/index.css`.
563
+
564
+ ## [0.20.45] - 2026-07-24
565
+
566
+ **Views: fields now render by type — human dates, boolean checks, aligned numbers,
567
+ link-styled URLs (polish V1).** Every field value a view shows (chips, table cells, the
568
+ boolean toggle) now funnels through one pure display seam, `FieldDisplay`, rendered inside
569
+ `FieldValueControl`'s trigger — so a date field reads "Today" / "Tomorrow" / "Jul 24"
570
+ instead of the raw key.
571
+
572
+ - **Dates** — `formatFieldDate` (in `lib/dates.ts`, beside `formatLongDate`): "Today" /
573
+ "Yesterday" / "Tomorrow" within ±1 day; "Jul 24" in the current year; "Jul 24, 2025"
574
+ otherwise. Unparseable values stay raw — honest over pretty. No overdue coloring by
575
+ design: the tag schema carries no done-semantics, so "past" is not "late".
576
+ - **Booleans** — ✓ (grass) / ✕ (dim) / the quiet "—" when unset. Toasts keep Yes/No.
577
+ - **Numbers** — table columns typed `number` right-align (header + cells); digits render
578
+ `tabular-nums`, never locale-reformatted.
579
+ - **URLs** — an `https?://` value renders link-styled (hostname + truncated path, accent),
580
+ but the trigger stays one-door: tap opens the editor, no nested anchor. The editor
581
+ popover gains an "Open ↗" anchor beside the ✓ commit when the current value is a URL.
582
+ - Enum values stay plain text (tinting is V2); strings render as-is under the existing
583
+ width clamps. Render-layer only — writes, toasts, and wire shapes untouched.
584
+
585
+ ## [0.20.44] - 2026-07-24
586
+
587
+ **Views: calendar createdAt fallback (PR F of the views train — the finale).** Calendar
588
+ without a date field now shows your notes by created date (read-only) instead of falling
589
+ back to a list. A `kind: "calendar"` view with no `date_field` used to render as a list;
590
+ now it always mounts the calendar, plotting each note on the local day of its `createdAt`
591
+ (the same reading the /calendar route uses), with a quiet hint — "Showing by created date —
592
+ set a date field to schedule." — and the Date-field control still on the config row as the
593
+ graduation path to the editable calendar.
594
+
595
+ - **Read-only means read-only** (the PR-E advisory, pinned by tests): with no date field
596
+ there is nothing a drop could write, so the entire date-write surface is gated off — day
597
+ cells attach NO drop props (no hover affordance under any event sequence, a note-payload
598
+ drag is never accepted), chips are plain spans (no drag source, no `useViewFieldWrite`
599
+ bound). Field CHIPS on the day panel stay editable — they write other fields through the
600
+ shared hook, exactly as ratified.
601
+ - The undated footnote is suppressed in fallback mode (every note has a createdAt); a
602
+ missing/unparseable `createdAt` (defensive — the wire type requires it) stays off the grid.
603
+ - Explicitly rejected (D8): per-note fallback — undated notes plotting on their createdAt
604
+ inside a date-field calendar would blur scheduled-vs-unscheduled and make drag ambiguous;
605
+ the undated footnote remains the honest state there.
606
+ - Render-mode only: no new kind, no new wire semantics, nothing written to the view note.
607
+ - `placeByCreatedAt` joins `placeOnCalendar` in `src/lib/views/grouping.ts`; the dispatch in
608
+ ViewSurface drops the calendar→list fall-through (board without a group-by still lists).
609
+
610
+ ## [0.20.43] - 2026-07-24
611
+
612
+ **Views: desktop drag — board lanes + calendar days (PR E of the views train).** Real pointer
613
+ drag as an additional affordance over the same write tap-to-move makes: drag a board card
614
+ between lanes (writes the group-by field), drag a calendar chip to another in-month day
615
+ (writes the date field as a bare `YYYY-MM-DD`, exactly what the date chip commits). Native
616
+ HTML5 DnD, no library — the touch path stays tap-to-move (the shipped Move menu, unchanged),
617
+ the keyboard/SR path stays the Move `role="menu"`, so drag only serves desktop pointers.
618
+
619
+ - **`src/lib/views/dnd.ts`** (new) — the shared drag machinery: a custom MIME type
620
+ (`application/x-parachute-note`) carries the note id so foreign drags (files, text) are
621
+ ignored entirely; `useNoteDragSource` (pointer-gated at mount — on touch it returns empty
622
+ props, markup byte-identical); `useNoteDropTarget` (depth-counted hover state for the drop
623
+ affordance); `useDropHandlerRegistry` (drops land on a LANE/DAY but the write lives with the
624
+ note-bound `useViewFieldWrite` in the card/chip — the registry routes between them, keeping
625
+ drag and tap ONE write path).
626
+ - **Board** — cards are drag sources (the wrapper, with `cursor-grab`), lanes are drop zones
627
+ with a subtle coral outline while hovered. A drop writes the lane's ORIGINAL typed value
628
+ (number stays number; the uncategorized lane writes `null`), same toast + card flash as
629
+ tap-to-move; dropping on the card's own lane is a no-op (no write, no toast).
630
+ - **Calendar** — day chips are drag sources, in-month cells are drop zones (inset coral
631
+ outline + tint). Same-day drops no-op; out-month cells take no drops; no "unschedule" zone
632
+ (the field chip's Clear covers it — deliberate). Chips now carry `data-note-id`, so the
633
+ microconfirmation flash pulses the chip on its new day.
634
+ - **The anchor trap** — `NoteCard`'s face is a `<Link>`, and anchors natively drag their href:
635
+ with drag active the Link gets `draggable={false}`, the wrapper owns the drag, and a
636
+ click-capture swallows the residue click after dragend — a drop never navigates (and a
637
+ calendar drop never toggles the day panel).
638
+ - Touch is untouched: the drag hooks are gated on `(pointer: fine)` and hand back empty props
639
+ otherwise — the Move-menu suites run unmodified.
640
+
641
+ ## [0.20.42] - 2026-07-24
642
+
643
+ **Views: the table lens — the field set as columns, click-to-edit cells (PR D of the views
644
+ train).** A fifth kind, `"table"`: a title column (a navigating link, the NoteRow/NoteCard
645
+ gesture) plus one column per resolved field (`resolveViewFields` — the same ordered set the
646
+ chips band renders), rows = the view results.
647
+
648
+ - **`src/components/views/TableView.tsx`** — every cell renders the shipped `FieldValueControl`
649
+ (enum menu / date picker / boolean toggle / text / number; no second editor), committing
650
+ through the shared `useViewFieldWrite` hook (PR A): immediate optimistic write, rollback on
651
+ error, microconfirmation toast + row flash (`data-note-id` on the row). An empty cell shows
652
+ the control's quiet "—" affordance. Mobile: a horizontal-scroll wrapper with a min-width
653
+ table (the BoardView pattern) — the page body never scrolls sideways; the title column is
654
+ sticky against that scroll. Accepted v1 caveat (as on the board): the editor popovers can
655
+ clip at the scroll container's edge.
656
+ - **Wire compat: `"table"` joins `VIEW_KINDS` additively** — every decoder degrades an unknown
657
+ kind to list silently, so old app versions render a table view as a list. No breakage;
658
+ covered by decode tests both directions (a `kind:"table"` note decodes as table; a
659
+ hypothetical unknown kind still lists).
660
+ - **Dispatch + affordances** — ViewSurface renders `kind === "table"` (no required config:
661
+ zero resolved fields is still a coherent title-column table); the lens switcher gains the
662
+ Table option (PR B's switcher maps `VIEW_KINDS`); `ViewNavIcon` + the switcher get a table
663
+ glyph (`IconTable`).
664
+
665
+ ## [0.20.41] - 2026-07-24
666
+
667
+ **Views: the Fields control — one field set per view, now editable in place (PR C of the views
668
+ train).** Every lens renders the same resolved field set (`resolveViewFields`: view override →
669
+ primary-tag schema → none); until now editing that set meant hand-writing JSON on the view note.
670
+ A "Fields" control now rides the config row beside the lens switcher, for every lens kind.
671
+
672
+ - **`src/components/views/FieldsControl.tsx`** — checkboxes over the UNION of the primary tag's
673
+ declared schema fields and the current effective set (an override field the schema never
674
+ declared is still listed, so it can be unchecked). Shown fields first in view order with
675
+ up/down reorder buttons; hidden schema fields follow; checking one appends it to the end.
676
+ Minimum ONE field enforced with a hint — the wire format can't express "show none" (an empty
677
+ `fields` list decodes to the schema default), so the control never drafts a state Save
678
+ couldn't persist. At phone widths the panel docks as a bottom sheet over a scrim; sm+ it
679
+ anchors as a popover under the trigger.
680
+ - **Edits ride PR B's config draft** — every change writes the full ordered list into the URL
681
+ (`fields`, comma-joined); the one "View modified" bar governs Save/Revert; Save persists
682
+ `fields` as a JSON-string array via the existing partial-patch/fork builders. No new save
683
+ machinery.
684
+ - **`useSchemaFieldNames`** (`src/lib/views/fields.ts`) — the union's schema half, fetch-deduped
685
+ with the existing tag-schema read.
686
+ - Rider: repo `CLAUDE.md` stub (purpose + gotchas — vitest-only tests, dev-branch flow, the
687
+ cloud source pin, the bun-linked dogfood ring).
688
+
689
+ ## [0.20.40] - 2026-07-24
690
+
691
+ **Views: explore-then-save — switch lens / adjust the query / change fields in place, Save or
692
+ Revert deliberately; forking a view now carries its full layout (PR B of the views train).** A
693
+ view's CONFIG (lens/kind, group-by, date-field, field set, query refinements) is now
694
+ explore-then-save: changing any of it is a temporary exploration living in the URL — leave and
695
+ it's discarded silently, back-button restores it, a shared link carries it — and the saved
696
+ `#view` note changes only on an explicit Save. DATA writes through the view (field chips, board
697
+ moves) stay immediate, as PR A shipped them; the two affordances never blur.
698
+
699
+ - **The config draft** (`src/lib/views/config.ts`) — `ViewConfigDraft` with a URL round-trip
700
+ (params `kind`/`group`/`date`/`fields`, disjoint from the shipped refinement params), the pure
701
+ `applyConfig` overlay, per-key normalization (setting a control back to the saved value clears
702
+ its draft key), and the two save-payload builders.
703
+ - **The in-place lens switcher** (`src/components/views/LensSwitcher.tsx`) — List / Board /
704
+ Calendar / Gallery on the view itself, lossless by construction: the results cache key excludes
705
+ the lens, so switching re-renders the one cached result set without a refetch. A board with no
706
+ group-by defaults to the tag schema's first enum-typed field; a calendar with no date-field to
707
+ the first date-typed one. Board-only Group and calendar-only Date-field selects ride alongside.
708
+ - **The "View modified — Save / Revert" bar** — ONE bar covers both axes (config draft + query
709
+ refinements), absorbing the refinement bar's standalone Save button. A bottom-anchored strip
710
+ (above the tab bar on phones, safe-area aware; the ambient Map button yields while the bar is
711
+ up). Save runs the existing update-or-fork sheet; Revert clears the URL and writes nothing.
712
+ - **Save writes a partial metadata patch** — kind + merged query plus exactly the config keys the
713
+ draft overrides (`group_by` canonical, `fields` as a JSON-string array), guarded by
714
+ `if_updated_at`. **Fork fix rides along**: forking used to write only kind+query, silently
715
+ dropping `group_by`/`date_field`/`fields` — forking a board lost its lanes. Fork now writes the
716
+ full effective config.
717
+
718
+ ## [0.20.39] - 2026-07-24
719
+
720
+ **Views: immediate field writes now confirm themselves — shared write hook + microconfirmation
721
+ (PR A of the views train).** Data writes through a view (edit a field chip, tap-to-move a card)
722
+ are immediate — they never wait for a "Save" — so the write now confirms itself the moment it
723
+ resolves.
724
+
725
+ - **`useViewFieldWrite(note, viewResultsKey)`** (`src/lib/views/write.ts`) — the shared write
726
+ hook every lens consumes (chips + board move today; table cells + drag next). Wraps the shipped
727
+ `useViewFieldMutation` (optimistic view-cache paint, rollback, server-`updatedAt` patch-back —
728
+ unchanged) and adds the microconfirmation on RESOLVE — not optimistically: offline writes
729
+ resolve instantly via the durable queue, so resolve-time is both instant and honest, while a
730
+ rejected write confirms nothing.
731
+ - **The microconfirmation** — a short success toast naming what changed ("✓ status → done";
732
+ booleans read Yes/No, a cleared field reads "✓ status cleared", the board echoes the tapped
733
+ lane's label) + a brief coral-outline flash on the affected card/row. Toasts gained an optional
734
+ per-toast `durationMs` (`src/lib/toast/store.ts`, `Toaster`) — microconfirmations run ~1800ms
735
+ so a burst of quick edits reads as pulses, not a toast wall; every existing toast keeps the 4s
736
+ default. The flash keyframe lives inside the app's single reduced-motion gate
737
+ (`src/styles/index.css`) — reduced-motion users still get the toast; `NoteCard` and `NoteRow`
738
+ carry `data-note-id` so the flash can target them.
739
+ - **Both existing call sites migrated** — the field chips (`NoteFieldChips`) and the board's
740
+ tap-to-move (`BoardView`) now write through the hook. Behavior is identical (same optimistic
741
+ paint, same rollback, same error-toast phrasings) plus the new confirmation.
742
+
743
+ ## [0.20.38] - 2026-07-22
744
+
745
+ **Views become one system: a shared foundation + tag-schema-driven fields + inline field chips.**
746
+ The editable-kanban slice proved that a view can show AND edit a note's field through one mutation
747
+ primitive; this turns that proof into the shared base every view kind stands on, and lights up the
748
+ first cross-kind piece — the fields a tag declares are now visible and editable from any view.
749
+
750
+ - **Foundation A1 — the mutation key reaches every kind.** `ViewSurface` now threads the
751
+ `useViewResults` cache key to list, gallery, and calendar renderers, not just the board
752
+ (`src/app/routes/ViewSurface.tsx`) — so any kind can write a field optimistically, not the board
753
+ alone.
754
+ - **Foundation A2 — one shared field editor.** The board's tap-to-move menu was the enum case of a
755
+ general control; it's now `FieldValueControl` (`src/components/views/FieldValueControl.tsx`),
756
+ driven by the tag schema field type: **enum → menu, date → date picker, boolean → toggle,
757
+ string/number → inline input**. The board consumes the enum case with its lanes as options — the
758
+ shipped tap-to-move is unchanged. Every write goes through the shared `useViewFieldMutation`
759
+ primitive (type-preserving, `null`-as-delete for a cleared field).
760
+ - **Foundation A3 — `lane_by` → `group_by`.** The grouping field renamed to the general
761
+ `group_by` (`src/lib/views/schema.ts`), with `lane_by` kept as a PERMANENT decoder alias so no
762
+ existing `#view` note breaks; `groupIntoLanes`/`resolveLaneOrder` (`src/lib/views/grouping.ts`)
763
+ are now framed as the shared grouping engine.
764
+ - **Configurable fields (cross-kind primitive).** A view's shown fields default to its primary
765
+ tag's declared schema fields (in schema order), and are configurable per view via a new optional
766
+ `fields` metadata key — an ordered subset/override (`src/lib/views/fields.ts`,
767
+ `resolveViewFields`). This one resolution is read by every kind; it degrades to today's behavior
768
+ (no fields) when the view has no single primary tag and no override.
769
+ - **First visible slice — inline field chips.** Cards (board/gallery/calendar) and list rows now
770
+ carry a small band of the resolved fields, each showing the note's current value; tapping a chip
771
+ opens `FieldValueControl` to edit that field IN PLACE, written through the shared mutation
772
+ primitive (optimistic, offline, rolls back on error). Absent when no fields resolve — the card
773
+ looks exactly as before (`src/components/views/NoteFieldChips.tsx`, `NoteCard`/`NoteRow` gained a
774
+ `footer` slot).
775
+ - **Sequential edits on one note no longer spuriously conflict.** `useViewFieldMutation` now folds
776
+ the server's new `updatedAt` back into the note's `["viewResults", …]` cache entry on success
777
+ (`src/lib/views/mutate.ts`), so a follow-up edit of the same card (status → priority → due, now
778
+ common with chips) sends a fresh `if_updated_at` baseline instead of the stale one — no more
779
+ spurious "Couldn't update…" reject-and-rollback on the second write.
780
+ - **Next slices (not in this PR):** the display switcher (kind-as-refinement) and query-adjust UI;
781
+ the calendar tray / drag / table kind / create-in-view come later.
782
+
783
+ ## [0.20.37] - 2026-07-22
784
+
785
+ **Hardening nits: quieter offline-hydration progress for screen readers + pre-auth cosmetics.** Two
786
+ filed low-risk items off the hardening backlog; no behavior change to the mirror engine or any flag.
787
+
788
+ - **a11y — hydration progress no longer spams screen readers (#77).** The one-time "Saving your vault
789
+ for offline · N of ~T" line ticks once per synced page during cold hydration; its `<output>` carried
790
+ an implicit polite live region, so each tick re-announced. It's now `aria-live="off"` — the count
791
+ stays VISIBLE but silent (`src/components/MirrorStatusLine.tsx`). The offline/synced STATE transition
792
+ ("Offline — showing your saved vault") keeps its implicit polite announcement. CSS/aria only; the
793
+ mirror engine is untouched.
794
+ - **Pre-auth cosmetics (#81, items a–c).**
795
+ - (a) Reworded stale comments in `src/lib/account/descriptor.ts` (+ `descriptor.test.ts`) that still
796
+ described the retired "null ⇒ magic-link" fallback — the rule has been "unresolved/unclassifiable ⇒
797
+ door-NEUTRAL shell" since the door-aware front door landed.
798
+ - (b) Added a component test for the WARM-CACHE hub paint: `peekDoorDescriptor` → hub means the FIRST
799
+ render is already the hub card, no neutral flash (`src/app/routes/Landing.test.tsx`).
800
+ - (c) Cold-boot self-heal: a first-fetch descriptor failure otherwise pinned the door NEUTRAL for the
801
+ whole SPA lifetime (the revalidation runs once). A fresh front-door mount now re-arms it via
802
+ `retryDoorDescriptorIfCold` (`descriptor.ts`, wired in `Landing.tsx`) — narrowly gated to the
803
+ cold-MISS case, a no-op once a door is known, so the "one fetch per lifetime" happy path is intact.
804
+
805
+ ## [0.20.36] - 2026-07-22
806
+
807
+ **Editable kanban — tap a card, move it between lanes (+ a shared view-mutation primitive).**
808
+ The first slice of the view-experience wave: the board stops being a read-only render and becomes a
809
+ task list you can rearrange. Every board card gains a small **"Move"** button that opens a menu of the
810
+ other lanes; picking one writes that lane's value onto the note and re-lanes the card instantly. The
811
+ read-only render is untouched for list/gallery/calendar.
812
+
813
+ - **Shared "view mutation" primitive (`src/lib/views/mutate.ts`):** `useViewFieldMutation(noteId,
814
+ viewResultsKey)` writes ONE metadata field via the existing `useUpdateNote` — server-side a shallow
815
+ RFC-7386 merge with null-as-delete (`parachute-vault/core/src/notes.ts:2981-2994`), so one key moves
816
+ and the note's siblings + body are untouched, and it rides the offline queue
817
+ (`src/lib/sync/queue.ts`) so a move works offline. `useUpdateNote.onSuccess` invalidates
818
+ `["notes"]`/`["note"]`/`["tags"]` but NOT `["viewResults", …]`
819
+ (`src/lib/vault/queries.ts:781-793`), so the primitive adds an **optimistic `setQueryData` on the
820
+ active view-results key with rollback on error** — the card moves the instant you tap, and snaps back
821
+ if the write fails; the live-reconcile layer (`live-query.ts`) still provides eventual truth.
822
+ `useViewResults` now returns its `queryKey` so the board can target it (`src/lib/views/queries.ts`).
823
+ - **Type preservation:** lane keys are stringified for grouping (`grouping.ts`), but writing a string
824
+ back to an `indexed` integer/boolean field is hard-rejected by the vault (`mcp-manifest.ts:514`), so
825
+ each lane now carries its ORIGINAL typed value and the move writes THAT (number `3`, not `"3"`).
826
+ Moving to the "No {field}" lane writes `{ [field]: null }` (null-as-delete).
827
+ - **Tap-to-move UI (`src/components/views/BoardView.tsx`, `NoteCard.tsx`):** the "Move" affordance is a
828
+ corner button laid OVER the card but OUTSIDE its navigating anchor (a button nested in an `<a>` is
829
+ invalid and would double-fire navigation); it opens a `role="menu"` of target lanes with an
830
+ outside-tap backdrop. Mobile-first and dependency-free. **Real drag-and-drop is the NEXT slice** (it
831
+ needs a touch DnD lib like dnd-kit — deliberately not added here).
832
+ - Tests (synthetic data only): the primitive writes exactly one key with its type preserved (and `null`
833
+ for the unset lane); the optimistic update re-lanes the card and rolls back on failure; tap-to-move
834
+ PATCHes `{ [laneBy]: value }`; an offline move enqueues without throwing.
835
+
836
+ ## [0.20.35] - 2026-07-22
837
+
838
+ **Tall focus-mode editor canvas — long-form writing gets the room the collapsed header frees up.**
839
+ Follow-up to the 0.20.34 freeze fix. That fix bound the editor pane to a definite `h-[60dvh]` so
840
+ `.cm-scroller` (not the page) is the scroll container — but `NoteEditor`'s focus mode collapses the
841
+ whole header card to a floating whisper, then still rendered the editor inside that same 60dvh box,
842
+ leaving ~40% of the viewport as dead space below the words — the opposite of "just me and the words"
843
+ for morning-pages writing.
844
+
845
+ - **Fix:** in focus mode the editor (and, on desktop, its side-by-side preview) pane now gets a taller
846
+ `h-[85dvh]` — still a DEFINITE, `dvh`-relative height paired with `min-h-0`, so the `.cm-scroller`-is-
847
+ the-scroll-container invariant (and thus the freeze fix) holds in focus mode too. Non-focus mode is
848
+ unchanged at `h-[60dvh]`. A single `paneHeight` var drives both panes so the desktop split stays a
849
+ matched pair. `src/app/routes/NoteEditor.tsx`.
850
+ - **Freeze-guard tests tightened:** the regression guards used `className.toContain("h-[60dvh]")`, which
851
+ also matches the substring inside `min-h-[60dvh]` — a silent demotion to `min-h`-only (which revives
852
+ the padding runaway) would have passed. Now a word-boundary regex (`/(?:^|\s)h-\[(?:60|85)dvh\]/`)
853
+ requires a standalone definite height, and a new test asserts focus mode gets the taller `h-[85dvh]`
854
+ while keeping `min-h-0`. `src/app/routes/NoteEditor.test.tsx`, `src/app/routes/NoteNew.test.tsx`.
855
+ - **Height-chain + iOS notes:** added a cross-reference comment at the `CodeMirrorEditor` host div
856
+ (`h-full overflow-auto`) pinning the four-link height chain (pane definite height → dropzone
857
+ passthrough → `h-full` host → `.cm-scroller`) against a future silent break, and a note flagging that
858
+ CM tooltips (`position:absolute`, e.g. the "/" slash menu) can clip at the pane's `overflow-auto` edge
859
+ on iOS — a manual-check exposure, no speculative fix. `src/components/CodeMirrorEditor.tsx`.
860
+
861
+ ## [0.20.34] - 2026-07-22
862
+
863
+ **Editor freeze fix — stop `scrollPastEnd` padding runaway (down-arrow freeze on long notes).**
864
+ After a writing session on a long note, holding or repeatedly pressing the down arrow at the bottom of
865
+ the editor could stall the tab for ~15s. Root cause: `scrollPastEnd()` (CodeMirror's stock extension,
866
+ added in editor Wave 1) sets `.cm-content` `padding-bottom` to `scroller.clientHeight − oneLine`. In a
867
+ CONTENT-sized editor the scroller's `clientHeight` *includes* the padding it wrote last pass, so every
868
+ cursor move at the viewport bottom re-reads inflated geometry and grows the padding by ~one doc-height —
869
+ on a long note it reaches millions of px and the layout/paint stalls. The editor was content-sized
870
+ because the height chain collapsed: `NoteEditor`/`NoteNew`'s editor grid was `min-h-[60vh]` + auto, so
871
+ the `height:100%` chain down to `.cm-scroller` never resolved to a definite value and `.cm-scroller`
872
+ never became the scroll container (the page was). CodeMirror's own docs: `scrollPastEnd` "should not be
873
+ enabled in editors that take the size of their content."
874
+
875
+ - **Fix (approach A — bound the pane):** the editor (and, on desktop, the side-by-side preview) pane now
876
+ gets a DEFINITE `h-[60dvh]` height plus `min-h-0`, so `.cm-scroller` is the real, content-INDEPENDENT
877
+ scroll container. `scrollPastEnd`'s padding now converges to one viewport (only when the doc actually
878
+ overflows) instead of running away. `dvh` (not `vh`) so a mobile soft keyboard shrinks the pane with
879
+ the visual viewport. Covers every `buildExtensions` surface — `NoteEditor` (edit route) and `NoteNew`
880
+ (compose) — in both raw and live-preview modes, desktop and mobile. `NoteNew`'s preview binds only at
881
+ `lg` (its panes stack on mobile, where a second fixed box would just be dead space above Attachments).
882
+ `src/app/routes/NoteEditor.tsx`, `src/app/routes/NoteNew.tsx`.
883
+ - Does not touch `scrollPastEnd()` itself or `bottomScrollMargin` (the #64 "jumping to paragraph top"
884
+ fix) — the scroll-off is unchanged; the bounded pane makes that area behave better, not worse.
885
+ - Regression guards in `NoteEditor.test.tsx` / `NoteNew.test.tsx` pin the structural cause (the pane
886
+ carries a definite height + `min-h-0`, not `min-h`-only). Manual verify on a long note:
887
+ `document.querySelector('.cm-content').style.paddingBottom` stays ~500–900px, not 6–8 digits.
888
+
889
+ ## [0.20.33] - 2026-07-21
890
+
891
+ **Offline-mirror runaway fix — stop re-fetching the same 200 notes forever.** On a self-hosted box the
892
+ mirror hydration walk never terminated: it kept re-applying the first page of notes (the `notesApplied`
893
+ count ran into the tens of thousands) and never persisted a cursor, so every refresh started cold. Root
894
+ cause was a broken cursor contract in the bundled SDK, plus an engine loop that couldn't defend against
895
+ it. Three fixes:
896
+
897
+ - **Bump `@openparachute/surface-client` `0.3.5` → `^0.3.6`** (the actual fix). 0.3.5's
898
+ `queryNotesCursor` omitted the `cursor` param on the bootstrap (empty-cursor) call and read the next
899
+ cursor ONLY from an `X-Next-Cursor` header the self-host daemon never emits (the cross-door contract
900
+ is the body's `next_cursor`). Net: every page returned the same first 200 notes with
901
+ `nextCursor: undefined`, so the drain never advanced, never persisted a cursor, and never terminated.
902
+ 0.3.6 sends `?cursor=` (empty included) and parses the `{ notes, next_cursor }` body envelope. Lockfile
903
+ deduped to a single 0.3.6 (surface-render's transitive `^0.3.3` resolves to it).
904
+ - **Engine defense-in-depth** (`src/lib/mirror/engine.ts`, closes app#79 items 1+3). A non-empty page
905
+ that carries no `next_cursor` is now a hard contract violation → error state (never
906
+ `lastSyncedAt`-stamped), instead of an infinite loop. The completion watermark is gated on CLEAN
907
+ empty-page exhaustion only — a no-advance stop no longer marks a partial mirror "synced" (#79 item 1).
908
+ The same contract guard is mirrored in the reconcile sweep's enumeration (`complete: false` → aborts
909
+ rather than over-deletes). Plus a hard per-drain/enumeration page cap (default 1000) as a final belt.
910
+ - **"N of ~T" hydration progress** (`src/components/MirrorStatusLine.tsx`, #79 item 3). The denominator
911
+ now reads the real wire field `stats.totalNotes` (both daemons emit it) instead of the SDK type's
912
+ stale `noteCount` (never on the wire → always undefined). Rides the already-fetched vault-info query,
913
+ so zero extra traffic; copy is "Saving your vault for offline · N of ~T", falling back to bare "· N"
914
+ when the total is unknown.
915
+
916
+ ## [0.20.32] - 2026-07-21
917
+
918
+ **Door-aware pre-auth — stop showing cloud onboarding on a self-hosted hub.**
919
+ The hub already serves a correct door descriptor (`door: "hub"` + an `auth` block), but the app
920
+ DEFAULTED to cloud onboarding (the magic-link email form + hardcoded pricing) whenever the descriptor
921
+ was unresolved, failed, stale, or absent — so a self-hosted box (and any surface-mount served by a
922
+ descriptor-less host) landed on a cloud sign-up. The door is a property of the SERVING origin's
923
+ runtime; the app now treats an unknown door as NEUTRAL, never cloud, and gates cloud copy on a
924
+ CONFIRMED cloud-shaped descriptor. No hostname assumptions; account endpoints stay
925
+ serving-origin-relative.
926
+
927
+ - **Neutral unknown-door state** (`src/app/routes/Landing.tsx`) — `FrontDoor` now forks into THREE
928
+ states instead of "hub-or-cloud": (a) UNRESOLVED (null / in-flight / unclassifiable) → a
929
+ door-NEUTRAL shell (mark + headline + one "Sign in" → the `/add` connect flow), with NO cloud email
930
+ form, NO pricing, NO "create your account"; (b) CONFIRMED cloud/magic-link → the existing email form
931
+ (unchanged); (c) CONFIRMED hub/password → the hybrid card below. This also fixes the surface-mount
932
+ class (app at `/surface/<slug>` on a descriptor-less host → neutral, not cloud onboarding).
933
+ - **Durable per-origin door cache** (`src/lib/account/descriptor.ts`) — the resolved door moved from
934
+ sessionStorage to **localStorage keyed by origin**, with **stale-while-revalidate**: a door already
935
+ known for the origin paints synchronously (`peekDoorDescriptor`, no neutral flash on a returning
936
+ tab) while a single background refetch runs and `onRevalidate` swaps in a changed door. A fetch
937
+ FAILURE never overwrites a known door and `null` is NEVER persisted — so a box that once identified
938
+ as a hub keeps painting hub across reloads and OFFLINE (pairs with the offline-mirror arc).
939
+ - **Hybrid hub sign-in card** (Aaron's ratified choice) — a confirmed-hub door renders one PRIMARY
940
+ action, **"Open your parachute"** (OAuth-connect at the serving origin — the hub's login rides
941
+ inside its authorize/consent — landing straight in the vault; reuses the `/add` `beginOAuth` path,
942
+ skipping the URL form), plus a quiet SECONDARY **"Manage this parachute"** (the existing ceremony
943
+ hop to the hub's `/login?next=<mount-aware /welcome>` → account session → vault manager). The card
944
+ names the box ("Sign in to `<serving-origin-host>`"); no cloud email/pricing anywhere on it.
945
+ - **Descriptor-driven pricing** — the cloud form's "Plans from $N a year" line is derived from the
946
+ descriptor's `plans` (cheapest advertised yearly, falling back to monthly) instead of a hardcoded
947
+ string, so the copy travels with the door (cloud carries `plans`; a hub's is empty → no price line).
948
+
949
+ ## [0.20.31] - 2026-07-21
950
+
951
+ **Offline mirror — correctness fix: never serve a PARTIAL list from a mid-hydration mirror.**
952
+ A vault's mirror hydrates by walking an `updated_at`-ordered cursor, but the offline list evaluator
953
+ (`readNotesList`) sorts by `created_at` (the vault's non-cursor list default). While the initial cold
954
+ hydration is still in flight, the mirror therefore holds an arbitrary, shifting SUBSET — so an offline
955
+ list read (or the cold-launch seed) could present a partial vault as if it were the whole thing, with
956
+ the visible set changing as hydration advanced. Aaron hit this live. This release gates mirror LIST
957
+ reads on a **completed initial hydration**.
958
+
959
+ - **Completeness gate in `readNotesList`** (`src/lib/mirror/read.ts`) — the evaluator now returns
960
+ `null` (caller stays network-only, exactly as for an unreproducible query shape) until the vault's
961
+ initial cold hydration has drained its cursor to exhaustion at least once. The signal is the
962
+ persisted **`lastSyncedAt`** watermark, which the engine writes only AFTER `drainCursor`'s full walk
963
+ completes — it is durable across restarts, is unaffected by the transient `hydrating` phase a warm
964
+ re-poll passes through every tick (so a complete mirror never briefly refuses reads), and is cleared
965
+ only by "Clear offline copy" (which correctly re-arms the gate so the re-fill runs cold). Because
966
+ both the offline fallback (`withMirrorList`) and the cold-launch seed (`useMirrorListSeed`) route
967
+ through `readNotesList`, one gate covers both. Before the mirror is ready, the app keeps its normal
968
+ network-first behavior and the "Saving your vault for offline · N" progress line is the user's cue.
969
+ - **Single-note reads unchanged** — `readNote` is intentionally NOT gated: an already-mirrored note
970
+ opens correctly mid-hydration, and a not-yet-mirrored one already falls through to the network.
971
+ - **Tag list exempt** — the mirrored tag list is written atomically (one `listTags()` → one
972
+ `setMirrorTags`) and only after a clean drain's reconcile sweep, so `getMirrorTags` never returns a
973
+ mid-hydration partial; no gate needed (documented at `withMirrorTags` in `queries.ts`).
974
+ - **Drain cadence** — verified `drainCursor` already loops to cursor exhaustion within a SINGLE tick
975
+ (not one page per 60s), so a cold hydration finishes as fast as the network allows; no fast-path
976
+ change was needed.
977
+
978
+ ## [0.20.30] - 2026-07-20
979
+
980
+ **Offline mirror — ACTIVATION: the durable-offline mirror is now ON by default.** Waves 1–4 built
981
+ and shipped the whole mirror behind a default-OFF flag — store, cursor hydration, deletes-reconcile,
982
+ local-first reads, staleness UX, storage ceiling/eviction, and a Settings surface. Aaron ratified
983
+ turning it on. This release flips that single lever: a fresh browser now hydrates its vault into
984
+ IndexedDB and serves reads local-first, with the offline/staleness UX live.
985
+
986
+ - **Default flipped ON** (`src/lib/mirror/flag.ts`) — `MIRROR_ENABLED_DEFAULT = true`. The
987
+ `parachute:mirror:enabled` localStorage override still works per-device and now cuts **both ways**:
988
+ `"false"` forces the mirror OFF (a per-device opt-out without a rebuild), `"true"` forces it ON.
989
+ With neither key set, the compile-time default decides. `isMirrorEnabled()` remains the single lever
990
+ every read/write/hydration path reads.
991
+ - **`clearOffline` meta cleanup** (#74) — "Clear offline copy" now also drops the vault's sync-state
992
+ meta (`state` / `lastSyncedAt` / `lastSweepAt` / `tags`) via a new `clearMirrorMeta` store helper,
993
+ not just the note rows + cursor. A cleared vault now reads truly EMPTY: a reload can't repaint a
994
+ stale "synced · last synced X ago", and the re-fill runs COLD (hydration progress shown) instead of
995
+ being treated as a warm no-op. The write queue (`pending` / `id_map` / `blob_path_map` / `blobs`) is
996
+ untouched — the same sacred-work exclusion as before; un-synced work is never dropped.
997
+ - **Engine crash-safety** (`src/lib/mirror/engine.ts`) — `syncOnce()` runs fire-and-forget from the
998
+ tick interval + online/visibility listeners (and directly from Settings "Sync now"), so it must
999
+ never reject. It now backstops the rare throws outside `drainCursor`'s own guard (chiefly a
1000
+ torn-down or evicted IndexedDB throwing from the pre-drain cursor reads or the error-state write)
1001
+ and resolves to an error result instead of surfacing an unhandled promise rejection.
1002
+
1003
+ ## [0.20.29] - 2026-07-20
1004
+
1005
+ **Views Wave 2b — the board, gallery, and calendar view KINDS now render.** A `#view` note already
1006
+ carried its `kind` (`list` | `board` | `calendar` | `gallery`), and the nav glyph already matched, but
1007
+ ViewSurface only ever drew a list — so a board laned by status, a meetings calendar, and a reference
1008
+ gallery all flattened into the same rows. ViewSurface now dispatches on `view.kind` over the SAME
1009
+ fetched results; the `list` kind is unchanged and any unknown/missing kind (or a board/calendar
1010
+ missing its lane/date config) still degrades to the list.
1011
+
1012
+ - **Board** (`kind: "board"`, `lane_by: <field>`) — result notes become COLUMNS keyed by the distinct
1013
+ values of the `lane_by` metadata field; notes missing that field collect in a trailing, muted
1014
+ "No {field}" lane (always last). Lane order honors the subject tag's schema `enum` when it declares
1015
+ one (the authored order), else a small built-in order for common fields (`status`, `priority`), else
1016
+ alphabetical. Lanes scroll horizontally on overflow; pinned notes surface within their lane, keeping
1017
+ their star. Empty lanes are never drawn.
1018
+ - **Gallery** (`kind: "gallery"`) — results as a responsive auto-fill grid of cover cards (a cover
1019
+ image when the note carries a directly-usable image attachment, else a text tile) — the "bookshelf".
1020
+ - **Calendar** (`kind: "calendar"`, `date_field: <field>`) — a real month grid: weeks as rows, days as
1021
+ cells, each dated note a chip on its day. Clicking a day with notes opens a panel of that day's notes
1022
+ below; ◀/▶ navigate months and it opens on the month of the most recent dated note. The `date_field`
1023
+ value is parsed defensively (ISO date / datetime, read on its wall-clock day); notes with a
1024
+ missing/unparseable date are omitted and counted in a footnote. The grid mechanics are shared with
1025
+ the `/calendar` route via `@/lib/dates`.
1026
+ - New: `src/lib/views/grouping.ts` (pure lane-grouping + calendar-placement logic, unit-tested),
1027
+ `src/components/views/{NoteCard,BoardView,GalleryView,CalendarView}.tsx`.
1028
+
1029
+ ## [0.20.28] - 2026-07-20
1030
+
1031
+ **Offline mirror — Wave 4: staleness UX + storage ceiling/eviction + a Settings surface, behind the
1032
+ same default-OFF flag.** Waves 1–3 built the mirror store, cursor hydration, deletes-reconcile, and
1033
+ local-first reads. This wave adds the user-facing edges: how a stale/offline copy is signalled, a
1034
+ per-vault storage ceiling so the mirror can't grow without bound, and a place to see + manage it.
1035
+ Still gated by the SAME flag, and the flag **STAYS default OFF** — with it off, every path below is
1036
+ byte-identical to the network-only behavior that shipped before (no UX renders, no eviction runs,
1037
+ the Settings row is absent). Activation is a separately ratified step.
1038
+
1039
+ - **Storage ceiling + eviction** (`src/lib/mirror/evict.ts`) — a **512 MB per-vault** ceiling
1040
+ (Aaron-ratified). Past it, the mirror **evicts note bodies oldest-`updatedAt` first** but **keeps
1041
+ the index row**: content/links/attachments are dropped and `contentEvicted` is set, while a
1042
+ snapshotted preview + title are retained, so every note stays listable and openable-with-preview
1043
+ offline (an evicted note shows "Connect to load this note" when opened offline). Eviction is
1044
+ `byteSize`-aware (prefers the vault's wire `byteSize`, falls back to a content-length estimate) and
1045
+ runs after a clean drain/sweep when over-ceiling, under the same per-vault Web Lock. It **NEVER
1046
+ evicts a bare local-id row or a row with a pending queue mutation** — the same sacred-work
1047
+ exclusion the reconcile sweep uses (un-synced work is never dropped).
1048
+ - **Staleness UX** (all subtle, flag-gated; COPY IS A DRAFT pending sign-off) —
1049
+ - a quiet chrome line while offline and serving the saved vault: "Offline — showing your saved
1050
+ vault · updated {relative} ago" (only once there's a saved vault to show);
1051
+ - a one-time hydration progress line on first fill: "Saving your vault for offline · {n}/{total}"
1052
+ (non-modal, gone on completion);
1053
+ - a subtle "Saved copy" marker under the title of a note served from the mirror while offline;
1054
+ - "Connect to load this note" in place of the body of a content-evicted note opened offline.
1055
+ - **SyncContext `mirror` slice** (`src/providers/SyncProvider.tsx`) — `{ enabled, state
1056
+ ("off"|"hydrating"|"synced"|"offline"|"error"), progress?, lastSyncedAt, syncNow, clearOffline }`,
1057
+ fed by new mirror-engine `onStateChange`(cold-hydration only) + `onProgress` callbacks. One source
1058
+ of truth for the chrome line, the note chip, and Settings.
1059
+ - **Settings → Offline section** — mirror status + last-synced, storage used against the 512 MB
1060
+ ceiling, **Sync now** (an incremental cursor run + sweep + eviction), and **Clear offline copy**
1061
+ (wipes this vault's `mirror_notes` + resets its cursor, with a confirm). Clear touches ONLY the
1062
+ mirror store — never the write queue / un-synced work. The whole section is hidden when the flag is
1063
+ off.
1064
+
1065
+ ## [0.20.27] - 2026-07-20
1066
+
1067
+ **Offline mirror — Wave 3: local-first READS + cold-launch offline, behind the same default-OFF
1068
+ flag.** Waves 1–2 built the mirror store, cursor hydration, and deletes reconciliation — all
1069
+ write-only/invisible. This wave adds the READ path so the app serves notes from the mirror when
1070
+ offline or on a cold launch. Still gated by the SAME flag, and the flag **STAYS default OFF** — with
1071
+ it off every path below is byte-identical to the network-only behavior that shipped before; with it
1072
+ on (dev/test), reads fall back to the mirror. Activation (flipping the flag on) is a separately
1073
+ ratified step — Aaron ratifies the storage ceiling + staleness UX + flag-on timing as a batch after
1074
+ Wave 4; this wave ships nothing user-visible on its own.
1075
+
1076
+ - **The read evaluator** (`src/lib/mirror/read.ts`) — `readNote(vaultId, id)` returns the FULL
1077
+ mirror row (content/links/attachments, never a lean stub), resolving a synced local id through the
1078
+ id-map and falling back to the optimistic local-id row for an offline-created note that hasn't
1079
+ drained. `readNotesList(vaultId, params)` is a client-side evaluator over `mirror_notes` for the
1080
+ SMALL query subset the list hooks actually send — tag filter (`tag_match` any/all), `path_prefix`,
1081
+ `has_tags`/`has_links`, `sort`, `limit`/`offset` — reproducing the vault's server semantics
1082
+ exactly, including the default sort by **`created_at DESC, id DESC`** (the vault's non-cursor list
1083
+ order — NOT `updated_at`). It returns `null` for anything it can't reproduce faithfully (a `search`
1084
+ FTS query, or any param outside the subset), so the caller stays network-only rather than ever show
1085
+ a list that DIFFERS from the server's. (Fidelity note: the app sends no `expand`, so exact-match tag
1086
+ filtering equals the vault's default `subtypes` expansion for FLAT vaults; a declared tag hierarchy
1087
+ would see child-tagged notes omitted under a parent-tag filter offline — documented, acceptable.)
1088
+ - **Network-first, mirror-fallback read hooks** (`src/lib/vault/queries.ts`) — `useNote`, `useNotes`,
1089
+ `useNotesForDateViews`, `useNotesForPathTree`, and `useTags` now (when the flag is on) try the
1090
+ network first and fall back to the mirror when the vault is offline (fast path, skips the network)
1091
+ or unreachable (`VaultUnreachableError` — the installed-PWA `onLine===true`-but-dead case). They
1092
+ seed `placeholderData` from the mirror so a cold launch paints the last-mirrored notes instantly,
1093
+ then background-revalidate. The switcher (`useAllNotesForSwitcher`) and graph (`useAllNotesWithLinks`)
1094
+ stay network-only (Wave-later); the schema-bearing tag reads (`useTagsWithSchema`/`useTag`) stay
1095
+ network-only (the mirror holds only `TagSummary`, not schemas).
1096
+ - **`networkMode: "always"` (flag-on only).** React Query's default `networkMode: "online"` pauses
1097
+ the queryFn while `navigator.onLine === false`, so a cold-launch-offline would render nothing
1098
+ regardless of the fallback. The touched hooks switch to `"always"` when the flag is on so the
1099
+ queryFn runs offline and reaches the mirror. When the flag is OFF, `networkMode` stays `"online"`
1100
+ and the queryFn/placeholderData are exactly as before — pinned by a "flag-off + offline PAUSES,
1101
+ never reads the mirror" test.
1102
+ - **Lean-vs-full coherence.** The mirror only ever holds FULL rows (hydration + write-path landings);
1103
+ the lean live list is display-only and never written to the mirror, so reading a note for the VIEW
1104
+ always gets full content. List rows returned from the mirror are full Notes (a superset of the lean
1105
+ shape — `NoteRow` derives its title from content).
1106
+ - **Tests** — `src/lib/mirror/read.test.ts` (16: evaluator fidelity for tag/path/has_*/sort+id-tiebreak/
1107
+ limit-offset, `search`→null, unknown-param→null, and `readNote` local-id resolution) +
1108
+ `src/lib/vault/queries.mirror.test.tsx` (8: flag-ON offline list + note render from a populated
1109
+ mirror; flag-ON online seeds-then-network-wins; flag-ON onLine-but-unreachable serves the mirror;
1110
+ flag-OFF offline pauses + never reads the mirror; flag-OFF online plain fetch; `networkMode:"always"`
1111
+ keeps online errors surfacing and retries engaged).
1112
+
1113
+ ## [0.20.26] - 2026-07-20
1114
+
1115
+ **Offline mirror — Wave 2: deletes reconciliation (full-ID sweep + live WS-remove), still behind
1116
+ the same default-OFF flag.** Wave 1's cursor tells the mirror what CHANGED (created/updated) but
1117
+ NEVER what was DELETED (`deleteNote` is a hard server-side delete, no tombstone), so a note deleted
1118
+ on the server stayed in the mirror forever; imported/restored notes that landed BEHIND the cursor
1119
+ watermark never appeared. This wave reconciles both. Still write-only/invisible — no read path
1120
+ consumes the mirror yet (Wave 3), and with the flag OFF every path stays inert.
1121
+
1122
+ - **The reconcile sweep** (`src/lib/mirror/engine.ts` `reconcileSweep` + `src/lib/mirror/reconcile.ts`)
1123
+ — a periodic full-ID sweep. It runs a fresh **lean** cursor walk (`include_content=false` → id +
1124
+ updatedAt only) from `""` at a large page limit (1000) to enumerate the COMPLETE current server-id
1125
+ set (Aaron's ~3400-note vault ≈ 4 requests), then diffs against the mirror: **mirror-has /
1126
+ server-lacks → delete locally**, **server-has / mirror-lacks → fetch full bodies + upsert** (heals
1127
+ behind-watermark imports), **server updatedAt newer than the mirror row → refetch full**
1128
+ (belt-and-suspenders). Triggers: after the first hydration, on app start when online (throttled to
1129
+ ≤ once / 6h via `mirror:<vaultId>:lastSweepAt`), and right after a cursor-error re-walk.
1130
+ - **The two critical safety properties.**
1131
+ - **Exclusion — never prune un-synced user work.** The delete phase EXCLUDES (a) any row whose id
1132
+ is a LOCAL id (`isLocalId` — offline-created, not yet synced) and (b) any row with a PENDING
1133
+ queue mutation (create/update/delete/link/delete-attachment), including the id-map resolution of
1134
+ a local id that has since drained to a server id. Both would destroy in-flight offline work.
1135
+ - **Abort-on-incomplete — never mass-delete on a failed/empty enumeration.** If the lean walk
1136
+ errors, breaks on a no-advance cursor, or completes but returns ZERO ids, the diff is REFUSED
1137
+ (no deletions) — an "absent" id only means "gone" when we provably enumerated the whole set.
1138
+ - **Live WS-remove → mirror delete** — the engine holds an unfiltered, lean live subscription
1139
+ (`VaultClient.subscribe`) bound to the active vault; on an unfiltered query a `remove` event IS a
1140
+ real delete (no filter it could merely fall out of), so it prunes `mirror_notes` immediately
1141
+ (honoring the same local-id/pending exclusion), closing the online-window delete gap the cursor
1142
+ can't — the sweep is then the cold-start/reconnect backstop. Content upserts stay the cursor
1143
+ poll's job (snapshot/upsert ignored).
1144
+ - **Wave-1 review follow-ups folded in** — (a) an id-change (path rename → new id) orphaned the
1145
+ old-id row; the sweep now prunes it (mirror-has / server-lacks), unless it's local/pending; (b) the
1146
+ cursor drain now BREAKS on a no-advance page (a non-empty page whose `next_cursor` didn't move) to
1147
+ harden against a contract violation instead of spinning.
1148
+ - **Tags mirroring** — the sweep refreshes the vault's tag list under `mirror:<vaultId>:tags` (best
1149
+ effort) so Wave 3's offline list can render tag filters without a round-trip.
1150
+ - **Tests** — `src/lib/mirror/reconcile.test.ts` (pure diff + protected-id collection incl. the
1151
+ local→server id-map bridge) and `src/lib/mirror/engine.sweep.test.ts` pin the safety properties:
1152
+ the sweep deletes a server-deleted note; NEVER deletes a local-id row or a pending row (stale +
1153
+ pending survives); ABORTS on a failed walk AND on an empty walk; backfills server-has/mirror-lacks;
1154
+ refetches on a newer server timestamp; prunes an id-change orphan (but not if pending); WS-remove
1155
+ deletes a synced note yet spares local/pending; the no-advance drain break; throttle holds within
1156
+ 6h. All behind the same default-OFF flag.
1157
+
1158
+ ## [0.20.25] - 2026-07-20
1159
+
1160
+ **Offline mirror — Wave 1: the durable local-copy foundation, behind a default-OFF flag.**
1161
+ Lays the groundwork for reading the whole vault offline / cold-launching offline (later waves):
1162
+ a complete LOCAL MIRROR of the vault's notes in IndexedDB, kept fresh by the vault's cursor
1163
+ incremental-sync primitive. This wave builds the STORE + hydration ENGINE + write-path upserts.
1164
+ It is **write-only and invisible** — no read path consumes the mirror yet, so with the flag ON the
1165
+ only observable effect is IndexedDB growth, and with it OFF every mirror code path is fully inert
1166
+ (no cursor traffic, no timers, no writes).
1167
+
1168
+ - **Flag** (`src/lib/mirror/flag.ts`) — `isMirrorEnabled()`. Default OFF via `MIRROR_ENABLED_DEFAULT`;
1169
+ a per-device override lives in localStorage `parachute:mirror:enabled`, read once at provider mount
1170
+ (a change takes effect on reload). Everything below is gated on it.
1171
+ - **IndexedDB v2** (`src/lib/sync/db.ts`) — bumps `DB_VERSION` 1→2, adding ONE store `mirror_notes`
1172
+ via the existing migration ladder. Composite key `[vaultId, id]` (a restored/imported vault copy
1173
+ shares note ids across vaults, so a bare id would collide); indexes `by-vault` and
1174
+ `by-vault-updated` (`[vaultId, updatedAt]`) for sorted offline lists. Value = the full Note
1175
+ (content, path, tags, metadata, links, attachment rows) + a `contentEvicted?` bookkeeping bit for
1176
+ a future eviction pass. The v2 upgrade is **purely additive** — it does NOT touch the v1 queue
1177
+ stores (pending/id_map/blob_path_map/blobs/meta), which hold un-synced user writes. Cursor + sync
1178
+ state live in the existing `meta` store under `mirror:<vaultId>:cursor` / `:state` / `:lastSyncedAt`.
1179
+ - **Hydration engine** (`src/lib/mirror/engine.ts`) — a `MirrorEngine` (sibling to `SyncEngine`) that
1180
+ walks `queryNotesCursor` (unfiltered, full shape) and upserts each page into `mirror_notes` in one
1181
+ txn, **persisting `next_cursor` after every page** (a killed app resumes exactly), terminating on
1182
+ `items.length === 0` (never on a falsy cursor — the watermark never is). A rejected cursor
1183
+ (`cursor_invalid` / `cursor_query_mismatch`) drops the stored cursor and re-walks from `""`
1184
+ (idempotent). Triggers: app start, `online`, visibility→visible, and a 60s interval while online;
1185
+ guarded per-tab by an in-flight flag and cross-tab by `navigator.locks` (`mirror:<vaultId>`),
1186
+ degrading to run-directly where Web Locks is unavailable. Active-vault only.
1187
+ - **Write-path upserts** — a create/update landing (direct online OR queue-drain) upserts the server
1188
+ Note into the mirror; a delete removes the row; a drained offline-create swaps its optimistic
1189
+ local-id row for the server row via the id-map. The write queue's own logic is unchanged — the
1190
+ mirror sink is additive and no-ops when the flag is off (`src/lib/sync/queue.ts`,
1191
+ `src/lib/sync/engine.ts`, `src/lib/vault/queries.ts`, `src/providers/SyncProvider.tsx`).
1192
+ - **No read-path changes** — that's Wave 3.
1193
+ - **Tests** — `src/lib/sync/db.migration.test.ts` (v1→v2 adds `mirror_notes` AND preserves the queue
1194
+ stores + their data), `src/lib/mirror/store.test.ts` (composite-key isolation, cursor/state meta,
1195
+ flag-gated helpers), `src/lib/mirror/engine.test.ts` (per-page cursor persistence, empty-page
1196
+ termination, resume, cursor-error re-walk), `src/lib/sync/queue.mirror.test.ts` (drain-landing
1197
+ upsert/remove/local-id swap; flag-off is inert).
1198
+
1199
+ ## [0.20.24] - 2026-07-20
1200
+
1201
+ **Perf: the notes list opens its live subscription in the lean shape — titles and previews,
1202
+ not every note's full body.** The notes list (VaultSurface's All view) and the date-grouped
1203
+ surfaces (Recent, Activity, Calendar) keep their react-query cache fresh over a vault live
1204
+ subscription (`useLiveNotesQuery`). That subscription's snapshot shipped every matching note's
1205
+ FULL content, even though the list only renders `NoteRow` (title + preview + tags + provenance)
1206
+ and never reads `note.content` — so a large vault paid to stream its entire corpus on connect.
1207
+ The list subscriptions now request `include_content=false`, so vault (parachute-vault #620) sends
1208
+ lean `NoteIndex` frames (byteSize + preview + displayTitle + tags/metadata, no body) on snapshot
1209
+ and upsert. The REST poll already omitted content by the vault's list default, so this is purely a
1210
+ live-path win; the switcher (Cmd+K first-line search) and the graph/link hooks stay FULL because
1211
+ they genuinely read content/links.
1212
+
1213
+ - **`src/lib/vault/queries.ts`** — `useNotes` and `useNotesForDateViews` now set
1214
+ `include_content=false` on the shared query params that drive both the poll and the live
1215
+ subscription. Left FULL, deliberately: `useAllNotesForSwitcher` (`include_content=true`),
1216
+ `useAllNotesWithLinks` (`include_links=true`), `useViewList`/`useViewResults`, and the single-note
1217
+ `useLiveNote`/`useNote` path (the read view + neighborhood graph).
1218
+ - **Back-compat with an older vault** (predates #620, ignores `include_content` on subscribe and
1219
+ sends full frames): still renders — the full shape is a superset of the lean one, and
1220
+ `displayTitle()` falls back to the first content line when no wire `displayTitle` is present.
1221
+ - **`src/lib/vault/queries.lean-list.test.tsx`** — new: the two list queries send
1222
+ `include_content=false`; the switcher/link hooks stay full; and the list renders both a lean
1223
+ `NoteIndex`-shaped note (no content) and an old vault's full frame without error.
1224
+
1225
+ ## [0.20.23] - 2026-07-20
1226
+
1227
+ **Fix: Enter and Backspace are now exact inverses — predictable line breaks, Obsidian-faithful.**
1228
+ Writing prose in the editor, hitting Enter then Backspace could leave "sometimes one line break,
1229
+ sometimes two." Root cause: prose Enter inserted TWO newlines (`\n\n`, a paragraph gap), but the
1230
+ default Backspace only removed ONE, stranding a `\n`. Enter now inserts exactly ONE newline, so
1231
+ Enter-then-Backspace returns the document to its byte-identical prior state in every context. A
1232
+ paragraph gap is a genuine blank line you make by pressing Enter twice (`\n\n`) and un-make with two
1233
+ Backspaces — exactly how Obsidian behaves. The ratified rendering is preserved: a single newline
1234
+ still renders as a `<br>` line break (surface-render `breaks: true`), so one Enter shows a visible
1235
+ break. **Revises the 2026-07-15 Typora-school ratification (Enter = paragraph) in favour of the
1236
+ Obsidian source-faithful model Aaron named; wants his on-device confirmation.**
1237
+
1238
+ - **`src/lib/editor/paragraph-break.ts`** — prose/fence/table Enter now delegates to CM6's
1239
+ `insertNewline` (a single `\n`), the exact inverse of the default `deleteCharBackward`. List/quote
1240
+ Enter is unchanged (`insertNewlineContinueMarkup` — marker continuation and empty-item-exits-list).
1241
+ Renamed the command `insertParagraphBreak` → `insertContextualNewline`, since it no longer inserts a
1242
+ paragraph break. `insertHardOrPlainBreak` (Shift+Enter, explicit `\`-hard-break) is unchanged.
1243
+ - **`src/components/CodeMirrorEditor.tsx`** — bind Backspace to lang-markdown's `deleteMarkupBackward`
1244
+ ahead of `defaultKeymap`, the canonical inverse of `insertNewlineContinueMarkup`: a Backspace right
1245
+ after a continued list/quote marker strips the marker cleanly (one level per press) instead of
1246
+ nibbling a single character; off markup it returns false and falls through to `deleteCharBackward`.
1247
+ - **`src/components/CodeMirrorEditor.newline.test.ts`** — updated the prose Enter test to expect one
1248
+ newline; added an "Enter then Backspace — byte-identical round-trip" block asserting the invariant
1249
+ across five prose contexts (end/mid/empty/after-paragraph/start-of-doc) plus the two-Enters/
1250
+ two-Backspaces paragraph-gap case, and a list-continuation clean-reversal test.
1251
+
1252
+ ## [0.20.22] - 2026-07-20
1253
+
1254
+ **Fix: the first line of a note now reads as its title in the read view and the note list, not just
1255
+ the editor.** The editor already styles a note's first non-empty line at title scale in place (the
1256
+ Bear / Apple Notes model — no literal `#` written; the vault's `displayTitle`). But when VIEWING a
1257
+ note or scrolling the note LIST, that same first line did not render as the title: the read view
1258
+ only promoted a literal leading `# H1`, and the list fell back to the path/timestamp. So a note that
1259
+ starts with plain text (by design) looked titled while editing and untitled everywhere else. All
1260
+ three surfaces now agree that the first non-empty content line IS the title.
1261
+
1262
+ - **`src/lib/note-title.ts`** — added `firstLineTitle`, the app's byte-for-byte mirror of the vault
1263
+ core's `computeDisplayTitle` (first non-empty line, one leading `#{1,6}` marker stripped, leading
1264
+ YAML frontmatter skipped, hard 120-code-point cap, `null` for empty content), and
1265
+ `stripFirstTitleLine`, which lifts that exact line out of a body. `displayTitle()` now PREFERS the
1266
+ vault's computed `displayTitle` field — which already rides the lean list shape (`toNoteIndex`) on
1267
+ the wire — so the list agrees with the vault by construction, and derives from content only when
1268
+ that field is absent (a full-content fetch, or a pre-`displayTitle` vault). `noteTitle` /
1269
+ `displayTitle` share this one derivation, so read / editor / list can't drift. Truncation is now a
1270
+ hard cap with no ellipsis, matching the vault (the sole behavior change to the existing helpers).
1271
+ - **`src/app/routes/NoteView.tsx`** — the read view now derives its page title from
1272
+ `firstLineTitle(note.content)` (plain first line OR literal `#` heading) instead of a literal-H1
1273
+ check, and strips that line from the rendered body via `stripFirstTitleLine` so the note isn't
1274
+ headed by its own first line twice (the extract-and-strip model the header already used for H1s,
1275
+ generalized). A one-line note whose only line became the title renders no body rather than the
1276
+ misleading "Nothing here yet" prompt; a genuinely empty note still shows that prompt with a
1277
+ timestamp/path title.
1278
+ - **`src/components/NoteRow.tsx`** — unchanged; it already renders `displayTitle(note)`, so the list
1279
+ fix flows through the `displayTitle` wire-preference above.
1280
+ - Cross-door: this is app-side rendering keyed off the vault's `displayTitle`, identical on
1281
+ self-host and cloud once shipped. Tests: `note-title.test.ts` (firstLineTitle / stripFirstTitleLine
1282
+ / the wire-preferred `displayTitle`), `NoteView.test.tsx` (plain-first-line title + strip, buried
1283
+ heading stays in-body, one-line note has no empty-state, genuinely-empty note keeps it),
1284
+ `NoteRow.test.tsx` (the vault's `displayTitle` renders as the row title, not the quickPath stamp).
1285
+
1286
+ ## [0.20.21] - 2026-07-20
1287
+
1288
+ **Fix: the "Vault session expired / Reconnect to vault" banner no longer sticks forever on the
1289
+ home-door vault.** On a self-hosted box where the hub serves the app at its own origin root, opening
1290
+ the app could show the red auth-halt banner; reconnecting loaded vault data correctly but the banner
1291
+ stayed pinned at the top and survived a full page reload.
1292
+
1293
+ - **`src/components/VaultStatusBanner.tsx`** — root cause: the auth-halt banner is a CROSS-ORIGIN
1294
+ OAuth-vault affordance. Its recovery action is `beginOAuth`, and its `lens:auth-halt:<id>` marker
1295
+ is only ever cleared on the OAuth refresh path (`refresh.ts`). A HOME-DOOR vault (`clientId ===
1296
+ "home-door"`, served same-origin by cloud or a hub) has no OAuth client — its per-vault token is
1297
+ re-minted from the account session cookie, and no home-door connect path (`openHostedVault` /
1298
+ `remintHostedVault`) touches the auth-halt store. So once a home-door vault acquired a halt (via
1299
+ `queries.ts` `onAuthRevoked` when a post-remint retry still 401s), it was orphaned: the successful
1300
+ re-mint loaded data but never cleared the localStorage-backed halt, and the banner re-appeared on
1301
+ every reload. Fix: the auth-halt banner now excludes home-door vaults
1302
+ (`halt && !isHostedVaultRecord(vault.clientId)`). Home-door session loss is already covered by the
1303
+ non-blocking `AccountSessionBanner` ("your sign-in ended → sign in"), which is the correct recovery
1304
+ door. The cross-origin OAuth reconnect banner is unchanged, and the network-unreachable axis still
1305
+ applies to home-door vaults (a home-door vault can be genuinely offline).
1306
+ - **`src/components/VaultStatusBanner.test.tsx`** — pins the visibility condition at the banner's
1307
+ logic layer: a home-door vault with a lingering halt renders nothing; a cross-origin OAuth vault
1308
+ with a halt still shows the reconnect banner (unbroken); a home-door vault that is both halted and
1309
+ unreachable still surfaces the unreachable banner.
1310
+
1311
+ ## [0.20.20] - 2026-07-20
1312
+
1313
+ **Voice: the per-capture "Transcribe" toggle now matches the app's other switches.** The switch
1314
+ shipped in Wave 3 (0.20.17) used a bespoke, smaller geometry that read as misaligned — the knob
1315
+ looked pushed too far to the right and sat unevenly in its track.
1316
+
1317
+ - **`src/app/routes/NoteNew.tsx`** — root cause: `TranscribeToggle` hand-rolled a smaller track +
1318
+ thumb (`h-6 w-11` track, `h-5 w-5` thumb) than the app's established switch chrome, while its own
1319
+ comment claimed it "mirrors Settings' switch." Settings' switches (Live preview, Transcribe-by-
1320
+ default) use `h-7 w-12` track + `h-6 w-6` thumb. Converged the toggle onto that exact geometry so
1321
+ the knob is the familiar size and seats the same way; behavior (default ON, per-capture,
1322
+ `transcribe:true/false` wiring, forced-off-when-out-of-minutes) is untouched. Display-only. jsdom
1323
+ has no layout, so the pixel-level alignment is an on-device eyeball; the existing behavior tests
1324
+ (aria-checked, presence, toggle wiring) stay green.
1325
+
1326
+ ## [0.20.18] - 2026-07-20
1327
+
1328
+ **Editor: stop the viewport jumping to the paragraph top while typing.** Writing a long note
1329
+ (morning pages) made the view lurch on essentially every new line, pinning the current line in a
1330
+ fixed high band with dead space below — a partial typewriter/snap effect.
1331
+
1332
+ - **`src/components/CodeMirrorEditor.tsx`** — root cause: the Wave-1 (0.20.17) scroll block set
1333
+ `EditorView.scrollMargins.of((view) => ({ bottom: view.dom.clientHeight * 0.3 }))`. Every
1334
+ keystroke asks CM to scroll the cursor into view (`applyDOMChange` sets `scrollIntoView: true`)
1335
+ and that margin inflates the scroll target downward by ~a third of the viewport, so CM treated
1336
+ the caret as "out of view" the moment it passed ~70% of the screen and re-scrolled on every new
1337
+ visual line — holding the caret at a fixed ~70% band with a large empty gap below. Replaced with a
1338
+ small, fixed scroll-off of two line-heights (new exported `bottomScrollMargin`), tied to
1339
+ line-height rather than viewport height so a taller screen can never inflate it. The caret now
1340
+ travels naturally down the viewport and the view only scrolls when the caret truly nears the
1341
+ bottom edge — standard-editor behavior, no snapping, no typewriter centering. `scrollPastEnd()` is
1342
+ unchanged (the last line can still reach the top). Verified in a real headless browser via CDP:
1343
+ under the old margin the caret pinned at ~67% and the view scrolled on every line; with the fix
1344
+ the caret descends 35%→90% with the viewport held still.
1345
+ - **`src/components/CodeMirrorEditor.scroll.test.ts`** (new) — pins the cause: the bottom scroll-off
1346
+ is a small fixed amount tied to line-height, identical on a 200px and a 10000px viewport (the old
1347
+ code would register 3000 on the tall one), and is the editor's only registered bottom scroll
1348
+ margin. jsdom has no layout, so the pixel-level scroll itself remains an on-device eyeball.
1349
+
1350
+ ## [0.20.17] - 2026-07-18
1351
+
1352
+ **Voice Wave 3 — one policy, spoken once: the per-capture Transcribe toggle + a unified Voice
1353
+ section in Settings.** Whether a voice note comes back as text is now a choice the user makes right
1354
+ where they capture, with a per-vault default they can set once.
1355
+
1356
+ - **`src/lib/capture/transcribe-default.ts`** (new) — the per-vault "transcribe by default"
1357
+ preference. Deliberately CLIENT-LOCAL per-vault (localStorage, `lens:transcribe-default:<vaultId>`,
1358
+ the same pattern as the path-tree + retention-choice flags), NOT server config: the client already
1359
+ owns the `transcribe:` decision per attachment at attach time, so the DEFAULT for that per-capture
1360
+ choice belongs with the app, per device — like the other capture-surface preferences. Defaults ON.
1361
+ - **`src/app/routes/NoteNew.tsx`** — a small "Transcribe this recording" switch sits with the record
1362
+ controls, ON by default (seeded from the per-vault preference). OFF makes the capture save
1363
+ audio-only: `saveWithAudio` folds the toggle into `willTranscribe` (`!outOfMinutes && transcribe`),
1364
+ so `buildVoiceCapturePlan` drops the pending markers + `segment_index` and every link sends
1365
+ `transcribe:false` — the SAME audio-only shape the out-of-minutes path already produces
1366
+ (segmentation still rolls a long recording; it just doesn't pre-seed transcription slots). The
1367
+ toggle is per-capture: it resets to the vault default at the top of every fresh capture, so a
1368
+ one-off "just the audio" never leaks into the next recording. When out of monthly minutes it renders
1369
+ OFF + disabled (Wave 2 already speaks the audio-only truth — no double-messaging). The panel copy
1370
+ now tells the truth for both states (transcript-coming vs audio-only). Capability honesty: the
1371
+ toggle only renders when the vault hasn't declared transcription disabled (the recorder is gated
1372
+ away entirely there, unchanged from #167).
1373
+ - **`src/app/routes/Settings.tsx`** — the "Voice recordings" section grows into a unified **Voice**
1374
+ section: a new "Transcribe recordings by default" switch (client-local, per device — sets what the
1375
+ capture toggle starts at) above the unchanged retention control ("Keep recordings" —
1376
+ `config.audio_retention`, server-side, per vault). Both knobs are gated by the same #167
1377
+ transcription-capability check; the scope difference (this device vs every device on the vault) is
1378
+ said out loud on each. The cloud door's stored `auto_transcribe` config stays inert — its
1379
+ wiring/removal is a separate door-parity decision (W3 non-goal).
1380
+
1381
+ ## [0.20.16] - 2026-07-18
1382
+
1383
+ **Voice Wave 2 — unlimited-length voice recording via invisible segmentation + honest edges.** A
1384
+ recording of any length now stays a set of standalone, transcribable audio containers, and the app
1385
+ tells the truth at every seam (minutes, failures, audio-only). Both server doors already merged the
1386
+ per-segment support (an attachment with a numeric `metadata.segment_index` gets its own per-part
1387
+ markers) and the `POST /api/notes/:id/retry-transcription` endpoint; this is the app half.
1388
+
1389
+ - **`src/lib/capture/segmented-recorder.ts`** (new) — `createSegmentedRecorder` rolls to a FRESH
1390
+ `MediaRecorder` every `SEGMENT_MS` (10 min) on the SAME microphone stream, so each segment is a
1391
+ standalone valid container (NOT `timeslice` chunks, which aren't independently decodable). The
1392
+ handoff is a synchronous start-next-before-awaiting-flush so effectively no audio is lost at the
1393
+ seam. `src/lib/capture/recorder.ts` gains a `releaseStreamOnStop` option (default true — the
1394
+ single-recorder callers keep "stop releases the mic") that lets the segmented recorder keep the
1395
+ stream alive across the roll; `memoFilename` gains a `-partN` suffix so same-timestamp segments
1396
+ don't collide.
1397
+ - **`src/lib/capture/use-voice-capture.ts`** — the hook drives the segmented recorder; `have-audio`
1398
+ now carries the ORDERED segment list and a `parts` count (a subtle "part k" hint while a long
1399
+ recording is in flight). The UX is unchanged: one timer, one Stop button, segmentation invisible.
1400
+ - **`src/lib/capture/voice-capture-plan.ts`** (new) — `buildVoiceCapturePlan` is the pure
1401
+ body-and-per-segment plan. **The common case is sacred:** a single-segment recording is
1402
+ byte-identical to 0.20.15 — bare `_Transcript pending._`, one embed, one link with NO
1403
+ `segment_index`. N>1 pre-seeds N per-part pending markers IN ORDER and each segment uploads as its
1404
+ own attachment with `transcribe:true` + a numeric `metadata.segment_index` (0-based).
1405
+ - **`src/app/routes/NoteNew.tsx`** — `saveWithAudio` executes the plan: each segment runs the SAME
1406
+ `validateFile` guard every other upload uses (a failure aborts the whole save, never a partial),
1407
+ then uploads + links in order. Minutes honesty (hosted door only, capability carries
1408
+ `minutes_remaining`): a quiet remaining-minutes line near the mic under ~30 min; at 0 the mic
1409
+ STAYS available but says plainly the capture saves as audio-only and sends `transcribe:false` (no
1410
+ server churn). Self-host (no minutes field) shows nothing new.
1411
+ - **`src/lib/transcription-status.ts`** — the derivation now scans ALL audio attachments: ANY
1412
+ pending → "Transcribing…" (with a "part k of n" hint via `deriveTranscriptionProgress` when
1413
+ segmented counts are known); none pending + any failed → the failed chip; the voice-limit marker
1414
+ still distinguishes cap from failure. The marker-fallback regexes recognize both the bare AND the
1415
+ `(part N)` forms. `retryOptimisticNote` flips failed segments + failure markers back to pending.
1416
+ - **`src/components/TranscriptionStatus.tsx`** + **`src/app/routes/NoteView.tsx`** — the failed chip
1417
+ gains a **Retry** action → `POST retry-transcription` (new `VaultClient.retryTranscription` +
1418
+ `useRetryTranscription`), optimistically flipping the chip to "Transcribing…"; the Wave 1 live
1419
+ subscription + pending poll track reality, and an honest 4xx ("nothing retriable") reverts the
1420
+ flip with a quiet toast rather than a stuck spinner.
1421
+ - **`src/lib/sync/{types,queue}.ts`** + **`src/lib/vault/client.ts`** — `link-attachment` rows +
1422
+ `linkAttachment` carry optional `metadata` (forwarded verbatim; the door contract keys per-part
1423
+ markers on `segment_index`).
1424
+
1425
+ ## [0.20.15] - 2026-07-17
1426
+
1427
+ **Voice Wave 1 — a voice note's transcript (and its failures) now appear in the open note view
1428
+ LIVE, no manual refresh.** The open-note cache had no real-time bridge: transcription completes
1429
+ server-side by rewriting the note body through the standard update layer (which broadcasts an
1430
+ `upsert` to matching subscribers on both doors), but nothing on the note-view side subscribed, so
1431
+ the transcript only showed on a reload. Three parts, no server change and no new dependencies:
1432
+
1433
+ - **`src/lib/vault/live-note.ts`** (new) — `useLiveNote(cacheId, note)` opens ONE live-query
1434
+ WebSocket subscription per open note, scoped to that note by its server `path` (exact-match,
1435
+ subscribable), over the same SDK `VaultClient.subscribe` the list hooks use. On any
1436
+ `upsert`/`remove` it INVALIDATES the `["note", vaultId, cacheId]` cache rather than writing the
1437
+ event's note through — the view fetches `includeAttachments`, the live event's note is
1438
+ list-shaped (no attachments), so an invalidate + refetch pulls the fresh body AND the fresh
1439
+ attachment `transcribe_status` the chip reads. Deltas-only (not the initial snapshot) to avoid a
1440
+ mount double-fetch; reconnect/backoff/token-refresh are the SDK's job. Wired in
1441
+ `src/app/routes/NoteView.tsx`; torn down on unmount / note-switch.
1442
+ - **`src/lib/vault/queries.ts`** — `useNote`'s `refetchInterval` is now `noteRefetchInterval` (new,
1443
+ exported, pure): the local-id bridge (2s) is preserved AND extended with a pending-transcription
1444
+ poll (4s) that keeps refetching while the note's own transcription is non-terminal, stopping on
1445
+ any terminal state (done / failed / voice-limit). The socket handles the live case; this poll is
1446
+ the safety net for drops/reconnects — belt and suspenders.
1447
+ - **`src/lib/transcription-status.ts`** (new) + **`src/components/TranscriptionStatus.tsx`** — the
1448
+ status chip now reads the AUDIO ATTACHMENT's `transcribe_status` (pending/done/failed) as the
1449
+ primary source, with the body markers kept as the fallback (they're the portable-markdown
1450
+ cross-door contract — never removed). `deriveTranscriptionState` is the one shared read (chip +
1451
+ poll can't disagree). Adds the cloud monthly voice-cap state as its own calm resting chip
1452
+ (distinguished from a genuine failure only by the `_Monthly voice limit reached…_` body marker,
1453
+ since the attachment stores `"failed"` for the cap too). The pending→failed/limit flip now
1454
+ surfaces live via the subscription + poll above.
1455
+
1456
+ ## [0.20.14] - 2026-07-17
1457
+
1458
+ **Polish — mobile formatting bar docks above the keyboard; note header's path/tags recede; the
1459
+ first line renders as a title; single-newline lines read as one thought.** Four feel-fixes from a
1460
+ live tablet test of 0.20.13. Display-only: no data-model changes and no new capabilities — only
1461
+ where things sit and how they read (FIX 3 writes NO markdown into the note; FIX 4 is CSS only).
1462
+
1463
+ - **`src/lib/editor/selection-toolbar.ts`** — on touch the selection formatting bar no longer
1464
+ floats at the selection (where Android paints its Copy / Select all callout directly over ours
1465
+ and iOS the loupe/handles, leaving ours untappable). It now DOCKS as a fixed bar at the bottom of
1466
+ the visual viewport, riding above the virtual keyboard — the Google Docs / Notion / Bear pattern.
1467
+ Rebuilt from a CM6 `showTooltip` StateField into a `ViewPlugin` that owns a plain fixed-position
1468
+ node in `document.body` (the only reliable containing block for `position: fixed` — a lingering
1469
+ `.enter-rise`/`.fade-up` transform on an ancestor would otherwise capture it). `bottom` tracks
1470
+ `window.visualViewport` (`innerHeight − height − offsetTop`) on its resize/scroll events, so the
1471
+ bar stays glued to the keyboard's top edge as it opens and closes and rests at the screen bottom
1472
+ when it's shut. Still coarse-pointer only (`matchMedia("(pointer: coarse)")`, read dynamically):
1473
+ desktop is unchanged — no bar, the `Mod-b`/`Mod-i`/… keymap drives formatting there. Same five
1474
+ buttons (B/I/S/`<>`/🔗) calling straight into the shared `format-commands` — the bar
1475
+ re-implements no transform of its own. Shown on a non-empty selection, hidden the instant it
1476
+ collapses; built lazily on first show so desktop never mounts a node. `pointerdown`/`mousedown`
1477
+ are still preventDefaulted (it matters more now the bar lives outside the editor — a tap must not
1478
+ blur it or drop the selection); ≥40px touch targets are set inline so the invariant holds in
1479
+ production and stays assertable in jsdom (styles/index.css isn't loaded there, and an
1480
+ `EditorView.theme` sheet no longer reaches a body-level node).
1481
+ - **`src/styles/index.css`** — new `.cm-format-toolbar-docked` (layout + top hairline +
1482
+ `env(safe-area-inset-bottom)` so the buttons clear the home indicator + the `enter-rise`
1483
+ entrance, registered in the one reduced-motion gate); the translucent surface reuses
1484
+ `.glass-panel`.
1485
+ - **`src/app/routes/NoteView.tsx`** — the note header's path and tags recede (Aaron: "we're really
1486
+ not orienting toward people updating the path"). The path leaves the primary header flow — it was
1487
+ a prominent mono `break-all` line right under the title — and becomes a single small, muted,
1488
+ TRUNCATING meta line at the FOOT of the header. Capability is preserved, not removed: the whole
1489
+ line is a one-tap copy button (path → clipboard, the "hand it to an AI agent" use case ratified
1490
+ 2026-07-17), with a small clipboard glyph and a distinct **"Copy path"** label so it never
1491
+ collides with the metadata card's "Copy note path". Tags reweight to normal-weight, tighter-
1492
+ spaced compact chips (were `font-medium`) — a quiet label strip under the title rather than a
1493
+ second headline. The reclaimed vertical space goes to content.
1494
+ - **`src/lib/editor/first-line-title.ts`** (new) + **`CodeMirrorEditor.tsx`** — the Bear / Apple
1495
+ Notes move: the note's first non-empty content line IS its title (the vault's `display_title` —
1496
+ first line, leading `#` stripped, frontmatter skipped, vault 0.7.3-rc), so the editor RENDERS
1497
+ that line at title scale (serif, `--text-3xl`, weight 650 — the same H1 top-of-ramp as the live-
1498
+ preview `.cm-lp-h1`). A CodeMirror **line decoration**, mode-agnostic (raw + live) — **not one
1499
+ byte of `# ` or any structure is written into the note**. Frontmatter is skipped via the shared
1500
+ `frontmatterEnd` helper (now exported from `live-preview.ts`, single source with the reveal
1501
+ guard, so the two never drift); it defers to an explicit ATX heading (`/^ {0,3}#{1,6}(?: |$)/`) so
1502
+ the styling never stacks on the editor's own heading treatment; and it tracks live — deleting the
1503
+ first line promotes the next non-empty one (recomputed on `docChanged`).
1504
+ - **`src/styles/index.css`** — line spacing for single-newline lines. A single newline renders as a
1505
+ `<br>` INSIDE one paragraph (surface-render's `breaks: true` / `remark-breaks` — verified: `line
1506
+ A\nline B` → `<p>line A<br>line B</p>`), so consecutive single-newline lines were inheriting the
1507
+ 1.78 long-form reading leading and reading as far apart as separate paragraphs. New
1508
+ `--lh-prose-tight: 1.5` token, applied surgically via `.prose-note p:has(br)` — **only** paragraphs
1509
+ that actually contain a line break tighten; normal wrapping paragraphs keep 1.78, and blank-line-
1510
+ separated blocks are separate `<p>`s that keep their real `0.75em` margins. Baseline-to-baseline
1511
+ step for single-newline lines (rendered view): **18px → 32.04px ⇒ 27.00px** (−15.7%), 22px →
1512
+ 39.16 ⇒ 33.00, 26px → 46.28 ⇒ 39.00; a paragraph step stays 45.54px @18px, so same-thought lines
1513
+ (27px) now read clearly distinct from paragraph breaks (45.5px). Theme-invariant (line-height
1514
+ carries no colour — one value, both light and dark). The **editor** needs no change: its lines
1515
+ are `.cm-line`s at line-height (`--lh-live` 1.7 live / 1.6 raw) with NO inter-line margin, so
1516
+ single-newline lines are already line-spacing-apart there and a blank line is a genuine empty line
1517
+ — it never had the paragraph-spacing problem the rendered `<br>` did.
1518
+
1519
+ Judgment calls (reviewer-facing):
1520
+
1521
+ - **Desktop is unchanged.** The 0.20.13 floating bar was already coarse-pointer-only — desktop
1522
+ showed no bar and drove formatting from the keymap — so "desktop keeps its current behavior"
1523
+ means it still shows nothing. Adding a floating bar to desktop would be a new feature, outside the
1524
+ display-only charter, so it was NOT added.
1525
+ - **Command set held.** The brief listed "bold/italic/code/link/todo"; the shipped bar keeps the
1526
+ existing `FORMAT_COMMANDS` set (bold/italic/strikethrough/code/link) rather than swapping
1527
+ strikethrough out for a todo button, so this stays a pure re-dock of the existing toolbar. A todo
1528
+ button is a one-line follow-up if wanted.
1529
+ - **Touch detection.** `matchMedia("(pointer: coarse)")`, read dynamically per update (a hybrid
1530
+ device can gain/lose a pointer mid-session), the same approach the 0.20.13 toolbar used;
1531
+ `window.visualViewport` drives positioning and degrades to `bottom: 0` where it's absent.
1532
+
1533
+ Gates (literal): `bun run test` (vitest) — **1869 passed / 1869 across 167 files, deterministically
1534
+ green, when the one PRE-EXISTING-flaky file is excluded** (`--exclude "**/nav-history.test.tsx"`);
1535
+ the full 168-file suite is **1872 / 1872** on a clean run. `bun run typecheck` — clean; `bun run
1536
+ lint` — clean (2 pre-existing `src/lib/vault/live-query.ts` `useExhaustiveDependencies` warnings,
1537
+ present on `main`); `bun run build` — ✓. The editor toolbar suite is rewritten for the docked
1538
+ mechanism (bar in `document.body`, coarse-only, show/hide-on-collapse, ≥40px targets, command
1539
+ dispatch, pointerdown containment); the NoteView metadata test folds in the header's one-tap "Copy
1540
+ path"; `first-line-title.test.ts` is new (plain-line title, no-stack-on-heading, live mode,
1541
+ frontmatter skip, leading-blank skip, live tracking across an edit). FIX 4 adds no tests (CSS only,
1542
+ verified via compiled-CSS grep + a real `remark-breaks` render probe). Note: `nav-history.test.tsx`
1543
+ carries a PRE-EXISTING, intrinsic flake (real 3s `setInterval` + real `window.history` + `waitFor`
1544
+ timing) — it fails ~2/5 runs **running entirely on its own**, machine-load-dependent, and is
1545
+ byte-identical to `main` (untouched by this diff). Flagged as a follow-up (fake-timers or `retry`).
1546
+
1547
+ ## [0.20.13] - 2026-07-17
1548
+
1549
+ **Editor Wave 2, PR 5 — the shared format commands, selection toolbar, and swipe-indent.**
1550
+ POLISH-WAVE PR 5 (5a swipe-indent, 5b selection toolbar) + EDITOR-STUDY §5-6's adopt list (real
1551
+ ⌘B/⌘I via a shared format-command module, ⌘⏎ create/toggle to-do, coarse-pointer-only selection
1552
+ toolbar, swipe right/left on list items to indent/outdent). Version note: rebased onto `main`
1553
+ after `0.20.9`–`0.20.12` landed from sibling branches; this one takes `0.20.13`.
1554
+
1555
+ - **`src/lib/editor/format-commands.ts`** — the ONE place bold/italic/strikethrough/code
1556
+ wrap-unwrap and link-wrap write bytes, used by both the toolbar and the keybindings below.
1557
+ Toggle detection is syntax-tree-based (`StrongEmphasis`/`Emphasis`/`Strikethrough`/`InlineCode`
1558
+ nodes), not character counting — a naive `**`-vs-`*` prefix check would confuse bold and italic;
1559
+ the tree already disambiguates them. A narrow string fallback handles the one case the tree
1560
+ can't see: an empty `**|**` pair (CommonMark requires content between emphasis delimiters, so it
1561
+ never parses as a mark node) collapses on a second press instead of nesting further markers.
1562
+ `toggleTodo` (⌘⏎) creates/toggles a to-do per touched line, deduped by ListItem node identity —
1563
+ a lazy-continuation line (no marker, no blank line, CommonMark keeps it part of the SAME list
1564
+ item) would otherwise resolve to the same TaskMarker as its neighbor and emit two colliding
1565
+ writes to one position.
1566
+ - **`src/lib/editor/list-indent.ts`** — "one grammar, three doors": `isListItemLine` (tree-based,
1567
+ shared), `listAwareIndent`/`listAwareOutdent` (Tab/Shift-Tab, live mode only, `return false` off
1568
+ a list line so native Tab is untouched elsewhere), and `swipeIndent()` (the pointer-gesture
1569
+ extension) all funnel through `@codemirror/commands`' own `indentMore`/`indentLess` — the same
1570
+ bytes Tab always produced. The swipe claims `pointerdown` on a list line (mirroring the checkbox
1571
+ widget's containment pattern) so a would-be swipe never flash-reveals the line, then either
1572
+ commits an indent/outdent, replays a plain tap (cursor placement) if the drag never crossed the
1573
+ threshold, or hands control back to native scroll — vertical scroll wins ties by never calling
1574
+ `preventDefault` on a `pointermove` until horizontal intent (≥48px, `|dx| > 2·|dy|`) is
1575
+ confirmed. IME-safe: composing state suppresses the commit.
1576
+ - **`src/lib/editor/selection-toolbar.ts`** — a CM6 `showTooltip`-facet floating toolbar (never a
1577
+ decoration), coarse-pointer-only (`matchMedia("(pointer: coarse)")`, checked dynamically, not
1578
+ cached at mount), non-empty-selection-only, `above: true` (CM auto-flips at screen edges). Five
1579
+ buttons (B/I/S/`<>`/🔗) call straight into the shared format-commands — the toolbar never
1580
+ re-implements a wrap of its own. `.glass-panel` + `--radius-lg` + `--shadow-lift` (the slash-menu
1581
+ family); button touch-target sizing lives in the tooltip's own `EditorView.theme()` (≥2.5rem)
1582
+ rather than a utility class, for the same reason the checkbox widget's hit-area does.
1583
+ - **`CodeMirrorEditor.tsx`** — wires `Mod-b`/`Mod-i`/`Mod-Shift-x`/`Mod-e`/`Mod-Enter` ahead of
1584
+ `defaultKeymap` in BOTH editor modes (raw mode's markdown is just as toggle-able as live mode's);
1585
+ `Tab`/`Shift-Tab` list-aware indent and the swipe gesture are live-preview-mode only; the
1586
+ selection toolbar is mode-agnostic.
1587
+ - **Review delta (ragged-selection byte corruption).** `findMarkNode`'s node-aligned check only
1588
+ recognized selections that matched a mark's boundary exactly — a ragged drag crossing a mark's
1589
+ edge (e.g. selecting `two** thr` inside `one **two** three`) fell through to the plain "wrap the
1590
+ selection" path and produced unbalanced markers (`one ****two** thr**ee`, an orphaned pair). Fixed
1591
+ with `findOverlappingMarks` + `wrapWithNormalization`: any selection that partially overlaps one
1592
+ or more marks of the toggled type expands to the union of the selection and every overlapping
1593
+ mark's full range, strips those marks' own delimiters, and wraps the resulting plain text fresh —
1594
+ "touch a mark, extend to cover it," the common editor convention for ragged selections, and
1595
+ self-consistent (re-selecting the result and toggling again hits the clean node-aligned unwrap
1596
+ path). Covers the single-mark-crossing case, the mirror (selection starts before/ends inside),
1597
+ and the double-mark-crossing case (selection spans two separate marks with plain text between).
1598
+ - **Review delta (IME safety gap).** None of the five keybindings checked composition state — CM's
1599
+ keymap dispatcher doesn't gate on it, so `Mod-Enter`/`Mod-b`/etc. could fire and mutate the
1600
+ document mid-IME-composition. Every exported command now bails (`return false`) when the calling
1601
+ `EditorView`'s `.composing` is true, mirroring the guard already in `swipeIndent()` and
1602
+ `live-preview.ts`'s checkbox widget.
1603
+
1604
+ Tests: `format-commands.test.ts` (wrap/unwrap on selection/caret-only/multi-line, the bold-vs-
1605
+ italic tree-disambiguation regression, the lazy-continuation todo-dedup regression, the three
1606
+ ragged-selection repro shapes plus an offset sweep asserting marker balance, IME-composing guards
1607
+ on every exported command including `toggleTodo`), `list-indent.test.ts` (tree-based list
1608
+ detection, Tab/Shift-Tab fall-through off a list line), `CodeMirrorEditor.format-commands.test.ts`
1609
+ (keybindings through the real wired keymap, both modes, plus Mod-b's IME guard through a real
1610
+ dispatch — Mod-Enter's own guard is proven at the unit level instead: CM6 has a separate "Enter
1611
+ confirms an in-progress IME composition" fallback that a `defineProperty`-faked `composing` signal
1612
+ [without the real event sequence backing it] triggers on its own, an unrelated code path the test
1613
+ file's own comment traces through in detail), `CodeMirrorEditor.touch-grammar.test.ts` (toolbar
1614
+ coarse/fine-pointer gating, swipe indent/outdent, vertical-scroll-wins-ties, plain-tap replay,
1615
+ mouse-ignored, IME-composing guard, raw-mode-unwired). Full suite: 167 test files / 1866 tests,
1616
+ clean (one unrelated pre-existing flake observed intermittently in `src/app/nav-history.test.tsx`,
1617
+ an `act()`-timing race untouched by this PR — passes cleanly in isolation); `typecheck` clean;
1618
+ `lint` clean (2 pre-existing warnings remain in `src/lib/vault/live-query.ts`, untouched by this
1619
+ PR); `build` clean.
1620
+
1621
+ ## [0.20.12] - 2026-07-17
1622
+
1623
+ **Auth Wave 2 — the app half (AUTH-W2-BRIEF §2, moves 2 + the app's app portion of W1's code
1624
+ endpoint).** Version note: the sibling chain (views-wave-2a, editor-w2-pr5, error-boundary) claims
1625
+ 0.20.9/.10/.11; this one takes 0.20.12, flagged for merge sequencing only — files are disjoint
1626
+ (`src/lib/account/*`, `src/components/VaultSwitcher.tsx`, `src/app/routes/{Account,CheckEmail}.tsx`),
1627
+ verified against each sibling branch's diff before starting.
1628
+
1629
+ - **The cached account bearer can't outlive the cookie's identity (move 2).** `store.ts`'s account-
1630
+ token cache now holds `{ token, identity }` instead of a bare token — `identity` is `email ??
1631
+ username` at mint time. `client.ts`'s `getSession()` (the boot oracle, fired on every session
1632
+ read — polling, page boots, mints) is the reconciliation chokepoint: it compares the door's answer
1633
+ against whichever identity the cached bearer was minted for, and on a mismatch (another tab
1634
+ signed out/in as someone else, or signed out entirely) drops the bearer AND bumps a new
1635
+ `useAccountSessionStore().identityEpoch` counter. Ambient consumers key off that epoch so a stale
1636
+ identity's cached rows can't linger past the switch: `use-summary.ts`'s shared `useAccountSummary`
1637
+ query, `VaultSwitcher.tsx`'s `["account","vaults"]` query, and `Account.tsx`'s own "Signed in as"
1638
+ boot fetch + vault-list effect all fold `identityEpoch` into their re-fetch trigger. A pre-
1639
+ migration bare-token sessionStorage entry fails the JSON parse and reads as absent — a silent
1640
+ one-time re-mint, no user-visible effect (pinned directly by a migration test).
1641
+ - **The `/check-email` 6-digit code field.** Probed the REAL deployed reality first (parachute-cloud
1642
+ `workers/identity/src/auth-handlers.ts` `handleCodeVerifyPost`): the JSON variant of `POST
1643
+ /auth/code` was deferred out of W1 — the endpoint unconditionally reads `req.formData()` and
1644
+ answers either a same-origin redirect (success) or a 200 HTML re-render (every failure, folded
1645
+ into one neutral message by design). `client.ts`'s new `verifySignInCode` posts form-encoded with
1646
+ `redirect: "manual"` and reads the RESPONSE SHAPE, not the body: a same-origin redirect becomes an
1647
+ opaque `res.type === "opaqueredirect"` response (the browser applies the Set-Cookie before
1648
+ filtering it into that shape, so the session is live); anything else is a failure. `CheckEmail.tsx`
1649
+ gains an "Or type the code from the email" disclosure: a single numeric input (not a 6-box grid —
1650
+ paste-robust against the full email line, e.g. "Your Parachute code: 123 456") that auto-submits
1651
+ at 6 digits, filters non-digits so paste-with-surrounding-text works, and shows the endpoint's own
1652
+ neutral error on a wrong code. KNOWN GAP, documented in `verifySignInCode`'s doc comment and not
1653
+ silently swallowed: a TOTP-enrolled account diverts through the same redirect shape without
1654
+ minting a session — the flow safely degrades (Welcome.tsx already bounces `!signed_in` to the
1655
+ front door) rather than hanging; TOTP is explicitly out of scope for this wave and the app has no
1656
+ 2FA UI on any sign-in path yet.
1657
+
1658
+ Tests: `client.test.ts` — identity-reconciliation suite (matching identity no-ops, a different
1659
+ identity drops bearer + bumps epoch, signed-out also drops, first-load isn't a false mismatch, a
1660
+ subsequent Bearer call re-mints against the new identity, username-fallback parity) + the bare-token
1661
+ migration-reads-as-absent pin + `verifySignInCode`'s form-encoding/opaqueredirect/failure-shape
1662
+ tests — 35 tests total (was 24). `CheckEmail.test.tsx` — the code field's reveal toggle, auto-submit
1663
+ at 6 digits with navigation, paste-with-context digit extraction, no-submit-before-6-digits, and the
1664
+ wrong-code error state (field cleared, no navigation) — 11 tests total (was 6). Full suite: 158 test
1665
+ files / 1778 tests, ×2 clean; `typecheck` clean; `lint` clean (2 pre-existing warnings remain in
1666
+ `src/lib/vault/live-query.ts`, untouched by this PR).
1667
+ ## [0.20.9] - 2026-07-17
1668
+
1669
+ **Views Wave 2a — the first default-pages cutover (Pinned + Archive).** VIEWS-RENDER-SPEC §7's
1670
+ ratified direction ("the pack is an override, never a dependency") lands for the two smallest
1671
+ lenses: `/notes?view=pinned` and `/notes?view=archived` now resolve their tag filter from a
1672
+ `ViewDef` instead of a hardcoded literal, while everything else about those pages — search,
1673
+ Filters panel, pagination, `NoteRowList` rendering — is unchanged.
1674
+
1675
+ - **`src/lib/views/defaults.ts`** — `builtInDefaultViewDef(pageId, roles)`: the app's own
1676
+ fallback `ViewDef` for Pinned/Archive, mirroring the `starter-ontology` pack's own queries
1677
+ (`{tag: "pinned"}` / `{tag: "archived"}`, `core/src/seed-packs.ts`) byte-for-intent but through
1678
+ `roles.pinned`/`roles.archived` — a vault that renamed those tags still gets a correct default.
1679
+ `resolveDefaultViewDef(pageId, packNote, roles)`: the pure resolver — a `Views/Pinned` or
1680
+ `Views/Archive` note wins when it's present, tagged `#view`, and its query parses
1681
+ (authoring-time-explicit); anything else (no note, wrong tag, unparseable query) falls back to
1682
+ the built-in instantly. A default page is load-bearing navigation, not a place to show "this
1683
+ view is broken" over someone else's corrupted note — that honesty stays scoped to `/views/:id`.
1684
+ Also lifted the pack's four view paths to named constants (`PINNED_VIEW_PATH`, etc.) so
1685
+ `DEFAULT_VIEW_PATHS` and the new page-path lookup can't drift from each other.
1686
+ - **`src/lib/views/queries.ts`** — `useDefaultViewDef(pageId, roles)`: looks up the pack note at
1687
+ its canonical path (exact `path=` match, not `path_prefix`) via TanStack Query, then resolves
1688
+ through the pure function above. `pageId === null` skips the lookup (every other preset).
1689
+ - **`VaultSurface.tsx`** — `SearchableLenses`'s `effectiveTags` for the pinned/archived presets
1690
+ now reads `queryTags(resolvedDef.query)` (unioned with whatever the Filters panel's TagBrowser
1691
+ adds) instead of inlining `roles.pinned`/`roles.archived` directly. Resolution never blocks or
1692
+ blanks the page: the built-in def is available synchronously on first render, and the query
1693
+ quietly upgrades if/when a pack note resolves.
1694
+
1695
+ Tests: `defaults.test.ts` (10, pure — built-in query shape, role indirection, pack-wins,
1696
+ malformed-note fallback, non-`#view`-tagged decoy ignored), `VaultSurface.defaultViews.test.tsx`
1697
+ (5, integration — no-pack-note is byte-equivalent to today's hardcoded `tag=pinned`/`tag=archived`
1698
+ query, a well-formed pack note's tag wins, a malformed pack note falls back without blanking the
1699
+ page). All-notes + Recent are unaffected (wave 2b).
1700
+
1701
+ ## [0.20.10] - 2026-07-17
1702
+
1703
+ **The ErrorBoundary net (issue #48).** Surfaced by the #47 review: the app had zero React
1704
+ ErrorBoundaries — any render-time throw during render took down the entire shell, not just the
1705
+ failing surface. Matters more now that views make agent-authored input reach render paths
1706
+ routinely (the #47 metadata-operator throw was the concrete instance; fixed at the source, but
1707
+ the class of bug remained). Display-only, small.
1708
+
1709
+ - **`src/components/ErrorBoundary.tsx`** (new) — the standard React class-component idiom (no
1710
+ `react-error-boundary` dependency in this repo). One generic `ErrorBoundary` class plus two
1711
+ calibrated fallbacks:
1712
+ - **`RouteErrorBoundary`** — wraps a single routed surface. On a throw, shows the same honest
1713
+ `ErrorState` card pattern NoteView's `NoteErrorBlock` already uses (title, human copy, the
1714
+ wire-level message tucked behind a collapsed "Technical detail" `<details>`, "Back to notes").
1715
+ Keyed by `location.key` — React Router doesn't remount a Route's element when only its params
1716
+ or search string change (e.g. `/n/1` → `/n/2` both match `/n/:id`; `/views/:id`'s refinements,
1717
+ Calendar's `?month=`, and DayView's `?date=` all update in place too), so without a key a
1718
+ caught error would keep showing over content that would otherwise render fine. Review caught a
1719
+ real gap here pre-merge: an earlier version keyed on `location.pathname` alone, which missed
1720
+ any search-only navigation (`?view=pinned` → `?view=archived`, same pathname) — `location.key`
1721
+ is react-router's own per-navigation-entry identity, so it changes on every push/replace
1722
+ (pathname, search, hash, or even a repeat push to the identical URL) without having to
1723
+ hand-assemble a composite string.
1724
+ - **`AppErrorBoundary`** — the last net. Full-page card + a plain reload button (no "Back to
1725
+ notes" — if this fired, the router subtree itself is gone).
1726
+ - **`src/app/App.tsx`** — every lazy-loaded route in the route table (Account, Activity, AddVault
1727
+ and its ceremony steps, Calendar, ConnectAI, DayView, Export, Import, NoteEditor, NoteNew,
1728
+ NoteView, OAuthCallback, Settings, Tags, VaultGraph, Vaults, Welcome, CheckEmail, ViewNew,
1729
+ ViewSurface) now renders behind its own `RouteErrorBoundary`. `App()`'s return is wrapped in one
1730
+ `AppErrorBoundary`, mounted above `QueryProvider`/`SyncProvider`/`BrowserRouter` so it also
1731
+ catches a throw from the chrome itself (Rail, Header, a provider), not just a routed surface.
1732
+ The eager routes (`BootGate`, `VaultSurface`) and the catch-all `NotFoundPage` are unwrapped —
1733
+ the top-level net is still their backstop. (VaultSurface's own containment is filed as a
1734
+ fast-follow, out of scope here.)
1735
+ - Tests: `src/components/ErrorBoundary.test.tsx` (the boundary in isolation — card shown, sibling
1736
+ chrome outside it survives, resets on an in-router navigation to a different note, resets on a
1737
+ search-only navigation under the same pathname — the review-caught regression) plus two
1738
+ integration files exercising the real `<App/>` tree — `App.error-boundary.route.test.tsx`
1739
+ (a mocked lazy route throws → card + chrome-stays-alive + Back to notes recovers) and
1740
+ `App.error-boundary.chrome.test.tsx` (a mocked `Header` throws → the top-level net's full-page
1741
+ card, no route chrome survives).
1742
+
1743
+ ## [0.20.8] - 2026-07-17
1744
+
1745
+ **Editor Wave 2 — focus mode.** POLISH-WAVE PR 4, plus EDITOR-STUDY §3.3's addition: one gesture
1746
+ and the room goes quiet. Scoped to the reading/writing rooms only (`/n/:id`, `/n/:id/edit`) — not
1747
+ a global mode. Version note: this PR was built in parallel with a sibling capture-chip PR that
1748
+ claims `0.20.7`; this one takes `0.20.8` on the assumption the sibling lands first (or adjacent) —
1749
+ flagged for the merge sequencing, not a collision either PR needs to resolve itself.
1750
+
1751
+ - **`src/lib/focus-mode.ts`** — a tiny, non-persisted zustand store (`on`/`setOn`/`toggle`, the
1752
+ `useQuickSwitchOpen` shape) plus `isFocusablePath()`, matching only `/n/:id` and `/n/:id/edit`.
1753
+ - **`FocusModeMount`** (`src/components/FocusModeMount.tsx`), mounted once inside the router:
1754
+ owns the `⌘.` door in (a no-op off a focusable route) and the route-change reset that keeps
1755
+ focus mode ephemeral — any navigation, including the read↔edit hop on the same note, leaves it
1756
+ behind.
1757
+ - **`AppShell`** (`App.tsx`, pulled out of `App()` so it can read the route): gates
1758
+ Rail/Header/BottomTabBar/AppFooter/SpeedDial/AmbientMapFab off the store AND `isFocusablePath`
1759
+ (belt-and-suspenders — the guard can't matter if the reset already fired, but it's the literal
1760
+ spec ask). Chrome disappears instantly rather than animating out, matching PR1's already-shipped
1761
+ "entrances only, exits stay instant" rule instead of relitigating it for this case. The Header/
1762
+ Rail's `env(safe-area-inset-top)` relocates onto the content wrapper while they're gone.
1763
+ - **Doors out:** `⌘.`, a floating top-right exit chip (`FocusModeExitChip`, `.glass-panel` +
1764
+ `.enter-fade` — PR1's floating-surface family), and — read route only — Escape. The editor route
1765
+ deliberately has no Escape handler: CodeMirror already binds Escape to cancel-edit
1766
+ (`CodeMirrorEditor.tsx`), so stacking a second meaning on the same key there would be unsafe;
1767
+ `⌘.` is the edit route's only keyboard door out, per spec.
1768
+ A quiet ghost "Focus" button (new `IconExpand`/`IconShrink` glyphs) lives in NoteView's action
1769
+ row and NoteEditor's header.
1770
+ - **EDITOR-STUDY §3.3's addition:** in the edit route, focus also collapses the editor's whole
1771
+ header card (path input, tag editor, Pin/Delete/Revert/Cancel/Save) down to a single floating
1772
+ save-state whisper — the SAME indicator from PR2 (`SaveStateWhisper`, extracted, not
1773
+ reinvented), relocated rather than rebuilt. `⌘S` and Escape-to-cancel keep working from the
1774
+ keyboard regardless — CodeMirror owns those bindings independent of what chrome is on screen.
1775
+
1776
+ Tests: `focus-mode.test.ts` (store + route matcher), `FocusModeMount.test.tsx` (⌘./Ctrl+.
1777
+ toggling, route-change reset), `NoteView.test.tsx` + `NoteEditor.test.tsx` (ghost buttons, header
1778
+ collapse/restore, Escape semantics per route), `AppFocusMode.test.tsx` (full-shell integration:
1779
+ chrome hide/restore, the safe-area-inset relocation, the `isFocusablePath` guard in isolation).
1780
+ ## [0.20.7] - 2026-07-17
1781
+
1782
+ **Capture chip: visible-default-removable + note-path copy affordance.** Two Aaron-ratified
1783
+ items.
1784
+
1785
+ - **Capture chip loosening.** `buildTextNotePayload` (`src/lib/capture/text-note.ts`) no
1786
+ longer force-injects the capture role tag at save time — it takes the tag row's chips as
1787
+ given. Both typed-composer surfaces (`NoteNew`'s tag editor and Home's `Composer`, which
1788
+ gains its own compact tag row once the card opens) now pre-populate the capture role tag as
1789
+ a visible, removable chip at compose time, and sync it in until the operator explicitly
1790
+ adds or removes a chip in that session — after which their chip set is authoritative and a
1791
+ deliberately-removed capture tag is never re-added underneath them. Someone who never
1792
+ touches the tag row still gets byte-identical behavior (the tag ends up in the payload,
1793
+ same as before); the pre-populated chip alone also doesn't trip the draft-autosave dirty
1794
+ check or NoteNew's leave-guard/Cancel confirm. Voice capture (`NoteNew`'s `saveWithAudio`)
1795
+ is untouched — it builds its own payload directly and keeps unconditionally applying its
1796
+ role tags, by design (out of scope for this loosening).
1797
+ - **Note-path copy affordance.** The note view's metadata card gets a **Path** row (reusing
1798
+ `CopyField`, the app's existing copy-with-toast-and-transient-label pattern) plus a
1799
+ standalone **Copy reference** button — path is plumbing, but on a note it stays grabbable
1800
+ because it's how you reference a note to an AI agent.
1801
+
1802
+ ## [0.20.6] - 2026-07-17
1803
+
1804
+ **Views Wave 1 — the view organ.** From VIEWS-RENDER-SPEC: a view is a note tagged `#view`
1805
+ whose metadata (`kind`, `query`, `lane_by`, `date_field`) is the definition — the note body
1806
+ stays prose for people. This wave lands the module, the list-kind renderer, and the Rail's
1807
+ new Views band. Purely additive: nothing about the four existing default pages changes
1808
+ behavior (their cutover to the same pipeline is Wave 2).
1809
+
1810
+ - **`src/lib/views/`** — the canonical module: `decodeViewDef()` never throws and never
1811
+ returns null for a `#view` note (unknown/absent `kind` degrades silently to `list`;
1812
+ malformed `query` JSON degrades to `query: null` + a recorded problem, never an implicit
1813
+ "everything" query); `viewQueryToNotesQuery()` maps the MCP-grammar query object to the
1814
+ vault's typed `NotesQuery`, dropping unrecognized keys with a named problem rather than
1815
+ silently passing them through to a server 400; `partitionPinned()` groups pinned results
1816
+ above the rest within the view's own result set (replacing the sort-to-top VaultSurface
1817
+ uses today). The spec's §8 legacy-saved-views adapter is **void** — that old
1818
+ `{kind:"saved-view", filters}` shape at `UI/Views/<name>` was confirmed unused in practice
1819
+ and is out of scope entirely: no adapter, no reconciliation, and such notes are explicitly
1820
+ excluded from the Rail band. `src/lib/saved-views/` is untouched (dead code; a follow-up
1821
+ deletes it outright).
1822
+ - **`ViewSurface`** (`/views/:id`, note id not path) — list kind this wave; a problems banner
1823
+ degrades honestly instead of blanking the page on a malformed view; a refinement bar
1824
+ (tag-include, tag-exclude-toggle on the base query's own chips, search, sort) reads/writes
1825
+ the URL so refinements survive reload and are shareable; the Save sheet offers "Update this
1826
+ view" or "Save as new view," defaulting on whether the signed-in principal's JWT `sub`
1827
+ matches the note's `createdBy` (display-only — never a security boundary).
1828
+ - **The Rail's new "Views" band** (between "Your notes" and "Explore") — fed by a `tag=view`
1829
+ query with no path prefix, the four shipped default-page paths excluded (they already have
1830
+ Rail rows), each item wearing a combined hue-dot + kind-glyph mark; a permanent "New view"
1831
+ row creates a `#view` note at `Views/<name>` (kind list, empty query) via the new
1832
+ `/views/new` ceremony and opens it.
1833
+ - **`src/lib/hue/hue.ts`** — the surface-owned hue module EDITOR-STUDY §7/§9 called for: a
1834
+ tag name resolves to one of 8 curated "garden" hues (sage/sky/sun/coral/grass/clay/ochre/
1835
+ plum) — hand-assigned for a handful of known roles, deterministically hashed for everything
1836
+ else. Zero data change; nothing is stored. A view's hue comes from its query's primary tag
1837
+ (the subject); the kind carries the glyph. Landed here per VIEWS-RENDER-SPEC §9 ("whichever
1838
+ PR train lands it first, the other imports") — no hue module existed on `main` yet.
1839
+ - **`NoteView`** gains the bridge's other half: a `#view`-tagged note shows "Open as view,"
1840
+ linking to `/views/:id`; `ViewSurface`'s own "Edit view note" is the trip back — two faces
1841
+ of one note, no separate view-editing UI.
1842
+
1843
+ ## [0.20.5] - 2026-07-17
1844
+
1845
+ **Editor Wave 1 — "one voice."** From the editor-experience design study: the editor and the
1846
+ reader become the same room. Presentation-only, no storage/path/wire changes.
1847
+
1848
+ - **Type-scale unification.** The page-title clamp is retuned to
1849
+ `clamp(var(--text-2xl), calc(var(--text-xl) + 1.2vw), 2.25rem)` — ~27px phone / ~31px tablet
1850
+ portrait / 36px desktop cap, a document heading rather than a poster. Live-editor headings move
1851
+ onto the shared serif ramp (`.cm-lp-h1` → `--font-serif` + `--text-3xl`, H2 → `--text-2xl`, H3 →
1852
+ `--text-xl`, H4-6 → `--text-lg`/600) instead of an ad-hoc sans em scale, so a heading is the same
1853
+ object at the same size in edit and read. A new `--lh-live: 1.7` token (meeting reading's 1.78
1854
+ and the old code-editor 1.6 in the middle) drives the live editor's scroller and every
1855
+ height-locked live-preview widget (`.cm-lp-hr`, `.cm-lp-embed-chip`) together, so the
1856
+ reveal-never-reflows invariant holds.
1857
+ - **Humanized default titles.** A `displayTitle()` refinement in `note-title.ts` pattern-matches
1858
+ `quickPath()`-shaped leaves (`Notes/YYYY/MM-DD/HH-MM-SS`) and renders them as a formatted
1859
+ timestamp ("July 16 · 10:48 PM", muted, placeholder weight) instead of the raw date-path leaf —
1860
+ in NoteRow, the Recent timeline (via NoteRow), QuickSwitch, and NoteView's page-title slot.
1861
+ Notes with real content keep today's title logic byte-identical. Composer placeholder copy is
1862
+ now "Name your note — or just start writing."
1863
+ - **Scroll-past-end.** CM6's stock `scrollPastEnd()` plus a ~30%-viewport bottom scroll margin, so
1864
+ typing at the bottom of a long note no longer pins the caret to the floor.
1865
+ - **Wikilink/external link distinction.** Solid underline for wikilinks ("stays home"), dashed
1866
+ underline for external links ("leaves") — in both the live editor (`.cm-lp-link` vs
1867
+ `.wikilink`) and the read view, which additionally gets a small departing-arrow after external
1868
+ links (CSS `::after`, no extra DOM).
1869
+ - **Tailwind v4 bracket-syntax fix (app#41).** 17 `text-[--color-x]`/`max-w-[--w-x]`-style
1870
+ arbitrary values across the app were silently compiling to invalid CSS (`color: --color-x`,
1871
+ missing the `var()` wrap) — converted to the `(--var)` parens shorthand, matching the fix
1872
+ already landed in Toaster.tsx. This restores the dark-accent WCAG-AA on-accent color override on
1873
+ every affected button/badge.
1874
+
1875
+ ## [0.20.4] - 2026-07-16
1876
+
1877
+ **Atmosphere set (uni-surface adoption, display-only — awaits Aaron's morning review, not
1878
+ merged on landing).** Four finishing touches adopted from Aaron's uni-surface "feels really
1879
+ good to write in" reference, working entirely within the app's existing coral/warm-forest
1880
+ token system (no palette import, no re-skin):
1881
+
1882
+ - **A second ambient ground wash.** `.app-canvas` already carried one grass-tinted radial wash
1883
+ top-center; it now carries a second, coral-tinted wash from the opposite corner
1884
+ (`--canvas-wash-accent`, tuned separately per theme: 5% light / 4% dark), so the page ground
1885
+ has two soft light sources instead of one. No `background-attachment: fixed` — it's painted on
1886
+ the normal-flow shell, so it scrolls with content (no mobile jank).
1887
+ - **Dark-theme shadows re-tinted off pure black.** Light-theme shadows were already
1888
+ forest/sage-tinted; dark-theme `--_d-shadow-*` used flat `#000`. A new `--_d-shadow-ink` mixes
1889
+ a whisper of the dark accent into black, so dark-mode elevation now reads as warm-near-black
1890
+ rather than neutral black, without losing the contrast a dark shadow needs against the night
1891
+ ground.
1892
+ - **`::selection` carries a soft accent wash** instead of the browser default, in both themes for
1893
+ free (one `color-mix(in srgb, var(--color-accent) 24%, transparent)` rule — no dark override
1894
+ needed since the accent token already flips per theme). AA-checked in both directions.
1895
+ - **`.note-row` hover-lift.** THE note row (Recent, day drill-in, `/notes` — one shared anatomy)
1896
+ now lifts `translateY(-1px)` with a soft shadow on hover, riding the existing
1897
+ `--dur-move`/`--ease-out` tokens. Gated to `(hover: hover)` (no stuck-lift on touch taps) and to
1898
+ `prefers-reduced-motion: no-preference` (double-guarded with the token-zeroing gate, same
1899
+ pattern as `.btn-primary`'s hover lift).
1900
+
1901
+ ## [0.20.3] - 2026-07-16
1902
+
1903
+ **UI-audit display train.** Display-only fixes from the overnight UI look-and-feel audit
1904
+ (walker capture across 6 viewport/theme configs) — no data-model changes, no IA restructuring,
1905
+ no new features.
1906
+
1907
+ - **Wide tables reachable on phone.** `.prose-note table` gets its own horizontal scroller
1908
+ (the GitHub-markdown-body `display: block; width: max-content; max-width: 100%; overflow-x:
1909
+ auto` pattern) instead of being silently clipped by `.app-canvas`'s `overflow-x: hidden` — an
1910
+ 8-column table's rightmost ~330px was permanently unreachable on a 390px viewport, with no
1911
+ scrollbar and no affordance.
1912
+ - **Human copy on note-load errors.** A missing/failed note used to show the literal internal
1913
+ request line (`GET /api/notes?id=…→404`), unwrapped and clipped on phone. Now: friendly copy
1914
+ ("Couldn't find this note…"), a "Back to notes" action beside "Try again", and the raw detail
1915
+ tucked into a collapsed, word-broken `<details>` so it can never clip again.
1916
+ - **A real not-found page.** An unrecognized multi-segment route (or the reserved `/vault/*` /
1917
+ `/u/*` path-space) used to silently teleport home with only a toast. It now renders a proper
1918
+ "Page not found" page with a "Back to notes" action, at the address you actually landed on.
1919
+ - **New-note surface, three fixes:** the auto-generated date-path title renders small/muted while
1920
+ untouched (a suggestion, not a headline) and switches to the normal display treatment once you
1921
+ type your own; the empty editor canvas shows a "Start writing…" placeholder (serif, italic,
1922
+ warm-muted ink — same quiet voice as the empty-note placeholder below); the compose screen's
1923
+ implicit `capture` tag stays as-is (investigated — see PR body).
1924
+ - **Search palette scrim.** ⌘K's command palette gets a proper backdrop — tinted with the app's
1925
+ own ink (not flat black) plus a soft blur, fading in over ~180ms, so the world behind recedes
1926
+ warmly instead of going dark; reduced-motion-gated like every other overlay. The floating
1927
+ pill/panel no longer reads as a transparent inline layer.
1928
+ - **Empty-note body placeholder.** An empty note's body used to be blank whitespace,
1929
+ indistinguishable from a failed load. Now: quiet "Nothing here yet." copy (serif, italic,
1930
+ warm-muted ink — the same voice `.prose-note blockquote` already carries) + a "Start writing"
1931
+ link into the editor.
1932
+ - **Tag-row touch targets.** The Tags page's Pin/Schema/Rename actions pad to a ≥44px effective
1933
+ hit area (padding + a negative-margin claw-back, so the visible row height is unchanged).
1934
+ - **No duplicate wordmark on phone arrival.** The mobile top-bar chrome (with its hamburger,
1935
+ which had nothing to open on a signed-out screen) no longer renders on the arrival route —
1936
+ Landing's own wordmark lockup is the only one on screen there.
1937
+ - **Task-list view/edit consistency.** View mode no longer shows both a bullet dot and a checkbox
1938
+ on the same task item — the bullet is redundant once the checkbox exists, and edit mode never
1939
+ showed it.
1940
+ - **Account email wraps instead of ellipsizing** on phone — exactly the string you'd want to read
1941
+ in full.
1942
+ - **Tag schema dialog teaches, not just asks.** An intro paragraph explains what a meta tag is;
1943
+ a live example (`status — string`, `meeting_date — date`) sits under the Fields legend. Copy
1944
+ only — the `date` field type was already wired end-to-end.
1945
+ - **`@openparachute/surface-render` bumped `^0.2.0` → `^0.3.0`.** Single newlines in a note's
1946
+ markdown now render as line breaks (Shift+Enter shows what you typed), matching the shared
1947
+ package's new `breaks: true` default — the app never overrides it.
1948
+
1949
+ ## [0.20.2] - 2026-07-16
1950
+
1951
+ **Type & spacing sweep (Polish PR 3/6).** Third of the polish-wave train (display-only — no
1952
+ data-structure, note-format, or wire-contract changes). Closes the gaps in rows, digits, and
1953
+ corners so the app reads as one set of decisions rather than five.
1954
+
1955
+ - **Tabular digits.** `tabular-nums` on every place a count or timestamp sits where digits should
1956
+ hold a column: `NoteRow`'s relative-time stamp, `QuickSwitch`'s tag counts + results-footer
1957
+ count, `Tags`'s per-tag count badge + the "N / M tags" footer, the Calendar day-grid numerals,
1958
+ and `NoteView`'s Outbound/Inbound link-count heading.
1959
+ - **Title balance.** `text-wrap: balance` on `.page-title` and `.hero-title`; `text-wrap: pretty`
1960
+ on `.prose-note p` — free elegance on every heading and paragraph that wraps. CodeMirror owns
1961
+ its own wrapping in the editor pane, untouched.
1962
+ - **Row title step.** `NoteRow`'s title moves from `text-sm` to `text-base` — it was sitting at
1963
+ the same size as its own preview line, flattening the hierarchy a list needs. Preview stays
1964
+ `text-sm`, the timestamp stays `text-xs`; the `items-baseline` row alignment and truncation are
1965
+ unaffected.
1966
+ - **Radius coherence.** STYLE.md's ramp puts interactive chrome at `lg`+; a `rounded-md` grep
1967
+ across `src/components` and `src/app/routes` turned up ~45 stray call sites still at the
1968
+ code/table radius. Buttons, inputs, and clickable rows move to `rounded-lg`; the mobile editor's
1969
+ pane-toggle tabs drop to `rounded-sm` to nest inside their already-`rounded-lg` tablist wrapper;
1970
+ floating popovers/dropdown menus (`TextSizeControl`, the saved-views + tag-suggestion menus in
1971
+ `VaultSurface`) move to `rounded-xl` + `shadow-lift`, matching the sync popover's PR-2 precedent;
1972
+ standalone confirm-dialog panels (`TagRenameDialog`, `DeleteNoteButton`, `RemoveAttachmentButton`,
1973
+ the iOS install hint) move to `rounded-2xl` + `shadow-lift`, matching `.dialog-panel`. Left
1974
+ alone by design: code/kbd/table chrome, non-interactive status/warning/error boxes (several
1975
+ carry pre-existing raw-color literals — a separate token-contract cleanup, not this PR's job),
1976
+ skeletons, and inline text chips.
1977
+
1978
+ ## [0.20.1] - 2026-07-16
1979
+
1980
+ **Calm micro-states (Polish PR 2/6).** Second of the polish-wave train (display/interaction only
1981
+ — no data-structure, note-format, or wire-contract changes). Every new transition consumes PR 1's
1982
+ motion tokens; nothing here escapes the reduced-motion gate.
1983
+
1984
+ - **"Saved" whisper.** After a checkpoint save (⌘S) succeeds, the editor header shows `Saved ✓` in
1985
+ accent for ~1.5s, then settles back to `saved just now` — a state + timeout in `EditorSurface`
1986
+ (`NoteEditor.tsx`), cleaned up on unmount. No spinner, no toast; the Save button's "Saving…"
1987
+ label is unchanged. The "unsaved" dot still wins the moment you resume typing.
1988
+ - **Sync dot token hygiene.** `SyncStatusIndicator`'s dot dropped its raw Tailwind color literals
1989
+ (`emerald-400` / `amber-400` / `sky-400` / `red-400` / `red-500`) for the semantic tokens:
1990
+ online → `--color-grass`, offline → `--color-warning`, syncing → `--color-sky` (pulse kept),
1991
+ halted → `--color-danger`, unreachable → a `color-mix()` toward canvas (keeps the
1992
+ lighter-red distinction from colour-blind-safe halted/unreachable). The popover's chrome moved
1993
+ from `rounded-md`/`shadow-lg` to the `--radius-xl`/`shadow-lift` floating-surface pair.
1994
+ - **Toaster surface.** Same floating-surface pair (`--radius-lg` + `shadow-lift`); the dismiss `×`
1995
+ button picks up `.focus-ring`. Entrance motion already landed in PR 1. Also fixes a live bug on
1996
+ the error-tone toast: Tailwind's `[--foo]` bracket arbitrary-value syntax takes the value
1997
+ literally (no `var()` wrap), so `border-[--color-danger-border]` etc. were compiling to the
1998
+ invalid `border-color: --color-danger-border` — silently dropped by the browser. Switched to the
1999
+ `(--foo)` parens form (the CSS-var shorthand that *does* wrap; the same form PR 1 already relies
2000
+ on for `duration-(--dur-move)`), confirmed against the built CSS. The same bracket-syntax bug is
2001
+ pre-existing in ~11 other files outside this PR's scope — flagged as a follow-up, not fixed here.
2002
+ - **Skeleton consolidation.** `VaultSurface`'s hand-rolled `RecentSkeleton` rows now render through
2003
+ the shared `Skeleton` primitive instead of a bespoke `animate-pulse` div, so reduced-motion
2004
+ coverage is inherited rather than re-implemented.
2005
+ - **Focus-visible sweep.** `.focus-ring` added to the note back-links (`NoteView`, `NoteEditor`),
2006
+ the footer's ecosystem link (`App.tsx`), and the draft-offer Restore/Discard buttons
2007
+ (`NoteEditor.tsx`) — every one of those stops was previously invisible on keyboard tab.
2008
+
2009
+ ## [0.20.0] - 2026-07-16
2010
+
2011
+ **Motion as a system (Polish PR 1/6).** Aaron's steer: "keep on cooking on the UI stuff — the
2012
+ focus is definitely polish, how it feels overall." First of the polish-wave train
2013
+ (display/interaction only — no data-structure, note-format, or wire-contract changes). The audit
2014
+ found five ad-hoc duration values, three easings, and every floating surface (NavSheet,
2015
+ QuickSwitch, the sync/vault/text-size popovers, the Toaster, `.dialog-panel`) appearing with zero
2016
+ entrance acknowledgment; reduced-motion was honored by hand in eight `motion-reduce:` sprinkles
2017
+ plus two CSS blocks rather than by system. This PR replaces all of it with one vocabulary.
2018
+
2019
+ - **Five motion tokens**, `@theme` in `index.css`: `--dur-quick` (120ms, state changes) /
2020
+ `--dur-move` (200ms, transforms/resizes) / `--dur-enter` (280ms, surfaces arriving) /
2021
+ `--ease-out` (the new calm-settle curve, promoted to the theme's `ease-out`) / `--ease-spring`
2022
+ (the existing `.btn` spring, promoted and reused).
2023
+ - **Every existing transition retimed to a token** — no visual redesign, nearest-token retiming
2024
+ only: `.btn`, `.input`/`.textarea`/`.select`, `.composer`, `.note-row`, `.tile` (CSS); Rail's
2025
+ width + chevron rotate, SpeedDial's three scale/rotate transitions, Composer's inline
2026
+ min-height transition (Tailwind `duration-(--dur-move)` / arbitrary-property call sites).
2027
+ `fade-up`'s duration moves to `--dur-enter`. Bare `transition-colors`/`transition-shadow` in
2028
+ files this PR was already touching (Rail, Composer, NavSheet, SpeedDial, QuickSwitch) picked up
2029
+ explicit `duration-(--dur-quick) ease-out` instead of Tailwind's implicit 150ms default; the
2030
+ remaining ~11 untouched `transition-colors` call sites elsewhere are left alone (within a hair
2031
+ of the token already, converting them is separate-PR churn).
2032
+ - **Entrances** — two new keyframe classes, `.enter-rise` (opacity + translateY(10px)→0,
2033
+ `--dur-enter`) and `.enter-fade` (opacity only, `--dur-quick`), both on `--ease-out`. Applied to
2034
+ every floating surface that used to pop: NavSheet's panel + scrim, QuickSwitch's dialog + pill
2035
+ column + results panel, the SyncStatusIndicator popover, the VaultSwitcher rail popover, the
2036
+ TextSizeControl popover, each Toaster entry, and — baked directly into the shared classes so
2037
+ future consumers inherit it for free — `.dialog-overlay`/`.dialog-panel`. Exits stay instant by
2038
+ design (calmer than deferred-unmount machinery for a one-frame dismissal).
2039
+ - **One reduced-motion gate.** `--dur-move`/`--dur-enter` zero to 0ms under
2040
+ `prefers-reduced-motion: reduce`, so every transform/resize/entrance that consumes them goes
2041
+ instant automatically — no per-component opt-in needed for new motion. `--dur-quick` is left
2042
+ alone (color/border/shadow settles aren't the vestibular-safety concern WCAG 2.3.3 targets).
2043
+ The eight per-callsite `motion-reduce:transition-none` sprinkles are retired in favor of this
2044
+ one block; the two SpeedDial `motion-reduce:hover:scale-100`/`group-hover:scale-100` guards are
2045
+ kept as-is (a distinct, stricter concern — suppressing the hover scale value itself, not just
2046
+ its transition timing).
2047
+
2048
+ ## [0.19.2] - 2026-07-16
2049
+
2050
+ **Reserve `/vault` + `/u` path-space for the one-origin domain (my. Phase A2).** Prep work for
2051
+ `my.parachute.computer`, the ratified one-origin door where `/vault/<name>/*` is a Cloudflare
2052
+ zone route to the vault worker (the data plane) dispatching ABOVE this app, and `/u/<handle>/*`
2053
+ is reserved for Phase B's per-account vault namespace. This app must never intercept either as
2054
+ its own — no wire/route change (nothing here already claims those prefixes), pure guardrails +
2055
+ pins so a future change can't accidentally collide. Patch bump — no user-visible behavior change.
2056
+
2057
+ - **Service worker**: `pwa-navigation-denylist.ts` denies `/^\/vault\//` and `/^\/u\//` — an
2058
+ installed PWA's `navigateFallback` must never swallow a `/vault/<name>/...` or
2059
+ `/u/<handle>/...` navigation into the cached SPA shell, even if the zone route is ever
2060
+ misconfigured. Bare `/vault` and `/u` (no further segment) stay undenied — there's no
2061
+ server-owned page at exactly that path, so a note literally named "vault" or "u" keeps
2062
+ resolving as it does today.
2063
+ - **Router-root reservation**: a comment block in `App.tsx` documents `/vault` and `/u` as
2064
+ permanently foreign prefixes; confirmed (and pinned via `App.test.tsx`) that the single-segment
2065
+ `/:id` bare-path shim can't collide with a two-segment `/vault/<name>` or `/u/<handle>/...`
2066
+ path — both already fall through to the `*` catch-all today.
2067
+ - **`base-url.ts` sanity**: pinned that `detectMountBase()` never misreads `/vault/*` or `/u/*`
2068
+ as a sub-mount — `MOUNT_PATTERNS` only recognizes `/surface/<slug>` and `/notes/`.
2069
+
2070
+ ## [0.19.1] - 2026-07-16
2071
+
2072
+ **Live-preview polish: reveal/containment/quote nits (A5), drop Pages-era public artifacts.**
2073
+ Three cosmetic fixes from PR #36's review, plus the app-side half of cloud#156's belt-and-
2074
+ suspenders cleanup. Patch bump — decoration-correctness fixes only, no new room.
2075
+
2076
+ - **N1 — wikilink reveal keeps its color mark.** Revealing a wikilink's line used to drop the
2077
+ `wikilink` style mark along with the hide-marks (the whole match was skipped on reveal); inline
2078
+ links never had this bug since their `style()` call was already unconditional (invariant 2). The
2079
+ wikilink loop in `buildDecorations` now uses the same `hide()`/`style()` split as the Link case:
2080
+ markers hide on reveal, the display-text color mark never does.
2081
+ - **N2 — wikilink containment symmetry.** The `Link` containment case used `break` (still descends
2082
+ into children) while `Image` used `return false` — so a wikilink like `[[target with **stars**]]`
2083
+ got its nested `**` hidden by the incidental StrongEmphasis parse of the fake inner Link node,
2084
+ splitting the display text out of its own color span. `Link` now matches `Image`'s `return false`.
2085
+ - **N3 — blockquote lazy-continuation border.** The quote-border line class was applied per-
2086
+ `QuoteMark`, so a lazy-continuation line (quote content with no leading `>`, per CommonMark) —
2087
+ still part of the same `Blockquote` node — missed its border. The line class now applies across
2088
+ the node's whole line range, same pattern as the FencedCode/CodeBlock loop just above it.
2089
+ - **Drop `public/_redirects`** (Cloudflare-Pages-era SPA-fallback rule) — this app deploys as a
2090
+ Cloudflare Worker (Static Assets + `run_worker_first`), and the identity worker does its own SPA
2091
+ fallback; Pages is dead for this app. `public/CNAME` didn't exist in this repo (nothing to drop
2092
+ there). cloud's `build-spa.sh` keeps its defensive strip of both files — belt (there) and
2093
+ suspenders (here), cloud#156.
2094
+
2095
+ ## [0.19.0] - 2026-07-16
2096
+
2097
+ **Live-preview editor — markup fades, todos become checkboxes, one calm pane (A4, the editor
2098
+ arc's flagship).** The CM6 editor stops dressing like a code editor: every line renders formatted
2099
+ (markup faded/hidden, `**bold**` styled bold, headings scaled up), the line(s) the cursor touches
2100
+ reveal raw markdown underneath. Live preview is the new default single-pane editing mode; the
2101
+ split-pane raw editor stays reachable via one Settings toggle (the escape hatch). Minor bump —
2102
+ default-ON behavior change to the editing surface, no wire/data-shape change.
2103
+
2104
+ - **Engine: `src/lib/editor/live-preview.ts`** (new) — a `ViewPlugin` computing decorations from
2105
+ the syntax tree over `view.visibleRanges` ONLY (never the whole doc), rebuilding on
2106
+ `docChanged || selectionSet || viewportChanged` and skipping rebuilds mid-IME-composition. Two
2107
+ invariants the whole design hangs on: (1) the module has exactly ONE `view.dispatch` call — the
2108
+ checkbox widget's tap handler; everything else only ever reads state and produces decorations;
2109
+ (2) reveal never changes vertical layout — heading font-size / code background / widget heights
2110
+ apply unconditionally, only marker VISIBILITY toggles on reveal.
2111
+ - **Parser switch (both modes):** `markdown()` → `markdown({ base: markdownLanguage })` — the bare
2112
+ default is commonmark-only; the GFM base is what puts `Task`/`TaskMarker` (todos),
2113
+ `Strikethrough`, and `Table` in the tree at all.
2114
+ - **Decoration inventory:** ATX headings (scaled, marker hidden), bold/italic/strikethrough
2115
+ (marker hidden, existing highlight already styles the span), inline code (chip), links (text
2116
+ styled, `](url)` hidden), wikilinks/embeds (regex-mirrored from
2117
+ `parachute-surface/packages/surface-render`'s `remark-wikilinks.ts` — neutral styling only, no
2118
+ resolved/unresolved split; a follow-up issue is filed there to export the regex so this mirror
2119
+ can be deleted), task checkboxes (real tappable `<input type="checkbox">`, ≥2.5rem hit area via
2120
+ a negative-margin padding trick, toggle writes exactly the one bracket character), bullet/
2121
+ ordered lists, blockquotes (border + existing italic), horizontal rules (widget), images/embeds
2122
+ (placeholder chip, v1 — no inline rendering). Fenced/indented code interiors and frontmatter are
2123
+ fully opaque to every pass — provably undecorated, tested. GFM tables render raw (out of scope
2124
+ for v1; dedicated editing UI later).
2125
+ - **Chrome (live mode only):** gutter gone (`lineNumbers()` omitted), prose font stack + size
2126
+ mirroring `.prose-note`, capped measure (`--w-prose`). Raw mode is byte-for-byte today's editor.
2127
+ - **Flag + Settings:** `src/lib/editor-mode.ts` (mirrors `text-size.ts`'s read/write pattern),
2128
+ key `notes:livePreview`, default ON (`"off"` is the one persisted sentinel). New "Live preview"
2129
+ toggle in Settings, adjacent to Text size. `CodeMirrorEditor` gains a `livePreview` prop, read
2130
+ once at `NoteEditor`/`NoteNew` mount — a runtime kill-switch, not a live Compartment swap. When
2131
+ ON, both routes collapse to single-pane (no `NoteRenderer` preview column, no mobile edit/
2132
+ preview tab strip); OFF is the exact split-pane raw editor, unchanged.
2133
+
2134
+ **Riders (separate commits, same PR):**
2135
+ - **R1 — `useAllNotesForSwitcher` (Cmd+K) and `useAllNotesWithLinks` (the graph) send `sort:
2136
+ "desc"`** (`src/lib/vault/queries.ts`): both previously sent a hard `limit` with no explicit
2137
+ sort, so the vault's `created_at ASC` default silently dropped the NEWEST notes — not the
2138
+ oldest — off a vault past `VAULT_GRAPH_NOTE_CAP`. Now matches the other capped-window queries.
2139
+ - **R2 — GFM table Enter/Shift+Enter route** (`src/lib/editor/paragraph-break.ts`, closes app#35):
2140
+ once the parser switch put `Table` in the tree, a table row gets the same treatment as a fence
2141
+ — a plain newline, never a paragraph break (which would explode a blank line into the table) or
2142
+ a hard-break backslash.
2143
+ - **N3 — the slash-menu's deliberately-deferred indented-code edge is closed**
2144
+ (`src/lib/editor/slash-completion.ts`): the completion SOURCE now gates on
2145
+ `syntaxTree(state).resolveInner(pos, -1)`, refusing to open inside `FencedCode`/`CodeBlock`/
2146
+ `InlineCode`/`CodeText` — while correctly still opening on a list-item continuation line
2147
+ indented 4+ spaces, which is NOT code to lezer (list context wins), where the old pure-regex
2148
+ matcher couldn't tell the difference. Applies to both editor modes.
2149
+
2150
+ **Review fixes (pre-merge, PR #36):** the PR's review pass (33/33 scripted real-browser checks at
2151
+ tablet + phone viewports, via Playwright) caught three issues fixed before merge:
2152
+
2153
+ - **M1 (font/theme precedence):** live mode was silently rendering mono@15px with zero inline
2154
+ padding instead of the intended prose look — `livePreviewChromeTheme` and the original shared
2155
+ `lensTheme` set `fontFamily`/`fontSize`/inline-padding on the SAME selectors at EQUAL
2156
+ specificity, and CM6 resolves that tie by observed stylesheet order, not by position in the
2157
+ `buildExtensions` array (the original "ordered after `lensTheme` so it wins" comment was
2158
+ verified false in a real browser). Fixed by making the two modes mutually exclusive font/padding
2159
+ authorities: `lensTheme` now carries only mode-agnostic chrome, a new `rawModeTypographyTheme`
2160
+ (`CodeMirrorEditor.tsx`) is raw mode's authority, `livePreviewChromeTheme` stays live mode's —
2161
+ never both included at once, so there's no tie to lose.
2162
+ - **S1 (reference-style links/images should render raw):** `[sic]` / `[text][ref]` / `![alt text]`
2163
+ have no `URL` child in the tree (confirmed against the actual parse output — true even when a
2164
+ matching `[ref]: url` definition exists elsewhere), so the `Link`/`Image` decoration cases now
2165
+ bail (`if (!url) break`/`return false`) before decorating — they were being treated as real
2166
+ links/embeds, which A4-SPEC §2 explicitly puts out of v1 scope.
2167
+ - **S2 (IME staleness, silent 1-char corruption):** the checkbox widget's tap handler used its
2168
+ build-time `markerFrom` closure to compute the dispatch range; a composition edit between when
2169
+ the widget was built and when it's tapped remaps the decoration set's ranges but never touches a
2170
+ widget instance's own captured fields, so a stale `markerFrom` could silently write into whatever
2171
+ character now sits at that old offset. Fixed: the tap handler derives its position FRESH via
2172
+ `view.posAtDOM`, then guards the dispatch on the live doc text at that position actually matching
2173
+ `[ ]`/`[x]` — a mismatch (including a fully detached/stale widget) downgrades to a missed tap,
2174
+ never a write.
2175
+
2176
+ **Manual pass:** the review's own scripted Playwright pass (33/33 checks, tablet 768×1024 + phone
2177
+ 390×844) covers A4-SPEC §10's manual checklist — live-mode chrome, markup fade/reveal, zero
2178
+ scroll-jump on reveal, checkbox tap-toggle + buffer proof, slash-menu-still-opens, and the full
2179
+ Settings-toggle round trip (ON → OFF raw mode → back ON). Re-run clean against this fix commit.
2180
+
2181
+ - Tests: `src/lib/editor/live-preview.test.ts` (33 — the invariant test across 5 corpus fixtures,
2182
+ reveal correctness, checkbox toggle exactness/undo/onChange-once/tap-never-eaten-by-reveal/
2183
+ stale-widget-guard, touch-target computed style, one-font-authority-per-mode computed style,
2184
+ fence/indented-code/table sanctity, frontmatter guard, wikilink/embed decoration incl. the
2185
+ Link-node double-decoration regression and reference-style links/images rendering raw, and the
2186
+ <50ms full-doc perf bound), `src/lib/editor-mode.test.ts` (3), `src/lib/vault/queries.sort.test.tsx`
2187
+ (2 — real fetch URL assertions, not a mocked `queryNotes`), plus additions to
2188
+ `src/components/CodeMirrorEditor.slash-menu.test.ts` (+5 — the N3 gate) and
2189
+ `src/components/CodeMirrorEditor.newline.test.ts` (+2 — the table Enter route). Fixtures:
2190
+ `src/lib/editor/__fixtures__/corpus/` (representative, not real vault content).
2191
+
2192
+ ## [0.18.0] - 2026-07-16
2193
+
2194
+ **Provenance badge + paragraph-break Enter (A2+A3 of the editor arc).** Two independent,
2195
+ Aaron-ratified pieces bundled in one PR: who/what wrote a note, and how Enter behaves while
2196
+ writing one. Minor bump — new display data + a keymap behavior change, no wire/data-shape
2197
+ change.
2198
+
2199
+ - **`ProvenanceBadge`** (`src/components/ProvenanceBadge.tsx`, `src/lib/note-provenance.ts`) —
2200
+ a small, factual attribution line reading the write-attribution fields vault#298 landed on the
2201
+ wire (`createdBy`/`createdVia`/`lastUpdatedBy`/`lastUpdatedVia`, all nullable), now on the app's
2202
+ `Note`/`NoteSummary` types via `@openparachute/surface-client` 0.3.5 (bumped from `^0.3.4`; see
2203
+ the PR description for why a dependency bump won over local types). Mounted in BOTH `NoteRow`
2204
+ (compact — one short fragment beside the relative-time stamp) and `NoteView`'s metadata panel
2205
+ (detail — the fuller created/updated pair, shown only when the two differ). FACTUAL PROVENANCE
2206
+ ONLY: `*Via` channels map to a friendly noun (`mcp` → "via MCP", `agent:<id>` → "via agent",
2207
+ `surface:notes` → "via Notes") — never a human-vs-AI guess. The raw principal (`*By`) never
2208
+ appears as visible text, only in a `title` tooltip. Null/legacy records render nothing — no
2209
+ "unknown" placeholder noise.
2210
+ - **Enter — context-aware paragraph break** (`src/lib/editor/paragraph-break.ts`, wired into
2211
+ `CodeMirrorEditor.tsx`'s keymap ahead of `defaultKeymap`, which otherwise binds Enter to
2212
+ `insertNewlineAndIndent`): in prose, a REAL blank line (two `\n`) so the file stays unambiguous
2213
+ CommonMark; in a list item or blockquote, delegates to `@codemirror/lang-markdown`'s own
2214
+ `insertNewlineContinueMarkup` (marker continuation, and an empty list item still exits the
2215
+ list — native behavior, untouched); inside a fenced code block, a single plain newline. **Shift
2216
+ +Enter — explicit hard break**: `\`-before-newline in prose (survives whitespace trimming,
2217
+ deliberately not the trailing-two-spaces convention); a plain single newline in
2218
+ lists/quotes/fences (a bare backslash there would land on a marker-less continuation line and
2219
+ misparse, or corrupt a fence's literal bytes). Both commands sit at the editor's default keymap
2220
+ precedence, so the slash-menu's own Enter-commits-completion binding (`Prec.highest` inside
2221
+ `@codemirror/autocomplete`) is tried first and still wins while the menu is open — nothing
2222
+ about A1 changed.
2223
+
2224
+ | Context | Enter | Shift+Enter |
2225
+ |---|---|---|
2226
+ | Prose | blank line (`\n\n`) | hard break (`\\\n`) |
2227
+ | List item | marker continuation (empty item exits) | plain newline |
2228
+ | Blockquote | marker continuation | plain newline |
2229
+ | Fenced code | plain newline | plain newline |
2230
+ | Slash-menu open | commits the completion | commits the completion |
2231
+
2232
+ - Tests: `src/lib/note-provenance.test.ts` (12 — the null/legacy case, created-only, same-vs-
2233
+ differing-principal, the raw-tooltip/never-visible-label contract, and the full via-label
2234
+ mapping table) and `src/components/ProvenanceBadge.test.tsx` (8 — both mount-point variants:
2235
+ renders-nothing, created-only, created+updated-differ, and the same-principal-suppresses-
2236
+ updated case in detail). `src/components/CodeMirrorEditor.newline.test.ts` (7 — prose/list/
2237
+ empty-list/fence Enter, prose/fence Shift+Enter, and Enter-with-menu-open-still-commits) against
2238
+ a real headless CM6 `EditorView`, the same pattern `CodeMirrorEditor.slash-menu.test.ts` uses.
2239
+
2240
+ ## [0.17.0] - 2026-07-16
2241
+
2242
+ **The "/"-command menu (Editor P0, editor arc).** First visible step of the "Notion-feel,
2243
+ markdown-underneath" editor direction (EDITOR-RESEARCH.md, Aaron's 2026-07-14 morning-pages
2244
+ mandate) — deliberately narrow: no live-preview work here, just a fast way to insert common
2245
+ markdown blocks without hand-typing `#`/`-`/`` ``` ``. Minor bump — a new interaction on the
2246
+ existing editor, no behavior change to anything that isn't typing `/`.
2247
+
2248
+ - **Engine: `@codemirror/autocomplete`**, wired into `CodeMirrorEditor.tsx` as the editor's
2249
+ ONLY completion source (`autocompletion({ override: [...] })`) — no clash surface with
2250
+ anything else, and it's a no-op outside its own trigger. ↑/↓, Enter, Esc, and click all come
2251
+ free from the library; nothing hand-rolled for keyboard nav or click-to-select.
2252
+ - **Trigger**: `/` at the start of a line, or with only whitespace before it on the line — never
2253
+ mid-word (`src/lib/editor/slash-commands.ts`'s `matchSlashTrigger`, a plain regex against the
2254
+ current line's text up to the cursor). Typing "and/or" never opens it. Filters live as more
2255
+ characters follow the `/`, matching against each command's label or a short keyword list
2256
+ (`matchesQuery`).
2257
+ - **The v1 command set** (label — inserts): Heading 1/2/3 — `# ` / `## ` / `### `; Bulleted list
2258
+ — `- `; Numbered list — `1. `; To-do — `- [ ] ` (GFM task list); Quote — `> `; Code block —
2259
+ fenced ` ``` ` pair with the cursor on the blank line inside; Divider — `---`, padded with a
2260
+ blank line on either side that doesn't already have one (a bare `---` right under a text line
2261
+ is a CommonMark Setext-heading underline, not a divider — `"Heading\n---"` renders as an H2);
2262
+ Image / attachment — opens the SAME upload flow the page's Attachments section already uses
2263
+ (`AttachmentPicker`'s new imperative `open()`, wired through a new `onRequestAttachment` prop on
2264
+ `CodeMirrorEditor`), not a second upload path.
2265
+ - **Every insert is a plain-text buffer edit** — literal markdown characters written straight into
2266
+ the doc via the same primitive `CodeMirrorEditorHandle.insertAtCursor` already uses for uploads,
2267
+ never a structured object that gets serialized afterward. That's the whole point of staying on
2268
+ CodeMirror 6 rather than a block-WYSIWYG engine (EDITOR-RESEARCH.md §5): there's only ever one
2269
+ representation of the note, so there's nothing for a second implementation to drift out of sync
2270
+ with.
2271
+ - **Esc layering preserved**: `@codemirror/autocomplete`'s own keymap runs at `Prec.highest`, so
2272
+ the first Escape closes an open menu (`closeCompletion`) without ever reaching the editor's own
2273
+ Escape binding; only a second Escape (menu already closed) falls through to the existing
2274
+ `onCancel`. Same precedence protects ⌘S, paste-file handling, and draft autosave — none of them
2275
+ sit inside the completion keymap, so they're untouched.
2276
+ - **Menu styling**: CodeMirror's own tooltip positioning (flip/clamp to stay on-screen) handles
2277
+ the same on-screen-on-tablet job the recent `TextSizeControl` fix did by hand — nothing bespoke
2278
+ needed here. Themed as a `.glass-panel`-family popover per STYLE.md's "command palette" surface,
2279
+ with touch-sized (2.5rem-min) rows.
2280
+ - **Deliberately out of scope**: live-preview decoration (Phase 1 of the editor arc), a GFM table
2281
+ skeleton (real tables are painful to hand-edit as raw markdown even with live preview — a
2282
+ dedicated table UI is a later, separate piece of work), and a `[[`-triggered wikilink
2283
+ autocomplete (needs a note-index search the editor doesn't have inline access to yet — a natural
2284
+ v1.1).
2285
+ - Tests: `src/lib/editor/slash-commands.test.ts` (22 tests — trigger matching incl. the mid-word
2286
+ negative, query filtering, and every command's `apply()` against a real headless CM6
2287
+ `EditorView`, exact-string doc + cursor-position assertions) and
2288
+ `src/components/CodeMirrorEditor.slash-menu.test.ts` (8 tests — the actual wiring
2289
+ `buildExtensions()` assembles, not a re-description of it: the full 10-command list opening on
2290
+ bare `/`, the "and/or" negative through the real completion source, live filtering, a
2291
+ real-Enter-keypress commit, the image command driving `onRequestAttachment`, and all three legs
2292
+ of the Esc-layering behavior via real `KeyboardEvent` dispatch on `contentDOM`).
2293
+
2294
+ ## [0.16.1] - 2026-07-12
2295
+
2296
+ **The SET UP shelf is state-derived, not a per-device sticky checklist (Wave-3).** Fixes the bug
2297
+ the owner hit: the shelf ("Write a note / Connect your AI / Bring notes over") re-appeared on
2298
+ every new device, because completion lived in per-device `localStorage` — an established account
2299
+ looked un-onboarded on a fresh browser. Patch bump — a bug fix + a removed affordance
2300
+ (`ConnectAI`'s manual tick), no new room.
2301
+
2302
+ - **`write` is now the ONLY tracked step.** `src/lib/home/checklist.ts`'s `deriveSteps` takes just
2303
+ live signals (`{ hasUserNote }`) and returns one step, done exactly when a real (non-seed,
2304
+ non-system) note exists in the vault — the same cross-device fact on every device, always. There
2305
+ is no more `HomeChecklistState`, no `overrides`, no `dismissed` flag, and no localStorage read or
2306
+ write anywhere in the module.
2307
+ - **`connect` investigated, then dropped as a tracked step** (per the owner's ratified fallback).
2308
+ No client-detectable, door-agnostic signal exists for "an AI is connected to this vault":
2309
+ the vault's own `oauth_clients` table is vestigial (parachute-vault 0.4.x moved OAuth issuance to
2310
+ the hub — vault is resource-server-only now); the hub's grant/consent list
2311
+ (`GET /api/grants`) is gated on `parachute:host:admin` — unreachable by an ordinary vault user,
2312
+ hub-only, nothing equivalent exists on the cloud door; and the cloud account-summary contract
2313
+ (`GET /account/summary`, `AccountSummary` in `src/lib/account/types.ts`) carries no connection
2314
+ field at all. A manual per-device tick here WAS the bug this rework closes, so rather than keep
2315
+ one, the step — and `ConnectAI.tsx`'s "I've connected my AI" button/badge — is gone; the page is
2316
+ now pure instructions with a plain "Done — back to your vault" link.
2317
+ - **`import` folded into `write`** — both were always just "get notes into the vault" from
2318
+ `hasUserAuthoredNote`'s point of view (an imported note is exactly as real as a typed one), so a
2319
+ second row tracking the identical fact was redundant even before this rework.
2320
+ - **`install` moved out of the shelf entirely.** Installing a PWA is legitimately per-device, so it
2321
+ has no business in a cross-device "is this vault set up" signal — and it already had its own
2322
+ fully independent, always-live nudge (`@/components/InstallPrompt` in the nav sheet, driven
2323
+ directly by `useInstallAffordance()`, never persisted). That nudge is untouched; it just can no
2324
+ longer make the whole multi-step shelf reappear, because there is no more multi-step shelf.
2325
+ - **`use-home-checklist.ts` reworked**: no persisted state left to load/save. It now holds only an
2326
+ in-memory, per-mount "hide this for now" (the shelf's ✕ still works, but dismissing is a
2327
+ this-session courtesy, not a lie that outlives the tab — reload, or switch vaults, and the shelf
2328
+ re-evaluates fresh from real state). Resets on a vault switch via React's "adjust state during
2329
+ render" pattern (comparing the incoming `vaultId` against the last-seen one), not an effect —
2330
+ keeps the hook lint-clean (biome's exhaustive-deps rightly flagged an effect whose body never
2331
+ read its own dependency).
2332
+ - **`nav/model.tsx`'s "Set up" band no longer reads any checklist/localStorage state at all** — it
2333
+ derives straight from `hasUserAuthoredNote`, the same signal the Recent lens's inline nudge uses,
2334
+ so the rail/sheet band and the inline nudge can never disagree about progress.
2335
+ - **Cross-device fix, proven**: `VaultSurface.recent.test.tsx` and `nav/model.test.tsx` each add a
2336
+ test that seeds ONLY a vault + its access token (no checklist blob of any kind) plus a real
2337
+ (non-seed) note, and asserts NO setup shelf/band renders — i.e. an established vault reads as
2338
+ onboarded on a simulated brand-new device. A companion test confirms a genuinely fresh/empty
2339
+ vault (seed guide only) still shows the guidance. Playwright screenshots (light + dark,
2340
+ fresh-vault-vs-established-vault) captured to a scratch path, not committed.
2341
+ - Tests: `checklist.test.ts` rewritten around the state-derived model (drops the five
2342
+ localStorage-persistence tests — there's no persistence left to test — adds a step-shape check
2343
+ and an imported-note-counts-as-write check); `ConnectAI.test.tsx` replaces the "marks connect
2344
+ done" test with one asserting no manual tick exists and nothing is written to storage;
2345
+ `VaultSurface.recent.test.tsx` and `nav/model.test.tsx` gain the cross-device proof tests above.
2346
+ Net **1537 → 1534** (the removed persistence-layer tests outnumber the new state-derived +
2347
+ cross-device ones) — all passing.
2348
+
2349
+ ## [0.16.0] - 2026-07-12
2350
+
2351
+ **Export surface — download your vault (Wave-3).** The app promises "Open format. Export
2352
+ anytime." on every surface, but had no export door — only Import. Adds one, mirroring Import's
2353
+ shell. Minor bump — a new room in the IA, no behavior change to anything existing.
2354
+
2355
+ - **New route `/export`** (`src/app/routes/Export.tsx`, lazy-loaded like every other secondary
2356
+ room): explains the promise ("a portable folder of your notes — plain Markdown, with your tags,
2357
+ links, and attachments included"), lists what's in the download, and one primary action —
2358
+ "Export my vault." Honest states only: "Preparing your export…" while in flight (no fake
2359
+ progress bar — a `.tar` stream has no meaningful percent), a plain error ("Couldn't reach your
2360
+ vault — try again.") on a network/server failure, and a **distinct** state for a 404.
2361
+ - **The 404 case is the load-bearing finding of this PR.** The vault REST contract's
2362
+ `GET /api/export` (read-scoped) exists **only on the cloud Durable-Object vault**
2363
+ (`workers/vault/src/vault-do.ts` `handleExport` — ships attachment binaries as
2364
+ `.parachute/attachments/<id>/<file>` sidecars, a complete backup). **The self-hosted bun vault
2365
+ has no HTTP route for this at all** — its `routing.ts` dispatch table only knows `/notes`,
2366
+ `/tags`, `/vault`, `/storage`, `/find-path`, `/subscribe`, `/health`, `/unresolved-wikilinks`;
2367
+ export there is CLI-only (`parachute-vault export <dir>`). Rather than assume the door-agnostic
2368
+ premise, this page calls the real endpoint and lets the response decide: a self-host vault 404s,
2369
+ which the client surfaces as `VaultNotFoundError` — the page renders a specific note ("Export
2370
+ over the web isn't available on this vault yet…") with the CLI pointer, instead of the generic
2371
+ network-error copy. No client-side door-type detection (unreliable — a self-hosted hub can also
2372
+ be the app's "home door"); the behavior itself is the source of truth.
2373
+ - **`exportVault()`** added to `src/lib/vault/client.ts`'s `VaultClient` — reuses the base
2374
+ `@openparachute/surface-client` class's protected `requestBlobWithRetry` (the same
2375
+ auth/refresh/reachability/404 contract `fetchAttachmentBlob` rides) rather than reimplementing a
2376
+ retry loop; the only new part is the URL (`GET /api/export`).
2377
+ - **The download**: the resolved `Blob` triggers a real browser download via the object-URL +
2378
+ briefly-attached-anchor trick (`{vault-name}-export-{date}.tar`, date derived at click time).
2379
+ - **Surfaced as Import's sibling**: an "Export notes" row in `/account`'s Connections card
2380
+ (next to "Import notes," same row shape, a new `sun`-toned icon circle — `IconExport` in
2381
+ `NavIcons.tsx`, mirroring `IconImport`'s tray with the arrow reversed) and a matching
2382
+ `EXPORT_ITEM` in the "Your parachute" nav band (desktop Rail + mobile NavSheet share the one
2383
+ model, so both projections pick it up for free). Left untouched, deliberately: the SpeedDial
2384
+ capture menu, the fresh-vault `QuickDoors` tiles, and the SET UP checklist — all three are
2385
+ onboarding/capture verbs ("get content in"), not a fit for "get your data out."
2386
+ - Tests: `Export.test.tsx` (renders, no-vault redirect, the authed GET + download trigger, the 404
2387
+ vs. network-error distinction) + updated exact-list assertions in `model.test.tsx`,
2388
+ `Rail.test.tsx`, and `Account.test.tsx` for the new nav item / Connections row.
2389
+
2390
+ ## [0.15.1] - 2026-07-12
2391
+
2392
+ **LZ-6 — lens wave close: docs, the stale-"Today" sweep, cruft removal.** The final PR of the
2393
+ Lens-Model wave (LENS-SPEC §7). No runtime behavior change — docs + comment hygiene + repo
2394
+ cleanup. Patch bump.
2395
+
2396
+ - **`NAVIGATION.md`** gains one decision-table row (LENS-SPEC §2): the All-lens filter writeback
2397
+ (`setSearchParams(…, { replace: true })` — VaultSurface mirroring the active search/tag filters
2398
+ into `?search=&tag=…` as they change) is **replace** — state mirroring, not a place change.
2399
+ - **`STYLE.md`** documents the width token `--w-surface: 52rem` (added in LZ-3): the ONE width the
2400
+ unified `VaultSurface` uses, between `--w-prose` (42rem) and `--w-page` (72rem). DayView + the
2401
+ other rooms keep their own widths.
2402
+ - **The stale-"Today" sweep** (LENS-SPEC §6 — the name "Today" died with the lens model): renamed
2403
+ the room-name references to the home surface (now the Recent lens) across code comments, one CSS
2404
+ comment, and test titles/fixtures — the WizardShell wordmark comment, `note-title`/`NoteRow`/
2405
+ `RecentTimeline`/`queries`/`use-summary` comments, the `.note-row` CSS comment, the `NoteRow`
2406
+ parity test (incl. the `todayRow`→`recentRow` local), and AddVault's `/`-route test stub. Kept
2407
+ untouched: every **date-domain** "Today" (DayView's day labels + `/today?date=` route, Calendar's
2408
+ today-cell + button, Activity's Today/Yesterday grouping, `RecentTimeline`'s day-group labels,
2409
+ `events.ts` grouping label) and the **historical/explanatory** references that document the arc
2410
+ (`model.tsx` "what Today was", `Rail.test`/`quick-switch` "Today retired", `App.tsx` "formerly
2411
+ Today's route", the offline/groupNotesByDay history comments).
2412
+ - **`app-audit/` removed from the repo** — four review screenshots (`w3-textsize-shots/*.png`)
2413
+ accidentally committed earlier; deleted and `app-audit/` added to `.gitignore` so review
2414
+ screenshots can never be committed again.
2415
+
2416
+ ## [0.15.0] - 2026-07-12
2417
+
2418
+ **LZ-5 — mobile gets the lens model: the on-surface lens strip + the 3-slot bar.** LENS-SPEC §5
2419
+ (ratified D2): below `lg` the surface itself carries the lens set, and the bottom bar slims from
2420
+ four slots to three — one surface, one surface tab. Every lens is ≤2 taps on a phone (the strip
2421
+ chip is 1); capture stays 1 tap. Minor bump: the mobile IA change.
2422
+
2423
+ - **The lens strip** (`components/LensStrip.tsx`, §5.1): a horizontal chip row on the surface
2424
+ directly under the masthead, `lg:hidden` (at lg+ the desktop rail owns the lens set — rendering
2425
+ both would duplicate the vocabulary D2 rejected). The chips ARE the nav model's lens band —
2426
+ `useNavBands()`'s "notes" items projected, same ids/labels/hrefs/order as the Rail and NavSheet
2427
+ render (single source, the F14 no-drift lesson; pinned by a strip↔rail parity test). Active
2428
+ chip wears the §3 grass-soft pill by the model's own matcher; tapping a chip is a push
2429
+ navigation to the lens URL; the row scrolls horizontally if cramped and renders on every lens,
2430
+ so a phone can leave Pinned/Archive in one tap. This is LZ-3's parked desktop `PresetFilterBar`
2431
+ reborn — same spot in the anatomy, the model's vocabulary instead of its own five-view list.
2432
+ - **The bottom bar 4→3** (§5.2): **Notes · [+] · Search**. The LZ-2 interim Recent/Notes tab
2433
+ pair collapses into ONE surface tab — "Notes" → `/` (the Recent lens is the front door), lit
2434
+ across the whole surface via the new `matchVaultSurface` matcher (`/`, `/notes` in every
2435
+ `?view=` dress, and the /n/:id + /today drill-ins that stay under it). The `?view=pinned|archived`
2436
+ no-tab-lights gap of the 4-slot bar is resolved: the surface tab claims them; WHICH lens you're
2437
+ wearing is the strip's job, not the bar's. The centre [+] stays the raised capture disc → `/new`
2438
+ (unchanged size/behavior — capture speed sacred); Search stays the palette entry; the NavSheet
2439
+ still carries everything including the Explore band and YOUR PARACHUTE.
2440
+ - **One projection per viewport, extended** (the notes#147 contract): the strip joins the mobile
2441
+ side — Rail `hidden lg:flex` ≥lg; LensStrip + BottomTabBar `lg:hidden` <lg; never both, never
2442
+ `md:`. The contract test now pins the strip's gate and its band parity with the rail.
2443
+ - Desktop untouched: rail, NavSheet contents, Explore band, SpeedDial all exactly as LZ-4 left
2444
+ them.
2445
+
2446
+ ## [0.14.0] - 2026-07-11
2447
+
2448
+ **LZ-4 — Recent joins: `/` and `/notes` are one surface.** The centerpiece's second half
2449
+ (LENS-SPEC §1.1 + §3): BootGate's vault-active branches render `<VaultSurface lens="recent"/>`,
2450
+ `Home.tsx` dissolves into the surface and is deleted. The lens rail now truly navigates between
2451
+ lenses of ONE component — Recent · All · Pinned · Archive are dresses over the same VaultSurface.
2452
+ Minor bump: the surface unification.
2453
+
2454
+ - **The Recent lens** (§3 anatomy): vault masthead (shared with every lens) · the composer
2455
+ (Recent + All are the writing lenses, decision i; focus-warmed while the vault is fresh) · the
2456
+ Recent-only furniture · the lens label "RECENT · what you've touched lately" · the day-grouped
2457
+ `RecentTimeline` window. `VaultSurface` internally dispatches two bodies — the capped live
2458
+ window vs the paginated searchable query (decision ii: different data machinery, one surface).
2459
+ - **Archived notes drop OUT of Recent** (§1.1): Home used to show them dimmed in the timeline;
2460
+ Recent now filters them out entirely — archived means set aside, not "touched lately". The
2461
+ show-archived capability lives on the All lens (Filters panel), unchanged.
2462
+ - **The floor** (§1.1): Recent caps at the most recent **14 days or 100 notes, whichever comes
2463
+ first** (local calendar days back from today, sorted by the same touch stamp the timeline
2464
+ buckets by), with a quiet foot line — "Looking for older notes? All notes →" — naming the edge
2465
+ and carrying the old header's All-notes door. A vault with notes but none inside the window
2466
+ gets an honest dormant line instead of a false "empty vault" invitation. The cap is what makes
2467
+ Recent *mean* recent.
2468
+ - **Recent-only furniture, confined** (§3 item 3): TrialCountdownNudge, QuickDoors, SetupNudge,
2469
+ PlanBacklink, and the fresh-mode composer warmth relocate from Home verbatim — on the Recent
2470
+ lens exclusively. DESIGN-SPEC §3.1's "on Today only" ambience rule now reads "on the Recent
2471
+ lens only": the same four sanctioned places, no expansion (the All/Pinned/Archive bodies never
2472
+ even fire the account-summary fetch). Fresh-vault onboarding is identical to the old Home.
2473
+ - **`Home.tsx` deleted**; its tests migrate to `VaultSurface.recent(.offline).test.tsx` with
2474
+ nothing losing coverage. `/today` bare → `/` and `/today?date=` → DayView stay exactly as
2475
+ they were; BootGate's own logic (`?add=` shim, signed-out Landing, session check, net-error)
2476
+ is untouched; VaultSurface stays the one eager FCP chunk for both doors.
2477
+ - **Remount honesty** (§3.2, accepted): `/`=BootGate vs `/notes`=VaultSurface are different
2478
+ element types, so a Recent↔All switch remounts — the composer restores its draft synchronously
2479
+ at mount, the LZ-1 blur/unmount flush protects mid-type switches, and the lists paint from the
2480
+ react-query cache. Covered by a test that types on Recent, switches to All, and finds the words
2481
+ intact.
2482
+
2483
+ ## [0.13.0] - 2026-07-11
2484
+
2485
+ **LZ-3 — one surface at `/notes`: Notes becomes the VaultSurface.** The centerpiece's first half
2486
+ (LENS-SPEC §3): `/notes` is no longer a "Notes room" with its own headline — it's THE surface over
2487
+ the vault, wearing the lens the rail picked. The vault name leads as the serif masthead on every
2488
+ lens; the composer rides the writing lens; the lens is a quiet label over the list, never a
2489
+ headline. `/` still renders the old Home this PR (Recent joins the surface in LZ-4). Minor bump:
2490
+ surface rebuild.
2491
+
2492
+ - **`Notes.tsx` → `VaultSurface.tsx`** (git-mv for history — the VaultPopover→VaultSwitcher
2493
+ precedent); component `Notes` → `VaultSurface`, still eager-loaded, lens derived from `?view=`
2494
+ (`all` default · `pinned` · `archived` · the `untagged`/`orphaned` maintenance sub-views). Every
2495
+ old URL — `/notes?view=…`, saved-view links (`?search=…&tag=…`), the `/pinned`-era shims —
2496
+ resolves exactly as before. Exported type `NotesPreset` renamed `VaultView`.
2497
+ - **The vault masthead** (§3 anatomy 1, every lens): the vault-name serif H1 + "Everything here is
2498
+ yours. Open format. Export anytime." — lifted from Home's masthead pattern, replacing the
2499
+ "All notes" H1. The vault is the identity; the lens is a label.
2500
+ - **Composer on the writing lens** (§3 anatomy 2; ratified decision i): LZ-1's extracted
2501
+ `<Composer>` mounts under the masthead on the **All lens only** for now (Recent joins in LZ-4).
2502
+ Pinned/Archive are browse lenses and the maintenance sub-views are triage — no composer on any
2503
+ `?view=`. Keyed by vault id (the draft-clobber guard, same as Home).
2504
+ - **Lens labels** (§3 anatomy 5): a sage eyebrow + quiet hint over the list — "ALL NOTES ·
2505
+ everything, searchable" / "PINNED · starred" / "ARCHIVE · set aside, never deleted" /
2506
+ "UNTAGGED · notes without any tags" / "ORPHANED · notes with no links" — replacing the
2507
+ SectionLabel title+count (the pager's "Showing m–n" still carries the numbers). Display label
2508
+ "Archive" (the param stays `view=archived`).
2509
+ - **Desktop chip row retired; maintenance views fold into Filters** (§1, §3 anatomy 4): the
2510
+ resting `PresetFilterBar` (VIEWS: All·Pinned·Archived·Untagged·Orphaned) no longer renders —
2511
+ the rail owns the lens set, and the chips duplicated Pinned/Archive on every desktop paint.
2512
+ Untagged/Orphaned move INTO the Filters panel's Refine column as a "Show only: Untagged ·
2513
+ Orphaned" row — same `?view=` URLs, quick-tag trailing control intact, the active chip links
2514
+ back to `/notes` so the filter toggles off. `PresetFilterBar` stays exported-but-unrendered:
2515
+ LENS-SPEC §5 rebirths it as the below-`lg` mobile lens strip in LZ-5.
2516
+ - **One width** (§3, `[spec-resolved]`): new token `--w-surface: 52rem` + a `.page-surface`
2517
+ wrapper — the surface sits between prose (42) and the old page (72). The All-lens Filters panel
2518
+ drops `md:grid-cols-3` → `md:grid-cols-2` (three columns are too cramped at 52rem — the
2519
+ sanctioned builder-discretion call, flagged for the [F] design review).
2520
+ - **Capability preserved end-to-end**: search, the Filters disclosure + count badge, sort,
2521
+ show-archived, path prefix, tags + pinned-tags + match mode, saved views (save/rename/update/
2522
+ delete + link hydration), the lazy Folders tree (no eager fetch), pagination, the untagged
2523
+ quick-tag control, Pinned/Archive's reduced chrome, the empty-vault calm arrival (now with the
2524
+ composer as the writing invitation). NoteRow untouched.
2525
+ - Tests move with the rename (`VaultSurface.test.tsx` + saved-views + offline) and the resting
2526
+ census re-baselines to search + Filters (2 controls); new coverage: masthead-on-every-lens,
2527
+ composer on/off per lens, `?view=` URL derivation, the Show-only row + its toggle-off.
2528
+
2529
+ ## [0.12.0] - 2026-07-11
2530
+
2531
+ **LZ-2 — the lens rail: YOUR NOTES becomes the lens set, EXPLORE holds the destinations.** The
2532
+ nav-model half of the Lens-Model one-surface pivot (LENS-SPEC §4): the vault is ONE surface and
2533
+ **Recent · All notes · Pinned · Archive** are lenses over it, split from the **Explore**
2534
+ destinations (Calendar · Tags · Activity · Map-earned). Every lens target is an EXISTING route
2535
+ (§2's zero-migration URL scheme — `/`, `/notes`, `/notes?view=pinned|archived`), so the rail is
2536
+ correct and every room reachable even before the LZ-3/LZ-4 surface merge. Minor bump: nav IA
2537
+ change.
2538
+
2539
+ - **`match` grows a search dimension** (`src/lib/nav/model.tsx`): `NavItem.match` takes
2540
+ `{ pathname, search }` (the new `NavLocation`) instead of a bare pathname — the Pinned/Archive
2541
+ lenses live in the `?view=` param. All three projections (Rail, NavSheet, BottomTabBar) pass the
2542
+ router location through; pathname-only rooms wrap trivially via a `pathIs` helper.
2543
+ - **The lens band** (id `"notes"`, label "Your notes"): **Recent** → `/` (the old Today grammar —
2544
+ `/`, `/today`, `/n/*` stay under it: drill-ins inherit the lens you came from); **All notes** →
2545
+ `/notes` matching every dress EXCEPT `view=pinned|archived` (untagged/orphaned maintenance
2546
+ filters and search/tag params highlight All); **Pinned** → `/notes?view=pinned`; **Archive** →
2547
+ `/notes?view=archived`. No counts on any lens in v1 (§1.1 — All has no cheap total). New
2548
+ NavIcons: clock (Recent), star (Pinned), lidded box (Archive); All notes keeps the notes glyph.
2549
+ - **The EXPLORE band** (id `"explore"`, label "Explore" — ratified D3): Calendar · Tags · Activity ·
2550
+ Map, items and the earned-Map gate byte-identical, just moved out of YOUR NOTES into their own
2551
+ band. `NavBand.id` union gains `"explore"`.
2552
+ - **Bottom bar interim relabel** (§5.3): the 4-slot bar stays until LZ-5's 3-slot redesign, but the
2553
+ "Today" tab reads **Recent** now (clock icon, the Recent lens's matcher) so the tab and the rail
2554
+ never disagree about what `/` is called (the F14 no-drift lesson). Sub-decision: the tab bar uses
2555
+ the model's matchers verbatim, so `/notes?view=pinned|archived` lights NO tab (those lenses
2556
+ aren't in the 4-slot set; the NavSheet carries them until LZ-5).
2557
+ - **Command palette** (`quick-switch/results.ts`): the "Today" command relabels to **Recent** —
2558
+ label only; id, target, and the `today`/`home` keywords unchanged, so muscle memory keeps
2559
+ working. All other rows (All notes, Pinned, Archived, Untagged, Orphaned) untouched.
2560
+ - **Tests** (1484 → 1494): the model suite pins the three-band shape plus an **active-state
2561
+ matrix** — exactly one item lights for each of `/`, `/notes`, `?view=pinned`, `?view=archived`,
2562
+ `?view=untagged` (All), `?search=…` (All), `/n/:id` (Recent), `/today?date=` (Recent),
2563
+ `/calendar`, `/map`, and `/all` (shim — none). The Rail↔NavSheet band-parity contract extends to
2564
+ the lens/Explore split and the lens hrefs; rendered-rail and sheet tests cover the search-aware
2565
+ aria-current states; the tab-bar suite covers the Recent relabel + the no-tab-on-Pinned interim.
2566
+
2567
+ ## [0.11.3] - 2026-07-11
2568
+
2569
+ **LZ-1 — extract the composer from Home into `components/Composer.tsx` (pure move, prep for the
2570
+ lens-model one-surface merge).** Structure-only refactor, zero behavior change and zero visual
2571
+ change: the ratified Lens-Model spec (LENS-SPEC.md §3.1 anatomy item 2) calls for the write-in-place
2572
+ composer to ride both the future Recent and All lenses once `Notes.tsx`/`Home.tsx` merge into one
2573
+ `VaultSurface` (LZ-3/LZ-4); this PR does the LZ-1 groundwork alone.
2574
+
2575
+ - **`src/components/Composer.tsx`** — the W2-10 composer `<form>` and all its logic, moved verbatim
2576
+ out of `Home.tsx` behind a clean `{ vault, focused? }` prop interface. Preserved exactly: the
2577
+ `NEW_NOTE_SCOPE` shared-draft wiring (`loadDraft`/`useDraftAutosave`), the **flush-on-blur guard**
2578
+ (the W2-10 review fold — blur flushes the debounced draft before any outside door's click can
2579
+ navigate away and drop the tail), the synchronous flush on the editor/mic links, the voice/mic
2580
+ gate (`useTranscriptionGate`), the `vault.id`-keyed remount, the save path
2581
+ (`buildTextNotePayload` + `useCreateNote`), focus-expands-the-card, the calm post-save fold,
2582
+ "Save to {vault}", "Open full editor →", and "Autosaves to {vault}". `COMPOSER_INPUT_ID` is now
2583
+ exported so `Home`'s empty-state "Write the first one" button can still focus the composer in
2584
+ place.
2585
+ - **`Home.tsx`** now imports and renders `<Composer key={vault.id} vault={vault} focused={...} />`
2586
+ exactly where the inline composer used to sit — nothing else about Home changed (nudges,
2587
+ QuickDoors, RecentTimeline, PlanBacklink untouched).
2588
+ - **Tests**: the composer's own suite (9 tests — textarea/no-nav, focus-expand, save, save-error,
2589
+ shared-draft-to-/new, the flush-on-blur regression, restore-from-/new, mic arrival, transcription
2590
+ gate) moved out of `Home.test.tsx` into a new `Composer.test.tsx`, re-pointed at `<Composer>`
2591
+ directly. `Home.test.tsx` keeps the surrounding-chrome coverage (masthead, quick doors, setup
2592
+ nudge, trial ambience) exercised through `<Home>` end to end. Test count unchanged (1484).
2593
+
2594
+ ## [0.11.2] - 2026-07-11
2595
+
2596
+ **W3 — text-size popover stays on-screen on tablet.** `TextSizeControl`'s "aA" popover always
2597
+ opened downward (`mt-2`) and right-anchored (`right-0`). The control renders at the FOOT of every
2598
+ container it's mounted in — currently the mobile/tablet NavSheet's bottom-sheet foot (InstallPrompt
2599
+ · TextSizeControl · ThemeToggle) — so opening downward from a trigger already near the bottom of
2600
+ the screen pushed the 160px panel below the physical viewport. Live-reproduced pre-fix at two
2601
+ tablet viewports via Playwright: the popover's measured bounding box landed at `y=1188` (height
2602
+ 118) against an 1180px-tall viewport, and `x=-92` against the left edge — off-screen on BOTH axes.
2603
+
2604
+ - **Measure-and-flip** (`TextSizeControl.tsx`): on open, a `useLayoutEffect` reads the trigger's
2605
+ real on-screen position via `getBoundingClientRect()` (viewport-relative regardless of how many
2606
+ scrollable ancestors — e.g. the NavSheet sheet — sit in between) and the panel's own size, then:
2607
+ flips to `bottom-full mb-2` (upward) when there isn't room below, `mt-2` (downward, the original
2608
+ behavior) otherwise; clamps the panel horizontally via an inline `left`/`right:auto` override so
2609
+ neither edge can cross the viewport (an 8px margin on both axes). Recomputes on resize and on
2610
+ scroll (capturing listener — catches scroll on any nested scrollable ancestor) while the popover
2611
+ is open. `null` measurement state falls back to the original `right-0` anchor, so first paint is
2612
+ pixel-identical to before this fix in the common (non-clipping) case. Exposes `data-placement`
2613
+ for tests; everything else about the popover (click-outside close, the three size options, the
2614
+ `role="dialog"`/`aria-expanded` a11y, the "current" pill) is unchanged. No new transitions added,
2615
+ so no `prefers-reduced-motion` handling was needed.
2616
+ - Pinned with three new tests (`TextSizeControl.test.tsx`): opens upward when there's no room
2617
+ below (the diagnosed sheet-foot shape), stays downward with ample room, and clamps horizontally
2618
+ at a narrow/left-edge trigger position.
2619
+ - Verified with Playwright at 820×1180 and 768×1024 (tablet) against the real dev server + NavSheet:
2620
+ popover fully within the viewport at both sizes post-fix (screenshots in
2621
+ `app-audit/w3-textsize-shots/`).
2622
+
2623
+ ## [0.11.1] - 2026-07-11
2624
+
2625
+ **W2-12 — identity → "Parachute" + brand favicon (F17).** The surface manifest still called
2626
+ itself "Notes" at `/surface/notes`, and the browser tab/PWA-install icon was a placeholder green
2627
+ ring-and-dot titled "Parachute Notes" — never the real brand. Patch bump: metadata + assets only,
2628
+ no runtime logic changes.
2629
+
2630
+ - **`meta.json` identity flip**: `name` `"notes"` → `"parachute"`, `displayName` `"Notes"` →
2631
+ `"Parachute"`, `path` `"/surface/notes"` → `"/surface/parachute"`, tagline → "The Parachute app
2632
+ — your parachute's front door." `pwa`, `pwa_service_worker`, `scopes_required`, `iconUrl`,
2633
+ `required_schema`, `$schema`, and meta.json's own `version` are unchanged. This rename only
2634
+ affects **future** surface installs — an existing on-disk `notes` install keeps its mount
2635
+ unless the operator re-installs; an in-place upgrade without an explicit `mount_path` is
2636
+ covered by the corresponding hub-side alias (companion PR).
2637
+ - **`vite.config.ts`**: `serviceInfo.name` `"parachute-notes"` → `"parachute-app"` so the built
2638
+ `dist/.parachute/info` matches the hub's `manifestName` discovery contract. `DISPLAY_NAME` was
2639
+ already `"Parachute"`.
2640
+ - **Brand favicon** — replaced the generic green `#4a7c59` ring-and-dot (titled "Parachute
2641
+ Notes") with the real Parachute mark from parachute.computer (coral canopy, suspension lines,
2642
+ golden payload box): `public/icon.svg` now carries the site's `parachute-favicon.svg` (titled
2643
+ "Parachute"); `apple-touch-icon-180x180.png`, `pwa-192x192.png`, `pwa-512x512.png`, and
2644
+ `maskable-icon-512x512.png` are the site's matching rasters (the maskable variant reuses the
2645
+ 512 asset — the site itself ships no dedicated safe-zone-padded maskable icon); `pwa-64x64.png`
2646
+ is a fresh high-quality downscale (no 64px source existed); `favicon.ico` was regenerated as a
2647
+ proper multi-size (16/32/48) ICO from the same mark (was single-size 48×48 of the old green
2648
+ icon). `index.html` gained explicit `<link rel="icon">` (SVG + ICO) and
2649
+ `<link rel="apple-touch-icon">` tags — previously the tab icon relied entirely on the browser's
2650
+ implicit `/favicon.ico` fallback; the SVG link now wins in modern browsers. Verified Vite
2651
+ correctly base-prefixes these absolute-path `<link>` hrefs under `VITE_BASE_PATH` (e.g.
2652
+ `/surface/parachute/icon.svg`), matching the existing JS/CSS asset convention — so the mark
2653
+ renders whether the app is root-hosted or surface-mounted.
2654
+ - Rider: freshened stale `/surface/notes/` example paths in comments (`main.tsx`, `App.tsx`,
2655
+ `oauth.ts`) to `/surface/parachute/` — the mount-detection logic itself is unchanged
2656
+ (`base-url.ts`/`sw-bootstrap.ts`/`pwa-manifest.ts` are already mount-generic).
2657
+
2658
+ ## [0.11.0] - 2026-07-11
2659
+
2660
+ **W2-11 — one NoteRow + `/notes` progressive disclosure (F9, N3).** The same note used to render
2661
+ two different ways a tap apart: Today's timeline drew title·time·preview·chips in a bordered
2662
+ day-card, while `/notes` drew dot·star·title·time·preview·chips in a flat hover list — and `/notes`
2663
+ greeted you with an eight-control filter wall before showing a single note. One row now, one calm
2664
+ header. Minor bump: the two note surfaces unify.
2665
+
2666
+ - **One shared `NoteRow`** (`src/components/NoteRow.tsx`) — the single anatomy every list surface
2667
+ renders: **dot/status · (pinned ★) · title · mono-path (when it adds something) · preview ·
2668
+ time · chips**, with archived rows dimmed+italic. Consumed by Today's timeline
2669
+ (`RecentTimeline`), the day drill-in (`DayView`), and the `/notes` list; both old row
2670
+ implementations are deleted. A parity test pins the SAME note fixture to **byte-identical row
2671
+ markup** on Today and `/notes`. Role tags (pinned/archived) resolve once per list, not per row;
2672
+ the untagged view's quick-tag control rides an optional `trailing` slot.
2673
+ - **The row pattern, codified** — `.note-row` gains the design system's press state
2674
+ (`:active` → grass-soft; hover stays the card tint; selection is never an underline). Today's
2675
+ rows pick up the dot, pinned star, and archived dimming they were missing; the bordered
2676
+ day-card container gives way to the same flat list `/notes` uses (the prototype's home shape —
2677
+ day headers still group and link to the day view).
2678
+ - **`/notes` rests at three control groups** — search field · view chips · one **Filters**
2679
+ disclosure. Sort, show-archived, title-prefix, tag browsing (pinned quick-picks + browse-by-tag
2680
+ + any/all match), saved views, and the lazy Folders tree all fold into the Filters panel
2681
+ (`#notes-filters`, a card with Refine / Tags / Views-and-folders columns on desktop). The
2682
+ redundant standalone Tags checklist (`TagFilter`) merged into the panel's TagBrowser; the
2683
+ header's "New note" pill retired (the mobile [+] tab and the desktop speed dial are the create
2684
+ doors at every width; the empty state keeps "Create one").
2685
+ - **Nothing hides surprisingly** — the panel is closed on every arrival (state is deliberately
2686
+ not persisted), and a count badge on the closed Filters button shows how many folded filter
2687
+ dimensions are live (e.g. a deep-linked `?tag=…&path_prefix=…` arrives closed with "Filters · 2").
2688
+ - **A fresh empty `/notes` is an invitation, not a wall** — search + view chips + "This vault has
2689
+ no notes yet · Create one" only: no Filters disclosure, no pager, no filter chrome over nothing
2690
+ (WALK-nav N3). The pager also hides anywhere there's nothing to page.
2691
+ - **Width difference stays deliberate** — Today reads at `page-prose` (42rem), `/notes` manages at
2692
+ `page` (72rem); only the row anatomy rhymes.
2693
+ - Rider: fixed a pre-existing biome format error in `Home.test.tsx` (main was red on
2694
+ `biome check`).
2695
+
2696
+ ## [0.10.0] - 2026-07-11
2697
+
2698
+ **W2-10 — the honest composer on Today (F10).** Home's "What's on your mind?" card looked like an
2699
+ input but was a `<Link to="/new">` — the first tap yanked you to a different screen; you could not
2700
+ type where the affordance said you could. Now it's real. Minor bump: the home hero goes from a
2701
+ navigation trick to an actual capture surface.
2702
+
2703
+ - **A real expanding textarea** (`Home.tsx` Composer): focus blooms the card open (200ms min-height
2704
+ ease, behind `prefers-reduced-motion`), the box auto-grows with the text, and typing happens in
2705
+ place. Resting anatomy unchanged — placeholder line, quiet "Autosaves to {vault}" note, the mic
2706
+ disc bottom-right.
2707
+ - **One shared draft with `/new`** — typing autosaves (debounced) into the SAME per-vault draft
2708
+ store the full editor reads (`NEW_NOTE_SCOPE`, notes#175 machinery — no second draft mechanism).
2709
+ A thought started on Today greets you on `/new`, and vice versa; the "Open full editor →" escape
2710
+ therefore costs nothing. Round-trip pinned end-to-end in NoteNew.test.
2711
+ - **No dropped tail on any hop (review fold)** — the debounced write is flushed synchronously on
2712
+ the composer's **blur** (fires on pointerdown, before ANY outside door — the mobile "+",
2713
+ speed-dial, palette, setup-nudge — navigates to `/new`, whose render-phase draft read would
2714
+ otherwise beat an unmount-time flush and lose the just-typed tail; worst case the whole note,
2715
+ since the autosave debounce re-arms on every keystroke). Per-link `onClick` flushes stay as
2716
+ belt-and-suspenders; a regression test pins the blur path.
2717
+ - **"Save to {vault}" without leaving Today**: commits through the same path NoteNew's text save
2718
+ uses — new shared `buildTextNotePayload` (`src/lib/capture/text-note.ts`: capture role tag +
2719
+ `#hashtag` extraction + `metadata.source: "text"`), `useCreateNote`, fire-and-forget
2720
+ `ensureNotesSchema`. No navigation: the composer clears, a quiet toast confirms, and the note
2721
+ settles into the recent list (`useCreateNote` now also invalidates `notesForDateViews` so
2722
+ Today/Calendar/Activity see creates immediately). A failed save keeps the words + says why.
2723
+ ⌘/Ctrl-⏎ saves.
2724
+ - **Mic → the W2-9 voice arrival** (`/new?voice=1`, recorder auto-starts once the capability gate
2725
+ settles), behind the same transcription gate as `/new`: an explicitly-disabled vault gets no mic
2726
+ and the honest two-door line instead (`VoiceUnavailableNote` extracted to `src/components/` —
2727
+ shared, not duplicated).
2728
+ - The empty-vault "Write the first one" CTA now focuses the composer in place instead of hopping
2729
+ to `/new` — the affordance and the action finally agree. Vault switches mid-compose remount the
2730
+ composer keyed by vault id (the notes#175 draft-clobber guard).
2731
+
2732
+ ## [0.9.0] - 2026-07-11
2733
+
2734
+ **W2-9 — speed-dial + command-palette presentation (adopt #5 #6).** The prototype's two
2735
+ capture/navigation affordances land in the app's language — honestly (no fake AI). Minor bump: two
2736
+ new visual surfaces (the desktop SpeedDial; the palette's bloom/sheet presentation).
2737
+
2738
+ - **Desktop speed-dial** (`SpeedDial.tsx`, prototype shot 15): a floating coral "+" disc, top-right,
2739
+ that expands DOWNWARD into three verbs — **New note** → `/new` · **Voice note** → `/new?voice=1`
2740
+ (lands IN voice capture, no extra tap) · **Import notes** → `/import`. Label pills beside forest
2741
+ icon discs; Escape/click-outside/route-change close; springy hover behind
2742
+ `prefers-reduced-motion`. **Desktop ≥lg only** — the mobile [+] still hops straight to `/new`
2743
+ (the breakpoint contract test pins both sides). Hidden under ceremonies (§4.1 rule 5) and on
2744
+ `/new` itself; top-right placement stays clear of the Map FAB (bottom-right) and the palette pill
2745
+ (bottom-centre).
2746
+ - **`/new?voice=1` voice arrival:** the create surface auto-starts the recorder once the
2747
+ transcription-capability gate has an ANSWER — never during the pending window (an auto-fired mic
2748
+ toward "_Transcription unavailable._" would be the product lying). New `useTranscriptionGate()`
2749
+ (capability + `settled`) backs it; `useTranscriptionCapability()` is unchanged for render gates.
2750
+ - **Command palette, restyled** (`QuickSwitch.tsx`, prototype shot 13) — same results engine
2751
+ (merged commands + notes + tags, same ranking, same keyboard nav), new presentation:
2752
+ - **Desktop:** a bottom-centre glass pill (`.glass-panel`, shadow grows `soft→lift` on focus);
2753
+ the result panel **blooms upward** above it (`--radius-2xl` + `--shadow-lift`).
2754
+ - **Mobile:** a full-screen sheet from the Search tab — pill row up top with an explicit Cancel,
2755
+ results filling the screen.
2756
+ - Opens from all three doors — rail Search, ⌘K, mobile Search tab (each pinned by a test).
2757
+ - **The honest "Smart" slot:** the pill's right side RESERVES space for a future Smart toggle as a
2758
+ clearly-inert placeholder (a dimmed `aria-hidden` span — not a control). The prototype's
2759
+ "Smart search" AI-prompt rows are mocked and the app has no ask-AI endpoint; shipping fake
2760
+ prompts violates the honesty rule (DESIGN-SPEC W2-9 [spec-resolved], §6-A2 owns the future
2761
+ toggle).
2762
+ - Ceremony-route gate (`CEREMONY_ROUTES`/`isCeremonyPath`) re-homed from `App.tsx` to
2763
+ `@/lib/nav/model` so chrome components share one list. New `IconPen`/`IconMic` glyphs.
2764
+
2765
+ ## [0.8.0] - 2026-07-11
2766
+
2767
+ **W2-8 — `/account` "Your parachute" + trial ambience (F4 full / WALK-manager #1).** The manager
2768
+ half gets a real home: the account page rebuilds into the prototype-language four-card stack, the
2769
+ plan can never silently vanish again, and the trial surfaces ambiently in exactly the four
2770
+ sanctioned places. Minor bump: a full surface rebuild plus a client-contract addition
2771
+ (`getAccountSummaryState`).
2772
+
2773
+ - **`/account` rebuilt as "Your parachute"** (DESIGN-SPEC §3.1): H1 + sub-line, no breadcrumb (a
2774
+ primary-nav room, F11), four cards at prose width — **Identity** (`SIGNED IN AS` eyebrow, email
2775
+ in Fraunces, inline plan chip: sun-soft trial countdown / quiet plan label / none on failure,
2776
+ the quiet Sign-out ghost), **Plan & billing**, **Your vaults**, **Connections**. The signed-out
2777
+ "This device" view is unchanged.
2778
+ - **The five Plan & billing states, all designed** — loading (skeleton lines) · billing-disabled
2779
+ (card absent) · **summary-fetch-FAILED (the card's own retry state: "Couldn't load your plan." ·
2780
+ "A hiccup reaching your account — your plan hasn't changed." · pill Retry that recovers in
2781
+ place)** · trial/free (current-plan line + interval picker + plan cards) · paid (portal pill).
2782
+ Previously a failed `GET /account/summary` silently removed ALL plan information from the app
2783
+ with no symptom and no retry (WALK-manager #1, `desktop-11-null-summary-account.png`).
2784
+ - **Failed ≠ absent — the tri-state summary seam.** New `getAccountSummaryState()` (client.ts):
2785
+ 200 → summary, **404/501 → `null`** (the door honestly serves no summary — a hub's steady
2786
+ state; the card is absent, never a retry that can't succeed), **anything transient → `"error"`**
2787
+ (network/5xx → the retry card). Ambient consumers read through the new `summaryOrNull()` helper
2788
+ over the shared hook (a chip collapses both `null` and `"error"` to no-chip); the old
2789
+ `getAccountSummary()` is removed.
2790
+ The shared `useAccountSummary()` hook now carries the tri-state (error answers are never cached:
2791
+ `staleTime` 0 on `"error"`), and `/account` consumes the same cached query as the switcher and
2792
+ the nav badge.
2793
+ - **Honest checkout errors:** a 400 `invalid_plan`/`invalid_interval`/`invalid_tier` now reads
2794
+ "That plan isn't offered on this cycle — pick another." — never "Billing isn't available right
2795
+ now." for a plan-shaped 400 (that line is reserved for real unavailability: 503/unknown).
2796
+ Billing errors render as the visible danger-soft line, not a dim gray whisper.
2797
+ - **Honest per-interval price lines** (decision a): each plan card lists every cycle it actually
2798
+ sells from the door's own labels — Entry reads "$3/quarter · $10/yr — about $1/mo", with the
2799
+ "about" equivalence only for tiers with no monthly cycle. Interval-picker mechanics unchanged
2800
+ (PR #11); this is the §3.1 restyle around them.
2801
+ - **Vaults card:** "n of m on your plan" meter (only when the summary carries both numbers),
2802
+ glyph-circle rows (initial in grass-soft · name in Fraunces · mono address · usage · "Open →"
2803
+ pill), the failure retry card kept, and the foot collapsed to **one verb** "+ Add a vault" →
2804
+ `/add-vault` (the chooser holds the create/connect fork; the old two-verb foot and the "All on
2805
+ this device →" header link retire — the rail/sheet's Vaults row carries that door now).
2806
+ - **Connections card:** two icon-in-soft-circle rows — "Connect your AI" → `/connect`, "Import
2807
+ notes" → `/import`; with no active vault the AI row dims with "Open a vault above to connect an
2808
+ AI to it."
2809
+ - **Trial ambience — exactly the four sanctioned places (decision b), nowhere else:** (1) the
2810
+ switcher foot line (W2-4, verified), (2) Home's `PlanBacklink` becomes "Free trial · N days
2811
+ left · Manage your account →" while trialing, (3) the rail/sheet "Account & plan" badge (W2-5,
2812
+ verified — now reads through the tri-state), (4) **the Today countdown nudge, only at
2813
+ `trial_days_left ≤ 7`**: a sun row under the composer — "Your trial ends in N days — see
2814
+ plans →" → `/account`. Not dismissible, never a modal, never on any other page.
2815
+
2816
+ ## [0.7.1] - 2026-07-11
2817
+
2818
+ **W2-7 — route renames with shims: `/all`→`/notes`, `/graph`→`/map` (F16).** Label–URL agreement:
2819
+ the nav rows have said "Notes" and "Map" since W2-5; the addresses now match. Patch bump: additive
2820
+ route-table change — every old bookmark still resolves, nothing existing breaks.
2821
+
2822
+ - **`/notes` is now the canonical Notes room** (`/all` becomes a `replace` shim, preserving the
2823
+ query string — `/all?view=pinned` lands on `/notes?view=pinned`). **`/map` is now the canonical
2824
+ Map room** (`/graph` becomes the same kind of query-preserving `replace` shim). New shared
2825
+ `App.tsx` helper `ShimPreservingQuery` implements both. The `/pinned`/`/archived`/`/untagged`/
2826
+ `/orphaned` view-shims retarget from `/all?view=` to `/notes?view=`.
2827
+ - **Mount-detection fix (review fold):** the legacy notes-daemon mount pattern in `base-url.ts`
2828
+ matched a bare `/notes` as well as `/notes/…`; a bare `/notes` now falls through to the root mount,
2829
+ so a hard load / refresh / share of the new `/notes` route on the root-hosted deploy renders the
2830
+ Notes list (not Home) and keeps `?view=`. `/notes/` and deep legacy routes still detect the mount.
2831
+ - Command-palette row label "Graph" → **"Map"** (was still drifting from the rail/sheet label).
2832
+ - **`src/lib/nav/model.tsx`'s `NOTES_TO`/`MAP_TO`/`matchNotes` flip to the new addresses** — the
2833
+ Rail, NavSheet, and BottomTabBar all consume these, so no component-level drift was possible.
2834
+ Every other inbound link (`AmbientMapFab`, the command palette's rows and tag-jump, `Notes.tsx`'s
2835
+ own `?view=`/saved-view/tag links, `NoteView`/`NoteEditor`/`NoteNew`/`Import`/`Home`/`Tags`'s back-
2836
+ and tag-links) retargets to `/notes`/`/map`.
2837
+ Zero stray `/all`/`/graph` references remain outside the App.tsx shim definitions (and the tests
2838
+ that exercise those shims directly).
2839
+ - **Route-order guard:** `/notes` is registered ahead of the `/:id` bare-path bookmark shim, so a
2840
+ note literally named "notes" is reachable only at `/n/notes` — the same accepted tradeoff as the
2841
+ ceremony denylist (React Router's ranked matching already prefers static segments over `/:id`
2842
+ regardless of declaration order; a regression test pins it).
2843
+ - **`NAVIGATION.md`** updated: the redirect-shims row drops its "pre-W2-7" placeholders now that
2844
+ the rename has shipped, and retargets the view-shims to `/notes?view=`.
2845
+
2846
+ ## [0.7.0] - 2026-07-11
2847
+
2848
+ **W2-6 — wizard chrome + stepped creation + activation honesty (F6 full / F7-ceremony /
2849
+ WALK-manager #2 full).** One ceremony shell with an escape on every step, the creation flow gets
2850
+ real URLs, and creating a vault stops silently switching you into it. Minor bump: the route table
2851
+ gains `/add-vault/create` + `/add-vault/ready` and `createHostedVault()` changes contract
2852
+ (mints only — no local store writes).
2853
+
2854
+ - **New `src/components/WizardShell.tsx` (DESIGN-SPEC §4.1, verbatim contract)** — the ONE
2855
+ full-screen ceremony chrome. The four duplicated local `Shell` components (Welcome,
2856
+ AddVaultChooser, AddVault, OAuthCallback) collapse into it. Rules enforced by the shell:
2857
+ wordmark is always a link; a quiet escape on every step (`escape: none` legal only for <3s
2858
+ auto-advancing beats — SigningIn, WelcomeBack, OAuthCallback's working beat, the creating
2859
+ tick); 3-segment progress (`Name · Making it · Ready`) renders ONLY on the creation ceremony;
2860
+ no spinner, ever. **Every escape is history-aware** (`useHistoryAwareBack`, W2-2's hook):
2861
+ "← Back"/"Maybe later" land wherever the person actually came from, degrading to a named
2862
+ fallback on a deep link — never a forward push-loop, never off-app. "← Back" sits in the top
2863
+ strip; "Maybe later" renders under the content (the prototype's "Skip for now" placement).
2864
+ - **Stepped creation URLs (§4.2), new `src/app/routes/AddVaultCreate.tsx`:** the naming form
2865
+ lives at `/add-vault/create` (`?first=1` = onboarding copy, reached from /welcome's
2866
+ first-vault branch); submit runs the creating beat **in-shell at the same URL** (a process,
2867
+ not a place); success **replaces** to `/add-vault/ready?vault=<name>` (consumes the naming
2868
+ entry); failure re-renders the form inline with the F12 friendly copy. Back from naming → the
2869
+ chooser; **Back from ready → the chooser** — the WALK-manager `desktop-33` stale-context repro
2870
+ is dead, locked by a real-BrowserRouter history-shape suite (`src/app/wizard-history.test.tsx`).
2871
+ - **THE CORRECTNESS FIX — activation honesty (`lib/account/hosted-vault.ts`):**
2872
+ `createHostedVault()` no longer composes `openHostedVault()` — it **mints only** (the
2873
+ account-side create call; no VaultRecord, no stored token, no active-vault switch; cloud's
2874
+ inline `vault_token` is deliberately discarded so "Maybe later" leaves zero unused credentials
2875
+ behind). The ready beat's **"Open {name} →"** is where activation actually happens
2876
+ (`openHostedVault` + the §4.4 "Now in {name}" toast + push to `/`); **"Maybe later"** (absent
2877
+ in `?first=1` onboarding) declines with no switch and no toast — every page behind the
2878
+ ceremony stays truthful. Returns the door's canonical vault name.
2879
+ - **`Welcome.tsx` slims to dispatcher + picker:** first-vault → replace
2880
+ `/add-vault/create?first=1`; welcome-back → auto-open beat → replace `/`; many → picker in
2881
+ place; `?new=1` → pure shim to `/add-vault/create` (old bookmarks keep working); `?pick=1`
2882
+ unchanged. The W2-2 param-keyed dispatch-resync guard is preserved.
2883
+ - **`CheckEmail.tsx`** gains its named escape — "← Back to sign in" (route-map row 27).
2884
+ - **Footer gating (§4.1 rule 5 / F21):** the App.tsx AGPL footer no longer renders under
2885
+ ceremony routes (route-list gate in `AppFooter`; `/` — Home or the marketing Landing — keeps
2886
+ it: the marketing front door's ecosystem footer stays deliberate).
2887
+ - **Inbound links retargeted:** the chooser's Create card, the switcher's "Create a vault" verb,
2888
+ the picker's "+ Create a new vault", and Account's two create links all point at
2889
+ `/add-vault/create` (no double-shim hop). `NAVIGATION.md` gains the new rows (creation
2890
+ success replace, ready-Open push, the `?new=1` shim) and every touched `navigate()` carries
2891
+ its rule citation.
2892
+ - Tests: new `WizardShell.test.tsx` (chrome rules, escape kinds, history-aware behavior,
2893
+ progress) + `AddVaultCreate.test.tsx` (both copy contexts, creating beat, replace-to-ready,
2894
+ **create-mints-only**, F12 copy, escapes) + `wizard-history.test.tsx` (the Back-shape proofs,
2895
+ BrowserRouter) + hosted-vault mints-only unit tests + App footer-gating tests; Welcome /
2896
+ chooser / CheckEmail / AddVault / switcher suites updated to the new shape.
2897
+ - **Closes F6 (full), F7 (ceremony half), WALK-manager #2 (fully).**
2898
+
2899
+ ## [0.6.0] - 2026-07-11
2900
+
2901
+ **W2-5 — two-zone rail + mobile NavSheet: the IA centerpiece (F14 / F15-structure / F21-header /
2902
+ adopt #4 #9 #12).** "Your notes" and "Your parachute" become NAMED zones, rendered on both form
2903
+ factors from ONE nav model. Minor bump: the navigation IA changes shape (labels, zones, the mobile
2904
+ menu) — every room stays reachable, no route changes.
2905
+
2906
+ - **New shared nav model `src/lib/nav/model.tsx` (`useNavBands()`, DESIGN-SPEC §2.1)** — the single
2907
+ source both projections render. Bands: YOUR NOTES (Today · Notes · Calendar · Tags · Activity ·
2908
+ Map-once-earned) · YOUR PARACHUTE (Account & plan · Vaults · Connect AI · Import notes) · SET UP
2909
+ (incomplete guided steps + an "n of m" count, hidden once done/dismissed — no more persistent
2910
+ "You're all set" row) · foot (Settings). Neither projection owns a room list, so desktop and
2911
+ mobile can't disagree again (F14's root fix). Labels land now ("Notes", "Map"); the route
2912
+ renames (`/all`→`/notes`, `/graph`→`/map`) are W2-7's.
2913
+ - **Desktop rail rebuilt (§2.2):** two labeled bands (sage eyebrows), the VaultSwitcher on top
2914
+ (the hinge), Settings pinned at the foot (theme toggle keeps its spot), `glass-panel` ground.
2915
+ **Collapsible to a 64px icon rail** — icons only, tooltips carry labels, the switcher shows the
2916
+ vault-initial glyph; width animates 300ms (reduced-motion aware); persisted in
2917
+ `localStorage("parachute.rail-collapsed")`. Calendar is promoted into the notes band (desktop
2918
+ reaches it from the nav for the first time); Home's W2-3 stopgap Calendar link retires.
2919
+ Account leaves the foot for the parachute band as "Account & plan", carrying a quiet sun-soft
2920
+ trial chip ("5d") while trialing (§3.1 ambience slot 3; summary fetch only for home-door vaults).
2921
+ - **New mobile `NavSheet.tsx` (§2.3)** replaces the ☰ dropdown junk-drawer: a bottom sheet
2922
+ (max-h 85dvh, rounded top, glass over a scrim, drag handle, focus-trapped dialog; closes on
2923
+ scrim/Escape/swipe-down/route change) rendering **exactly the rail's bands in the rail's order**,
2924
+ with the switcher band inline at the top (§2.4 rows, not a nested popover) and the foot carrying
2925
+ Settings + the ☰ era's extras (InstallPrompt · TextSizeControl · ThemeToggle). **☰ and the
2926
+ header's vault pill both open this one sheet** (the pill lands on the switcher band) — one menu
2927
+ vocabulary. **Tags and Vaults become reachable on mobile for the first time** (F14).
2928
+ - **VaultSwitcher variants (§2.4 contract):** the panel (row model unchanged) extracts to an
2929
+ internal `SwitcherPanel`; `rail` keeps the trigger + popover, new `sheet` renders the rows
2930
+ inline in the NavSheet, and `header` owns no panel anymore — the pill delegates to the NavSheet.
2931
+ - **Map gate agreement (route-map row 11):** the Map nav row is earned-gated on BOTH projections;
2932
+ `AmbientMapFab` is the pre-earn door on both form factors and now hides on BOTH once earned
2933
+ (it used to linger on mobile).
2934
+ - **Header noise (F21, header part):** the "No vault connected" state line is gone; the bar shows
2935
+ the wordmark (no vault) or the pill + sync dot (vault).
2936
+ - **BottomTabBar** unchanged in shape (Today · Notes · [+] · Search; [+] stays a direct hop to
2937
+ `/new`) — its active-state matching now imports the model's shared rules instead of a copy.
2938
+ - **a11y (review fold):** the NavSheet focus-trap now pulls focus back in whenever it sits outside
2939
+ the sheet (the container itself or the scrim button) — Shift+Tab can no longer walk backwards out
2940
+ to the page behind the scrim. Route-change close and the trap-backwards guard both gain tests.
2941
+ - Tests: new `src/lib/nav/model.test.tsx` (zones/order/gates/chip/shelf) + `NavSheet.test.tsx`
2942
+ (bands, F14 reachability, active state, close gestures incl. route-change + trap-backwards,
2943
+ no-vault foot); the breakpoint contract extends with a **band-parity assertion** (Rail ≡
2944
+ NavSheet, unearned + earned) and the NavSheet's `lg:hidden` gate; Rail/Header/AmbientMapFab/
2945
+ VaultSwitcher suites updated to the new IA.
2946
+ - **Closes F14 (full), F15 (structure), F21 (header-noise part), adopt #4 #9 (nav rows) #12
2947
+ (shelf collapse).**
2948
+
2949
+ ## [0.5.3] - 2026-07-11
2950
+
2951
+ **W2-4 — vault switcher v2: the manager hinge (F2 full / F13 full / F4 ambient half / WALK-manager #2+#3).**
2952
+ `VaultPopover` becomes `VaultSwitcher` (file history kept) — the one canonical door for
2953
+ switch / open / create / connect, plan-aware and trial-aware (DESIGN-SPEC §2.4 + §3.2).
2954
+
2955
+ - **Three row sources, partitioned honestly:** ON THIS DEVICE (local vaults — ✓ current +
2956
+ switch rows) · IN YOUR ACCOUNT (the door account's hosted vaults **not on this device**,
2957
+ each with an "Open →" verb — F13's real fix: Open is scoped to exactly not-here, so it can
2958
+ never silently reopen the vault you're already in) · FROM YOUR HUB (hub-published,
2959
+ unconnected — the OAuth Connect door, unchanged). Account matching is by normalized URL
2960
+ plus a name-match for home-door records (slugs are the identity at the door); a hub entry
2961
+ sharing an account vault's URL collapses into the one-click Open row.
2962
+ - **Inline add-vault verbs (F2 full):** "+ Create a vault" (→ the create flow, today
2963
+ `/welcome?new=1`; W2-6 retargets to `/add-vault/create`) and "⌂ Connect your own" (→ `/add`)
2964
+ — the AddVaultChooser's cards inlined at the switcher's foot. `/add-vault` stays the
2965
+ deep-linkable page form; the switcher is the primary door.
2966
+ - **Plan-aware create (WALK-manager #3):** at the plan's vault limit the Create row renders
2967
+ the upsell — "N of N vaults on your plan — Upgrade →" → `/account` (sun-tinted circle) —
2968
+ so the 409 is unreachable from the switcher. Degrades to the plain Create row when no
2969
+ summary exists (self-host / signed out / fetch failed); the server still guards.
2970
+ - **Trial ambience (F4, decision b):** a quiet "Free trial · N days left" foot line →
2971
+ `/account`, only while the door reports `trial_days_left`.
2972
+ - **Switch confirmation everywhere (WALK-manager #2, §4.4):** new `switchVault(id, {toast})` +
2973
+ `announceVaultSwitch(label)` helpers (`src/lib/vault/switch.ts`) — every path that changes
2974
+ the active vault now toasts "Now in {vault}": switcher rows + account Open, `/vaults` Make
2975
+ active, the picker, the welcome-back auto-open, the creation ready-beat's Open, `/add`'s
2976
+ already-connected deep-link switch, and a fresh OAuth connect landing. Already-active
2977
+ no-ops don't announce (nothing switched).
2978
+ - **New shared `useAccountSummary()` hook** (`src/lib/account/use-summary.ts`) — one TanStack
2979
+ query (staleTime 5 min) for every plan/trial consumer; lazy (`enabled`), never gates first
2980
+ paint. The switcher enables it (and the account vault-list query) only while its panel is
2981
+ open. Future consumers: the rail badge (W2-5), `/account` + Today nudge (W2-8).
2982
+ - **§3.2 visuals:** panel is a `--radius-2xl` card with `--shadow-lift`; rows use the
2983
+ icon-in-soft-circle pattern (vault-initial glyph squares — grass-soft when on this device,
2984
+ neutral otherwise); sage-eyebrow section labels ("On this device" / "In your account" /
2985
+ "From your hub"); friendly error copy on a failed Open (F12 mapping, never a wire code).
2986
+ - Both entry points kept: the desktop rail's vault card and the mobile header's vault pill
2987
+ (NavSheet integration is W2-5). The dead `inline` variant is gone.
2988
+ - Tests: `VaultSwitcher.test.tsx` (evolved from `VaultPopover.test.tsx` — row-model partition
2989
+ incl. F13 scoping + name-match rules, at-limit upsell with Create absent, trial-line
2990
+ presence/absence, switch/Open toasts, verb navigation, all prior hub/OAuth coverage);
2991
+ new `switch.test.ts`; Make-active toast in `Vaults.test.tsx`; picker/welcome-back/ready-beat
2992
+ toasts in `Welcome.test.tsx`.
2993
+ - **Closes F2 (full), F13 (full), F4 (ambient half), WALK-manager #2 (confirmation half) + #3.**
2994
+
2995
+ ## [0.5.2] - 2026-07-11
2996
+
2997
+ **W2-3 — merge `/today` into `/`: one room, one name on both form factors (F8).**
2998
+ A structure PR (depends on W2-2). `/` (Home) and `/today`'s no-param timeline were near-duplicate
2999
+ rooms — both rendered `page-prose` + `useNotesForDateViews()` + the same `RecentTimeline`, and the
3000
+ desktop rail called the route "Today" while the mobile tab called it "Home". This PR folds the
3001
+ duplicate room away and wires the first real consumer of W2-2's `useHistoryAwareBack` hook.
3002
+
3003
+ - **`Today.tsx` → `DayView.tsx`.** The route (`/today`) no longer renders a front-door timeline —
3004
+ that duplicate is gone, absorbed into Home (nothing in Home changed except a new Calendar link,
3005
+ below). `/today` with no `?date=` is now a redirect shim → `/` (NAVIGATION.md: (a) shim, replace).
3006
+ `/today?date=YYYY-MM-DD` survives unchanged as the day drill-in, reached from Calendar cells and
3007
+ Home's day-header links. `DayView` moved from App.tsx's eager import set into the lazy-loaded set —
3008
+ it's no longer on the FCP-critical boot path now that the no-param case is a shim.
3009
+ - **`DayView`'s "← Back" is wired to `useHistoryAwareBack("/")`** (W2-2's hook, unwired until now) —
3010
+ goes back to wherever you actually came from (Calendar, another day, Home) when there's real
3011
+ history behind the entry, else falls back to `/`. The prev/next-day links, the "Today" jump link,
3012
+ and the invalid-date escape now all point at `/` directly (rather than round-tripping through the
3013
+ `/today` shim).
3014
+ - **Home gained a Calendar link in its header.** The rail carries no Calendar row yet (that's
3015
+ W2-5); Today's old timeline was the only desktop door to Calendar, so folding it away would have
3016
+ silently removed that reachability. Home's header now carries the link on both form factors.
3017
+ - **The mobile bottom tab now reads "Today", not "Home"** (`BottomTabBar.tsx`) — matching the
3018
+ desktop rail's label for the same `/` route (F8/F14: one room, one name everywhere).
3019
+ - Tests: `DayView.test.tsx` (renamed from `Today.test.tsx`) covers the `/today` shim (with and
3020
+ without an active vault) and the history-aware back button (via a real `BrowserRouter`, same
3021
+ pattern as `history.test.tsx` — a `MemoryRouter` can't drive `window.history.state.idx`);
3022
+ `Home.offline.test.tsx` (renamed from `Today.offline.test.tsx`) ports the error-over-data
3023
+ coverage to the room that now owns it; `groupNotesByDay`'s unit tests moved to a new
3024
+ `RecentTimeline.test.tsx` next to the function; `Home.test.tsx` gained the Calendar-link,
3025
+ day-header-hop, and genuinely-empty-vault cases; `BottomTabBar.test.tsx` updated for the label.
3026
+ - **Closes F8.**
3027
+
3028
+ ## [0.5.1] - 2026-07-11
3029
+
3030
+ **W2-2 — navigation history policy: Back no longer exits the app (F7 / DESIGN-SPEC §4.3).**
3031
+ A structure PR against the ratified push/replace decision table — no UI redesign, no wizard-
3032
+ chrome changes (those are W2-6). Fixes the specific "gratuitous replace" bugs that collapsed
3033
+ the browser history stack, and writes the policy down so future PRs have one table to check
3034
+ navigation calls against.
3035
+
3036
+ - **New `NAVIGATION.md`** (repo root) — the §4.3 decision table verbatim: user-initiated
3037
+ transitions push; `replace` is reserved for redirect shims, one-shot param consumption,
3038
+ transient auto-advancing beats, and the single post-auth landing. Documents the "accepted
3039
+ limit" (a magic-link tab's thin stack is a deliberate consequence of the table, not a bug —
3040
+ the cure is the wizard-chrome escape hatches, W2-6).
3041
+ - **New `useHistoryAwareBack(fallbackTo)` hook** (`src/lib/nav/history.ts`) — goes back in
3042
+ history when there's genuinely a prior in-app entry, else falls back to a named route. It
3043
+ keys off `window.history.state.idx` (react-router's monotonic entry index), *not*
3044
+ `location.key !== "default"`: a first-entry `replace` mints a fresh non-default key while
3045
+ leaving no real history behind, so a key-based check would let `navigate(-1)` step off-app
3046
+ (back to the email client) — the `idx > 0` check never does. No consumers yet (W2-3/W2-6's
3047
+ job); ships now so those PRs import rather than reinvent it.
3048
+ - **Fixed five gratuitous `replace`s → `push`** (F7 offenders, all named in PLAN.md): the
3049
+ post-auth "ready" beat's "Open my vault →" (Welcome.tsx), the vault picker's "Open →" and
3050
+ "+ Create a new vault" (Welcome.tsx), the already-signed-in card's "Open {vault} →"
3051
+ (Landing.tsx), and `/account`'s VaultsBlock "Open →" (Account.tsx). Each used to collapse
3052
+ the history stack on a user-initiated forward step; Back from Home now returns to the
3053
+ picker/chooser/account page instead of skipping straight past it.
3054
+ - **The `/welcome` dispatcher now re-syncs its picker/naming fork to the URL.** The one
3055
+ same-route param push (picker → "+ Create a new vault" → `/welcome?new=1`) exposed a stale
3056
+ guard: the dispatch effect keyed on the retry counter alone, so a browser POP back to the
3057
+ picker re-ran but bailed, stranding the naming form where the picker should have returned.
3058
+ The guard now keys on the retry counter *and* the URL params, so a param POP re-dispatches
3059
+ and the picker comes back (same fix for the `?pick=1` variant). The forward flow still skips
3060
+ the refetch (the vault list is already in hand).
3061
+ - **Fixed a gratuitous `push` → `replace`**: NoteNew's save (text and audio) and
3062
+ DeleteNoteButton's post-delete redirect now replace instead of pushing — Back from a
3063
+ freshly-saved note no longer lands on a cleared ghost draft, and Back after a delete no
3064
+ longer lands on the deleted note's dead `/n/<id>` view.
3065
+ - **The catch-all (`*` → `/`) now toasts** ("That page doesn't exist — brought you home.")
3066
+ instead of silently teleporting a typo'd/stale URL home with zero acknowledgment.
3067
+ - **Every navigation call this table governs carries a one-line `// NAVIGATION.md: ...`
3068
+ citation** — the ceremony/auth flow (Landing, CheckEmail, Welcome, AddVaultChooser,
3069
+ AddVault, OAuthCallback, Import, NoteNew, NoteEditor), the App.tsx route table (all shims
3070
+ + the catch-all + the boot `?add=` one-shot), and the ten "no active vault" route guards
3071
+ (Settings, NoteView, Today, Tags, Home, Activity, NoteEditor, Notes, VaultGraph, ConnectAI).
3072
+ - **Tests**: `useNavigationType()`-probe assertions (new `src/test/nav-probe.tsx` harness)
3073
+ pin push-vs-replace at the component level for every fixed call; a new
3074
+ `src/app/nav-history.test.tsx` drives the full `<App/>` (real `BrowserRouter`, real
3075
+ `window.history`) through both golden flows and asserts real `history.length` deltas,
3076
+ mirroring WALK-nav.md's own live-browser methodology.
3077
+
3078
+ ## [0.5.0] - 2026-07-11
3079
+
3080
+ **W2-1 — the hybrid skin: cream/sage palette + Fraunces/Figtree (DESIGN-SPEC
3081
+ §1, ratified decision c).** The visual foundation of the Wave-2 IA redesign.
3082
+ Value-edits to the existing token architecture — every token NAME and the
3083
+ component classes that read them are preserved; the app looks warmer/serif
3084
+ immediately but keeps its current structure (component restyling trails in
3085
+ later Wave-2 PRs).
3086
+
3087
+ - **Palette (light).** The blush paper becomes the prototype's cream/sage
3088
+ world: canvas `#fdfaf4→#fafaf6`, recessed `#f7f1e6→#f7f5ec`, card
3089
+ `#ffffff→#fefefa` (a breath off the canvas, not hard white); ink moves from
3090
+ warm brown to **forest** (`#2a2521→#233c2a`, 11.46:1) with sage-cast
3091
+ secondary text; hairlines go sage (`#e2d9c8→#d6e2d6`). **Coral stays the
3092
+ accent, unchanged** (`#bf4a2a` — 4.75:1 on the new canvas). The
3093
+ `.app-canvas` wash flips coral→grass (the "blush wash → misty sage morning"
3094
+ move, via the new `--canvas-wash` token: 7% light / 5% dark).
3095
+ - **Dark = "forest night" (net-new design).** The warm-brown night becomes
3096
+ the cream/sage world's complement: deep green-cast near-blacks
3097
+ (`#1a1917→#161d18` etc.), sage-tinted secondary text, the lightened coral
3098
+ `#ec7a5c` kept as accent (6.14:1 on the new ground). Same `--_d-*`
3099
+ single-source architecture, value edits only; both dark gates
3100
+ (`prefers-color-scheme` and `data-theme`) untouched structurally. The dark
3101
+ prose code/`.hljs` surfaces drop their stale warm-brown hex for
3102
+ `--color-bg-soft` so they ride the theme.
3103
+ - **The sage/sky naming fix.** `--color-sage` was a *blue* (== `--color-sky`)
3104
+ — a trap for anyone building "the sage world." The five inline
3105
+ `var(--color-sage)` call sites (Account, Welcome, CheckEmail, Landing ×2 —
3106
+ all blue side-door links) repoint to `var(--color-sky)`; `--color-sage` now
3107
+ means the prototype's **green** (`#527e5d` light / `#8fb096` dark). End
3108
+ state: sage = green, sky = blue, as any reader would assume.
3109
+ - **Type — Fraunces + Figtree, self-hosted.** `@fontsource-variable/fraunces`
3110
+ (opsz + opsz-italic — the optical-size axis is the display voice, and the
3111
+ `.accent-word` device needs real italics) + `@fontsource-variable/figtree`,
3112
+ bundled by Vite, `woff2` added to the workbox precache glob so type works
3113
+ offline (~344 KiB of fonts, within the spec's ~350 KB budget).
3114
+ `font-display: swap`; the tuned system stacks remain as fallbacks. The
3115
+ stacks keep their var names (`--font-serif`/`--font-sans`); `--font-round`
3116
+ retires to an alias of `--font-sans` — Figtree carries chrome.
3117
+ - **Shadows, radii, glass.** `--shadow-soft`/`-lift` retune to the
3118
+ prototype's sage-tinted negative-spread geometry; the `--shadow-sm/-md/-lg`
3119
+ ramp keeps its geometry with the tint moved warm-brown→forest. Radii:
3120
+ `--radius-lg` 10→12px, `--radius-xl` 14→16px, **new `--radius-2xl` 24px**;
3121
+ `.btn`/`.input`/`.select` become pills, `.textarea` → xl, `.card`/
3122
+ `.dialog-panel` → 2xl (dialog also steps up to `--shadow-lift`). New
3123
+ `.glass-panel` class (translucent cream over blur) for the coming
3124
+ rail/sheet/palette. `.btn-primary` gains the springy scale(1.02) hover
3125
+ lift behind `prefers-reduced-motion`. Button/input inline padding stepped
3126
+ up slightly so labels breathe inside the pill curve.
3127
+ - **Eyebrows.** `.eyebrow` moves `--color-fg-dim`→`--color-sage` (the
3128
+ prototype's in-app label rule); new `.eyebrow-accent` (coral) reserved for
3129
+ the marketing Landing.
3130
+ - **PWA manifest.** `background_color` `#fdfaf4→#fafaf6` (splash matches the
3131
+ new canvas); `theme_color` stays coral.
3132
+ - **Docs.** STYLE.md retuned to the hybrid tokens; documents `.accent-word`'s
3133
+ rule of use (one italic accent word per headline, serif headings only),
3134
+ `.glass-panel`, the sage/sky rename, and the eyebrow rule.
3135
+ - All 35 DESIGN-SPEC §1 contrast claims reproduced with the WCAG
3136
+ relative-luminance formula (script + table in the PR).
3137
+
3138
+ ## [0.4.3] - 2026-07-11
3139
+
3140
+ **Entry-billing story — the interval picker + honest pricing (F1 app half /
3141
+ F3, decision a).** Cloud now publishes per-interval pricing on each door plan
3142
+ (quarterly/yearly for Entry, all three cycles for Standard/Plus/Power); the
3143
+ app previously had no way to see or choose a cycle at all — `startCheckout`
3144
+ sent no `interval`, cloud defaulted to monthly, and Entry (which has no
3145
+ monthly Price — Stripe's flat per-transaction fee eats a $1 charge) 400'd
3146
+ every time, with the app folding that into a generic "Billing isn't available
3147
+ right now."
3148
+
3149
+ - **A segmented billing-interval picker** on the Account surface's upgrade
3150
+ cards (`UpgradePlans`, `Account.tsx`) — one global selector for the whole
3151
+ ladder (Monthly · Quarterly · Yearly), showing only cycles at least one
3152
+ offered tier actually sells, defaulting to the cheapest available. Entry
3153
+ (no monthly) shows a disabled placeholder + its own cheapest real cycle as
3154
+ a hint instead of ever presenting a button that would 400 — switching to
3155
+ Quarterly or Yearly enables it normally.
3156
+ - **Honest per-interval prices (F3).** Each card renders the door's own
3157
+ per-cycle label for the selected interval (e.g. "$3/quarter", "$10/yr")
3158
+ instead of a bare "$1/mo" that contradicted checkout. A descriptor with no
3159
+ `intervals` data (an older cloud, or a hub) degrades to the exact
3160
+ pre-existing `price_month` display and an interval-less checkout call —
3161
+ no picker, no behavior change.
3162
+ - **Landing copy (F3, decision a).** "From $1/mo after." → "Plans from $10 a
3163
+ year." — Entry no longer advertises a cycle it doesn't sell.
3164
+ - **Contract:** `DoorPlan.intervals?: Partial<Record<"monthly"|"quarterly"|
3165
+ "yearly", {available, price?, label?}>>` (`lib/account/types.ts`), additive
3166
+ and tolerant. `normalizeDescriptor` sanitizes each plan's `intervals`
3167
+ independently — a malformed cycle entry (or the whole block) is dropped
3168
+ per-plan, mirroring the existing `auth`/`plans` shape-guarding, never
3169
+ trusted wholesale.
3170
+
3171
+ ## [0.4.2] - 2026-07-11
3172
+
3173
+ **Navigation dead-ends — the add-vault chooser wired in, wizard escape
3174
+ hatches, friendly errors.** A cohesive set of "you can't get back" fixes from
3175
+ the owner's experiential audit (F2/F6/F12/F13/F18) — all redesign-independent;
3176
+ the coming IA rework (two-zone nav, route renames, the push/replace history
3177
+ policy) is separate and unaffected.
3178
+
3179
+ - **F2 — the orphaned add-vault chooser is wired in.** `/vaults`'s "Add vault"
3180
+ buttons (header + empty-state) pointed at `/add` — the **self-hosted**
3181
+ connect URL form, a dead-end for a cloud user with no path to "create" and
3182
+ no way back. They now open `/add-vault` (`AddVaultChooser.tsx`), the
3183
+ purpose-built Open/Create/Connect chooser that had **zero inbound links**
3184
+ until now. `/add` stays exactly what it was: the leaf the chooser's
3185
+ "Connect a self-hosted vault" card targets.
3186
+ - **F13 — the chooser's "Open" card no longer silently reopens your only
3187
+ vault.** With exactly one account vault, `/welcome`'s dispatcher auto-runs
3188
+ the welcome-back beat and opens the vault you're likely already in — "open a
3189
+ vault not on this device" was a no-op bounce. The card now links to
3190
+ `/welcome?pick=1`, which forces the picker regardless of vault count.
3191
+ - **F6 — the full-screen wizard screens have a way out.** The Wordmark
3192
+ (`ParachuteMark.tsx`) is now a real `<Link to="/">` everywhere it renders
3193
+ (Landing, Welcome, AddVault, AddVaultChooser, CheckEmail, OAuthCallback) —
3194
+ with no active vault, none of these screens had ANY other chrome, so the
3195
+ (often-broken) browser Back button was the only exit. The vault-naming
3196
+ form, the self-hosted connect form (`/add`), the add-vault chooser, and
3197
+ `/check-email` also gain an explicit quiet "← Back" beside it, each
3198
+ resolving to a sensible destination (the chooser, `/vaults`, or `/` — which
3199
+ itself degrades correctly whether or not a vault is already active on this
3200
+ device).
3201
+ - **F12 — vault-creation and vault-open failures read as prose, not a wire
3202
+ code.** Root cause: `client.ts`'s `jsonOrThrow` preferred the server's
3203
+ machine `error` code (`vault_limit_reached`) over its accompanying friendly
3204
+ `message` ("You've reached your plan's vault limit…") — a plan-limit hit
3205
+ rendered as a bare snake_case string. `jsonOrThrow` now prefers `message`.
3206
+ A new `lib/account/error-copy.ts` (`describeAccountError`) adds
3207
+ belt-and-suspenders mapping for the cases the server-message fix doesn't
3208
+ cover (a bare code with no message, or an unrecognized one) — used by the
3209
+ vault-naming form's creation error and the Account surface's
3210
+ `VaultsBlock.open` error.
3211
+ - **F18 — `STYLE.md` rewritten against the real palette.** It documented the
3212
+ retired forest-green identity (`#4a7c59`); `src/styles/index.css` has been
3213
+ the coral brand pass (`#bf4a2a`/`#e05d3c`, grass/sun/sage secondaries) for a
3214
+ while. Rewritten against the actual tokens + component classes (including
3215
+ the arrival/wizard classes added since — `.tile`, `.composer`, `.hero-title`,
3216
+ `.nudge-sun`, …) so nobody styles from the stale reference.
3217
+
3218
+ ## [0.4.1] - 2026-07-11
3219
+
3220
+ **Billing section — Stripe-direct, no cloud-console re-login (Plan A).**
3221
+ "Manage plan & billing" now goes STRAIGHT to Stripe instead of hopping to
3222
+ `cloud.parachute.computer/console` (a different origin, host-scoped cookies —
3223
+ that hop forced a re-login before the console's own second hop to Stripe).
3224
+ Pairs with cloud rc.74's new Bearer billing endpoints — deploy together.
3225
+
3226
+ - **`AccountSummary` gains `billing_enabled` + `has_billing_customer`, drops
3227
+ `manage_billing_url`** (types.ts). The Account surface's Billing section is
3228
+ gated purely on this data: `billing_enabled: false` (self-hosted hub / no
3229
+ Stripe configured) ⇒ the section doesn't exist at all — never a false door to
3230
+ a plan that isn't there.
3231
+ - **NEW `openBillingPortal()` / `startCheckout(tier, interval?)`** (client.ts) —
3232
+ Bearer-gated POSTs to cloud's `POST /account/billing/{portal,checkout}`,
3233
+ riding the same account-bearer plumbing (mint, cache, re-mint-once-on-401) as
3234
+ every other `/account/*` call. Both return `{ url }` on 200; a typed
3235
+ `BillingApiError` (with a `code`: `no_billing_customer` /
3236
+ `already_subscribed` / `invalid_tier` / `invalid_interval` / `invalid_plan` /
3237
+ `unconfigured`) on 409/400/503 so the UI can show a small inline message
3238
+ instead of crashing.
3239
+ - **The Billing section** (`Account.tsx`, replacing the old inline billing
3240
+ button): an **existing subscriber** (`has_billing_customer: true`) sees the
3241
+ current-plan line + a **"Manage plan & billing ↗"** button that calls
3242
+ `openBillingPortal()` and redirects straight to the returned Stripe URL
3243
+ (`window.location.assign` — cross-origin, so a top-level nav, not a
3244
+ fetch-follow). A **trial/free account** sees the door's upgrade ladder as
3245
+ plan cards (from `descriptor.plans`, the door descriptor — P4), current tier
3246
+ marked, each purchasable tier an **"Upgrade to \<name\>"** button that calls
3247
+ `startCheckout(tier)` and redirects the same way. The app renders plan DATA
3248
+ it's already handed and makes two typed redirect calls — zero Stripe
3249
+ knowledge, zero pricing math, zero cloud-origin imports.
3250
+ - **Identity card simplified**: "Signed in as X" + Sign out only — plan/billing
3251
+ now lives entirely in its own Billing section.
3252
+ - **Home's plan backlink now stays in-app.** The Home surface's quiet backlink
3253
+ (`PlanBacklink`) was the SAME cloud-console → re-login seam from the Home
3254
+ surface — it `href`-ed to `cloud.parachute.computer/console`. It now renders
3255
+ **"Manage your account →"** as an in-app react-router `Link to="/account"`
3256
+ (same origin, no re-login), shown only for home-door (account-minted) vaults
3257
+ — a foreign self-hosted vault has no account on this door, so no backlink.
3258
+ The `/account` surface (with the new Billing section) owns Manage-vs-Upgrade
3259
+ + the Stripe-direct hop from there.
3260
+ - **`src/lib/vault/console-url.ts` (and its test) removed** — the host-sniffed
3261
+ `manageBillingUrl` fallback and `Home`'s backlink were its only consumers;
3262
+ with both routed in-app, `cloudConsoleUrl` is dead and gone. The app now has
3263
+ **zero** cloud-console links (the only remaining `/console` reference is the
3264
+ service-worker navigation denylist, which forces server-owned ceremony paths
3265
+ past the SW to the origin — infrastructure, not a link).
3266
+ - **White-screen hardening (review folds)**: `normalizeDescriptor` now drops a
3267
+ malformed `plans` (non-array, or an array with a null/primitive element) the
3268
+ same way it drops a malformed `auth` — a door we don't control can't
3269
+ white-screen the Billing cards' `plans.map(p => p.id …)`. `billingResult`
3270
+ guards the `200 {url}` body (non-empty string required) so a contract-broken
3271
+ `200 {}` can't `assign(undefined)`. And a genuine session expiry (post-retry
3272
+ 401 → `SessionExpiredError`) now rides the app's existing session-ended
3273
+ handling (`markExpired` → the account session banner) instead of being masked
3274
+ as a generic billing message.
3275
+
3276
+ ## [0.4.0] - 2026-07-11
3277
+
3278
+ **Descriptor-driven, door-agnostic front door — HUB-PARITY P4.** The same app
3279
+ can now boot against a hub OR cloud by reading the door's descriptor at
3280
+ `GET /.well-known/parachute-account`; safe to ship pre-hub (cloud's descriptor
3281
+ without an `auth` block falls back to today's magic-link behavior byte-for-byte).
3282
+
3283
+ - **NEW `src/lib/account/descriptor.ts`** — `getDoorDescriptor()`, same-origin,
3284
+ public, memoized in-module + `sessionStorage` (`parachute:door-descriptor`)
3285
+ so boot pays one fetch. `null` on any non-200/network/parse failure. The app
3286
+ pins these shapes locally (its contract-of-record convention) — it does not
3287
+ import `door-contract`. A malformed `auth` block from a door we don't control
3288
+ (`methods` not a string array, `signin_path` not an absolute path, `auth: {}`)
3289
+ is DROPPED (→ magic-link fallback) rather than trusted, so an off-spec
3290
+ self-hosted descriptor can't white-screen the app or hop to `"undefined?next"`;
3291
+ an unrecognized-but-well-formed method (e.g. `passkey`) is kept so the app
3292
+ still hops to the door's own sign-in page (forward-compat).
3293
+ - **The front door's ONE door-conditional branch** (`Landing.tsx`): a
3294
+ `magic_link` door (or no descriptor) renders the existing email form,
3295
+ byte-unchanged; a password-only door renders a ceremony-hop card
3296
+ ("Sign in to your parachute." → **[Continue to sign in →]**) that hands off
3297
+ to the door's own sign-in page, mount-aware (`next` is prefixed with the
3298
+ app's runtime mount — e.g. `/app/welcome` under a hub — via the new
3299
+ `withMount` helper in `base-url.ts`; `signin_path` itself is never
3300
+ mount-prefixed, it's the door's own origin-rooted path). A password door
3301
+ with a `signup_path` gets a quiet "New here? Create your account →"; without
3302
+ one, "Accounts on this parachute are created by its operator." The
3303
+ self-hosted side door stays on both branches.
3304
+ - **Vault-address echo from `vault_url_template`** (`Welcome.tsx`'s naming
3305
+ form, shared by first-vault onboarding and the add-vault flow): once a door
3306
+ advertises a template, the live echo shows the real address
3307
+ (`{name}` substituted); falls back to the slug-only echo when absent —
3308
+ preview-only, post-creation addresses still come from the create/list
3309
+ responses.
3310
+ - **Hub-session tolerance**: `AccountSession` gains optional `username` +
3311
+ `password_change_required`. "Signed in as X" falls back `email ?? username`
3312
+ (Landing's already-signed-in card, Account's header). A `403
3313
+ {error:"force_change_password"}` (or a pre-empting
3314
+ `session.password_change_required`) on the account-token mint sets a
3315
+ non-blocking gate → a "Finish setting your password" banner
3316
+ (`HubGateBanner`, new in `AccountSessionBanner.tsx`) linking to
3317
+ `/account/change-password`; a `423` sets an "admin screen is locked" gate.
3318
+ Both are weather, never a wall — reading local notes is never gated.
3319
+
3320
+ ## [0.3.4] - 2026-07-10
3321
+
3322
+ Account surface polish (PR-2 review nits):
3323
+
3324
+ - **Vault-load failure ≠ empty.** A failed `GET /account/vaults` (transient 500 /
3325
+ session lapse) now renders a **"Couldn't load your vaults — Retry"** card, not
3326
+ the "create your first" empty-state (which could invite a duplicate vault). The
3327
+ empty-state is reserved for a genuinely empty list; the create/connect
3328
+ affordances are hidden on a load failure. Retry re-fetches just the list.
3329
+ - **No fabricated meter.** The plan's "N of M vaults" line renders only when the
3330
+ door gives BOTH `vaults_used` and `vault_limit` — a limit without a count no
3331
+ longer prints a false `0 of M`.
3332
+ - **Billing opens in a new tab** (`target="_blank" rel="noreferrer"`) so the app
3333
+ stays open when you pop out to the console — matching the "land right back
3334
+ here" intent.
3335
+
3336
+ ## [0.3.3] - 2026-07-10
3337
+
3338
+ **The Account surface — the app AS the manager** (SYNTHESIS "The shape"). The
3339
+ person lives in the app and drives their whole account through the account
3340
+ bearer; Cloud shrinks to the counter you visit only to sign up, pay, or change
3341
+ plan.
3342
+
3343
+ - **`/account`** (new route, in Rail foot + mobile menu): "Signed in as {email}"
3344
+ + a plan/usage line, your Cloud vaults (from `GET /account/vaults`, each with a
3345
+ `Cloud` chip + Open →, plus Create / Connect-self-hosted), and an AI-connections
3346
+ pointer. Door-agnostic (reads the account API at the serving origin) and
3347
+ **graceful**: no cloud door / signed out → a calm "this device" view (local
3348
+ vaults + connect), never a crash.
3349
+ - **`AccountSummary` canonical contract** (`GET /account/summary`,
3350
+ Bearer-gated `account:<id>:read`) + `getAccountSummary()` client through the
3351
+ same `bearerFetch`. **Seamed**: cloud may not have shipped it yet, so the
3352
+ plan/usage line renders only when present — never fabricated numbers. The
3353
+ `[Manage plan & billing →]` target prefers the door's `manage_billing_url` and
3354
+ falls back to the cloud console derived from a vault host (door-agnostic seam,
3355
+ TODO to derive purely from a door descriptor).
3356
+ - **Provenance vocabulary** (`vaultProvenance`): `Cloud` (home-door) vs
3357
+ `Self-hosted · host` (/add) chips — now on both the Account list (all `Cloud`)
3358
+ and `Vaults.tsx` (both kinds; the raw dev scope string is gone). Vault removal
3359
+ copy is honest: **"Remove from this device"** (notes stay in the vault).
3360
+ - **Settings** "Manage" now links **Account → in-app `/account`** (was a console
3361
+ bounce); the one true trip out (Stripe billing) lives behind Account's button.
3362
+ - Reviewer nits folded (same files): the account bearer's in-flight C2 mint is
3363
+ memoized (concurrent-C3 dedup — the Account screen fires listVaults +
3364
+ getAccountSummary at once); the Bearer layer drops `credentials:"include"` (C3
3365
+ reads only the header); a stale dead re-export in `hosted-vault.ts` removed.
3366
+
3367
+ ## [0.3.2] - 2026-07-10
3368
+
3369
+ **P0 wire fix — the account client now attaches the account bearer on every
3370
+ `/account/vaults*` call.** Verified against cloud's real source
3371
+ (`workers/identity/src/account-api.ts` + `account-auth.ts`): that surface (C3)
3372
+ is **Bearer-gated** by the account token (`aud="account"`,
3373
+ `account:<id>:{read,admin}`) — the session cookie alone gets a 401. The merged
3374
+ PR-1 client sent `credentials: "include"` + CSRF but no `Authorization: Bearer`,
3375
+ so the FIRST `/account/vaults` call 401'd. (Green-on-mocks hid it — the mocks
3376
+ mocked our assumed shapes, not the wire.)
3377
+
3378
+ - **`client.ts`** grows a Bearer layer: `listVaults` / `createVault` /
3379
+ `mintVaultToken` route through a `bearerFetch` that mints the account token
3380
+ (C2 `POST /account/token`), caches it (`lens:account_token`), attaches
3381
+ `Authorization: Bearer <token>`, and **re-mints once on a 401** from the live
3382
+ session cookie. `getSession` / `POST /account/token` / `POST /auth/magic` stay
3383
+ on the cookie+CSRF layer (that IS how the bearer is obtained). C3 bodies drop
3384
+ `__csrf` (the Bearer layer isn't CSRF-gated).
3385
+ - **`logout`** now posts an `x-www-form-urlencoded` body — cloud's
3386
+ `handleLogoutPost` reads `req.formData()`, so the old JSON POST silently
3387
+ no-op'd server-side.
3388
+ - **`createVault` / `mintVaultToken` / `openHostedVault` / `createHostedVault`**
3389
+ no longer thread `csrf` (the client self-sources the account bearer); the
3390
+ Welcome / Landing call sites drop the redundant `getSession`.
3391
+ - **Tests round-trip the REAL wire**: `client.test.ts` asserts the
3392
+ `Authorization: Bearer` header IS sent on every C3 call, that the account
3393
+ token is minted + cached + re-minted on 401, and that logout is form-encoded.
3394
+
3395
+ ## [0.3.1] - 2026-07-10
3396
+
3397
+ PWA service worker → **auto-update**. A new deploy now wins on the next load
3398
+ without the manual "reload" prompt — the judge URL is iterated on and shown to
3399
+ Aaron, so a returning visitor should never be stuck on a stale bundle.
3400
+
3401
+ - `registerType: "prompt"` → `"autoUpdate"`; workbox `skipWaiting` +
3402
+ `clientsClaim` + `cleanupOutdatedCaches` so the generated SW self-activates
3403
+ and claims open pages, and purged precaches never 404 a claimed page.
3404
+ - `UpdateBanner` is now a silent auto-updater (no banner). In autoUpdate mode
3405
+ the plugin fires `onNeedReload` on the new worker's `activated` event (the
3406
+ prompt-mode `needRefresh`/`updateServiceWorker` path is dead); the app routes
3407
+ that through `reloadAfterServiceWorkerUpdate` — a one-shot `controllerchange`
3408
+ listener + fallback timeout that reloads exactly once even if the event is
3409
+ dropped (notes#148/#165). Offline capability unchanged.
3410
+
3411
+ ## [0.3.0] - 2026-07-10
3412
+
3413
+ Arrival IA fix (Aaron's live feedback): the front door is an **entry fork**, not
3414
+ vault-naming. The naming delight moves to first-run onboarding after an account
3415
+ exists.
3416
+
3417
+ - **Landing → entry fork.** PRIMARY: "Sign in or create your Parachute" — one
3418
+ warm email field → the hosted door's magic-link ceremony
3419
+ (`cloud.parachute.computer`) via the app's OAuth 2.1 + PKCE + DCR machinery
3420
+ (`beginHostedSignin`, email as `login_hint`, no URL typing; magic-link unifies
3421
+ new vs returning). SECONDARY (quieter): "Connect a self-hosted vault" → the
3422
+ existing `/add` flow. Keeps the parachute mark, trust chips, warmth. Removed
3423
+ the origin door-probe from the arrival.
3424
+ - **New `/welcome` first-run route** — the relocated "What should we call your
3425
+ vault?" screen. The hosted flow lands here via `OAuthCallback`'s `redirect`;
3426
+ it pre-fills an already-chosen name (returning users confirm) or starts empty
3427
+ for a machine-default name (fresh accounts name it). Sets the vault display
3428
+ name via a new `renameVault` store action (threads through rail/home/title).
3429
+ - Fixed the dead `text-[--color-on-accent]` / `text-red-400` in `OAuthCallback`.
3430
+
3431
+ Door-dependent seams (noted, not faked): authoritative server-side vault naming
3432
+ and the precise "brand-new account" signal await the hosted account/vault API
3433
+ (`/.well-known/parachute-account` is 404 today — C4/C5). The hosted button does a
3434
+ real handoff to the live cloud ceremony; naming persists locally + seams the
3435
+ server PATCH.
3436
+
3437
+ ## [0.2.0] - 2026-07-10
3438
+
3439
+ Extend the warm-paper/coral/serif design system INWARD to the inner surfaces —
3440
+ so clicking past Home no longer lands on structurally-notes-ui screens. Restyle +
3441
+ light IA-align only; all machinery (vault client, sync/outbox, CRUD, routing,
3442
+ `lens:*` storage) intact.
3443
+
3444
+ - **Tier 1 (from home/rail):** `Notes` (All Notes — warm `.note-row` rows w/ grass
3445
+ dots + coral view chips + section labels), `Today`, `NoteView` (calm reading),
3446
+ `NoteNew` (the composer opened up — serif title field, `.composer` warmth, voice
3447
+ affordance), `NoteEditor` (serif chrome around CodeMirror).
3448
+ - **Tier 2:** `Settings` (the calm "dissolved console" — serif section headings,
3449
+ warm cards, selectable boxes), `Tags`, `Activity`.
3450
+ - **Tier 3:** `ConnectAI` (warm MCP-URL box + Claude/ChatGPT assistant tiles +
3451
+ grass trust line), `Import`, `AddVault`, `Vaults`.
3452
+ - Swept many broken `text-[--color-on-accent]` arbitrary utilities (dead in
3453
+ Tailwind v4 → dark-on-coral, fails AA) to the generated `text-on-accent` /
3454
+ `.btn-primary`, and hardcoded `red-500`/`amber-500` literals to the
3455
+ `danger`/`warning` tokens. Light + dark paired, phone-correct.
3456
+
3457
+ ## [0.1.0] - 2026-07-10
3458
+
3459
+ Founding scaffold of the Parachute super-surface.
3460
+
3461
+ - Seeded the substrate from `parachute-surface/packages/notes-ui` (0.2.1): the
3462
+ vault client, OAuth/PKCE auth, offline sync/outbox layer, IndexedDB/OPFS, notes
3463
+ CRUD, and the `surface-client` / `surface-render` integration — kept intact.
3464
+ - Resolved workspace deps to published npm (`@openparachute/surface-client@0.3.4`,
3465
+ `@openparachute/surface-render@0.2.0`).
3466
+ - Root-hosted by default (`base: /`, `VITE_BASE_PATH=/`) for a standalone origin.
3467
+ - Renamed the service identity to `parachute-app` / "Parachute".
3468
+ - Kept the frozen `lens:*` storage namespace (origin-isolated; keeps the sync
3469
+ layer untouched).
3470
+ - Rebuilt the shell + arrival + home to match the synthesized prototype: warm
3471
+ paper tokens (grass/sun/sage families, softer shadows, generous radii), the
3472
+ calm centered arrival with vault-name-as-identity threading, and a warm home
3473
+ with a focused composer and quiet quick-actions.