Add link previews and icons; make PIG_INVITE_CODE actually work

Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons,
generated from an HTML template by a script so the mark, wordmark and tagline
cannot drift from the product. The apple-touch-icon referenced in index.html
was a 404 until now. Icons are drawn on an opaque plate with inset because iOS
rounds corners and Android may apply a circle — an edge-to-edge mark loses its
ears to that crop. og:image is absolute, which is the single most common
reason a card unfurls blank.

PIG_INVITE_CODE was a lie. Signup validates against the invites table, so
setting the variable only flipped a label in the UI — an operator would set it,
hand the code to a colleague, and watch them be rejected. It is now reconciled
into a real invite row at boot: setting it issues, changing it rotates and
revokes the predecessor, and removing it revokes. Verified all three, plus that
a restart with an unchanged code does not duplicate.

Two bugs found while doing that:

- `uses_remaining` was jsonb, so the SQL decrement could never have worked.
  Now integer. The generated migration failed because Postgres has no implicit
  jsonb->integer cast, so the USING clause is hand-written.
- Redemption keyed off `redeemedAt`, which would have made every reusable
  invite single-use — a confusing way to lock a team out. Availability now
  comes from `usesRemaining`, and redemption records who used it most recently
  without consuming it.

Sign-in gains a password option alongside the magic link, defaulting to
password since that is the daily path. PIG stores neither; both are handled by
the identity provider and PIG only ever sees the resulting token.
autocomplete is set so password managers and iOS can fill.

Verified: full migration chain applies to a fresh Postgres, the CSP hash for
the inline theme script is unchanged by the rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:43:10 -07:00
parent 93818a2d2c
commit 0551e8dd6e
19 changed files with 6855 additions and 53 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* Render the social card and icon set to PNG.
*
* node apps/web/tools/render-assets.mjs
*
* Generated rather than hand-drawn so that the mark, the wordmark and the
* tagline cannot drift apart from the product, and so regenerating after a
* brand change is one command.
*
* Uses the system Chrome via `channel: 'chrome'` rather than Playwright's own
* bundled browser, so `npm install` does not pull a ~150MB download for a
* script that runs occasionally, by hand, when the brand changes.
*/
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const publicDir = join(here, '..', 'public');
const PIG_SVG = `
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="INK"/>
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="INK"/>
<path d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z" fill="INK"/>
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="PAPER"/>
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
</svg>`;
const icon = (ink, paper) => PIG_SVG.replaceAll('INK', ink).replaceAll('PAPER', paper);
/**
* An icon page with padding around the mark.
*
* Home-screen and maskable icons are cropped by the platform — iOS rounds the
* corners, Android may apply a circle. A mark drawn edge to edge loses its
* ears to that crop, so it is inset inside a filled plate.
*/
function iconPage({ size, inset, background, ink, radius = 0 }) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
*{margin:0;padding:0;box-sizing:border-box}
body{width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;
background:${background};border-radius:${radius}px;overflow:hidden}
svg{width:${size - inset * 2}px;height:${size - inset * 2}px}
</style></head><body>${icon(ink, background)}</body></html>`;
}
const browser = await chromium.launch({ channel: 'chrome' });
async function shoot(html, path, width, height, scale = 1) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: scale,
});
const page = await ctx.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.screenshot({ path, omitBackground: false });
await ctx.close();
console.log(` ${path.replace(publicDir + '/', '')} ${width * scale}×${height * scale}`);
}
console.log('Rendering assets…');
// Social card. Rendered at 1× because 1200×630 is already the target size —
// unfurlers do not use a 2× variant and the extra bytes cost load time.
const { readFile } = await import('node:fs/promises');
const ogHtml = await readFile(join(here, 'og-template.html'), 'utf8');
await shoot(ogHtml, join(publicDir, 'og.png'), 1200, 630, 1);
// Apple touch icon. iOS composites onto whatever it likes and rounds the
// corners itself, so this is drawn on an opaque white plate with generous
// inset — a transparent one renders black on some home screens.
await shoot(
iconPage({ size: 180, inset: 26, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'apple-touch-icon.png'),
180,
180,
);
// PWA icons. `maskable` gets a larger inset so the safe zone survives a
// circular mask on Android.
await shoot(
iconPage({ size: 192, inset: 24, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-192.png'),
192,
192,
);
await shoot(
iconPage({ size: 512, inset: 64, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-512.png'),
512,
512,
);
await shoot(
iconPage({ size: 512, inset: 112, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-maskable-512.png'),
512,
512,
);
// No separate tiny favicon is rendered. Chrome hangs screenshotting viewports
// below roughly 128px, and it is unnecessary: browsers prefer the SVG, and
// icon-192 downscales cleanly for the ones that do not.
await browser.close();
console.log('Done.');