@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.
- package/CHANGELOG.md +3473 -0
- package/LICENSE +661 -0
- package/README.md +68 -0
- package/dist/.parachute/info +8 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/assets/Account-BhmA30V5.js +1 -0
- package/dist/assets/Activity-BgJa7Woi.js +1 -0
- package/dist/assets/AddVault-BWa_-QjM.js +1 -0
- package/dist/assets/AddVaultChooser-B3ElIlza.js +1 -0
- package/dist/assets/AddVaultCreate-DKJfYFJ1.js +1 -0
- package/dist/assets/Calendar-DUwSvnCY.js +1 -0
- package/dist/assets/CheckEmail-BQYQLTLL.js +1 -0
- package/dist/assets/ConnectAI-DCtARp-w.js +1 -0
- package/dist/assets/DayView-nficJMEc.js +1 -0
- package/dist/assets/Export-DNAoKjAn.js +1 -0
- package/dist/assets/Import-CoFeXNMx.js +20 -0
- package/dist/assets/NoteEditor-DtrdRbqx.js +1 -0
- package/dist/assets/NoteNew-C0DMe5Q8.js +8 -0
- package/dist/assets/NoteRenderer-Dl9X9uRt.js +37 -0
- package/dist/assets/NoteView-B2QH25fP.js +3 -0
- package/dist/assets/OAuthCallback-Dj26N8oJ.js +1 -0
- package/dist/assets/PinArchiveButtons-BXZFiW-i.js +1 -0
- package/dist/assets/Settings-BuXl91ig.js +1 -0
- package/dist/assets/TagPage-q7PgbEHn.js +1 -0
- package/dist/assets/TagSchemaEditor-DIHppYmj.js +1 -0
- package/dist/assets/Tags-BqJ9vWM5.js +1 -0
- package/dist/assets/VaultGraph-CaiNmEtX.js +3 -0
- package/dist/assets/Vaults-CTsUsDH8.js +1 -0
- package/dist/assets/ViewNew-BegTqdOq.js +1 -0
- package/dist/assets/ViewSurface-ClCtZW0N.js +1 -0
- package/dist/assets/Welcome-CN4Zh9jP.js +1 -0
- package/dist/assets/WizardShell-ZqrzQKxp.js +1 -0
- package/dist/assets/attachment-upload-l7qA8_xw.js +2 -0
- package/dist/assets/editor-mode-D9fveTEx.js +1 -0
- package/dist/assets/figtree-latin-ext-wght-normal-DCwSJGxG.woff2 +0 -0
- package/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
- package/dist/assets/fraunces-latin-ext-opsz-italic-CGbN9UgK.woff2 +0 -0
- package/dist/assets/fraunces-latin-ext-opsz-normal-CJcjJNj7.woff2 +0 -0
- package/dist/assets/fraunces-latin-opsz-italic-lSdLDfvT.woff2 +0 -0
- package/dist/assets/fraunces-latin-opsz-normal-DihXLNYH.woff2 +0 -0
- package/dist/assets/fraunces-vietnamese-opsz-italic-DxWqP7Ku.woff2 +0 -0
- package/dist/assets/fraunces-vietnamese-opsz-normal-Czevyj-6.woff2 +0 -0
- package/dist/assets/history-ByMClxJV.js +1 -0
- package/dist/assets/index-CaleIHD8.css +1 -0
- package/dist/assets/index-Dwo2xe8_.js +66 -0
- package/dist/assets/provenance-B6WFYTWW.js +1 -0
- package/dist/assets/react-force-graph-2d-BMU3lxLC.js +44 -0
- package/dist/assets/transcribe-default-LVAxyzpn.js +1 -0
- package/dist/assets/types-BaJkUfo6.js +1 -0
- package/dist/assets/url-QSQwrjSV.js +1 -0
- package/dist/assets/useAttachmentUploader-C3UnTg01.js +30 -0
- package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/dist/favicon.ico +0 -0
- package/dist/icon.svg +21 -0
- package/dist/index.html +31 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/maskable-icon-512x512.png +0 -0
- package/dist/pwa-192x192.png +0 -0
- package/dist/pwa-512x512.png +0 -0
- package/dist/pwa-64x64.png +0 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-9c191d2f.js +1 -0
- package/meta.json +20 -0
- 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.
|