From 37d532066483af5dbb90b478848a1a06e4124976 Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 6 Aug 2026 03:07:00 -0700 Subject: [PATCH] Both doors get a share card, and they are not the same card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- .gitignore | 5 + deploy/Caddyfile.snippet | 27 ++++ index.html | 52 +++++++- public/og-office.png | Bin 0 -> 399609 bytes public/og-tera.png | Bin 0 -> 564101 bytes scripts/brand-assets/capture.mjs | 161 +++++++++++++++++++++++ scripts/brand-assets/og.html | 218 +++++++++++++++++++++++++++++++ vite.config.ts | 104 ++++++++++++++- 8 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 public/og-office.png create mode 100644 public/og-tera.png create mode 100644 scripts/brand-assets/capture.mjs create mode 100644 scripts/brand-assets/og.html diff --git a/.gitignore b/.gitignore index 9d4e091..738d3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ public/props/ public/kits/ public/offices/ docs/ + +# Intermediate art for the share cards. Regenerated by +# `scripts/brand-assets/capture.mjs`; the finished cards in `public/` are what +# ships, and these are 2.8 MB of screenshot nobody needs in history. +scripts/brand-assets/art-*.png diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 195d2ff..4de1fa7 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -18,6 +18,33 @@ # The API and the static build are deliberately the same origin. Nothing here # needs CORS, which is why TERA_CORS_ORIGIN defaults to empty — set it only for # a Vite dev server on another port. +# +# +# ## Two doors out of one root +# +# `office.` and `tera.` are one build, and the app reads its own hostname to +# decide which one it is. A crawler cannot do that — it reads the HTML and +# nothing else — so `npm run build` emits a second shell, `office.html`, which is +# byte-identical below `` and carries the office's title, description and +# share card. Point the office door's fallback at it and the two unfurl as the +# two places they are: +# +# office.lumbridgecorp.com { +# import tera_api +# root * /srv/tera/dist # the SAME root as tera., not a copy +# try_files {path} {path}/index.html /office.html +# file_server +# } +# +# The root is shared rather than copied on purpose — a deploy cannot then leave +# the two doors on different builds — and `office.html` comes out of the same +# `npm run build` as `index.html`, so the shells cannot drift from each other +# either. The only line that differs between the two site blocks is the +# `try_files` fallback. +# +# A deployment that skips this is not broken: the office door keeps working and +# simply unfurls with the city's card, which is what it did before there was a +# second shell at all. (tera_api) { handle /api/v1/* { diff --git a/index.html b/index.html index 1a6f50f..b012477 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,57 @@ and logs the 404 to the console, which buries the errors that matter. --> - Lumbridge Simulate — San Francisco + + + Tera — cities from above, and an office you can walk into + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+

+

+

+
+

lumbridgecorp.com

+

Apache-2.0

+
+ + + + diff --git a/vite.config.ts b/vite.config.ts index 3c75592..38f4ac6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,107 @@ -import { defineConfig } from "vite"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +/** + * The office's copy of the page, emitted from the city's. + * + * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind + * two names, and the app works out which door it is at runtime by reading its + * own hostname. A crawler cannot: it reads the HTML and nothing else. So a + * single `index.html` means both doors unfurl with the same title, the same + * sentence and the same picture — and the office door's whole reason to exist is + * that it is a different place. + * + * The obvious fix, a second hand-written `office.html`, is the one 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". Two 900-line files each carrying the whole inline stylesheet would + * drift on the first CSS change, and nothing would notice, because the drift is + * invisible until somebody opens the other door. + * + * So there is one source and the build emits both shells. Everything outside the + * `ogc:` markers in `index.html` is copied byte for byte — the stylesheet, the + * DOM, the script tag Vite has already rewritten to the hashed bundle — and only + * the block between them is replaced. A change to the interface reaches both + * doors by construction, and the only thing that can differ is the thing that is + * supposed to. + * + * It runs in `writeBundle` rather than `transformIndexHtml` because it needs the + * *finished* document, after Vite has substituted the asset URLs. Transforming + * earlier would emit a page pointing at unhashed source paths. + */ +function twoDoors(): Plugin { + const MARKER_END = ""; + + const OFFICE = ` + Spaces — an office you can walk around + + + + + + + + + + + + + + + + + + `; + + return { + name: "lumbridge:two-doors", + apply: "build", + async writeBundle(options) { + const dir = options.dir ?? "dist"; + const html = await readFile(join(dir, "index.html"), "utf8"); + + const open = html.indexOf("ogc:start"); + const end = html.indexOf(MARKER_END); + // Loudly, rather than by silently shipping two identical shells. A card + // that is quietly the wrong one is the failure this plugin exists to + // prevent, so losing the markers must not be a no-op. + if (open === -1 || end === -1) { + this.error("index.html has no ogc:start/ogc:end markers — cannot emit office.html"); + return; + } + // The opening marker sits inside a larger comment, so cut from the start + // of that comment: otherwise the explanation ships on both doors while + // being true of only one. + const from = html.lastIndexOf("" + + OFFICE + + html.slice(end + MARKER_END.length); + + await writeFile(join(dir, "office.html"), office, "utf8"); + }, + }; +} export default defineConfig({ // Mounted under tera.lumbridgecorp.com in production; the trailing // slash matters, since every asset URL is resolved against it. base: process.env.TERA_BASE ?? "/", + plugins: [twoDoors()], build: { outDir: "dist", target: "es2022", @@ -11,6 +109,10 @@ export default defineConfig({ // verbatim — so `import.meta.env` was never substituted there and the page // could not be told which identity provider to sign in against. It is a real // entry now; the built URL (`/login.html`) is unchanged. + // + // `office.html` is deliberately not a third entry: it is the same document + // as `index.html` with a different head, so making it one would give it its + // own copy of the bundle graph. It is emitted after the build instead. rollupOptions: { input: { index: "index.html", login: "login.html" } }, }, });