From d319cc812834eae4c7381b3fed157adab6bbcc4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:51:16 +0000 Subject: [PATCH 1/4] Plan the next features: the second player, the front door, access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- docs/feature-plan.md | 419 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 docs/feature-plan.md diff --git a/docs/feature-plan.md b/docs/feature-plan.md new file mode 100644 index 0000000..3874b2e --- /dev/null +++ b/docs/feature-plan.md @@ -0,0 +1,419 @@ +# The feature plan — what to build next, and why + +A **plan**, not a commitment. Nothing here is frozen; items get cut or +reshaped as they meet the code. What is fixed is the diagnosis below, which +is the argument for the ordering. + +## The diagnosis + +Menagerie is unusually well built for the **first** person to arrive. You can +hatch in a second, the survey is careful, the creature is charming, the +threat model is honest, and losing work is hard. Almost every recent commit +has made that first session better. + +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 in the app serves the space _between_ +two people: + +- You share a phrase and then have no idea whether they filled it out, or + whether they changed an answer after you last looked. The out-of-band + channel the app exists to avoid ("hey did you do the thing yet") becomes + the coordination layer. +- A newcomer is asked to spend twenty minutes on a survey before they have + ever seen what a comparison looks like. The payoff is invisible until + after it has been paid for. +- The one artifact holding the whole relationship — the edit phrase — has no + physical form, and typing a six-word phrase on a phone with no assistance + is a typo minefield guarded by a multi-second Argon2 wait. + +And the app's audience — people negotiating relationship shape and intimacy, +heavily overlapping queer, neurodivergent and disabled communities — is not +well served by a screen-reader experience with one `aria-live` region, zero +programmatic focus management, and charts with no non-visual equivalent. + +So: **the space between two people**, then **the front door**, then +**everyone who can't use it today**. Reach and offline come last because +they multiply a loop that should work first. + +--- + +## Track A — The second player + +The highest-value work in the tree. Each item shortens the path from "I +shared my phrase" to "we looked at this together." + +### A1 · Freshness in the menagerie + +**What.** Each creature you keep shows whether it has changed since you last +looked at it: _new answers since you compared_, or _profile is gone_. + +**Why it's cheap.** `GET /v2/profiles/view/:locator` already returns +`{ blob_view, version }`, and `version` increments on every save. The check +is one existing request per connection with no decrypt, no new endpoint, and +no server change. + +**Where.** + +- `libs/core/src/hatch/priv-data.ts` — add optional `lastSeenVersion?: number` + and `lastCheckedAt?: number` to `SavedConnection`. Optional is the whole + trick: `migratePrivData` already fills absent fields, so no `v` bump and no + new migration. +- `libs/core/src/hatch/hatch-client.ts` — a `fetchViewVersion(client, phrase)` + beside `fetchViewPayload`, deriving the locator and returning the version + without decrypting. +- `src/app/stores/profile-session.store.ts` — record `lastSeenVersion` when a + connection is viewed or compared; a `refreshConnections()` that fills in + current versions. +- `src/app/menagerie/menagerie.component.ts` — badge, and an explicit + "Check for updates" control. + +**Design decisions to make deliberately.** + +- **Refresh on page load plus an explicit button. No background polling.** + A poll bumps `last_viewed_at`, which is good (it holds off GC) but also + hands the server a heartbeat that says "this person is still watching this + profile" on a schedule. One refresh per visit is indistinguishable from + visiting, which is the point. +- Deriving the view locator costs Argon2id per connection. Cache the + locator, not just the version, or a menagerie of ten becomes a ten-second + wait. Locators are already secret-equivalent and already live in an + edit-key-encrypted blob, so caching one costs nothing new. +- A 404 means deleted, expired, or re-minted, and the UI must not guess + which. "This creature's phrase no longer opens anything" plus a remove + action. + +**Done when** a saved connection that saves an answer elsewhere shows as +updated on the next menagerie visit, with a spec covering fresh / updated / +gone, and the About page's honest-limits list mentions the refresh. + +**Size.** Medium. The most valuable single item in this document. + +### A2 · A demo compare, before you hatch + +**What.** Two fictional creatures with full answer sets, comparable from the +landing page in one tap, showing the real panels with real scores — +including a dealbreaker alert and a mutual-desire reveal, because those are +the two things nobody expects and everybody remembers. + +**Why.** It is the only way to show the payoff before charging for it, and +it fixes the cold start for every audience at once: the curious couple who +won't commit, the polyam power user evaluating whether this beats their +spreadsheet, the person who wants to see what their partner would see. + +**Where.** A `src/app/demo/` route rendering `CompareComponent`'s panels +against bundled payloads rather than fetched ones — the compare pipeline +already separates "load and decrypt" from "score and render" +(`compare-model.ts` takes payloads), so the seam exists. Landing page gets +the entry point. + +**Constraints.** + +- Demo payloads must be built from real schema ids, and a spec must assert + they still decode after any schema change. A demo that rots is worse than + none. +- It must work with the server unreachable or unconfigured — nothing about + it should touch `HatchClient`. This doubles as the one page that still + works when the profile server is down. +- Reuse the QA cast in `docs/qa-profiles.md` rather than inventing a third + set of fixtures, if their answers make a good story. + +**Done when** a visitor with no profile and no server can see a full +comparison from the landing page, and the demo is one obvious step from +hatching. + +**Size.** Medium. + +### A3 · Close the loop when a stranger views you + +**What.** Someone opens `#/view/` from a QR with no profile of their +own. Today they read and leave. They should be offered: hatch your own, then +compare against the one you're looking at — with the viewed phrase already +in the compare slot and offered for their menagerie. + +**Why.** This is the viral loop, and it currently ends in a dead end at the +exact moment of maximum interest. + +**Where.** `src/app/view/view.component.ts` (a CTA when +`session.active()` is false), and `src/app/stores/compare.store.ts` to carry +the pending phrase across the hatch. + +**Done when** a first-time viewer can go from a scanned QR to a two-way +comparison without ever typing a phrase by hand. + +**Size.** Small. + +### A4 · "Enough to compare" as a real moment + +**What.** The survey has 23 `tier: 'core'` items among 96. Finishing the core +tier should be an explicit, celebrated milestone that hands you your share +controls, not a quiet threshold you cross without noticing. + +**Why.** It converts survey fatigue into a checkpoint, and it puts the share +prompt at the moment the profile first becomes worth sharing. + +**Where.** `src/app/dashboard/`, and whatever computes section progress. + +**Done when** completing the core tier produces a distinct state with the +view phrase, QR, and "send this to someone" in it. + +**Size.** Small. + +--- + +## Track B — Make the front door survivable + +### B1 · The backup card + +**What.** A printable card: the creature portrait, both phrases, the view QR, +the date, and one line saying exactly what each phrase does and what losing +the edit phrase costs. Print to paper for a drawer, or to PDF for a password +manager. + +**Why.** Permanent, unrecoverable loss is the product's worst failure mode +and its only mitigation today is "write it down." This is an afternoon of +work against it. + +**Where.** A route under `/settings` plus a print stylesheet in +`libs/ui/src/styles/`. `window.print()` and `@media print` — no new +dependency, no canvas rasterizing, and the browser's own "save as PDF" does +the export. + +**Constraints.** The card carries the edit phrase, so it needs an unmissable +warning that printing it puts full edit control on a piece of paper, and it +must never be reachable from a view-only session. + +**Done when** a logged-in profile can produce a one-page card that reads +correctly in both themes and in print, with an e2e or component spec +asserting both phrases and the QR are on it. + +**Size.** Small–medium. + +### B2 · Phrase entry that forgives + +**What.** Three things, in ascending order of payoff: + +1. **Did-you-mean before Argon2.** A phrase whose words aren't in the + wordlists cannot possibly be right, and checking that is instant while + the KDF takes seconds. Reject early, name the word that's wrong, and + offer the edit-distance-1 candidate. Today a typo costs a multi-second + wait and a generic failure. +2. **Per-word autocomplete** on the phrase inputs. Wordlists are public by + design (`persona/wordlists.ts`, `tail-wordlists.ts`, EFF), so suggesting + from them leaks nothing that isn't already shipped in the bundle. +3. **Tolerant normalization** — spaces or hyphens, any case, stray + punctuation, pasted URLs. `canonicalViewPhrase` and `extractViewPhrase` + already do part of this; make sure both inputs use them consistently. + +**Where.** `src/app/landing/landing.component.ts`, +`src/app/edit-login/edit-login.component.ts`, and a shared phrase-input +component in `libs/ui` once there's a second copy — per CLAUDE.md, extract +on the third. + +**Constraint.** The EFF list is a lazy chunk and should stay one: load it on +first focus of an edit-phrase field, not on landing-page paint. + +**Done when** a one-letter typo in either phrase produces an instant, +specific, correctable error instead of a slow generic one. + +**Size.** Medium. + +### B3 · Confirm the edit phrase at hatch + +**What.** One lightweight confirmation step after hatching — retype a +single word from the edit phrase, or tick "I've saved this" next to the +backup-card link. + +**Why.** It converts "I'll write it down later" into a decision made while +the phrase is still on screen. Pair it with B1 so the confirmation has +somewhere to send people. + +**Constraint.** It must be skippable. The instant, frictionless hatch is a +real feature and a modal gate would spoil it. + +**Size.** Small. + +--- + +## Track C — Accessibility + +Track A and B make the product better. This track makes it usable at all for +people it currently locks out, and C2 is good enough to be a headline +feature rather than a compliance chore. + +### C1 · Routing, focus, and announcements + +**What.** A skip link; focus moved to the view heading on navigation; a +polite live region announcing the new page; focus-visible styling audited +across the custom controls. + +**Why.** The app is a hash-routed SPA with **zero** `focus()` calls and one +`aria-live` region. For a screen-reader user every navigation is silent and +leaves focus stranded on the link they just activated — this is the +difference between "hard" and "impossible." + +**Where.** `src/app/app.ts` / `app.html` (the `
` already +exists as a target), plus a small router subscription. + +**Size.** Small. Highest ratio of impact to effort in this document. + +### C2 · The plain-language compare narrative + +**What.** A new compare panel that says, in sentences: what you two agree on +most, where you differ most, which dealbreakers need a conversation, and +what the directional fit scores actually mean. + +**Why.** Three wins in one component. It is the non-visual equivalent of the +radar, strips, and matrix. It is a cognitive-accessibility win for everyone +under stress — and people read compatibility results under stress. And it is +the thing people will screenshot and quote to each other, which no chart +currently is. + +**Where.** `src/app/compare/panels/narrative.panel.ts`, registered in +`app.config.ts` with `provideComparePanel({ id: 'narrative', order: 12 })` — +right after the headline. The panel receives a fully computed `CompareModel` +(`pair`, `interlocks`, `grid`, `desireRows`), so this is presentation over +data that already exists, not new scoring. + +**Constraint.** Phrasing carries real weight here: it must describe +differences without moralizing about them, and never imply a verdict on a +relationship. Every sentence template deserves the same care the survey copy +got. + +**Size.** Medium. The most interesting design problem in the plan. + +### C3 · Chart alternatives and SVG semantics + +**What.** Under each chart panel, a collapsible "Read this as a table." +Proper roles and labels on the SVG components in `libs/ui`, and +`aria-hidden` on the purely decorative ones. + +**Where.** `libs/ui/src/` chart components; the compare panels get the +disclosure. + +**Size.** Medium, mostly repetitive. + +### C4 · Contrast and forced colors + +**What.** Support `prefers-contrast: more` and `forced-colors: active` at the +token layer, and audit real contrast ratios in both themes — particularly +the `fine` class, the persona-derived accent colors, and the chart series +hues, which are generated rather than hand-picked and therefore unaudited. + +**Where.** `libs/ui/src/styles/` tokens. The existing +`prefers-reduced-motion` blocks in `_base.scss` are the model to follow. + +**Size.** Medium. + +### C5 · Keyboard and screen-reader audit of the survey controls + +**What.** Walk the importance controls, answer chips, scale inputs, and the +interest matrix with a keyboard and a screen reader. These are custom +controls carrying the product's core interaction; they need real roles, +states, and arrow-key behavior, not just `aria-label`. + +**Size.** Medium, and it will produce its own list. + +--- + +## Track D — Reach and resilience + +### D1 · Make it a PWA + +**What.** A manifest and a service worker: installable, and the survey +fillable offline with the draft syncing on reconnect. + +**Why.** A static hash-routed site with client-side crypto and a local draft +store is nearly the ideal PWA already. Someone filling out ninety-six +questions on a commute currently loses to a tunnel. + +**Constraints — the reason this is behind Track A.** + +- `moxy.config.json` must never be served stale from a cache, or an app + update can't move the server address. Network-first for that file, always. +- Caching an app whose entire premise is "nothing identifying is stored" + needs a deliberate answer about what the cache holds and how logging out + clears it. +- `npm run e2e` drives the production build; a service worker changes what + that build serves and the suite will need to account for it. + +**Size.** Medium, with a long tail of caching bugs. Do it when the loop +above it works. + +### D2 · Sharing and printing the result + +**What.** Web Share API for phrases and links on mobile (with the current +copy-to-clipboard as fallback), and a print stylesheet for the compare view. + +**Why.** People will want to bring a comparison to a conversation, and a +printout is the one form that doesn't require both people to hold a phone. +The B1 print stylesheet does most of the work. + +**Size.** Small. + +### D3 · Internationalization groundwork + +**What.** Extract user-facing strings from the inline templates so a +translation becomes possible. No translation yet. + +**Why.** Every component shipped with inline copy raises the price of the +first translation. This is the cheapest it will ever be, and it only gets +worse. + +**Caveat.** It touches nearly every component, so it wants a quiet window +between features rather than a slot in a wave. + +**Size.** Large and boring. Schedule honestly or not at all. + +--- + +## Sequencing + +**Wave 1 — the loop.** A1 freshness · A3 viewer CTA · C1 focus and +announcements. The two ends of the share loop, plus the accessibility fix +that costs almost nothing and unblocks the most people. + +**Wave 2 — the front door.** A2 demo compare · B1 backup card · B2 phrase +entry. What a newcomer meets, and what keeps them from losing everything. + +**Wave 3 — the words.** C2 narrative panel · A4 core-tier milestone · B3 +phrase confirmation. The copy-heavy work, once the structure around it is +settled. + +**Wave 4 — the sweep.** C3 chart alternatives · C4 contrast · C5 control +audit. Best done together, as one audit with one vocabulary. + +**Wave 5 — reach.** D1 PWA · D2 share and print. Multiply the loop after +it works. + +**Unscheduled.** D3 i18n. + +## Invariants this plan touches + +None of it touches the frozen crypto vectors, the append-only survey schema, +or the wordlists. Two items come close and are worth stating plainly: + +- **A1** adds fields to `SavedConnection` inside `PrivData`. They must be + optional so `migratePrivData` keeps opening older blobs without a `v` bump. + Adding a required field there is a migration, and a migration on the + edit-key blob is the one that can lock someone out of their own profile. +- **A2** bundles fixture payloads built from real schema ids. They need a + spec that fails when the schema moves under them, or the demo silently + rots into a lie about the product. + +Everything else is presentation, input handling, and copy. + +## Deliberately not doing + +- **Accounts, logins, or notifications.** The async gap is real, but any + push channel needs an address to push to, and there isn't one by design. + A1 is the honest version of this feature. +- **Free-text anything.** No bios, no messages, no custom questions. "There + isn't even a free-text field" is load-bearing: it is what makes every + answer comparable and every profile unidentifiable. +- **Server-side matching or discovery.** A directory of profiles is a + different product with a different threat model, and it would put the + server in a position the current design spent a lot of effort denying it. +- **Retention mechanics.** Streaks, reminders, and re-engagement nudges all + need to know who you are and when you left. Transience is a feature here; + the GC policy says so out loud. From 386d19b4cfe0e858536d81d8498343c43b772359 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 04:25:54 +0000 Subject: [PATCH 2/4] Accessibility: focus follows navigation, and every page has a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- libs/ui/src/styles/_base.scss | 50 ++++++++++++++++++++ src/app/app.config.ts | 7 ++- src/app/app.html | 13 +++++- src/app/app.routes.ts | 11 +++++ src/app/app.spec.ts | 45 ++++++++++++++++++ src/app/app.ts | 37 ++++++++++++++- src/app/page-title.strategy.spec.ts | 72 +++++++++++++++++++++++++++++ src/app/page-title.strategy.ts | 34 ++++++++++++++ 8 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 src/app/page-title.strategy.spec.ts create mode 100644 src/app/page-title.strategy.ts diff --git a/libs/ui/src/styles/_base.scss b/libs/ui/src/styles/_base.scss index 44d0597..c23b083 100644 --- a/libs/ui/src/styles/_base.scss +++ b/libs/ui/src/styles/_base.scss @@ -25,6 +25,56 @@ a { color: var(--accent); } +/* ---------- accessibility primitives ---------- */ + +/* One visible focus ring for everything keyboard-reachable. Before this only + text inputs and .q-mark had any focus styling, so tabbing through the app + was invisible. :focus-visible keeps it off mouse clicks. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* Present to a screen reader, absent to everything else. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* The skip link rides above the sticky header (z-index 20) when focused. */ +.skip-link { + position: absolute; + left: 8px; + top: -100px; + z-index: 40; + padding: 10px 14px; + border: 0; + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--accent-ink); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: top 0.12s ease-out; +} +.skip-link:focus { + top: 8px; +} + +/* #view takes focus on every navigation so the next Tab starts at the new + page — but it is a focus target, not a control, so it shows no ring. */ +#view:focus { + outline: none; +} + /* ---------- shell ---------- */ /* Six nav links plus the session chip, logout and theme is a full row; keep diff --git a/src/app/app.config.ts b/src/app/app.config.ts index ea083b5..4229eec 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,10 +4,11 @@ import { provideAppInitializer, provideBrowserGlobalErrorListeners, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { provideRouter, TitleStrategy, withHashLocation } from '@angular/router'; import { getSection } from '@moxy/core'; import { routes } from './app.routes'; import { provideComparePanel } from './compare/compare-panels.token'; +import { PageTitleStrategy } from './page-title.strategy'; import { ServerConfigStore } from './stores/server-config.store'; export const appConfig: ApplicationConfig = { @@ -17,6 +18,10 @@ export const appConfig: ApplicationConfig = { // rewrite rules — #/view/ works from a QR scan anywhere. provideRouter(routes, withHashLocation()), + // Names every route once: browser tab, history, and the shell's live + // region all read the same string. + { provide: TitleStrategy, useExisting: PageTitleStrategy }, + // Resolve the profile server address before anything routes. provideAppInitializer(() => inject(ServerConfigStore).init()), diff --git a/src/app/app.html b/src/app/app.html index 21f0c39..1a4241e 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,3 +1,7 @@ + + +
-
+ +
+ +

{{ announcement() }}

+