The feature plan, and Wave 1: freshness, the stranger's way onward, and focus - #24
Merged
Merged
Conversation
The tree is polished for the first person to arrive and thin for the second, which is the one the compare needs. This is the argument for what to build next and in what order, written down before any of it gets built. Five waves across four tracks: closing the share loop (freshness in the menagerie, a demo compare before you hatch, a CTA for a stranger who scanned your QR), making phrase loss survivable (a printable backup card, phrase entry that forgives a typo before Argon2 charges for it), accessibility (route focus and announcements, a plain-language compare narrative that doubles as the chart alternative), and reach (PWA, print, i18n groundwork). Each item names its files, its constraints, and what "done" means. Notes the two items that touch invariants — optional-only fields on SavedConnection so migratePrivData keeps opening old blobs, and a schema-freeze spec for the demo payloads — plus the four things this plan deliberately refuses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs
This is a hash-routed SPA, so the browser announces nothing when the route changes and leaves focus on the link that was just activated. A screen-reader user never entered the page they opened, and a keyboard user tabbed on through the header. There were zero focus() calls and one aria-live region in the whole app. The shell now moves focus to #view on every navigation but the first (the page load's own focus is already right, and moving it would be a jump nobody asked for), and announces the page it landed on. A skip link jumps the keyboard past the header — a button, not an <a href="#view">, because hash routing owns the fragment and an anchor would navigate to the "view" route instead. Route titles drive both. PageTitleStrategy names each route once and that string becomes the tab, the history entry, and the announcement; before this every page was "Menagerie — anonymous compatibility profiles", so browser history was unreadable. Also adds the two things the design system was missing: a real :focus-visible ring (only text inputs and .q-mark had any focus styling, so tabbing was invisible) and an .sr-only utility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs
Two halves of the same gap: nothing in the app served the space between two people. You shared a phrase and then had no idea whether they filled it out, so "did you do the thing yet" happened over exactly the out-of-band channel this app exists to avoid. A kept creature now says when it has new answers. The server already returns a version beside each view blob and bumps it on every save, so this needs no new endpoint, no server change, and no decrypt — it compares a save counter it cannot read the meaning of. The refresh is read-only and happens when you open the page. Nothing polls in the background: a poll would hand the server a heartbeat saying someone is still watching that profile, and an automatic write would both commit a half-edited draft and hand another tab a CAS conflict. Marking a creature seen persists its baseline through a new path that writes the record as it already stands, leaving the draft alone — opening someone's profile is not a decision to save your own answers. The About page's honest ledger says what those extra reads look like. SavedConnection gains two optional fields: the derived view locator (cached because it costs an Argon2id pass, and no more secret than the phrase beside it) and the version last looked at. Optional is load-bearing — migratePrivData fills absent fields, so blobs written before this keep opening with no version bump and no upgrader, and a connection with no baseline reads as "nothing new" rather than "all new". The freshness decision itself is a pure function in core, tested there. The second half: a stranger who scanned a QR could only read and leave — the one onward link went to the landing page, which forgets the creature they came for at the moment they were most interested. They can now hatch from that page and keep it, arriving at the survey with that creature already in their menagerie and queued for comparison. A fresh profile has no answers, so an empty compare would be a lie about what just happened. fetchView returns the locator and version the fetch already paid for, so adding a creature from the page displaying it costs no second derivation; fetchViewPayload stays as the thin wrapper for callers that want only answers. The e2e covers the whole loop end to end: B keeps A, A answers something, B is told, B looks, the badge stays gone across a reload — which is the part that only works because the baseline reached the server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs
CI caught two real defects the local run hid. The badge came back after viewing a profile. Marking seen hung off the View link's click handler, so the write raced the navigation away from it — on a fast machine the reload won and the baseline never reached the server. Reading the profile now marks it seen, from the page that shows the answers, with the version that page already fetched. That page then sits still long enough for the write to land, and opening a kept creature by URL or QR clears the badge too, which the click handler never did. The second is worse and was found by the assertion the first fix moved past: an effect reading view.value() unconditionally. A resource THROWS from value() when it is in its error state — which is why the template branches on error() first — so every dead phrase threw inside the effect and took the "couldn't open that profile" card down with it. Guarded, with the reason written down. Compare no longer clears the badge; only opening the profile does. That is the honest rule for a page whose whole job is showing someone's answers, and it costs nothing a second visit doesn't fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs
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.
Adds
docs/feature-plan.mdand implements its first wave.The diagnosis
The tree is polished for the first person to arrive: hatching is instant, the survey is careful, the creature is charming, losing work is hard. But the product's value only exists for the second person — the compare is the payoff, and a compare needs two finished profiles and a moment where both people look at it. Almost nothing served the space between two people:
aria-liveregion, zero programmatic focus management, and charts with no non-visual equivalent.The plan
docs/feature-plan.md— sixteen items across four tracks, sequenced into five waves, each naming its files, its constraints, and what "done" means. Track A is the second player, B is front-door survivability (backup card, phrase entry that forgives a typo before Argon2 charges for it), C is accessibility, D is reach. It also records what it deliberately refuses — accounts and notifications, free-text fields, server-side discovery, retention mechanics — since each would quietly dissolve the threat model.Wave 1, implemented here
A1 · Freshness in the menagerie. A kept creature now says when it has new answers. The server already returns a
versionbeside each view blob and bumps it on every save, so this needs no new endpoint, no server change, and no decrypt — it compares a save counter it cannot read the meaning of.The refresh is read-only and happens when you open the page. Nothing polls in the background: a poll would hand the server a heartbeat saying someone is still watching that profile, and an automatic write would both commit a half-edited draft and hand another tab a CAS conflict. Marking a creature seen persists its baseline through a new path that writes the record as it already stands, leaving the draft alone. The About page's honest ledger says what those extra reads look like.
A3 · A way onward for a stranger. Someone who scanned a QR had one onward link, to the landing page — which forgets the creature they came for at the moment they were most interested. They can now hatch from that page and keep it, arriving at the survey with that creature already in their menagerie and queued for comparison. A fresh profile has no answers, so an empty compare would have been a lie about what just happened.
C1 · Focus, announcements, and a name for every page. The shell moves focus to
#viewon every navigation but the first, and announces where it landed. A skip link jumps the keyboard past the header — a button, not an<a href="#view">, because hash routing owns the fragment.PageTitleStrategynames each route once and that string becomes the tab, the history entry, and the announcement. Plus the two things the design system lacked: a real:focus-visiblering and an.sr-onlyutility.Invariants
Nothing here touches the frozen crypto vectors, the append-only schema, or the wordlists.
SavedConnectiongains two optional fields — the cached view locator (it costs an Argon2id pass, and is no more secret than the phrase beside it) and the version last looked at. Optional is load-bearing:migratePrivDatafills absent fields, so blobs written before this keep opening with novbump and no upgrader, and a connection with no baseline reads as "nothing new" rather than "all new". The freshness decision is a pure function in core, tested there.Verification
The full ladder is green:
format:check, both typechecks, all four unit suites (192 core / 34 ui / 12 app / 35 server),build, ande2e.The e2e covers the freshness loop end to end — B keeps A, A answers something, B is told, B looks, and the badge stays gone across a reload, which is the part that only works because the baseline reached the server.
Note for reviewers: the initial-bundle budget warning predates this branch (519.91 kB on
main, 524.75 kB here).