Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 39 additions & 8 deletions docs/adoption-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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.

---

Expand All @@ -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

Expand Down
75 changes: 75 additions & 0 deletions e2e/run-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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`);
Expand Down
11 changes: 10 additions & 1 deletion locale/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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). ",
Expand Down Expand Up @@ -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. ",
Expand All @@ -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. ",
Expand All @@ -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. ",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added public/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions scripts/social-card.ts
Original file line number Diff line number Diff line change
@@ -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 (
`<svg viewBox="0 0 ${n} ${n}" width="150" height="150" shape-rendering="crispEdges">` +
spriteRects(sprite) +
'</svg>'
);
}).join('');

const html = `<!doctype html><meta charset="utf-8">
<style>
@page { margin: 0 }
html, body { margin: 0; padding: 0; }
body {
width: 1200px; height: 630px; box-sizing: border-box;
background: #fdfcfa; color: #26262b;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
display: flex; flex-direction: column; justify-content: space-between;
padding: 76px 80px 0;
}
h1 { font-size: 96px; margin: 0; letter-spacing: -2px; color: #4a3aa7; }
p.lede { font-size: 44px; margin: 18px 0 0; font-weight: 600; line-height: 1.15; }
p.sub { font-size: 28px; margin: 20px 0 0; color: #57555f; line-height: 1.35; }
.cast { display: flex; justify-content: space-between; align-items: flex-end;
padding-bottom: 56px; }
</style>
<body>
<div>
<h1>Menagerie</h1>
<p class="lede">Compatibility, minus the identity.</p>
<p class="sub">Anonymous profiles you compare by sharing a phrase.<br>No accounts, no names — the server stores only ciphertext it can't read.</p>
</div>
<div class="cast">${sprites}</div>
</body>`;

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}`);
Loading