Files
pig/apps/web/tools/render-assets.mjs
T
karti 0551e8dd6e 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>
2026-08-12 19:43:10 -07:00

110 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.');