Offline saves, a draft that survives the tab, and 648 addressable strings - #30
Merged
Merged
Conversation
…vives the tab The plan held half of D1 back because "the survey fillable offline" looked like it required breaking the invariant DraftStore states outright — answers in memory only, "so a shared computer holds no plaintext answers after the tab closes" — and that is a product trade, not a caching detail. It turned out not to be a trade. The plan's own second shape, "encrypt the draft under a key held in memory", undersold itself: the key is editKey, derived from the edit phrase by Argon2id and never written anywhere. The draft goes to disk under it, a closed tab leaves ciphertext nobody on the device can open, and typing the phrase again is what brings it back. The invariant survives verbatim. It also solves the identity problem for free — a draft written under one profile's key fails to decrypt under another's, so there is no identifier to store and no way for the next person on a shared machine to inherit anything. Off by default all the same, because the honest limit is the interaction with the other opt-in: tick "remember my edit phrase" as well and this browser holds both the lock and the key. Settings says so, in place, and only when both are on. About says it too. Three things the item did not predict: - "Fillable offline" was already true; the loss was at the end. HatchError shipped its machine token as its message, so a save that died in a tunnel toasted "hatch network". Every failure kind now has a sentence written for someone mid-edit, and network is its own SaveState — not an error — with a bar saying the answers are safe and a retry that fires on the browser's own online event. Not a poll: this is the click you already made, finishing, so the About page's "nothing happens while you're away" holds. - Blob.prototype.stream was load-bearing and shouldn't have been. Every encrypted blob reached CompressionStream through a Blob, which jsdom does not implement, so the whole crypto layer was untestable in the app suite. compress.ts feeds the transform directly now: same deflate-raw byte for byte, two fewer APIs, and the vault's at-rest assertions run as units. - The cheap version of the disk test is a lie. Writes are debounced, so an e2e that reads localStorage straight after a click reads the blob from before it and passes while proving nothing. It waits for the value to change. Ladder green including e2e, which now covers both: a save aborted mid-flight shows the offline bar and completes itself on reconnect, and a kept draft comes back after a reload as unsaved, unreadable at rest, and gone once saved.
…ords The plan filed i18n as "extract user-facing strings from the inline templates", which misses where most of the copy actually is. The survey schema is 296 strings — more than half of everything this product says to a person — and it lives in libs/core, which may not import a framework (no-angular.spec fails the build otherwise). $localize comes from @angular/localize. So Angular's extractor cannot see the survey at all, and "translate Menagerie" was impossible for reasons no amount of template markup would fix. That is the part that needed designing, so it went first. The keys are the interesting bit. schema/sections.ts is append-only under a checked-in freeze fixture: ids are forever and options never reorder, both enforced by schema.spec.ts. So the schema already contains a set of permanent identifiers, and keys built from them (it.<id>.label, it.<id>.o<index>, sec.<id>.title) cannot be invalidated by any edit the freeze permits — relabelling and appending are exactly the edits that leave existing keys meaning what they meant. A translation survives them for free. Which means the frozen file does not change at all. The English stays where it is, readable, and doubles as the fallback: a missing or blank row degrades to English one string at a time rather than showing a gap. - libs/core/src/i18n/messages.ts — twenty lines: a bag, a loader, a lookup. - schema/labels.ts — the accessors, and the rule: never read item.label, item.options[i], section.title or a scale anchor for display. - The four helpers CLAUDE.md already names as the shared funnels — itemLabel, interestLabel, importanceLabel, answerChips — now route through it, so most call sites were already correct. Two guards, because a layer nobody is forced through is decoration: - messages.spec walks the schema with every key translated to a marker and fails if any rendered string comes back English. It also compares the checked-in messages.en.json to the live schema, so adding a question without `npm run i18n:extract` fails CI rather than quietly handing a translator a stale file. - schema-copy.spec is a source guard in the style of no-angular.spec: it reads src/app and libs/ui and rejects the direct reads. That is the failure this cannot catch any other way — a component that interpolates item().label works perfectly, reviews clean, and is the one untranslatable string on the page. Fifteen call sites moved behind the accessors on the way, including the survey editors, the compare panels, the view page and the community bands. One rename fell out of it: the view page's display group was called `section` while carrying an already-translated title, which is exactly the ambiguity the guard cannot see through — it is a `group` now, which is what it is.
…nes missed
The app's own copy now carries i18n markers, and a locale is chosen and
loaded at runtime. With the schema layer from the previous commit that is 648
addressable strings and no translation, which is what the item asked for.
Runtime loading rather than a build per locale, because this is a static
bundle with hash routing on Pages: per-locale builds mean per-locale
directories and a redirect, and the deploy is meant to stay one folder of
files. One fetch before bootstrap, only when a locale other than the source
is wanted, and every failure path resolves to English rather than rejecting —
a missing or broken catalogue must never be why someone can't open their
profile. Both catalogues ship in one file per language, so "chrome in one
language, questions in another" is not a state the app can reach.
Coverage is the whole problem here, and it needs a different proof than the
schema half did. A string nobody marked compiles, reviews clean, and is
simply untranslatable — no compiler and no unit test can see it. So the e2e
builds a pseudo-locale from the extracted catalogue, every message wrapped in
guillemets, and drives the real UI with it: plain English on screen is the
bug, and it shows up in a screenshot. It is generated on every run rather
than committed, so it cannot drift from the templates the way a fixture
would.
Three things the sweep turned up that markup alone would have missed:
- Copy hiding inside interpolation. Seventeen `{{ cond ? 'Saving…' : 'Save
now' }}` ternaries were invisible to every text-node pass, including my
own. They are @if/@else with marked spans now, which is what they should
have been anyway.
- Thirty-seven toasts and confirms in TypeScript, now $localize tagged.
- One singular/plural ternary that was really an ICU plural, and now is.
Costs, stated plainly rather than discovered later:
- @angular/localize and the message metadata add ~19 kB to the initial
bundle, which was already 35 kB over its 500 kB warning budget and is now
54 kB over. The error budget is 1 MB.
- The Angular patch line moved 22.1.3 -> 22.1.4 across the toolchain, because
@angular/localize pins its compiler exactly. Within the existing carets.
- The template catalogue has no drift guard the way the domain one does —
Angular ships no such check — so `npm run i18n:extract` after a copy change
is a habit. CLAUDE.md says so, and the e2e catches it for the handful of
strings it names by hand.
locale/README.md is the translator's entry point: what the two files are, why
there are two, and how to check your work covers the screen.
package.json was widened from ^22.1.3 back to ^22.1.0 before commit, but the lockfile still recorded the narrower range; npm noticed on the next install. Resolved versions are unchanged.
The successor to feature-plan.md, whose sixteen items all shipped. This one is smaller and later in the funnel: the loop works when someone drives it, and what remains is friction at the edges — the unfurl that arrives before the app does, the moment right after the first comparison, the unstated cost of the core set, and doors (install, compare-from-nothing) that exist but are unmarked. The grilling is part of the document, because half of what was considered did not survive it: shipping a machine translation died on the quality of exactly the copy that matters most, the language picker deferred with it, per-creature unfurls died on SSR, manifest screenshots on maintenance, and read receipts before reaching the table. Five items, two waves. E1 (send your creature back after a compare — the one structural gap left in the viral chain) alone in the first wave; the four small front-door items together in the second. No new cryptography, no server changes, no schema changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finishes the last two open items in
docs/feature-plan.md— D1's held-back half and D3 — and opens their successor.D1 — the offline draft turned out not to be a trade
The plan held this back because "the survey fillable offline" looked like it required breaking the invariant
DraftStorestates outright: answers in memory only, "so a shared computer holds no plaintext answers after the tab closes."The plan's own second option — "encrypt the draft under a key held in memory" — undersold itself. That key is
editKey, derived from the edit phrase by Argon2id and never written anywhere. So the draft goes to disk under it, a closed tab leaves ciphertext nobody on the device can open, and typing the phrase again brings it back. The invariant survives verbatim. It also solves profile-binding for free: a draft written under one profile's key fails to decrypt under another's, so there is no identifier to store and no way for the next person on a shared machine to inherit anything.Still off by default, because the honest limit is the interaction with the other opt-in: tick "remember my edit phrase" as well and the browser holds both the lock and the key. Settings says so, in place, only when both are on.
Two things upstream of that, which the item did not predict:
HatchErrorshipped its machine token as its message, so a save that died in a tunnel toastedhatch network. Every failure kind now has a sentence written for someone mid-edit, andnetworkis its ownSaveState— not an error — with a bar saying the answers are safe and a retry that fires on the browser'sonlineevent. Not a poll: it is the click you already made, finishing.Blob.prototype.streamwas load-bearing and shouldn't have been. Every encrypted blob reachedCompressionStreamthrough aBlob, which jsdom does not implement, so the crypto layer was untestable in the app suite.compress.tsfeeds the transform directly now — same deflate-raw byte for byte, two fewer APIs.D3 — the half Angular can't do, then the half it can
The item said "extract user-facing strings from the inline templates", which misses where the copy is. The survey schema is 296 of 648 strings and lives in
libs/core, which may not import a framework, while$localizecomes from@angular/localize. Angular's extractor could never see the survey, so no amount of template markup would have made a translation possible.The keys came free.
schema/sections.tsis append-only under a checked-in freeze — ids are forever, options never reorder — so keys built from them (it.<id>.o<index>) cannot be invalidated by any edit the freeze permits. The frozen file did not change at all; the English stays where it is and doubles as the fallback.Coverage needed three guards, because a string nobody marked compiles and reviews clean:
messages.spec.tsloads a bag where every key maps to a marker and fails if any rendered string comes back English. It also fails when the checked-in catalogue drifts from the schema.schema-copy.spec.tsis a source scan in the style ofno-angular.spec.ts— it rejectsitem.labeland friends outright.Then the mechanical half: 352 template messages, 37 toasts and confirms as
$localize, 17 ternaries hiding copy inside interpolation, one ICU plural. Runtime locale loading rather than a build per locale, since this is a static bundle with hash routing on Pages.Costs, stated
@angular/localizepins its compiler exactly. Within the existing carets.npm run i18n:extractafter a copy change is a habit, recorded in CLAUDE.md.Also here
docs/adoption-plan.md, the successor plan: five UX items in two waves, plus the record of what was considered and killed (a machine translation of intimate copy, per-creature unfurls, read receipts).Verification
Full ladder green — format, both typechecks, all four unit suites, production build, and the e2e including the new
offline-save,kept-draft, andpseudo-localesteps.🤖 Generated with Claude Code
https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs
Generated by Claude Code