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 ( + `' + ); +}).join(''); + +const html = ` + +
+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.
- {{ - 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. + }
- 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. +