Both doors get a share card, and they are not the same card
A link to tera.lumbridgecorp.com or office.lumbridgecorp.com unfurled as a bare
blue URL. No picture, no sentence, and a title — "Lumbridge Simulate — San
Francisco" — that was wrong at one of the two doors and stale at the other.
There were no `og:` or `twitter:` tags in the document at all. For a project
whose entire pitch is that you should look at it, that is the most expensive
missing markup in the repo.
Both cards are screenshots of the running app, not drawings of it, and that is
the load-bearing decision rather than a shortcut. `scripts/brand-assets/` builds
them in two passes: shoot the city and the office out of `dist/`, then render
`og.html` over those shots at exactly 1200x630. The thing is worth looking at,
and a drawing of it goes stale in silence — which is not hypothetical. The card
currently live on lumbridgecorp.com is a viewport screenshot of a marketing page
that has since been rewritten, so that preview advertises a positioning the site
no longer uses, and it has been doing so for a month with nobody noticing. A
card regenerated from `dist/` by one command is a card that can be kept true by
running the command.
The clock is shifted to midday for the capture, because the sun is real —
`observe()` computes it from `new Date()` — and a card regenerated at two in the
morning is an honest photograph of a black rectangle. Shifted rather than
frozen: everything else runs off `requestAnimationFrame`, and a stopped clock
stalls the frame loop the screenshot is waiting on.
Every string on the cards is the project's own. The headlines are what
`README.md` already says each half is; "Clone it and it works" is CONTRACT.md
§0's acceptance test in the words `main.ts` uses for it; the licence in the
corner is the one in the repo root. A share card is the most-read and least
reviewed sentence a project has, which is exactly why it should not be where new
claims get invented.
The harder half was that the two doors are one bundle. The app sorts out which
door it is by reading its own hostname; a crawler cannot, because it reads the
HTML and nothing else — so one `index.html` means both doors unfurl as the same
place, and being a different place is the office door's whole reason to exist. A
second hand-written shell is what the Caddy config already argues against for the
static root ("sharing the root rather than copying it means a deploy cannot leave
the two doors on different builds"), and two 900-line files each carrying the
inline stylesheet would drift on the first CSS change with nothing to notice.
So the build emits both. Everything outside the `ogc:` markers is copied byte for
byte — verified: `office.html` and `index.html` are identical below `</head>` and
point at the same bundle hash — and only the head block differs. A change to the
interface reaches both doors by construction. It runs in `writeBundle` rather
than `transformIndexHtml` because it needs the finished document, after Vite has
rewritten the asset URLs, and it errors rather than no-oping if the markers go
missing: a card that is quietly the wrong one is the failure the plugin exists to
prevent.
`deploy/Caddyfile.snippet` documents the one line that turns it on — the office
door's `try_files` fallback pointing at `/office.html` off the same shared root.
A deployment that skips it is not broken; the office door keeps working and
unfurls with the city's card, which is what it did before.
Not deployed. The live Caddyfile still falls back to /index.html for the office
host, so this needs that one-line change on cloud-2 before office.
lumbridgecorp.com unfurls as the office.
Both shells boot clean in Chrome with zero console errors, 31 client tests and
the no-binary gate still pass — `public/` is exempt from it, which is where the
two PNGs live.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Regenerate the share cards.
|
||||
*
|
||||
* node scripts/brand-assets/capture.mjs
|
||||
*
|
||||
* Two passes, because the cards are backed by the running app rather than by a
|
||||
* drawing of it. First it shoots the city and the office out of a built `dist/`;
|
||||
* then it renders `og.html` over those shots at exactly 1200x630 and writes the
|
||||
* two PNGs into `public/`, from where Vite copies them verbatim.
|
||||
*
|
||||
* Rasterising with headless Chromium rather than a converter is the convention
|
||||
* `lumbridge-v4/scripts/brand-assets/README.md` already set on this box, for the
|
||||
* reason it gives: there is no ImageMagick, no `rsvg-convert` and no `sharp`
|
||||
* here, and a browser renders the CSS the card was designed in anyway.
|
||||
*
|
||||
* ### Why the art is a screenshot and not an illustration
|
||||
*
|
||||
* Because the thing is worth looking at, and because an illustration of it goes
|
||||
* stale silently. The card that shipped on lumbridgecorp.com was a viewport
|
||||
* screenshot of a marketing page that had since been rewritten, so the preview
|
||||
* advertised a positioning the site no longer used and nothing noticed for a
|
||||
* month. A card regenerated from `dist/` by one command is a card that can be
|
||||
* kept true by running that command.
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..", "..");
|
||||
const PUBLIC = join(ROOT, "public");
|
||||
|
||||
/**
|
||||
* Midday, fixed.
|
||||
*
|
||||
* The sun is real — `observe()` computes it from `new Date()` — so a card
|
||||
* regenerated at two in the morning is an honest photograph of a black
|
||||
* rectangle. The clock is shifted rather than frozen because the app drives
|
||||
* everything else off `requestAnimationFrame`, and a stopped clock stalls the
|
||||
* frame loop the screenshot is waiting on.
|
||||
*/
|
||||
const NOON = "2026-08-06T12:40:00-07:00";
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
/** A static server over one directory, with the SPA fallback the app expects. */
|
||||
function serve(dir, port, { spa = false } = {}) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||
for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const body = await readFile(join(dir, candidate));
|
||||
res.writeHead(200, { "content-type": MIME[extname(candidate)] ?? "application/octet-stream" });
|
||||
res.end(body);
|
||||
return;
|
||||
} catch {
|
||||
/* try the next candidate */
|
||||
}
|
||||
}
|
||||
res.writeHead(404).end("not found");
|
||||
});
|
||||
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
|
||||
}
|
||||
|
||||
const CHROME_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
// Software GL, so this runs on a box with no display and no GPU.
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
// The app reads its own hostname to decide which door it is. Resolving both
|
||||
// names at the local server is what makes one `dist/` produce both shots.
|
||||
"--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1",
|
||||
];
|
||||
|
||||
const clockShim = `{
|
||||
const target = new Date(${JSON.stringify(NOON)}).getTime();
|
||||
const skew = target - Date.now();
|
||||
const Real = Date;
|
||||
globalThis.Date = class extends Real {
|
||||
constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); }
|
||||
static now() { return Real.now() + skew; }
|
||||
};
|
||||
}`;
|
||||
|
||||
async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) {
|
||||
// 2x, so the art is still sharp when a timeline shows the card at 600px wide
|
||||
// on a retina screen.
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1400, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await page.addInitScript(clockShim);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(settle);
|
||||
if (key) {
|
||||
await page.mouse.move(700, 450);
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
// The chrome comes off. The card supplies its own typography, and the app's
|
||||
// panels shrunk to card size are unreadable furniture.
|
||||
await page.evaluate(() => {
|
||||
for (const sel of ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#boot", "#scrim"]) {
|
||||
document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
|
||||
console.log("art ", file);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
async function renderCard(browser, which, out) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
|
||||
await page.goto(`http://127.0.0.1:8799/og.html?card=${which}`, { waitUntil: "networkidle" });
|
||||
// The art is a background image, so `networkidle` is not proof it has decoded.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: join(PUBLIC, out) });
|
||||
console.log("card ", out);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* `--cards-only` re-renders the two PNGs from art already on disk.
|
||||
*
|
||||
* The app pass is a minute of software rasterisation and the card pass is two
|
||||
* seconds, and every iteration on a headline needs only the second. Without the
|
||||
* flag, tuning a line of copy costs a minute each time, which is how a card ends
|
||||
* up shipped with the first wording anybody tried.
|
||||
*/
|
||||
const cardsOnly = process.argv.includes("--cards-only");
|
||||
|
||||
const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
|
||||
const assets = await serve(HERE, 8799);
|
||||
const browser = await chromium.launch({ channel: "chrome", args: CHROME_ARGS });
|
||||
try {
|
||||
if (!cardsOnly) {
|
||||
await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png");
|
||||
await shootApp(browser, "http://tera.lumbridgecorp.com:5210/", "art-tera.png", { key: "2" });
|
||||
}
|
||||
await renderCard(browser, "tera", "og-tera.png");
|
||||
await renderCard(browser, "office", "og-office.png");
|
||||
} finally {
|
||||
await browser.close();
|
||||
app?.close();
|
||||
assets.close();
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tera / Spaces — share cards</title>
|
||||
<!--
|
||||
One template, two doors, for the same reason the app is one bundle behind
|
||||
two names: `?card=tera` and `?card=office` differ in four strings and one
|
||||
background, and two files would drift the moment somebody adjusted the
|
||||
scrim on one of them.
|
||||
|
||||
No webfonts. The card is rasterised by a headless browser on a box that may
|
||||
or may not have network at the time, and a card whose type silently falls
|
||||
back to Times is worse than one that never used a webfont — so this uses
|
||||
the same system stack the app itself uses (`ui-monospace` throughout, with
|
||||
Georgia for the headline, which is the face `index.html`'s own no-JS
|
||||
fallback already sets).
|
||||
|
||||
1200x630 is the size every unfurler crops toward: Twitter/X's
|
||||
`summary_large_image`, Open Graph's own recommendation, Slack, Discord,
|
||||
iMessage, LinkedIn. Nothing important goes within 48px of an edge, because
|
||||
several of them shave a few pixels and one of them rounds the corners.
|
||||
-->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #06080b; }
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: #06080b;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* The art is the product, shot from the running app — see `capture.mjs`.
|
||||
`cover` plus a per-card origin, because the two shots want cropping
|
||||
toward different halves of themselves. */
|
||||
.art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
/*
|
||||
Two scrims, not one, and they do different jobs.
|
||||
|
||||
The horizontal one buys contrast for the type: the left 58% goes to
|
||||
near-solid so a white headline sits on a known background rather than on
|
||||
whatever pixel the render happened to put there — which is the failure
|
||||
that makes screenshot-backed cards unreadable at timeline size. The
|
||||
vertical one is a vignette that keeps the top and bottom edges from
|
||||
competing with the corner text, and it is much weaker, because the whole
|
||||
point is that you can still see the place.
|
||||
*/
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(6, 8, 11, 0.97) 0%, rgba(6, 8, 11, 0.9) 38%,
|
||||
rgba(6, 8, 11, 0.45) 62%, rgba(6, 8, 11, 0.12) 100%),
|
||||
linear-gradient(180deg, rgba(6, 8, 11, 0.45) 0%, rgba(6, 8, 11, 0) 30%,
|
||||
rgba(6, 8, 11, 0) 68%, rgba(6, 8, 11, 0.6) 100%);
|
||||
}
|
||||
|
||||
/* The app's own hairline grid, at the app's own opacity. It is the one
|
||||
piece of furniture both this and the lumbridgecorp.com card share, so
|
||||
the two read as a family without being the same layout. */
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 64px 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #f2b134;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
/* The same coin the lumbridgecorp.com card leads with. */
|
||||
.coin {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 34% 30%, #ffe8ac, #f2b134 48%, #d9932b 100%);
|
||||
box-shadow: 0 0 16px rgba(242, 177, 52, 0.45);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 400;
|
||||
font-size: 68px;
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.022em;
|
||||
margin: 0 0 26px;
|
||||
max-width: 15ch;
|
||||
color: #f4f6f9;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 21px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.005em;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
margin: 0;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
/* Bottom left, on the baseline the padding already establishes. The host
|
||||
is the one string on the card that is not a claim about anything. */
|
||||
.host {
|
||||
position: absolute;
|
||||
left: 72px;
|
||||
bottom: 56px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
.host b { color: #ffd68a; font-weight: 400; }
|
||||
|
||||
.licence {
|
||||
position: absolute;
|
||||
right: 72px;
|
||||
bottom: 56px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="art" id="art"></div>
|
||||
<div class="scrim"></div>
|
||||
<div class="grid"></div>
|
||||
<div class="body">
|
||||
<p class="eyebrow"><span class="coin"></span><span id="eyebrow"></span></p>
|
||||
<h1 id="headline"></h1>
|
||||
<p class="sub" id="sub"></p>
|
||||
</div>
|
||||
<p class="host">lumbridgecorp.com<b id="host"></b></p>
|
||||
<p class="licence">Apache-2.0</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/*
|
||||
Every string here is the project's own.
|
||||
|
||||
The headline for each door is the sentence `README.md` already uses to
|
||||
say what that half is, and the sub-lines describe only what the code
|
||||
actually does — a sun and moon computed locally, an office that renders
|
||||
with no server at all, a licence that is in the repo root. A share card
|
||||
is the most-read sentence a project has and the least reviewed, which is
|
||||
exactly why it should not be the one place new claims get invented.
|
||||
*/
|
||||
const CARDS = {
|
||||
tera: {
|
||||
eyebrow: "Lumbridge Simulate",
|
||||
headline: "Cities from above.",
|
||||
// The last sentence is CONTRACT.md §0's acceptance test, in the words
|
||||
// `main.ts` already uses for it.
|
||||
sub: "A real sun and moon over a board you can fly around, and an office you can step into. Clone it and it works.",
|
||||
host: "tera.",
|
||||
art: "art-tera.png",
|
||||
// Low, so the far edge of the board — where the terrain stops and the
|
||||
// sky begins in a hard diagonal — stays out of frame. A card with the
|
||||
// seam in it reads as a rendering fault rather than as a map.
|
||||
position: "56% 88%",
|
||||
},
|
||||
office: {
|
||||
eyebrow: "Lumbridge Spaces",
|
||||
headline: "An office you can walk around.",
|
||||
sub: "The rooms, the desks and who is at them — one floor plan, rendered from a data file anybody can copy.",
|
||||
host: "office.",
|
||||
art: "art-office.png",
|
||||
position: "62% 46%",
|
||||
},
|
||||
};
|
||||
|
||||
const which = new URLSearchParams(location.search).get("card") ?? "tera";
|
||||
const card = CARDS[which] ?? CARDS.tera;
|
||||
document.getElementById("eyebrow").textContent = card.eyebrow;
|
||||
document.getElementById("headline").textContent = card.headline;
|
||||
document.getElementById("sub").textContent = card.sub;
|
||||
document.getElementById("host").textContent = "";
|
||||
document.querySelector(".host").innerHTML =
|
||||
'<b>' + card.host + '</b>lumbridgecorp.com';
|
||||
const art = document.getElementById("art");
|
||||
art.style.backgroundImage = 'url("' + card.art + '")';
|
||||
art.style.backgroundPosition = card.position;
|
||||
document.title = which;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user