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 af53452..e116cae 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 @@ -145,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 @@ -162,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 @@ -177,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 @@ -188,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. --- @@ -199,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 2a6e592..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'); @@ -727,6 +757,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..ab16ecc 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). ", @@ -115,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. ", @@ -138,6 +142,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. ", @@ -158,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. ", @@ -166,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", @@ -300,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 0000000..282e755 Binary files /dev/null and b/public/social-card.png differ diff --git a/scripts/social-card.ts b/scripts/social-card.ts new file mode 100644 index 0000000..52b2565 --- /dev/null +++ b/scripts/social-card.ts @@ -0,0 +1,69 @@ +// Render the link-preview card to public/social-card.png. +// +// npm run social-card +// +// The first sight of Menagerie for most second people is an unfurl in a chat +// app, not the site — so this image is doing the introduction. It is +// deliberately generic: it names the product, never the sender, because a +// phrase link and a bare link must be indistinguishable in a preview. That +// preview is visible to shoulders the phrase was never meant for. +// +// Generated rather than hand-exported so it stays reproducible from the repo, +// and drawn from the real sprites so it cannot drift into showing creatures +// the app doesn't have. Chromium does the rasterising because SVG is not a +// format link unfurlers reliably render, and playwright-core is already here +// for the e2e. +import { chromium } from 'playwright-core'; +import { fileURLToPath } from 'node:url'; +import { CREATURE_SPRITES } from '../libs/ui/src/creatures/pixel-grids'; +import { spriteRects } from '../libs/ui/src/creatures/pixel-art'; + +/** Wide, unmistakable silhouettes — this is read at thumbnail size. */ +const CAST = ['fox', 'owl', 'otter', 'hedgehog', 'deer', 'axolotl']; + +const OUT = fileURLToPath(new URL('../public/social-card.png', import.meta.url)); +const CHROMIUM = process.env.CHROMIUM_BIN ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; + +const sprites = CAST.map((name) => { + 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/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..4ed8139 100644 --- a/src/app/compare/compare.component.ts +++ b/src/app/compare/compare.component.ts @@ -1,13 +1,22 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + 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'; import { ComparePanelsComponent } from './compare-panels.component'; +import { ShareBackComponent } from './share-back.component'; @Component({ selector: 'moxy-compare', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ComparePanelsComponent], + imports: [ComparePanelsComponent, RouterLink, ShareBackComponent], template: `

Compare profiles

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

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

+ }
} } @@ -108,6 +131,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); + }); +}); 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." /> + + + + + + + + + + +