Skip to content

The feature plan, and Wave 1: freshness, the stranger's way onward, and focus - #24

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

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

Conversation

@Randolin

@Randolin Randolin commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Adds docs/feature-plan.md and 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:

  • You shared a phrase and 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 stranger who scanned a QR could only read and leave.
  • An audience overlapping heavily with disabled communities met an app with one aria-live region, 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 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. 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 #view on 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. PageTitleStrategy names 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-visible ring and an .sr-only utility.

Invariants

Nothing here touches the frozen crypto vectors, the append-only schema, or the wordlists.

SavedConnection gains 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: migratePrivData fills absent fields, so blobs written before this keep opening with no v bump 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, and e2e.

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).

claude added 3 commits August 28, 2026 03:51
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
@Randolin Randolin changed the title Plan the next features: the second player, the front door, access The feature plan, and Wave 1: freshness, the stranger's way onward, and focus Aug 28, 2026
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
@Randolin
Randolin merged commit 0cd812b into main Aug 28, 2026
2 checks passed
@Randolin
Randolin deleted the claude/product-strategy-positioning-u1vwig branch August 28, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants