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.
diff --git a/e2e/run-e2e.mjs b/e2e/run-e2e.mjs
index 8074b6b..8b92ba1 100644
--- a/e2e/run-e2e.mjs
+++ b/e2e/run-e2e.mjs
@@ -608,6 +608,52 @@ try {
if (!(await pageB.locator('a', { hasText: 'Their profile' }).count())) {
fail('reply view-phrase attachment missing');
}
+ // --- freshness: B keeps A, A answers something, B is told ----------------
+ // The whole point of keeping a creature: noticing it changed without having
+ // to ask its owner out of band.
+ step = 'menagerie-keep';
+ await pageB.goto(viewUrl);
+ await pageB.waitForSelector(`text=${personaName}`, { timeout: 30000 });
+ await pageB.click('text=💾 Add to my menagerie');
+ await pageB.waitForSelector('text=joined your menagerie', { timeout: 30000 });
+ await pageB.goto(`${BASE}#/menagerie`);
+ await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
+ await pageB.waitForSelector('text=Check for updates', { timeout: 60000 });
+ if ((await pageB.textContent('body')).includes('new answers')) {
+ fail('a creature kept a moment ago is reported as having new answers');
+ }
+
+ step = 'menagerie-updated';
+ await page.goto(`${BASE}#/me`);
+ await page.waitForSelector('.profile-head');
+ await editCategory(page, 'What I value', async (card) => {
+ await card.locator('.q-row', { hasText: 'Togetherness' }).locator('.pip-scale').nth(5).click();
+ });
+ // reload(), not goto(): B is already on this URL and the browser treats a
+ // same-fragment goto as nothing at all. Reloading also proves the point —
+ // the baseline came back from the server, not from a signal still in memory.
+ // Two waits, because the page has to finish an Argon2id session restore
+ // before the refresh it kicks off can say anything.
+ await pageB.reload();
+ await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
+ await pageB.waitForSelector('text=new answers', { timeout: 60000 });
+
+ step = 'menagerie-seen';
+ // Reading the profile is what clears it — and it stays cleared across a
+ // reload, which is the part that only works because the baseline reached
+ // the server. Wait for the page to stop talking before reloading, or the
+ // reload races the very write being asserted.
+ await pageB.click('a:has-text("View")');
+ await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
+ await pageB.waitForLoadState('networkidle');
+ await pageB.goto(`${BASE}#/menagerie`);
+ await pageB.reload();
+ await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
+ await pageB.waitForSelector('text=Check for updates', { timeout: 60000 });
+ if ((await pageB.textContent('body')).includes('new answers')) {
+ fail('the badge came back after the profile was looked at');
+ }
+
// Park B on A's (still-current) profile page: after A regenerates, this
// stale page's boop attempt must be turned away.
await pageB.goto(viewUrl);
diff --git a/libs/core/src/hatch/hatch-client.ts b/libs/core/src/hatch/hatch-client.ts
index 866e4ff..d4529b0 100644
--- a/libs/core/src/hatch/hatch-client.ts
+++ b/libs/core/src/hatch/hatch-client.ts
@@ -370,17 +370,55 @@ export class HatchClient {
}
}
+/** Everything one view fetch learned, for callers that want more than answers. */
+export interface FetchedView {
+ readonly payload: ProfilePayload;
+ /** The derived locator — worth keeping, it cost an Argon2id pass. */
+ readonly viewLocator: string;
+ /** Save count, the same number freshness checks compare against. */
+ readonly version: number;
+}
+
/**
- * Fetch and decrypt the open payload a view phrase points at — the one
- * derive→fetch→decrypt→migrate pipeline every viewer shares. Null when the
- * server has no record (deleted, expired, or re-minted).
+ * The one derive→fetch→decrypt→migrate pipeline every viewer shares. Null
+ * when the server has no record (deleted, expired, or re-minted).
*/
-export async function fetchViewPayload(
+export async function fetchView(
client: HatchClient,
viewPhrase: string,
-): Promise {
+): Promise {
const { viewLocator, viewKey } = await deriveViewKeys(viewPhrase);
const record = await client.getView(viewLocator);
if (!record) return null;
- return migrateToCurrent(await decryptBlob(record.blob_view, viewKey));
+ return {
+ payload: migrateToCurrent(await decryptBlob(record.blob_view, viewKey)),
+ viewLocator,
+ version: record.version,
+ };
+}
+
+/** Just the answers, for the callers that want nothing else. */
+export async function fetchViewPayload(
+ client: HatchClient,
+ viewPhrase: string,
+): Promise {
+ return (await fetchView(client, viewPhrase))?.payload ?? null;
+}
+
+/**
+ * How many times the profile behind a locator has been saved, or null when
+ * nothing answers to it (deleted, expired, or re-minted). No key needed: the
+ * version is metadata beside the ciphertext, so a viewer can tell that a
+ * profile changed without being able to read a word of it.
+ *
+ * Takes a locator rather than a phrase so callers holding a cached one skip
+ * the Argon2id derivation. The read still transfers the whole blob — the API
+ * has no metadata-only route — so this is cheap in CPU, not in bytes.
+ */
+export async function fetchViewVersion(
+ client: HatchClient,
+ viewLocator: string,
+): Promise {
+ const record = await client.getView(viewLocator);
+ return record ? record.version : null;
}
diff --git a/libs/core/src/hatch/priv-data.spec.ts b/libs/core/src/hatch/priv-data.spec.ts
new file mode 100644
index 0000000..3346921
--- /dev/null
+++ b/libs/core/src/hatch/priv-data.spec.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+import { connectionFreshness, migratePrivData, type PrivData } from './priv-data';
+
+describe('connectionFreshness', () => {
+ it('is current while the version has not moved', () => {
+ expect(connectionFreshness({ lastSeenVersion: 4 }, 4)).toBe('current');
+ });
+
+ it('is updated once the profile has been saved again', () => {
+ expect(connectionFreshness({ lastSeenVersion: 4 }, 7)).toBe('updated');
+ });
+
+ // A profile can only ever be re-keyed forward, but a stale baseline read
+ // from another device must never render as a negative "update".
+ it('is current when the server is somehow behind the baseline', () => {
+ expect(connectionFreshness({ lastSeenVersion: 9 }, 4)).toBe('current');
+ });
+
+ it('is gone when nothing answers to the locator', () => {
+ expect(connectionFreshness({ lastSeenVersion: 4 }, null)).toBe('gone');
+ });
+
+ // The badge is for changes you missed. A creature you have never opened —
+ // one kept before freshness checks existed — has no missed changes.
+ it('adopts the current version as the baseline when there is none', () => {
+ expect(connectionFreshness({}, 12)).toBe('current');
+ expect(connectionFreshness({ lastSeenVersion: undefined }, 12)).toBe('current');
+ });
+
+ it('still reports a never-opened creature as gone', () => {
+ expect(connectionFreshness({}, null)).toBe('gone');
+ });
+});
+
+describe('migratePrivData and the connection fields', () => {
+ function blobWith(connection: Record): unknown {
+ return {
+ v: 1,
+ viewPhrase: 'mellow-verdant-lobster-mistwoven-emberlit-fernhollow',
+ answers: {},
+ desiresSalt: null,
+ connections: [connection],
+ };
+ }
+
+ // The whole reason both fields are optional: a blob written before they
+ // existed has to keep opening, with no version bump and no upgrader.
+ it('opens a connection saved before freshness existed', () => {
+ const legacy = blobWith({
+ id: 'a',
+ label: 'kestrel',
+ viewPhrase: 'x-y-z-a-b-c',
+ notes: '',
+ addedAt: 1,
+ updatedAt: 1,
+ });
+ const priv: PrivData = migratePrivData(legacy);
+ expect(priv.connections[0].viewLocator).toBeUndefined();
+ expect(priv.connections[0].lastSeenVersion).toBeUndefined();
+ expect(connectionFreshness(priv.connections[0], 3)).toBe('current');
+ });
+
+ it('round-trips the cached locator and the baseline', () => {
+ const priv: PrivData = migratePrivData(
+ blobWith({
+ id: 'a',
+ label: 'kestrel',
+ viewPhrase: 'x-y-z-a-b-c',
+ notes: '',
+ addedAt: 1,
+ updatedAt: 1,
+ viewLocator: 'ff00',
+ lastSeenVersion: 2,
+ }),
+ );
+ expect(priv.connections[0].viewLocator).toBe('ff00');
+ expect(connectionFreshness(priv.connections[0], 3)).toBe('updated');
+ });
+});
diff --git a/libs/core/src/hatch/priv-data.ts b/libs/core/src/hatch/priv-data.ts
index 0fed4f5..af54369 100644
--- a/libs/core/src/hatch/priv-data.ts
+++ b/libs/core/src/hatch/priv-data.ts
@@ -14,6 +14,45 @@ export interface SavedConnection {
notes: string;
addedAt: number;
updatedAt: number;
+ /**
+ * The view locator derived from `viewPhrase`, cached because deriving it
+ * costs a full Argon2id pass — a menagerie of eight would otherwise spend
+ * half a minute just working out what to ask the server about. It is no
+ * more secret than the phrase sitting beside it in this same blob.
+ *
+ * Optional: connections saved before freshness checks existed have none,
+ * and `migratePrivData` fills absent fields rather than versioning them.
+ */
+ viewLocator?: string;
+ /**
+ * The profile's version the last time this person actually looked at it.
+ * The server bumps a profile's version on every save, so anything higher
+ * means new answers since. Absent means never looked — which reads as
+ * "nothing new", not "everything is new".
+ */
+ lastSeenVersion?: number;
+}
+
+/** Where a kept creature stands relative to the last time you looked. */
+export type ConnectionFreshnessState = 'current' | 'updated' | 'gone';
+
+/**
+ * Compare a kept connection against what the server holds now.
+ *
+ * `currentVersion` is null only for a profile that genuinely answers to
+ * nothing — deleted, expired, or re-minted. A failed request is not that, and
+ * callers must not collapse the two: an unreachable server would otherwise
+ * report every creature you know as gone.
+ */
+export function connectionFreshness(
+ connection: Pick,
+ currentVersion: number | null,
+): ConnectionFreshnessState {
+ if (currentVersion === null) return 'gone';
+ // No baseline means it has never been opened from here, and a change you
+ // were never shown is not a change you missed.
+ const seen = connection.lastSeenVersion ?? currentVersion;
+ return currentVersion > seen ? 'updated' : 'current';
}
/**
diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts
index 48f5775..8127d6d 100644
--- a/libs/core/src/index.ts
+++ b/libs/core/src/index.ts
@@ -59,6 +59,14 @@ export * from './hatch/phrases';
export { encryptBlob, decryptBlob } from './hatch/blob';
export * from './hatch/priv-data';
export * from './hatch/hatch-api';
-export { HatchClient, HatchError, fetchViewPayload, type HatchFailure } from './hatch/hatch-client';
+export {
+ HatchClient,
+ HatchError,
+ fetchView,
+ fetchViewPayload,
+ fetchViewVersion,
+ type FetchedView,
+ type HatchFailure,
+} from './hatch/hatch-client';
export * from './storage/storage';
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/about/about.component.ts b/src/app/about/about.component.ts
index 360e402..4890759 100644
--- a/src/app/about/about.component.ts
+++ b/src/app/about/about.component.ts
@@ -178,6 +178,13 @@ import { GC_EMPTY_HUMAN, GC_IDLE_HUMAN } from '@moxy/core';
phrases and tiny QR codes possible. You can self-host it — one dependency-free file in the
repository.
+
+ Your menagerie adds one read per kept creature when you open that page, so it can tell you
+ which of them have new answers. The server can’t read a word of what comes back — it
+ compares a save counter — but those reads look exactly like views, and they say that someone
+ is still interested in that profile. That’s why nothing checks in the background: the
+ requests happen when you open the page, and never while you’re away.
+
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 @@
+
+
+
@@ -58,10 +62,17 @@
-
+
+
+
+