From e8591d88f89aadd03896a7976f94a85b602eef2b Mon Sep 17 00:00:00 2001 From: Randolin Date: Sat, 29 Aug 2026 05:13:52 +0000 Subject: [PATCH 1/2] E1: send your creature back, offered where it finally makes sense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/adoption-plan.md | 18 ++- e2e/run-e2e.mjs | 45 ++++++ locale/messages.json | 4 + src/app/boop/boop-composer.component.ts | 14 +- src/app/compare/compare.component.ts | 74 +++++++++- src/app/compare/share-back.component.ts | 43 ++++++ src/app/compare/share-back.spec.ts | 189 ++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 src/app/compare/share-back.component.ts create mode 100644 src/app/compare/share-back.spec.ts diff --git a/docs/adoption-plan.md b/docs/adoption-plan.md index af53452..f9c5c58 100644 --- a/docs/adoption-plan.md +++ b/docs/adoption-plan.md @@ -124,7 +124,23 @@ walks that exact path (B compares, boops back with phrase attached, A's menagerie gains the creature), and the panel is absent on the demo and absent when a boop was already sent. -**Size.** Medium. The most valuable item in this document. +**Size.** Medium. The most valuable item in this document. **Shipped**, with +two deviations the code argued for: + +- **No `suggestAttachView` input.** The plan wanted the composer to open with + the checkbox highlighted; an input that pre-ticks would break the very rule + the item states, and one that merely draws attention is mechanism for + nothing. The panel's copy names the tick instead — guidance with no code, + and the composer is untouched. +- **The "already sent" gate had to be snapshotted, not read live.** + `prepareBoop` writes the sent-boop ledger the moment the composer _opens_, + not when it sends — so the obvious live read made the panel delete itself + the instant anyone used it, taking their half-written boop and their + "Booped!" confirmation with it. The e2e found this; no unit test would + have, because it needs a real click. It is a `linkedSignal` keyed on the + pair now, with an `untracked` ledger read that is load-bearing rather than + decorative: a linkedSignal computation tracks everything it reads, so a + plain read reintroduces the bug exactly. ### E2 · The unfurl card diff --git a/e2e/run-e2e.mjs b/e2e/run-e2e.mjs index 2a6e592..c5dd1fb 100644 --- a/e2e/run-e2e.mjs +++ b/e2e/run-e2e.mjs @@ -727,6 +727,51 @@ try { if (compareBody.includes('Impact play')) fail('one-sided desire leaked in compare'); await shot(page, '06-compare.png'); + // --- the loop closes: send your creature back ---------------------------- + // A is comparing against B and B can be booped, so the offer is live. It + // must state how comparisons work without diagnosing what B holds, and it + // must never perform the attachment tick on anyone's behalf. + step = 'share-back'; + { + const panel = page.locator('moxy-share-back'); + await panel.waitFor({ timeout: 30000 }); + const offer = await panel.textContent(); + if (!offer.includes('Send your creature back')) fail('share-back offer missing its heading'); + if (!offer.includes('Include my view phrase')) fail('share-back does not name the tick'); + for (const claim of ['waiting', 'viewed', 'hasn’t seen']) { + if (offer.includes(claim)) fail(`share-back claims something it can't know: ${claim}`); + } + + await panel.locator('button', { hasText: '👉 Boop' }).click(); + await panel.locator('.boop-check', { hasText: 'Curious to connect' }).locator('input').check(); + const attach = panel.locator('.boop-check', { hasText: 'Include my view phrase' }); + // The panel proposes; the composer's box is still the person's to tick. + if (await attach.locator('input').isChecked()) fail('share-back pre-ticked the attachment'); + await attach.locator('input').check(); + await panel.locator('button', { hasText: 'Send boop' }).click(); + await panel.locator('text=Booped!').waitFor({ timeout: 45000 }); + + // Already sent, so the offer stands down. Leave and come back rather than + // reloading: the comparison itself is in-memory by design and a reload + // would empty it, proving nothing about the offer. + await page.goto(`${BASE}#/me`); + await page.waitForSelector('.profile-head', { timeout: 45000 }); + await page.goto(`${BASE}#/compare`); + await page.waitForSelector('text=Overall alignment', { timeout: 60000 }); + if (await page.locator('moxy-share-back').count()) { + fail('share-back re-offered a creature it had already sent'); + } + + // And the point of the whole thing: B can now reach A's profile. + await pageB.goto(`${BASE}#/menagerie`); + await pageB.waitForSelector('text=says it’s from', { timeout: 45000 }); + const inbox = await pageB.textContent('body'); + if (!inbox.includes(personaName)) fail('the returned creature did not reach B'); + if (!(await pageB.locator('a', { hasText: 'Their profile' }).count())) { + fail('the returned boop carried no view phrase'); + } + } + // --- groups: create, join both tiers, compare, kick, re-mint -------------- step = 'group-create'; await page.goto(`${BASE}#/groups`); diff --git a/locale/messages.json b/locale/messages.json index 878da7d..57db366 100644 --- a/locale/messages.json +++ b/locale/messages.json @@ -87,6 +87,8 @@ "614027880250747579": "Preparing…", "1471701680789048774": "Reply sent — this exchange is complete.", "362079130839812948": "Booped! If they’re interested, their one reply will appear on your dashboard.", + "2054936685978360086": "One reply, then the channel closes. Share only what you choose to.", + "1259475218192933281": "A boop says “I’m interested” — no message box, no pressure. They can reply once or quietly decline; you won’t be notified either way.", "3063198073582644382": "What are you hoping for?", "8932063094285886619": "Include my view phrase", "5334881894485006056": " They’ll see your full open profile and can boop you back. Your creature stays anonymous — but a shared view phrase can’t be unshared (regenerating your creature is the only undo). ", @@ -138,6 +140,8 @@ "8176302782334337147": "Highlighted rows are mutual — everyone answered is at least “Curious”.", "4960886639567794659": "Values, side by side", "4230432534936583332": "Each dot is a person. Distance between dots is the actual gap.", + "7349333897142711827": "Send your creature back", + "5838641252478503523": " A comparison only exists for whoever holds both phrases. You have {$INTERPOLATION}’s; if they don’t have yours, a boop can carry it — tick {$START_TAG_STRONG}Include my view phrase{$CLOSE_TAG_STRONG} below and they’ll see your creature and can compare from their side. ", "2759266804068105206": "Meet the menagerie", "9070075826440424913": " Every profile hatches as one of these 64 creatures — the animal is the third word of its view phrase. Same phrase, same creature, for everyone who looks. ", "107744438304604316": " Art is first-party pixel work and always improving — the creature on your dashboard may get a glow-up someday, but it will never change species. ", diff --git a/src/app/boop/boop-composer.component.ts b/src/app/boop/boop-composer.component.ts index 56d00e3..19f0f47 100644 --- a/src/app/boop/boop-composer.component.ts +++ b/src/app/boop/boop-composer.component.ts @@ -52,12 +52,14 @@ type Phase = 'idle' | 'staging' | 'composing' | 'sending' | 'done'; @default {

- {{ - replyTo() - ? 'One reply, then the channel closes. Share only what you choose to.' - : 'A boop says “I’m interested” — no message box, no pressure. They can reply - once or quietly decline; you won’t be notified either way.' - }} + @if (replyTo()) { + One reply, then the channel closes. Share only what you choose to. + } @else { + A boop says “I’m interested” — no message box, no pressure. They can reply once or + quietly decline; you won’t be notified either way. + }

What are you hoping for? diff --git a/src/app/compare/compare.component.ts b/src/app/compare/compare.component.ts index 5fa15df..1b2ef67 100644 --- a/src/app/compare/compare.component.ts +++ b/src/app/compare/compare.component.ts @@ -1,13 +1,21 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + linkedSignal, + untracked, +} from '@angular/core'; import { ToastService, seriesVar } from '@moxy/ui'; import { CompareStore } from '../stores/compare.store'; import { ProfileSessionStore } from '../stores/profile-session.store'; import { ComparePanelsComponent } from './compare-panels.component'; +import { ShareBackComponent } from './share-back.component'; @Component({ selector: 'moxy-compare', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ComparePanelsComponent], + imports: [ComparePanelsComponent, ShareBackComponent], template: `

Compare profiles

@@ -82,6 +90,9 @@ import { ComparePanelsComponent } from './compare-panels.component'; @if (store.model(); as m) { @if (m.payloads.length >= 2) { + @if (shareBack(); as back) { + + } } @else {

@@ -108,6 +119,65 @@ export class CompareComponent { ); }); + /** + * The other party in a two-way comparison you are part of, when they can + * receive a boop at all. + * + * Every clause is a gate someone would otherwise hit as a dead end: 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; + * and a comparison you are not in is not yours to answer. + */ + private readonly otherParty = computed(() => { + const m = this.store.model(); + const mine = this.session.viewPhrase(); + if (!m || !this.session.active() || !mine) return null; + + const good = m.slots.filter((s) => s.payload); + if (good.length !== 2) return null; + const meIndex = good.findIndex((s) => s.ref === mine); + if (meIndex < 0) return null; + + const them = good[1 - meIndex]; + const target = them.payload?.k; + if (!target) return null; + + return { + target, + name: them.persona?.name ?? them.label ?? 'them', + emoji: them.persona?.emoji ?? them.emoji ?? '🥚', + }; + }); + + /** + * Who this profile had already booped when this pair was loaded — and + * deliberately not since. + * + * `prepareBoop` writes the sent-boop ledger the moment the composer opens, + * not when the boop is sent, so reading the ledger live made the panel + * delete itself the instant anyone used it, taking their half-written boop + * and their "Booped!" confirmation with it. Snapshotting at the pair is the + * fix: the offer is a conversation you are in the middle of, and it stands + * until the comparison itself changes. + */ + private readonly alreadyBooped = linkedSignal({ + source: () => this.otherParty()?.target.inbox ?? null, + // `untracked` is load-bearing, not decoration: a linkedSignal computation + // tracks every signal it reads, so a plain read of the ledger would make + // it a live dependency again and reintroduce exactly the bug this exists + // to fix. The pair is meant to be the only thing that resets this. + computation: () => untracked(() => new Set(this.session.sentBoops().map((b) => b.label))), + }); + + protected readonly shareBack = computed(() => { + const them = this.otherParty(); + if (!them) return null; + // SentBoop records the creature name it was addressed to, and within one + // person's own ledger that name is a sufficient key. A collision would + // only mean not re-offering, which is the safe direction to be wrong in. + return this.alreadyBooped().has(them.name) ? null : them; + }); + protected slotName(slotIndex: number): string { const m = this.store.model(); if (!m) return '…'; diff --git a/src/app/compare/share-back.component.ts b/src/app/compare/share-back.component.ts new file mode 100644 index 0000000..645f268 --- /dev/null +++ b/src/app/compare/share-back.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { BoopReachability } from '@moxy/core'; +import { BoopComposerComponent } from '../boop/boop-composer.component'; + +/** + * The last unlinked step in the loop, offered where it finally makes sense. + * + * Someone scans a QR, views a stranger's profile, hatches, answers, and + * compares. They now know everything; the person who shared the phrase still + * has nothing — no phrase, no creature, nothing their menagerie can refresh. + * The mechanism to fix that already exists and is 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. + * + * 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 it must never imply + * they are waiting or watching. It states how comparisons work and offers the + * means. Attaching the phrase stays a tick inside the composer: the panel + * proposes, the person disposes, and the de-anonymization ladder is untouched. + */ +@Component({ + selector: 'moxy-share-back', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [BoopComposerComponent], + template: ` +

+

Send your creature back

+

+ A comparison only exists for whoever holds both phrases. You have {{ name() }}’s; if they + don’t have yours, a boop can carry it — tick Include my view phrase below + and they’ll see your creature and can compare from their side. +

+ +
+ `, +}) +export class ShareBackComponent { + readonly target = input.required(); + readonly name = input.required(); + readonly emoji = input('🥚'); +} diff --git a/src/app/compare/share-back.spec.ts b/src/app/compare/share-back.spec.ts new file mode 100644 index 0000000..9ee4580 --- /dev/null +++ b/src/app/compare/share-back.spec.ts @@ -0,0 +1,189 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { MemoryStorage, type BoopReachability, type Persona } from '@moxy/core'; +import { signal } from '@angular/core'; +import { CompareComponent } from './compare.component'; +import { CompareStore } from '../stores/compare.store'; +import { ProfileSessionStore } from '../stores/profile-session.store'; +import { APP_STORAGE } from '../stores/storage.token'; +import type { CompareModel, CompareSlot } from './compare-model'; + +const MINE = 'brave-azure-otter-mistwoven-emberlit-fernhollow'; +const THEIRS = 'calm-bright-owl-moonlit-honeywarmed-willowbrook'; +const REACH: BoopReachability = { pub: 'PUB', inbox: 'INBOX' }; + +function persona(name: string): Persona { + return { + words: name.split('-').slice(0, 3) as unknown as Persona['words'], + name: name.split('-').slice(0, 3).join('-'), + emoji: '🦦', + color: '#0b5e8a', + color2: '#1e5f9e', + colorIndex: 11, + }; +} + +function slot(ref: string, opts: { reach?: BoopReachability; broken?: boolean } = {}): CompareSlot { + if (opts.broken) return { ref, error: 'nope' }; + return { + ref, + payload: { v: 2, a: { 'ab.age': 1 }, ...(opts.reach ? { k: opts.reach } : {}) }, + persona: persona(ref), + }; +} + +function model(slots: readonly CompareSlot[]): CompareModel { + const good = slots.filter((s) => s.payload); + return { + slots, + payloads: good.map((s) => s.payload!), + names: good.map((s) => s.persona!.name), + emojis: good.map((s) => s.persona!.emoji), + grid: [], + pair: null, + interlocks: [], + pairwise: [], + mutualSeekingCount: 0, + desireRows: [], + withTokensCount: 0, + }; +} + +/** + * The share-back offer is the loop's last step, so what matters is not that + * it renders but that it stays silent in every situation where the offer + * would be wrong: a comparison you are not part of, a third party, someone + * unreachable, and someone you already sent your creature to. + */ +describe('the share-back offer', () => { + let store: { model: ReturnType> } & Record< + string, + unknown + >; + let session: ProfileSessionStore; + + function render(): string { + const fixture = TestBed.createComponent(CompareComponent); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + beforeEach(async () => { + store = { + model: signal(undefined), + entries: signal([]), + full: false, + remove: () => undefined, + clear: () => undefined, + addPhrase: () => true, + addFromText: () => true, + }; + await TestBed.configureTestingModule({ + imports: [CompareComponent], + providers: [ + provideRouter([]), + { provide: APP_STORAGE, useValue: new MemoryStorage() }, + { provide: CompareStore, useValue: store }, + ], + }).compileComponents(); + + session = TestBed.inject(ProfileSessionStore); + session.active.set(true); + session.viewPhrase.set(MINE); + }); + + it('offers to send your creature back after a two-way comparison', () => { + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + const text = render(); + expect(text).toContain('Send your creature back'); + // Names the tick rather than performing it: the panel proposes only. + expect(text).toContain('Include my view phrase'); + // States how comparisons work; never claims to know what they hold. + expect(text).toContain('if they don’t have yours'); + }); + + it('says nothing about whether they are waiting or watching', () => { + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + const text = render().toLowerCase(); + for (const forbidden of ['waiting', 'hasn’t seen', 'has not seen', 'still hasn', 'viewed']) { + expect(text, forbidden).not.toContain(forbidden); + } + }); + + it('stays silent in a comparison you are not part of', () => { + store.model.set(model([slot(THEIRS, { reach: REACH }), slot('a-b-c-d-e-f', { reach: REACH })])); + expect(render()).not.toContain('Send your creature back'); + }); + + it('stays silent for three profiles, where there is no single "them"', () => { + store.model.set( + model([slot(MINE), slot(THEIRS, { reach: REACH }), slot('a-b-c-d-e-f', { reach: REACH })]), + ); + expect(render()).not.toContain('Send your creature back'); + }); + + it('stays silent when the other profile cannot be booped', () => { + // A group snapshot or a profile that predates boops: no reachability. + store.model.set(model([slot(MINE), slot(THEIRS)])); + expect(render()).not.toContain('Send your creature back'); + }); + + it('stays silent once this profile has already booped that creature', () => { + session.sentBoops.set([ + { + id: 'b1', + label: persona(THEIRS).name, + emoji: '🦉', + replyBox: { locator: 'L', token: 'T', key: 'K' }, + sentAt: 1, + status: 'sent', + }, + ]); + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + expect(render()).not.toContain('Send your creature back'); + }); + + it('survives the composer writing the ledger the moment it opens', () => { + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + const fixture = TestBed.createComponent(CompareComponent); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Send your creature back'); + + // prepareBoop records the sent boop when the composer OPENS, not when it + // sends. Read live, that ledger write would delete the panel out from + // under someone mid-boop, taking their draft and their confirmation. + session.sentBoops.set([ + { + id: 'b1', + label: persona(THEIRS).name, + emoji: '🦉', + replyBox: { locator: 'L', token: 'T', key: 'K' }, + sentAt: 1, + status: 'pending', + }, + ]); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Send your creature back'); + + // A different pair re-reads the ledger, so the offer stands down there. + store.model.set(model([slot(MINE), slot(THEIRS, { reach: { pub: 'P2', inbox: 'INBOX2' } })])); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain( + 'Send your creature back', + ); + }); + + it('stays silent when logged out, since there is no creature to send', () => { + session.active.set(false); + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + expect(render()).not.toContain('Send your creature back'); + }); + + it('is not part of the printable document', () => { + store.model.set(model([slot(MINE), slot(THEIRS, { reach: REACH })])); + const fixture = TestBed.createComponent(CompareComponent); + fixture.detectChanges(); + const panel = (fixture.nativeElement as HTMLElement).querySelector('moxy-share-back .card'); + expect(panel?.classList.contains('no-print')).toBe(true); + }); +}); From 3e8642dd4d1398493f1fdf44f1c582f0cd3d4a14 Mon Sep 17 00:00:00 2001 From: Randolin Date: Sat, 29 Aug 2026 05:20:17 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Wave=20E-2:=20the=20door=20=E2=80=94=20an?= =?UTF-8?q?=20unfurl=20card,=20a=20stated=20cost,=20and=20two=20taught=20e?= =?UTF-8?q?mpty=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- .github/workflows/deploy.yml | 19 +++++++ docs/adoption-plan.md | 29 +++++++--- e2e/run-e2e.mjs | 30 ++++++++++ locale/messages.json | 7 ++- package.json | 1 + public/social-card.png | Bin 0 -> 47135 bytes scripts/social-card.ts | 69 +++++++++++++++++++++++ src/app/compare/compare.component.ts | 14 ++++- src/app/dashboard/dashboard.component.ts | 17 +++--- src/app/demo/demo.component.ts | 5 +- src/app/settings/settings.component.ts | 4 ++ src/index.html | 21 +++++++ 12 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 public/social-card.png create mode 100644 scripts/social-card.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ac5918d..3084619 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -84,6 +84,25 @@ jobs: JSON.stringify({ serverUrl: process.env.MOXY_SERVER_URL ?? "" }) + "\n");' cat dist/moxy/browser/moxy.config.json + # og:image must be absolute — most link unfurlers will not resolve a + # relative one — but the source can't hardcode a host, because anyone + # may self-host this bundle anywhere. So the deploy that knows its own + # address is what fills it in, from the same kind of repo variable that + # already carries the server URL; a copy served from somewhere else + # keeps the relative path rather than pointing at ours. Unset is a + # degraded preview, never a failed build. + - name: Stamp the deployed origin into the link-preview image URL + env: + MOXY_SITE_URL: ${{ vars.MOXY_SITE_URL }} + run: | + node -e 'const fs = require("fs"); + const base = (process.env.MOXY_SITE_URL ?? "").replace(/\/+$/, ""); + if (!base) { console.log("no site URL known — og:image stays relative"); process.exit(0); } + const f = "dist/moxy/browser/index.html"; + fs.writeFileSync(f, fs.readFileSync(f, "utf8") + .replace(/(property="og:image" content=")social-card\.png/, `$1${base}/social-card.png`)); + console.log(`og:image → ${base}/social-card.png`);' + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/docs/adoption-plan.md b/docs/adoption-plan.md index f9c5c58..e116cae 100644 --- a/docs/adoption-plan.md +++ b/docs/adoption-plan.md @@ -161,7 +161,14 @@ unfurl is visible to shoulders the phrase is not meant for. **Done when** a `#/view/…` link and a bare link both unfurl with the card in a chat client, and the image is regenerable from the repo. -**Size.** Small. +**Size.** Small. **Shipped.** `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 doesn't have. One thing the item missed: +`og:image` has to 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 instead of +failing the build. ### E3 · Say what it costs @@ -178,7 +185,10 @@ to be. **Done when** both surfaces state the bound, the strings carry i18n markers, and `i18n:extract` has run. -**Size.** Tiny. +**Size.** Tiny. **Shipped**, and it turned up a second i18n miss: the core +marker was a multi-line ternary inside an interpolation, invisible to the D3 +sweep's text-node pass for the same reason the boop composer's blurb was. Two +marked branches now. ### E4 · The compare page teaches its own empty state @@ -193,7 +203,7 @@ already exist. **Done when** both variants render, are translatable, and the demo link is absent once any profile is loaded. -**Size.** Tiny. +**Size.** Tiny. **Shipped.** ### E5 · Mention the install @@ -204,7 +214,8 @@ instructions, which rot. **Where.** `src/app/settings/settings.component.ts`. -**Size.** Tiny. +**Size.** Tiny. **Shipped**, and it says what the cache actually holds rather +than promising offline access to profiles it can never have. --- @@ -215,10 +226,14 @@ the only new e2e path, and it deserves an undiluted review. **Wave E-2 — the door.** E2 + E3 + E4 + E5 in one pass: all small, all front-of-funnel, one wave of copy-heavy diffs and one screenshot review. +**Shipped.** -Before either: merge the outstanding branch (D1 + D3, four commits) so this -plan starts from a green `main` that already contains the offline saves and -the i18n rails E3/E4 depend on. +Both waves are done. The one thing neither could do for itself is look at the +link-preview card, which is a judgement about a picture; it is checked into +`public/social-card.png` and regenerable. + +The prerequisite (merging D1 + D3 so this plan starts from a green `main`) is +done — PR #30. ## Invariants this plan touches diff --git a/e2e/run-e2e.mjs b/e2e/run-e2e.mjs index c5dd1fb..b5b7129 100644 --- a/e2e/run-e2e.mjs +++ b/e2e/run-e2e.mjs @@ -59,6 +59,7 @@ const MIME = { '.json': 'application/json', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', + '.png': 'image/png', }; const server = createServer((req, res) => { const path = decodeURIComponent(new URL(req.url, 'http://x').pathname); @@ -379,6 +380,35 @@ try { await shot(pseudo, '01c-pseudo-locale.png'); } + // --- the link preview, which is the app's first impression for most ------ + // Served from the real build, because the failure this catches is an asset + // that never shipped or a tag that lost its image — both of which look fine + // in source and produce a blank card in every chat app. + step = 'unfurl'; + { + const head = readFileSync(join(DIST, 'index.html'), 'utf8'); + for (const tag of ['og:title', 'og:description', 'og:image', 'og:image:alt', 'twitter:card']) { + if (!head.includes(tag)) fail(`link preview missing ${tag}`); + } + // Generic by design: a phrase link and a bare link must be + // indistinguishable in a preview a whole group chat can see. + const preview = /property="og:(title|description)" content="([^"]*)"/g; + for (const [, , text] of head.matchAll(preview)) { + for (const leak of ['shared', 'someone', 'their profile', 'invited']) { + if (text.toLowerCase().includes(leak)) fail(`link preview describes the sender: ${leak}`); + } + } + // A plain GET, not page.goto: an unfurler is a crawler with no service + // worker, and navigating would (correctly) be answered with the app shell, + // because under hash routing every navigation is the same document. + const card = await page.request.get(`${BASE}social-card.png`); + if (!card.ok()) fail('the link preview image is not in the build'); + if (!(card.headers()['content-type'] ?? '').includes('image/png')) { + fail('the link preview image is not a PNG — unfurlers will not render SVG'); + } + if ((await card.body()).length < 5000) fail('the link preview image is suspiciously empty'); + } + step = 'landing-configure'; await page.fill('input[aria-label="Profile server URL"]', main.url); await page.click('text=Use this server'); diff --git a/locale/messages.json b/locale/messages.json index 57db366..ab16ecc 100644 --- a/locale/messages.json +++ b/locale/messages.json @@ -117,6 +117,8 @@ "2303489635985691360": "Clear all", "1481605098744018914": " Comparisons happen entirely in this tab and vanish when you leave — the server only ever sees encrypted lookups. ", "699726738176158122": " Add at least two profiles to see the comparison — your own, people you’ve saved, or any view phrase you’ve been given. ", + "8774312932070731485": " Waiting on someone? Share your phrase or QR from {$START_LINK}your profile{$CLOSE_LINK}, and paste theirs here once they’ve answered. ", + "6861508645368701636": " Not sure what this looks like? {$START_LINK}See a worked example{$CLOSE_LINK} — two fictional creatures, the real panels. ", "1196420170769634706": "That profile is already here", "1081783254060859098": "Agreement, item by item", "5171640707814251990": " Every question you both answered, placed by how closely your answers sit. Hover a dot to see the question and both answers. ", @@ -162,6 +164,8 @@ "5538639050278697282": "Keep someone's phrase", "1536087519743707362": "Dismiss", "7382892965016633694": "My answers", + "1658867796419099936": "Core complete — comparisons have their footing", + "8481472613462342620": "Core {$INTERPOLATION} of {$INTERPOLATION_1} — about five minutes in all, and comparisons work best from a full one", "8673906902144377191": " Nothing here yet. Add a category above — every question is optional, and only what you answer is ever shown. ", "455755960340581733": "What a comparison looks like", "5087076643664546719": " Two profiles that don’t exist, compared for real. Every number below is computed by the same code your own comparisons use — nothing here is a screenshot, and nothing here is stored or sent anywhere. ", @@ -170,7 +174,7 @@ "1427333043726369377": "Go to the start", "8256691183966383213": " Invented for this page. They have opposite ideas about drinking, and one of them made that a dealbreaker — which is the kind of thing worth knowing early, and the kind of thing this survey exists to surface. ", "4845283443784862186": "Your turn", - "6614535383443041247": " Hatching takes a second and needs no account, no email, and no name. Answer the core set, share your phrase with one person, and you get this — about the two of you. ", + "7067174920925265964": " Hatching takes a second and needs no account, no email, and no name. Answer the core set — about five minutes — share your phrase with one person, and you get this, about the two of you. ", "6448778379135420171": "Hatching…", "2010687771546657539": "🥚 Hatch my creature", "3163044740176820503": "Get started", @@ -304,6 +308,7 @@ "1043459631891336498": "Keep unsaved answers on this device — encrypted under your edit phrase, so they come back when you next log in here", "2686892630579191692": " Both boxes are ticked, so this browser holds your edit phrase {$START_EMPHASISED_TEXT}and{$CLOSE_EMPHASISED_TEXT} the answers it unlocks. Anyone with this device has both halves — fine on a phone only you use, worth reconsidering on a shared one. ", "6927917500623453775": "Your {$START_TAG_STRONG}new edit phrase{$CLOSE_TAG_STRONG} — the old one is dead. Save this one now:", + "3793902472279617045": " Menagerie can be added to your home screen. It opens without the network afterwards — the app’s own files only; profiles still come from the server. ", "7106919148220447040": "🖨️ Backup card", "7079434707194703659": "Change edit phrase", "1127750932472778489": "Log out on this device", diff --git a/package.json b/package.json index 51eab59..b254562 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "e2e": "node e2e/run-e2e.mjs", "server": "node server/moxy-sync-server.ts", "sprites": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./scripts/ts-resolve.mjs scripts/sprite-sheet.ts", + "social-card": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./scripts/ts-resolve.mjs scripts/social-card.ts", "seed:qa": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./scripts/ts-resolve.mjs scripts/seed-qa.ts", "i18n:extract": "ng extract-i18n && npm run i18n:domain", "i18n:domain": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./scripts/ts-resolve.mjs scripts/extract-messages.ts", diff --git a/public/social-card.png b/public/social-card.png new file mode 100644 index 0000000000000000000000000000000000000000..282e7555403121bdc85cba3a4a58ba1ca2d9ebcd GIT binary patch literal 47135 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 Enq^nbYz^x`+^3FNy@5cwt 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 { + const sprite = CREATURE_SPRITES[name]; + if (!sprite) throw new Error(`no sprite for ${name} — pick another for the card`); + const n = sprite.rows.length; + return ( + `` + + spriteRects(sprite) + + '' + ); +}).join(''); + +const html = ` + + +
+

Menagerie

+

Compatibility, minus the identity.

+

Anonymous profiles you compare by sharing a phrase.
No accounts, no names — the server stores only ciphertext it can't read.

+
+
${sprites}
+`; + +const browser = await chromium.launch({ executablePath: CHROMIUM }); +const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); +await page.setContent(html); +await page.screenshot({ path: OUT }); +await browser.close(); +console.log(`wrote ${OUT}`); diff --git a/src/app/compare/compare.component.ts b/src/app/compare/compare.component.ts index 1b2ef67..4ed8139 100644 --- a/src/app/compare/compare.component.ts +++ b/src/app/compare/compare.component.ts @@ -6,6 +6,7 @@ import { linkedSignal, untracked, } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { ToastService, seriesVar } from '@moxy/ui'; import { CompareStore } from '../stores/compare.store'; import { ProfileSessionStore } from '../stores/profile-session.store'; @@ -15,7 +16,7 @@ import { ShareBackComponent } from './share-back.component'; @Component({ selector: 'moxy-compare', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ComparePanelsComponent, ShareBackComponent], + imports: [ComparePanelsComponent, RouterLink, ShareBackComponent], template: `

Compare profiles

@@ -99,6 +100,17 @@ import { ShareBackComponent } from './share-back.component'; Add at least two profiles to see the comparison — your own, people you’ve saved, or any view phrase you’ve been given.

+ @if (session.active()) { +

+ Waiting on someone? Share your phrase or QR from + your profile, and paste theirs here once they’ve answered. +

+ } @else { +

+ Not sure what this looks like? See a worked example — two + fictional creatures, the real panels. +

+ }
} } diff --git a/src/app/dashboard/dashboard.component.ts b/src/app/dashboard/dashboard.component.ts index 7217fba..a73bcaa 100644 --- a/src/app/dashboard/dashboard.component.ts +++ b/src/app/dashboard/dashboard.component.ts @@ -177,15 +177,14 @@ import { ProfileSessionStore } from '../stores/profile-session.store';

My answers

- {{ - coreDone() - ? 'Core complete — comparisons have their footing' - : 'Core ' + - coreAnswered() + - ' of ' + - coreTotal + - ' — comparisons work best from a full core' - }} + @if (coreDone()) { + Core complete — comparisons have their footing + } @else { + Core {{ coreAnswered() }} of {{ coreTotal }} — about five minutes in all, and + comparisons work best from a full one + }
diff --git a/src/app/demo/demo.component.ts b/src/app/demo/demo.component.ts index 8df54dc..3c66190 100644 --- a/src/app/demo/demo.component.ts +++ b/src/app/demo/demo.component.ts @@ -65,8 +65,9 @@ import { ServerConfigStore } from '../stores/server-config.store';

Your turn

- Hatching takes a second and needs no account, no email, and no name. Answer the core set, - share your phrase with one person, and you get this — about the two of you. + Hatching takes a second and needs no account, no email, and no name. Answer the core set — + about five minutes — share your phrase with one person, and you get this, about the two of + you.

@if (ready()) {
} +

+ Menagerie can be added to your home screen. It opens without the network afterwards — the + app’s own files only; profiles still come from the server. +

🖨️ Backup card diff --git a/src/index.html b/src/index.html index 1265874..07606e6 100644 --- a/src/index.html +++ b/src/index.html @@ -10,6 +10,27 @@ content="Hatch an anonymous compatibility profile, share it as a phrase or QR code, and lay profiles side by side to see where you overlap. No accounts, no names, no tracking — the server stores only ciphertext it can't read." /> + + + + + + + + + + +