Skip to content

Offline saves, a draft that survives the tab, and 648 addressable strings - #30

Merged
Randolin merged 5 commits into
mainfrom
claude/product-strategy-positioning-u1vwig
Aug 29, 2026
Merged

Randolin merged 5 commits into
mainfrom
claude/product-strategy-positioning-u1vwig

Conversation

@Randolin

Copy link
Copy Markdown
Owner

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 DraftStore states 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:

  • "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 online event. Not a poll: it is the click you already made, finishing.
  • 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 crypto layer was untestable in the app suite. compress.ts feeds 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 $localize comes 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.ts is 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.ts loads 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.ts is a source scan in the style of no-angular.spec.ts — it rejects item.label and friends outright.
  • The e2e builds a pseudo-locale from the extracted catalogue and drives the real UI with it; plain English on screen is the bug, visible in a screenshot.

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

  • +19 kB initial bundle. It was 35 kB over the 500 kB warning budget; now 54 kB over. Error budget is 1 MB.
  • Angular 22.1.3 → 22.1.4 across the toolchain, because @angular/localize pins its compiler exactly. Within the existing carets.
  • No drift guard on the template catalogue — Angular ships none. npm run i18n:extract after 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, and pseudo-locale steps.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs


Generated by Claude Code

…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.
@Randolin
Randolin merged commit 3d68f93 into main Aug 29, 2026
2 checks passed
@Randolin
Randolin deleted the claude/product-strategy-positioning-u1vwig branch August 29, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant