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 @@ + + +
-
+ +
+ +

{{ announcement() }}

+