Skip to content

The adoption plan, both waves: close the loop, and mark the doors - #31

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

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

Conversation

@Randolin

Copy link
Copy Markdown
Owner

All five items of docs/adoption-plan.md. No new cryptography, no server changes, no schema changes.

E1 — send your creature back

The last unlinked step in the loop. Someone scans a QR, views a stranger's profile, hatches, answers, compares — and the person who shared the phrase still has nothing. No phrase, no creature, nothing their menagerie can refresh.

The mechanism already existed and was already the app's own designed escalation: a boop carrying a view phrase is exactly "here is my creature back." What was missing was the offer, at the one moment the product has just proved itself to the person being asked. So this is a panel hosting the existing composer, not a feature — the de-anonymization ladder is untouched and attaching the phrase stays a tick the person makes.

The copy is conditional on purpose. This app cannot know whether the other person already holds your phrase, so it must not diagnose their state and must never imply they are waiting or watching. A spec bans the vocabulary of surveillance from it.

Six gates, each a dead end somebody would otherwise hit: a three-way comparison has no single "them"; a group snapshot carries no boop reachability by design; a profile older than boops has none either; a comparison you are not part of is not yours to answer; logged out there is no creature to send; and a creature you already booped is not re-offered. The demo is excluded structurally rather than by a flag.

Two deviations the code argued for:

  • The plan wanted a suggestAttachView input. One that pre-ticks would break the rule the item itself states; one that merely draws attention is mechanism for nothing. The panel's copy names the tick instead.
  • The "already sent" gate cannot read the ledger live. prepareBoop records the sent boop when the composer opens, not when it sends — so the obvious implementation made the panel delete itself the instant anyone used it, taking their half-written boop and their "Booped!" confirmation. The e2e found this; no unit test would have, because it needs a real click. It is a linkedSignal keyed on the pair now, and the untracked read inside is load-bearing: a linkedSignal computation tracks everything it reads, so a plain read puts the bug straight back.

E2–E5 — the door

  • The unfurl card. Deliberately generic: it names the product, never the sender, because a #/view/<phrase> link and a bare link must be indistinguishable in a preview a whole group chat can read. npm run social-card renders it through the pinned Chromium from the real sprites. og:image must be absolute or unfurlers ignore it, and the source can't hardcode a host, so the deploy stamps it from a repo variable exactly as it does the server URL — unset degrades the preview rather than failing the build.
  • Say what it costs. "About five minutes" on the demo CTA and the core marker. A fact, no timer, no percentage.
  • The compare empty state teaches the two moves that exist from there: logged out, the worked example; logged in, share and paste.
  • The install line in Settings, at the volume this front door allows, saying what the cache actually holds rather than promising offline profiles it can never have.

Also

Two more i18n misses of the same shape — multi-line ternaries inside interpolations, invisible to the D3 sweep's text-node pass. The template catalogue is 361 messages.

Verification

Full ladder green — format, both typechecks, all four unit suites, production build, e2e. New coverage: nine unit tests on the share-back gates including the ledger latch, an e2e walking the whole path (A compares, is offered, ticks the attachment itself, sends, sees the offer stand down, B's inbox gains a link to A's profile), and an e2e guarding the preview tags, their sender-anonymity, and that the image is a real PNG in the build.

The one thing neither wave could do for itself is look at the card — it's at public/social-card.png.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs


Generated by Claude Code

The last unlinked step in the loop. Someone scans a QR, views a stranger's
profile, hatches, answers, compares — and the person who shared the phrase
still has nothing. No phrase, no creature, nothing their menagerie can
refresh. The mechanism to fix that already existed and was already the app's
own designed escalation: a boop carrying a view phrase is exactly "here is my
creature back". What was missing was the offer, at the one moment the product
has just proved itself to the person being asked.

So this is a panel, not a feature. It hosts the existing composer and adds
framing; the de-anonymization ladder is untouched, and attaching the phrase
stays a tick the person makes.

The copy is conditional on purpose. This app cannot know whether the other
person already holds your phrase — they may have been handed it in the same
conversation — so it must not diagnose their state and must never imply they
are waiting or watching. It says how comparisons work and offers the means; a
spec bans the vocabulary of surveillance from it.

Six gates, each one a dead end somebody would otherwise hit: a three-way
comparison has no single "them"; a group snapshot carries no boop
reachability by design; a profile older than boops has none either; a
comparison you are not part of is not yours to answer; logged out there is no
creature to send; and a creature you already booped is not re-offered. The
demo is excluded structurally rather than by a flag — it renders the panels
component directly, and this lives one level up in the compare page.

Two things the plan did not predict:

- It wanted a `suggestAttachView` input on the composer. An input that
  pre-ticks would break the rule the item itself states, and one that merely
  draws attention is mechanism for nothing. The panel's copy names the tick
  instead: guidance with no code.

- The "already sent" gate cannot read the ledger live. `prepareBoop` records
  the sent boop when the composer OPENS, not when it sends, so the obvious
  implementation made the panel delete itself the instant anyone used it —
  taking their half-written boop and their "Booped!" confirmation with it.
  The e2e found it; no unit test would have, because it needs a real click.
  It is a linkedSignal keyed on the pair now, and the `untracked` read inside
  is load-bearing: a linkedSignal computation tracks everything it reads, so
  a plain read puts the bug straight back.

Also fixes an i18n miss from the template sweep: the composer's opening
blurb was a two-branch ternary inside an interpolation spanning lines, which
the sweep's text-node pass could not see. It is @if/@else with marked spans
now, which brings the template catalogue to 356.

Ladder green including e2e, which walks the whole path: A compares against B,
is offered the panel, ticks the attachment itself, sends, sees the offer
stand down, and B's inbox gains a boop carrying a link to A's profile.
…pty states

Four small front-of-funnel items, all copy except the card.

E2, the link preview. For most second people the first sight of Menagerie is
an unfurl in a chat app, and it was a bare URL. The card is deliberately
generic — it names the product, never the sender — because a #/view/<phrase>
link and a bare link must be indistinguishable in a preview that a whole
group chat can read. Hash routing makes that the only possible answer anyway:
the fragment never reaches a server, so no crawler can ever see whose profile
a link is for.

`npm run social-card` renders it through the pinned Chromium the e2e already
uses, from the real sprites, so it cannot drift into showing creatures the app
does not have. One thing the plan missed: og:image must be absolute or most
unfurlers ignore it, and the source cannot hardcode a host because anyone may
self-host this bundle. So the deploy stamps it from a repo variable, exactly
as it already does for the profile-server URL, and an unset variable degrades
the preview rather than failing the build.

E3, the cost. The core set is about five minutes and nothing said so; people
commit to bounded tasks. Stated on the two surfaces where someone decides —
the demo's hatch CTA and the core marker — as a fact, with no timer and no
percentage.

E4, the compare page's empty state. Compare-with-nothing is where a curious
person lands after hatching alone, and it was an input with a placeholder. It
now teaches the two moves that exist from exactly there: logged out, see the
worked example; logged in, share your phrase and paste theirs when they're
done.

E5, the install. The PWA has worked since wave 5 and nothing mentioned it.
One line in Settings, where people who already trust the app are, and it says
what the cache actually holds rather than promising offline access to
profiles it can never have.

Two things fell out:

- Another i18n miss, same shape as the last one: the core marker was a
  multi-line ternary inside an interpolation, invisible to the D3 sweep's
  text-node pass. Two marked branches now — 361 template messages.

- The unfurl e2e has to fetch the card with a plain GET rather than
  navigating to it. A service worker answers every navigation with the app
  shell, which is correct under hash routing and exactly what a crawler will
  never do. Modelling the real client is what makes the check mean anything.

Ladder green including e2e, which now guards the tags, the sender-anonymity
of their text, and that the image is a real PNG in the build.
@Randolin
Randolin merged commit f92a4d1 into main Aug 29, 2026
2 checks passed
@Randolin
Randolin deleted the claude/product-strategy-positioning-u1vwig branch August 29, 2026 05:23
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.

1 participant