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:
@@ -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.');
|
||||
Reference in New Issue
Block a user