feat(brand): phase 2 — the branding pipeline #5
@@ -32,6 +32,12 @@ jobs:
|
|||||||
# PLAN.md §7 — no colour literal outside src/styles/tokens.css.
|
# PLAN.md §7 — no colour literal outside src/styles/tokens.css.
|
||||||
run: npm run check:tokens
|
run: npm run check:tokens
|
||||||
|
|
||||||
|
- name: Branding pipeline
|
||||||
|
# PLAN.md §7 — brand-default is complete, every /brand/* URL the source asks for
|
||||||
|
# resolves against the route's own allowlist, and every brand string the boot
|
||||||
|
# rewrite replaces is distinctive enough to replace blindly.
|
||||||
|
run: npm run check:brand
|
||||||
|
|
||||||
- name: Types
|
- name: Types
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
|
|||||||
39
PLAN.md
39
PLAN.md
@@ -309,6 +309,34 @@ the build and the mount could never replace them.
|
|||||||
`brand.json` exists so that renaming the product, changing the Discord invite or adding a contact
|
`brand.json` exists so that renaming the product, changing the Discord invite or adding a contact
|
||||||
address does not require a rebuild either — the same class of change as swapping a logo.
|
address does not require a rebuild either — the same class of change as swapping a logo.
|
||||||
|
|
||||||
|
### How phase 2 actually built it
|
||||||
|
|
||||||
|
Three decisions taken during the build (org lead, 2026-08-20). They refine the mechanism above
|
||||||
|
rather than change what it promises.
|
||||||
|
|
||||||
|
**D14 — one raster in, every size out.** Only `logo.png`, `wordmark.svg`, `og-image.png`,
|
||||||
|
`theme.css` and `brand.json` are baked into `brand-default/`. Every other image in the table above
|
||||||
|
— all the logo sizes, both install icons, the apple-touch icon, the favicons and the `.ico` — is
|
||||||
|
**derived at request time** from whichever `logo.png` is in force, cached in memory, and limited to
|
||||||
|
an allowlist of sizes. Precomputing them would have meant an operator producing fifteen files to
|
||||||
|
change a mark, and the realistic outcome of that is a deployment with a new header and the old
|
||||||
|
favicon. "A file copy" now means one file.
|
||||||
|
|
||||||
|
**D15 — brand text is applied at boot, not at render.** §6 prerenders every page, so a value read
|
||||||
|
at build time is baked into HTML the mount cannot reach; §7 promises otherwise. `npm start` runs
|
||||||
|
`scripts/applyBrand.mjs` before the server opens a socket, rewriting the built HTML from what was
|
||||||
|
baked to what the mount says. Every page stays prerendered, Pagefind still has static HTML to index,
|
||||||
|
and the documentation is covered by the same pass as the marketing pages. The alternatives — server
|
||||||
|
-rendering the brand-bearing pages, which is the whole site because of the footer, or accepting
|
||||||
|
build-time text — were rejected. The script rewrites from a **record of what it last applied**
|
||||||
|
rather than from the defaults, because the naive version works exactly once and then silently
|
||||||
|
ignores every later edit.
|
||||||
|
|
||||||
|
**D16 — the mark is the real emblem** (D11 carried through). The header shows `runic-emblem.png`,
|
||||||
|
not phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one
|
||||||
|
mark. The cost, accepted: it is raster art, so `theme.css` cannot recolour it — changing the mark
|
||||||
|
means replacing `logo.png`.
|
||||||
|
|
||||||
### The rule that keeps the promise true
|
### The rule that keeps the promise true
|
||||||
|
|
||||||
**Every colour, radius, shadow and font in the site's stylesheet is a CSS custom property defined in
|
**Every colour, radius, shadow and font in the site's stylesheet is a CSS custom property defined in
|
||||||
@@ -319,6 +347,17 @@ Without that check, "one CSS file changes the appearance" decays into "one CSS f
|
|||||||
the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
|
the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
|
||||||
diligence is not.
|
diligence is not.
|
||||||
|
|
||||||
|
`scripts/checkBrand.mjs` is the second half of it, added in phase 2: it fails the build if
|
||||||
|
`brand-default/` is incomplete, if any `/brand/*` URL in the source would 404 against the route's
|
||||||
|
own allowlist, or if a brand string is short enough that replacing it blindly at boot could corrupt
|
||||||
|
a page.
|
||||||
|
|
||||||
|
**The mounted stylesheet wins by cascade layer, not by link order.** `tokens.css` is wrapped in
|
||||||
|
`@layer tokens` and `theme.css` is unlayered, so the mount takes precedence wherever the browser
|
||||||
|
encounters it. The first attempt relied on `theme.css` being linked last, and it did not work:
|
||||||
|
Astro emits its own stylesheet after the head markup, so the site's tokens landed after the
|
||||||
|
operator's and every override was silently a no-op.
|
||||||
|
|
||||||
Token names deliberately match `website/client/src/styles/theme.css` where the concepts line up
|
Token names deliberately match `website/client/src/styles/theme.css` where the concepts line up
|
||||||
(`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
|
(`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
|
||||||
is legible in the other.
|
is legible in the other.
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -66,6 +66,44 @@ restart; a mounted `theme.css` can only redefine custom properties, so a literal
|
|||||||
piece of the site an operator can never reach. Without the check, "one CSS file changes the
|
piece of the site an operator can never reach. Without the check, "one CSS file changes the
|
||||||
appearance" becomes "one CSS file changes most of the appearance".
|
appearance" becomes "one CSS file changes most of the appearance".
|
||||||
|
|
||||||
|
**`checkBrand.mjs`** guards the two things about the branding pipeline that fail quietly. It puts
|
||||||
|
every literal `/brand/...` URL in the source through the route's own classifier, so a template
|
||||||
|
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
|
||||||
|
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
|
||||||
|
|
||||||
|
## Branding is bind-mounted data
|
||||||
|
|
||||||
|
`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
|
||||||
|
empty, partial or full. **Every file resolves against the mount first and the defaults second, per
|
||||||
|
file**, so overriding only `theme.css` leaves every logo stock and an empty mount produces exactly
|
||||||
|
the stock site. Nothing here goes through Vite, which would fingerprint the filenames into the build
|
||||||
|
and put them out of the mount's reach.
|
||||||
|
|
||||||
|
**Rebranding is one file.** `brand-default/` holds a single raster — `logo.png` — and `GET /brand/*`
|
||||||
|
derives every size the site asks for from whichever `logo.png` is in force: the header mark at three
|
||||||
|
pixel ratios, the install icons, the apple-touch icon, the favicons and a real multi-resolution
|
||||||
|
`favicon.ico`. Drop in one file, restart, and the browser tab and the installed icon change with the
|
||||||
|
header.
|
||||||
|
|
||||||
|
**Brand text is applied at boot.** Pages are prerendered, so the site name, tagline and links are
|
||||||
|
baked into HTML that a mounted file cannot reach. `npm start` runs `scripts/applyBrand.mjs` first,
|
||||||
|
which rewrites the built HTML from what it last applied to what the mount now says — recorded in
|
||||||
|
`dist/.brand-applied.json`, so the second edit works as well as the first. An empty mount makes it a
|
||||||
|
no-op.
|
||||||
|
|
||||||
|
**A mounted `theme.css` wins by cascade layer, not by link order.** `tokens.css` is inside
|
||||||
|
`@layer tokens`; the mounted stylesheet is unlayered and therefore beats it wherever the browser
|
||||||
|
encounters it. Do not "fix" this by reordering the links — Astro emits its own stylesheet after the
|
||||||
|
head markup, which is what made the ordering approach silently useless.
|
||||||
|
|
||||||
|
To try it: put a `theme.css`, a `logo.png` or a `brand.json` in `brand/`, run `npm run build` and
|
||||||
|
`npm start`. `curl -I` any `/brand/*` URL and the `X-Brand-Source` header says which of mount,
|
||||||
|
defaults or derivation answered.
|
||||||
|
|
||||||
|
Regenerating the stock assets is a separate, manual step — `npm run brand:assets` — because it reads
|
||||||
|
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
|
||||||
|
committed so that CI never needs either.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -77,11 +115,13 @@ src/
|
|||||||
layouts/, components/ The marketing chrome.
|
layouts/, components/ The marketing chrome.
|
||||||
pages/ Marketing routes.
|
pages/ Marketing routes.
|
||||||
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
||||||
|
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
|
||||||
lib/brand.mjs The single accessor for brand text.
|
lib/brand.mjs The single accessor for brand text.
|
||||||
|
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
|
||||||
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||||
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||||
brand-default/ The stock brand, baked into the image and always complete.
|
brand-default/ The stock brand, baked into the image and always complete.
|
||||||
scripts/ The build-time checks.
|
scripts/ The build-time checks, plus applyBrand (boot) and brand:assets (manual).
|
||||||
```
|
```
|
||||||
|
|
||||||
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||||
|
|||||||
@@ -32,15 +32,23 @@ export default defineConfig({
|
|||||||
title: 'Runic Gateway',
|
title: 'Runic Gateway',
|
||||||
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
|
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
|
||||||
disable404Route: true,
|
disable404Route: true,
|
||||||
|
// Not a file in `public/`: the brand route derives this from whichever `logo.png` is
|
||||||
|
// mounted (§7), so the docs' tab icon changes with a rebrand like everything else.
|
||||||
|
// Starlight's default is `/favicon.svg`, which does not exist here — every docs page
|
||||||
|
// was requesting a 404 for it.
|
||||||
|
favicon: '/brand/favicon.ico',
|
||||||
// Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
|
// Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
|
||||||
// but has the docs honour the reader's preference.
|
// but has the docs honour the reader's preference.
|
||||||
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
|
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
|
||||||
components: {
|
components: {
|
||||||
// Not the `logo` option: that renders an <img>, and our mark is drawn in
|
// Not the `logo` option: that takes an asset imported through Vite, which
|
||||||
// currentColor so it inherits --gold and follows a mounted theme.css. An SVG
|
// fingerprints the filename into the build — and a fingerprinted logo is one the
|
||||||
// loaded through <img> is a separate document with nothing to inherit from, so it
|
// bind mount can never replace (§7). The override points at the stable
|
||||||
// renders black on black. The override inlines it instead — see the component.
|
// `/brand/*` URL instead.
|
||||||
SiteTitle: './src/components/DocsSiteTitle.astro',
|
SiteTitle: './src/components/DocsSiteTitle.astro',
|
||||||
|
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
||||||
|
// the manifest and the OG card entirely. See the component.
|
||||||
|
Head: './src/components/DocsHead.astro',
|
||||||
},
|
},
|
||||||
credits: false,
|
credits: false,
|
||||||
sidebar: docsSidebar,
|
sidebar: docsSidebar,
|
||||||
|
|||||||
BIN
brand-default/logo.png
Normal file
BIN
brand-default/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
BIN
brand-default/og-image.png
Normal file
BIN
brand-default/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
116
brand-default/theme.css
Normal file
116
brand-default/theme.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
theme.css — the stock theme (PLAN.md §7)
|
||||||
|
|
||||||
|
THIS FILE IS DELIBERATELY EMPTY OF RULES.
|
||||||
|
|
||||||
|
It is loaded last on every page, after the site's own stylesheet, so anything
|
||||||
|
it declares wins. The stock site needs to override nothing, so the stock copy
|
||||||
|
overrides nothing — an empty mount and a stock deployment must produce the
|
||||||
|
same pixels, and the simplest way to guarantee that is for the default to say
|
||||||
|
nothing at all.
|
||||||
|
|
||||||
|
It ships anyway, rather than being absent, for two reasons: the `<link>` in
|
||||||
|
every page's head must resolve to a stylesheet rather than a 404, and this is
|
||||||
|
the file an operator copies out, edits and mounts back. What follows is the
|
||||||
|
whole reference they need.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
HOW TO RECOLOUR THIS SITE
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Copy this file into the directory bind-mounted at /app/brand, uncomment the
|
||||||
|
block below, change the values, and restart the container. No rebuild, no
|
||||||
|
image push. Every asset and every field resolves against the mount first and
|
||||||
|
the baked-in defaults second, per file, so overriding theme.css alone leaves
|
||||||
|
the logo, the icons and the text exactly as they are.
|
||||||
|
|
||||||
|
docker cp <container>:/app/brand-default/theme.css ./brand/theme.css
|
||||||
|
$EDITOR ./brand/theme.css
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
Only custom properties belong here. Every colour, radius, shadow and font in
|
||||||
|
the site is one, defined in a single file, and `scripts/checkTokens.mjs`
|
||||||
|
fails the build if a literal ever appears anywhere else — so there is no
|
||||||
|
corner of the design this file cannot reach. Ordinary CSS rules will work,
|
||||||
|
but they are the thing that breaks on the next release; properties are the
|
||||||
|
supported surface.
|
||||||
|
|
||||||
|
Names match the product's own `client/src/styles/theme.css` where the
|
||||||
|
concepts line up, so a theme written for a Runic Gateway deployment is
|
||||||
|
legible here and mostly portable.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0e1318; Page ground
|
||||||
|
--bg-deep: #0b0f14; Header and footer ground
|
||||||
|
--panel-a: #192231; Panel gradient, top
|
||||||
|
--panel-b: #141a21; Panel gradient, bottom
|
||||||
|
--panel-flat: #11161d; Flat panels, code blocks
|
||||||
|
--line: #2a3544; Borders
|
||||||
|
--line-soft: #1d2733; Hairlines and dividers
|
||||||
|
|
||||||
|
--accent: #7f99bd; Links and interface emphasis
|
||||||
|
--ink: #eef3f8; Brightest text
|
||||||
|
--head: #e6edf6; Headings
|
||||||
|
--text: #c4cdd8; Body copy
|
||||||
|
--muted: #aeb8c4; Secondary copy
|
||||||
|
--dim: #6f7d8e; Captions and metadata
|
||||||
|
|
||||||
|
--gold: #c8a368; Emphasis, rules, the display face
|
||||||
|
--gold-deep: #946b3c; Gold borders. Too dark for text.
|
||||||
|
--gold-bright: #e4cb90; Highlights on gold
|
||||||
|
--portal: #15b4de; The live-state signal and diagram lines
|
||||||
|
--portal-deep: #0b6398; Glow fills. Too dark for text.
|
||||||
|
--portal-bright: #1bd6f1;
|
||||||
|
--danger: #ff4e43; Errors and destructive actions
|
||||||
|
|
||||||
|
--mode-live: #5fb98a; Status pill: running
|
||||||
|
--mode-maint: #e6c26a; Status pill: maintenance
|
||||||
|
|
||||||
|
--display: 'Cinzel Variable', Georgia, serif;
|
||||||
|
--sans: 'Inter Variable', system-ui, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
--radius-pill: 999px;
|
||||||
|
--radius-panel: 12px;
|
||||||
|
--radius-card: 10px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
|
||||||
|
--measure: 68ch; Reading measure
|
||||||
|
--page-max: 1180px; Content column
|
||||||
|
--gutter: 24px;
|
||||||
|
--header-h: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
The documentation pages carry a light theme as well, because §11 has the docs
|
||||||
|
honour the reader's preference while the marketing pages stay dark. Those
|
||||||
|
values are separate properties, so a light-mode change does not disturb the
|
||||||
|
dark one:
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--light-bg: #f6f8fb;
|
||||||
|
--light-panel: #ffffff;
|
||||||
|
--light-line: #d6dee9;
|
||||||
|
--light-ink: #16202c;
|
||||||
|
--light-text: #33414f;
|
||||||
|
--light-muted: #5a6875;
|
||||||
|
--light-accent: #3c5f8f;
|
||||||
|
--light-gold: #7a5a24;
|
||||||
|
--light-portal: #0a5f80;
|
||||||
|
}
|
||||||
|
|
||||||
|
TWO THINGS THIS FILE CANNOT DO
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The logo is artwork, not a colour. It is raster art shared with the product's
|
||||||
|
own site and the Android launcher icon, so no property recolours it — replace
|
||||||
|
`logo.png` in the mount instead, and the header mark, the favicon, the
|
||||||
|
install icons and every other size follow from that one file.
|
||||||
|
|
||||||
|
Contrast is not checked for you. The stock palette is held to WCAG AA against
|
||||||
|
the stock ground, and each value's measured ratio is recorded next to it in
|
||||||
|
`src/styles/tokens.css`. Change the ground without changing the ink and that
|
||||||
|
guarantee is gone, silently.
|
||||||
|
|
||||||
|
============================================================================ */
|
||||||
5
brand-default/wordmark.svg
Normal file
5
brand-default/wordmark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 116 KiB |
11
package-lock.json
generated
11
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
"@astrojs/check": "^0.9.10",
|
||||||
|
"opentype.js": "^2.0.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6317,6 +6318,16 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opentype.js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ot": "bin/ot"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"check:facts": "node scripts/checkFacts.mjs",
|
"check:facts": "node scripts/checkFacts.mjs",
|
||||||
"check:tokens": "node scripts/checkTokens.mjs",
|
"check:tokens": "node scripts/checkTokens.mjs",
|
||||||
"verify": "npm run check:tokens && npm run check:facts && npm run check && npm run build"
|
"check:brand": "node scripts/checkBrand.mjs",
|
||||||
|
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||||
|
"verify": "npm run check:tokens && npm run check:brand && npm run check:facts && npm run check && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.4",
|
"@astrojs/node": "^11.1.4",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
"@astrojs/check": "^0.9.10",
|
||||||
|
"opentype.js": "^2.0.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
232
scripts/applyBrand.mjs
Normal file
232
scripts/applyBrand.mjs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* applyBrand.mjs — brand TEXT from the bind mount (PLAN.md §7)
|
||||||
|
*
|
||||||
|
* Runs immediately before the server, as part of `npm start`. For an empty mount — the
|
||||||
|
* stock deployment, and the common case — it reads two small files, finds nothing to do
|
||||||
|
* and exits. It is not a build step and it is not a template engine.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* THE PROBLEM THIS SOLVES
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* §7 promises that renaming the product, changing the Discord invite or publishing a
|
||||||
|
* different contact address is the same class of change as swapping a logo: edit the file
|
||||||
|
* in the mount, restart, done. §6 prerenders every page. Those two are in direct conflict,
|
||||||
|
* because a value read at build time is baked into HTML that no mounted file can reach.
|
||||||
|
*
|
||||||
|
* Assets escape the conflict by being served per request from `/brand/*`. Text cannot: it
|
||||||
|
* is inside the markup.
|
||||||
|
*
|
||||||
|
* Three ways out were considered and the org lead chose this one (2026-08-20):
|
||||||
|
*
|
||||||
|
* 1. THIS — rewrite the built HTML at boot, before the server opens a socket. Every page
|
||||||
|
* stays prerendered, Pagefind still has static HTML to index in phase 10, and the docs
|
||||||
|
* are covered by the same pass as the marketing pages.
|
||||||
|
* 2. Mark the brand-bearing pages `prerender = false`. Simpler, but the footer is on
|
||||||
|
* every page, so "the handful" is the whole site — and the docs would have to stay
|
||||||
|
* static for search anyway, leaving them showing the stock name.
|
||||||
|
* 3. Accept text as build-time and amend §7. Cheapest, and it gives up the promise.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* WHY IT REWRITES FROM A RECORD RATHER THAN FROM THE DEFAULTS
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* The obvious version of this script replaces the DEFAULT value with the mounted one. It
|
||||||
|
* works exactly once. The second time an operator edits the mount — renaming from "Foo" to
|
||||||
|
* "Bar" — the default no longer appears anywhere in the HTML, every replacement matches
|
||||||
|
* nothing, and the site silently keeps saying "Foo". The bug would surface as "the first
|
||||||
|
* change worked and the second did nothing", which is a miserable thing to debug.
|
||||||
|
*
|
||||||
|
* So the script records what it baked, in `dist/.brand-applied.json`, and the next run
|
||||||
|
* rewrites from that record to the new values. A fresh image has no record and starts from
|
||||||
|
* the defaults, which is the same thing said differently.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* WHAT MAKES PLAIN STRING REPLACEMENT SAFE HERE
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* Not much, on its own — which is why `scripts/checkBrand.mjs` exists. It fails the build
|
||||||
|
* if any rewritable default is short enough to collide with ordinary markup or prose. The
|
||||||
|
* check is the mechanism; the eight-character minimum below is only its last line.
|
||||||
|
*
|
||||||
|
* Replacing the site name across the docs as well as the marketing pages is deliberate. If
|
||||||
|
* the product is renamed, prose that says "Runic Gateway" should say the new name too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
const DIST = process.env.BRAND_DIST || path.join(ROOT, 'dist');
|
||||||
|
const CLIENT = path.join(DIST, 'client');
|
||||||
|
const RECORD = path.join(DIST, '.brand-applied.json');
|
||||||
|
|
||||||
|
const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
|
||||||
|
const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields that appear in markup as literal text, and may therefore be rewritten.
|
||||||
|
*
|
||||||
|
* `demoUrl` is not one of them and is handled separately below: its default is the empty
|
||||||
|
* string, and there is no such thing as replacing every occurrence of "".
|
||||||
|
*/
|
||||||
|
const TEXT_FIELDS = ['siteName', 'tagline', 'contactEmail', 'discordInvite', 'giteaOrg'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this length a value is too likely to occur inside unrelated markup — a class name,
|
||||||
|
* an attribute, a word in a sentence — for a blind replacement to be safe. `checkBrand.mjs`
|
||||||
|
* enforces the same floor at build time, where the failure is cheap; this is the copy that
|
||||||
|
* runs in production, where being wrong means corrupted pages.
|
||||||
|
*/
|
||||||
|
const MIN_REWRITABLE_LENGTH = 8;
|
||||||
|
|
||||||
|
const REWRITABLE_EXTENSIONS = new Set(['.html', '.webmanifest']);
|
||||||
|
|
||||||
|
function readJson(file, label) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return null;
|
||||||
|
// A malformed mounted brand.json must not take the site down.
|
||||||
|
//
|
||||||
|
// The alternative — exit non-zero and let the container crash-loop — surfaces the typo
|
||||||
|
// immediately, and that is genuinely tempting. But an operator editing a mount is
|
||||||
|
// watching the logs, whereas the restart six months later that trips over the same file
|
||||||
|
// is unattended, and a marketing site that is up with stock branding beats one that is
|
||||||
|
// down with correct branding.
|
||||||
|
console.error(`\n[brand] ${label} is not valid JSON and will be IGNORED:\n ${file}\n ${error.message}\n`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$comment` keys are documentation for whoever opens the mounted copy, not fields. */
|
||||||
|
const fieldsOf = (object) =>
|
||||||
|
Object.fromEntries(Object.entries(object || {}).filter(([key]) => !key.startsWith('$')));
|
||||||
|
|
||||||
|
const defaults = fieldsOf(readJson(path.join(DEFAULT_DIR, 'brand.json'), 'the stock brand.json'));
|
||||||
|
const mounted = fieldsOf(readJson(path.join(MOUNT_DIR, 'brand.json'), 'the mounted brand.json'));
|
||||||
|
|
||||||
|
if (!Object.keys(defaults).length) {
|
||||||
|
console.error(
|
||||||
|
`\n[brand] no stock brand.json at ${path.join(DEFAULT_DIR, 'brand.json')}.\n` +
|
||||||
|
`brand-default/ is baked into the image and must always be complete (§7).\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(mounted)) {
|
||||||
|
if (!(key in defaults)) {
|
||||||
|
console.warn(`[brand] the mounted brand.json sets an unknown field "${key}" — ignoring it.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = { ...defaults, ...mounted };
|
||||||
|
const previous = { ...defaults, ...(fieldsOf(readJson(RECORD, 'the applied-brand record')) || {}) };
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------
|
||||||
|
Work out what actually changed
|
||||||
|
--------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
const replacements = [];
|
||||||
|
|
||||||
|
for (const field of TEXT_FIELDS) {
|
||||||
|
const from = previous[field];
|
||||||
|
const to = resolved[field];
|
||||||
|
if (typeof from !== 'string' || typeof to !== 'string' || from === to) continue;
|
||||||
|
|
||||||
|
if (from.length < MIN_REWRITABLE_LENGTH) {
|
||||||
|
console.error(
|
||||||
|
`[brand] refusing to rewrite "${field}": the value being replaced (${JSON.stringify(from)}) ` +
|
||||||
|
`is under ${MIN_REWRITABLE_LENGTH} characters and would match unrelated markup.`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements.push({ field, from, to });
|
||||||
|
// Astro escapes `&`, `<`, `>` and `"` when it writes a value into markup, so a Discord
|
||||||
|
// invite or a Gitea URL carrying a query string appears in the HTML in its escaped form.
|
||||||
|
// Adding the escaped pair rather than unescaping the document keeps this a string
|
||||||
|
// operation on bytes, with no parser to disagree with the browser's.
|
||||||
|
const escapedFrom = escapeHtml(from);
|
||||||
|
if (escapedFrom !== from) replacements.push({ field, from: escapedFrom, to: escapeHtml(to) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The demo slot (§15 / D12) is a rendering decision rather than a piece of text, and this
|
||||||
|
* is the one place a string replacement can still express it.
|
||||||
|
*
|
||||||
|
* The markup contract, which phase 3 writes and this script relies on:
|
||||||
|
*
|
||||||
|
* <a class="demo-cta" href="" data-demo-url="">See it running</a>
|
||||||
|
*
|
||||||
|
* `global.css` hides `[data-demo-url='']`, so a stock build renders nothing. Setting
|
||||||
|
* `demoUrl` in the mount turns both empty attributes into the URL, which fills the link and
|
||||||
|
* reveals it in the same edit. Going back to an empty value reverses it, because the
|
||||||
|
* previous value is in the record.
|
||||||
|
*/
|
||||||
|
const demoFrom = previous.demoUrl || '';
|
||||||
|
const demoTo = resolved.demoUrl || '';
|
||||||
|
|
||||||
|
if (demoFrom !== demoTo) {
|
||||||
|
const attr = (value) => `href="${escapeHtml(value)}" data-demo-url="${escapeHtml(value)}"`;
|
||||||
|
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacements.length) {
|
||||||
|
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------
|
||||||
|
Rewrite
|
||||||
|
--------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
if (!existsSync(CLIENT)) {
|
||||||
|
console.error(`\n[brand] no build to rewrite at ${CLIENT}. Run \`npm run build\` first.\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) yield* walk(full);
|
||||||
|
else if (REWRITABLE_EXTENSIONS.has(path.extname(entry.name))) yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||||
|
let filesTouched = 0;
|
||||||
|
|
||||||
|
for (const file of walk(CLIENT)) {
|
||||||
|
const before = readFileSync(file, 'utf8');
|
||||||
|
let after = before;
|
||||||
|
|
||||||
|
for (const { field, from, to } of replacements) {
|
||||||
|
if (!after.includes(from)) continue;
|
||||||
|
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||||
|
after = after.split(from).join(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (after !== before) {
|
||||||
|
writeFileSync(file, after);
|
||||||
|
filesTouched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(RECORD, `${JSON.stringify(resolved, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`[brand] applied the mounted brand to ${filesTouched} file(s):`);
|
||||||
|
for (const { field, from, to } of replacements) {
|
||||||
|
if (from.startsWith('href=')) continue; // the demo pair, reported once below
|
||||||
|
console.log(` ${field.padEnd(14)} ${JSON.stringify(from)} -> ${JSON.stringify(to)} (${counts.get(field)}x)`);
|
||||||
|
}
|
||||||
|
if (demoFrom !== demoTo) {
|
||||||
|
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||||
|
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||||
|
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||||
407
scripts/buildBrandAssets.mjs
Normal file
407
scripts/buildBrandAssets.mjs
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* buildBrandAssets.mjs — PLAN.md §7, §11, D11
|
||||||
|
*
|
||||||
|
* Generates the stock brand assets in `brand-default/` from the project's real artwork.
|
||||||
|
* Its output is COMMITTED: `brand-default/` is baked into the image and must always be
|
||||||
|
* complete (§7), and CI must not need the artwork, a font file, or a working network to
|
||||||
|
* build the site. This script is an authoring tool, run by hand when the mark changes.
|
||||||
|
*
|
||||||
|
* node scripts/buildBrandAssets.mjs # regenerate everything
|
||||||
|
* node scripts/buildBrandAssets.mjs --check # verify the committed output is current
|
||||||
|
*
|
||||||
|
* WHAT IT WRITES, AND WHAT IT DELIBERATELY DOES NOT
|
||||||
|
* -------------------------------------------------------------------------------------
|
||||||
|
* Four files, and only four:
|
||||||
|
*
|
||||||
|
* logo.png 512x512 the canonical raster mark
|
||||||
|
* wordmark.svg the horizontal lockup, emblem + "Runic Gateway"
|
||||||
|
* og-image.png 1200x630 the link preview card
|
||||||
|
* theme.css written by hand, not here — listed only so the set is legible
|
||||||
|
*
|
||||||
|
* Every other size and format the site asks for — logo-64.webp, icon-192.png, favicon.ico,
|
||||||
|
* apple-touch-icon.png — is DERIVED AT RUNTIME by `src/pages/brand/[...file].ts` from
|
||||||
|
* whichever `logo.png` is in force. That is the decision that keeps §7's promise literally
|
||||||
|
* true: "swapping a logo is a file copy" means ONE file, not fifteen. Precomputing the
|
||||||
|
* derivatives here would mean an operator who drops in a new logo.png gets a new header
|
||||||
|
* mark and the old favicon, which is worse than either outcome.
|
||||||
|
*
|
||||||
|
* THE SOURCES LIVE OUTSIDE THIS REPOSITORY, ON PURPOSE
|
||||||
|
* -------------------------------------------------------------------------------------
|
||||||
|
* The emblem belongs to the product (D11 — the same file is the website's logo and the
|
||||||
|
* Android launcher icon; adopting it is what makes the three surfaces one product), and
|
||||||
|
* Cinzel's outlines come from the Android app's font directory because opentype.js cannot
|
||||||
|
* read the WOFF2 that `@fontsource-variable/cinzel` ships. Both are read from the sibling
|
||||||
|
* checkouts in the workspace and neither is vendored: a 1.4 MB PNG and a 125 KB TTF in a
|
||||||
|
* repository that needs them once per redesign is a cost paid on every clone forever.
|
||||||
|
*
|
||||||
|
* Override either with --emblem / --cinzel / --inter if the workspace is laid out
|
||||||
|
* differently. Without them the script fails loudly rather than quietly skipping a file,
|
||||||
|
* because a half-regenerated brand-default is worse than an untouched one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import opentype from 'opentype.js';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const WORKSPACE = path.resolve(ROOT, '..');
|
||||||
|
const OUT = path.join(ROOT, 'brand-default');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const CHECK_ONLY = argv.includes('--check');
|
||||||
|
|
||||||
|
function flag(name, fallback) {
|
||||||
|
const at = argv.indexOf(`--${name}`);
|
||||||
|
return at !== -1 && argv[at + 1] ? path.resolve(argv[at + 1]) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES = {
|
||||||
|
emblem: flag(
|
||||||
|
'emblem',
|
||||||
|
path.join(WORKSPACE, 'website/client/public/assets/img/runic-emblem.png')
|
||||||
|
),
|
||||||
|
cinzel: flag(
|
||||||
|
'cinzel',
|
||||||
|
path.join(WORKSPACE, 'android-app/app/src/main/res/font/cinzel_variable.ttf')
|
||||||
|
),
|
||||||
|
inter: flag(
|
||||||
|
'inter',
|
||||||
|
path.join(WORKSPACE, 'android-app/app/src/main/res/font/inter_variable.ttf')
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, file] of Object.entries(SOURCES)) {
|
||||||
|
if (existsSync(file)) continue;
|
||||||
|
console.error(
|
||||||
|
`\nbuildBrandAssets: the ${name} source is missing.\n\n expected: ${file}\n\n` +
|
||||||
|
`This script reads the product's own artwork from the sibling checkouts in the\n` +
|
||||||
|
`workspace (see the header). Pass --${name} <path> if yours is elsewhere.\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------
|
||||||
|
Tokens
|
||||||
|
-------------------------------------------------------------------------------------
|
||||||
|
The generated assets are part of the design system, so their colours come from the token
|
||||||
|
file rather than from this script. Same flat regex as `src/lib/tokens.mjs`, for the same
|
||||||
|
reason: the file is one we own and keep flat, and a CSS parser here would be a
|
||||||
|
dependency bought for four lookups.
|
||||||
|
|
||||||
|
Note the direction of the exception. `checkTokens.mjs` forbids a colour literal in
|
||||||
|
`src/`; the literals it writes into `brand-default/` are fine and are meant to be there,
|
||||||
|
because those files ARE the stock brand — the very thing an operator replaces. */
|
||||||
|
const tokens = Object.fromEntries(
|
||||||
|
readFileSync(path.join(ROOT, 'src/styles/tokens.css'), 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)
|
||||||
|
.map((m) => [m[1], m[2].trim()])
|
||||||
|
);
|
||||||
|
|
||||||
|
const brand = JSON.parse(readFileSync(path.join(OUT, 'brand.json'), 'utf8'));
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------
|
||||||
|
Type
|
||||||
|
------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cinzel and Inter both ship as variable fonts, and opentype.js reads the DEFAULT instance
|
||||||
|
* unless told otherwise — for Cinzel that is wght 400, which is too light to carry a
|
||||||
|
* wordmark. `variation.set` moves the axis before the outlines are taken.
|
||||||
|
*/
|
||||||
|
function loadFont(file, weight) {
|
||||||
|
const font = opentype.parse(readFileSync(file).buffer);
|
||||||
|
font.variation.set({ wght: weight });
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text as outlines, never as a `<text>` element.
|
||||||
|
*
|
||||||
|
* An SVG referencing a font family only renders correctly where that font is installed.
|
||||||
|
* Loaded through `<img>` — which is how `wordmark.svg` is used — the SVG is an independent
|
||||||
|
* document that cannot see the page's `@font-face` rules, and librsvg (which sharp uses to
|
||||||
|
* rasterise the OG card) resolves families through fontconfig, where Cinzel is not. Both
|
||||||
|
* would silently fall back to a serif default. Outlines have no such dependency: the shape
|
||||||
|
* is the file.
|
||||||
|
*
|
||||||
|
* The same class of mistake as phase 1's `currentColor`-through-`<img>` bug — an SVG in an
|
||||||
|
* `<img>` inherits nothing from the page, neither colour nor fonts.
|
||||||
|
*/
|
||||||
|
function textPath(font, text, size, { x = 0, y = 0, tracking = 0, fill }) {
|
||||||
|
const scale = size / font.unitsPerEm;
|
||||||
|
const parts = [];
|
||||||
|
let cursor = x;
|
||||||
|
|
||||||
|
// `charToGlyph` per character rather than `stringToGlyphs`, which runs opentype.js's
|
||||||
|
// shaper and throws on Cinzel: "substitutionType : 62 lookupType: 6 - substFormat: 2 is
|
||||||
|
// not yet supported", from a `ccmp` lookup it cannot read. Shaping buys nothing here —
|
||||||
|
// the strings are Latin, and Cinzel is an all-caps face with no ligatures to form — so
|
||||||
|
// the plain mapping is both sufficient and the more predictable of the two.
|
||||||
|
const glyphs = [...text].map((char) => font.charToGlyph(char));
|
||||||
|
|
||||||
|
for (const [i, glyph] of glyphs.entries()) {
|
||||||
|
// Every glyph is drawn at the ORIGIN and moved into place with a transform, rather
|
||||||
|
// than drawn at `cursor` directly.
|
||||||
|
//
|
||||||
|
// Asking opentype.js for a path at a non-zero origin produces NaN coordinates in some
|
||||||
|
// glyphs — which glyph depends on the exact cursor value, so it moves as the string or
|
||||||
|
// the tracking changes. An SVG path parser stops at the first malformed command and
|
||||||
|
// renders what it had, so the failure is silent and partial: the first draft of this
|
||||||
|
// lockup read "Runic Gate" and looked like a typo rather than a bug. At the origin the
|
||||||
|
// output is clean for every glyph, with and without the variation axis set.
|
||||||
|
const glyphPath = glyph.getPath(0, 0, size);
|
||||||
|
if (glyphPath.commands.length) {
|
||||||
|
const dx = cursor.toFixed(2);
|
||||||
|
const dy = y.toFixed(2);
|
||||||
|
parts.push(`<path transform="translate(${dx} ${dy})" d="${glyphPath.toPathData(2)}"/>`);
|
||||||
|
}
|
||||||
|
cursor += glyph.advanceWidth * scale + tracking;
|
||||||
|
// Kerning is per PAIR, so it is applied looking ahead rather than per glyph.
|
||||||
|
if (glyphs[i + 1]) cursor += font.getKerningValue(glyph, glyphs[i + 1]) * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = `<g fill="${fill}">${parts.join('')}</g>`;
|
||||||
|
|
||||||
|
// The guard that makes the bug above unable to ship again. A malformed path degrades
|
||||||
|
// quietly in every renderer; this file is generated once and committed, so the check
|
||||||
|
// costs nothing and the alternative is noticing in a link preview.
|
||||||
|
if (markup.includes('NaN') || markup.includes('undefined')) {
|
||||||
|
throw new Error(
|
||||||
|
`buildBrandAssets: the outlines for ${JSON.stringify(text)} contain a malformed ` +
|
||||||
|
`coordinate. This is the opentype.js positioning bug described above — the glyphs ` +
|
||||||
|
`must be drawn at the origin and translated.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: cursor - x, markup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The advance width of a run, without building the outlines — for centring. */
|
||||||
|
function measure(font, text, size, tracking = 0) {
|
||||||
|
return textPath(font, text, size, { tracking, fill: 'none' }).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------
|
||||||
|
The mark
|
||||||
|
------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The emblem is a 1024x1024 illustration that does not fill its canvas — trimmed it is
|
||||||
|
* 931x975, and off-centre by 43px. Left alone, a 40px header logo would render the mark at
|
||||||
|
* about 36px and sit visibly high.
|
||||||
|
*
|
||||||
|
* So: trim the transparent margin, then re-centre on a square canvas with a small even
|
||||||
|
* margin. Every derivative the runtime produces descends from this, which is what makes
|
||||||
|
* "the header mark and the favicon are the same shape" true by construction rather than by
|
||||||
|
* care.
|
||||||
|
*/
|
||||||
|
async function canonicalLogo(size = 512) {
|
||||||
|
const margin = 0.02; // 2%, so the ring never touches a rounded mask's edge
|
||||||
|
const inner = Math.round(size * (1 - margin * 2));
|
||||||
|
|
||||||
|
const trimmed = await sharp(SOURCES.emblem)
|
||||||
|
.trim({ threshold: 1 })
|
||||||
|
.resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return sharp({
|
||||||
|
create: {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite([{ input: trimmed, gravity: 'centre' }])
|
||||||
|
.png({ compressionLevel: 9, palette: false })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------
|
||||||
|
The outputs
|
||||||
|
------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The horizontal lockup (§7): the emblem beside the product name.
|
||||||
|
*
|
||||||
|
* The emblem rides along as a base64 PNG rather than a link, because a `<img src>`-loaded
|
||||||
|
* SVG cannot fetch a sibling file — same isolation rule as the fonts above. It is embedded
|
||||||
|
* at 2x the drawn size so the lockup stays sharp on a retina display without carrying the
|
||||||
|
* full 512.
|
||||||
|
*/
|
||||||
|
async function buildWordmark() {
|
||||||
|
const cinzel = loadFont(SOURCES.cinzel, 600);
|
||||||
|
|
||||||
|
const H = 120;
|
||||||
|
// The mark does not fill the lockup's height. `canonicalLogo` trims the artwork to its
|
||||||
|
// own edges, so a mark drawn at the full 120 touches the top and bottom of the canvas and
|
||||||
|
// reads as cropped — the ring's extremities sit exactly on the boundary. The inset is
|
||||||
|
// optical breathing room, not padding to align anything.
|
||||||
|
const markSize = 104;
|
||||||
|
const gap = 26;
|
||||||
|
const type = 62;
|
||||||
|
const tracking = type * 0.04; // matches .brand-lockup__name letter-spacing in global.css
|
||||||
|
|
||||||
|
const embedded = await sharp(await canonicalLogo(512))
|
||||||
|
.resize(markSize * 2, markSize * 2)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Cap height rather than baseline: Cinzel is all-caps, so optical centring means
|
||||||
|
// centring the caps box, not the em box.
|
||||||
|
const capHeight = cinzel.tables.os2.sCapHeight
|
||||||
|
? (cinzel.tables.os2.sCapHeight / cinzel.unitsPerEm) * type
|
||||||
|
: type * 0.7;
|
||||||
|
const baseline = H / 2 + capHeight / 2;
|
||||||
|
|
||||||
|
const name = textPath(cinzel, brand.siteName, type, {
|
||||||
|
x: markSize + gap,
|
||||||
|
y: baseline,
|
||||||
|
tracking,
|
||||||
|
fill: tokens['--gold'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = Math.ceil(markSize + gap + name.width);
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" role="img" aria-label="${brand.siteName}">
|
||||||
|
<title>${brand.siteName}</title>
|
||||||
|
<image x="0" y="${(H - markSize) / 2}" width="${markSize}" height="${markSize}" xlink:href="data:image/png;base64,${embedded.toString('base64')}"/>
|
||||||
|
${name.markup}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return Buffer.from(svg, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link preview card (§7).
|
||||||
|
*
|
||||||
|
* Everything on it is derived: the mark from the emblem, the name and tagline from
|
||||||
|
* `brand.json`, every colour from `tokens.css`. Nothing is typed in twice, so the card
|
||||||
|
* cannot drift from the site the way a hand-made one does.
|
||||||
|
*
|
||||||
|
* It is a committed FILE rather than a runtime render because an operator who changes the
|
||||||
|
* tagline in the mounted `brand.json` should be able to replace the card by dropping in a
|
||||||
|
* PNG, which is the same gesture as replacing the logo — and because rendering type at
|
||||||
|
* request time would put a font dependency into the container for one image.
|
||||||
|
*/
|
||||||
|
async function buildOgImage() {
|
||||||
|
const W = 1200;
|
||||||
|
const H = 630;
|
||||||
|
|
||||||
|
const cinzel = loadFont(SOURCES.cinzel, 600);
|
||||||
|
const inter = loadFont(SOURCES.inter, 400);
|
||||||
|
|
||||||
|
const markSize = 180;
|
||||||
|
const nameSize = 74;
|
||||||
|
const nameTracking = nameSize * 0.04;
|
||||||
|
const taglineSize = 30;
|
||||||
|
|
||||||
|
const nameWidth = measure(cinzel, brand.siteName, nameSize, nameTracking);
|
||||||
|
const taglineWidth = measure(inter, brand.tagline, taglineSize);
|
||||||
|
|
||||||
|
const markY = 118;
|
||||||
|
const nameBaseline = markY + markSize + 96;
|
||||||
|
const taglineBaseline = nameBaseline + 74;
|
||||||
|
|
||||||
|
const mark = await sharp(await canonicalLogo(512))
|
||||||
|
.resize(markSize, markSize)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const name = textPath(cinzel, brand.siteName, nameSize, {
|
||||||
|
x: (W - nameWidth) / 2,
|
||||||
|
y: nameBaseline,
|
||||||
|
tracking: nameTracking,
|
||||||
|
fill: tokens['--gold'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagline = textPath(inter, brand.tagline, taglineSize, {
|
||||||
|
x: (W - taglineWidth) / 2,
|
||||||
|
y: taglineBaseline,
|
||||||
|
fill: tokens['--muted'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The glow is the portal's own colour at low opacity — the same treatment §11 asks for
|
||||||
|
// behind the diagrams, so the card reads as part of the site rather than a poster of it.
|
||||||
|
const backdrop = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glow" cx="50%" cy="${((markY + markSize / 2) / H) * 100}%" r="46%">
|
||||||
|
<stop offset="0%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.30"/>
|
||||||
|
<stop offset="65%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.06"/>
|
||||||
|
<stop offset="100%" stop-color="${tokens['--portal-deep']}" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${W}" height="${H}" fill="${tokens['--bg']}"/>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#glow)"/>
|
||||||
|
<rect x="0" y="${H - 6}" width="${W}" height="6" fill="${tokens['--gold-deep']}"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const type = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
||||||
|
${name.markup}
|
||||||
|
${tagline.markup}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return sharp(Buffer.from(backdrop))
|
||||||
|
.composite([
|
||||||
|
{ input: mark, left: Math.round((W - markSize) / 2), top: markY },
|
||||||
|
{ input: Buffer.from(type), left: 0, top: 0 },
|
||||||
|
])
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------
|
||||||
|
Write, or verify
|
||||||
|
------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const artifacts = [
|
||||||
|
['logo.png', await canonicalLogo(512)],
|
||||||
|
['wordmark.svg', await buildWordmark()],
|
||||||
|
['og-image.png', await buildOgImage()],
|
||||||
|
];
|
||||||
|
|
||||||
|
const digest = (buffer) => createHash('sha256').update(buffer).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
|
let stale = 0;
|
||||||
|
|
||||||
|
for (const [name, bytes] of artifacts) {
|
||||||
|
const file = path.join(OUT, name);
|
||||||
|
const existing = existsSync(file) ? readFileSync(file) : null;
|
||||||
|
const unchanged = existing && existing.equals(bytes);
|
||||||
|
const size = `${(bytes.length / 1024).toFixed(1)} kB`.padStart(9);
|
||||||
|
|
||||||
|
if (CHECK_ONLY) {
|
||||||
|
if (unchanged) {
|
||||||
|
console.log(` ok ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||||
|
} else {
|
||||||
|
stale++;
|
||||||
|
console.error(` STALE ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(file, bytes);
|
||||||
|
console.log(` ${unchanged ? 'same' : 'wrote'.padEnd(4)} ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CHECK_ONLY && stale) {
|
||||||
|
console.error(
|
||||||
|
`\nbuildBrandAssets --check: ${stale} committed asset(s) no longer match what the\n` +
|
||||||
|
`sources produce. Run \`node scripts/buildBrandAssets.mjs\` and commit the result.\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
CHECK_ONLY
|
||||||
|
? '\nbuildBrandAssets: the committed brand-default assets are current.'
|
||||||
|
: '\nbuildBrandAssets: brand-default is regenerated. Commit the result.'
|
||||||
|
);
|
||||||
231
scripts/checkBrand.mjs
Normal file
231
scripts/checkBrand.mjs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* checkBrand.mjs — PLAN.md §7
|
||||||
|
*
|
||||||
|
* The branding pipeline makes two promises that nothing else in the build can verify, and
|
||||||
|
* both fail quietly rather than loudly. This is their mechanism, in the same spirit as
|
||||||
|
* `checkTokens.mjs`: diligence does not survive contact with a year of commits.
|
||||||
|
*
|
||||||
|
* 1. EVERY `/brand/*` URL THE SITE ASKS FOR MUST ACTUALLY RESOLVE.
|
||||||
|
* The route serves an allowlist of names and derives a fixed set of sizes. A template
|
||||||
|
* that asks for `/brand/logo-44.webp` — a plausible number that is not on the list —
|
||||||
|
* gets a 404, and a missing logo is exactly the kind of thing that looks like a
|
||||||
|
* styling glitch and survives review. So every literal `/brand/...` in the source is
|
||||||
|
* put through the route's own classifier, rather than a copy of its rules.
|
||||||
|
*
|
||||||
|
* 2. EVERY REWRITABLE BRAND STRING MUST BE SAFE TO REPLACE BLINDLY.
|
||||||
|
* `applyBrand.mjs` swaps brand text in built HTML with plain string replacement.
|
||||||
|
* That is safe only while the values are distinctive: a `siteName` of "Site", or a
|
||||||
|
* tagline that contains the site name inside it, would corrupt pages at boot on a
|
||||||
|
* machine nobody is watching. Checking it here makes the failure a red build.
|
||||||
|
*
|
||||||
|
* 3. `brand-default/` must be complete, because §7 says it always is.
|
||||||
|
*
|
||||||
|
* node scripts/checkBrand.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { classify } from '../src/lib/brandAssets.mjs';
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const DEFAULTS = path.join(ROOT, 'brand-default');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const fail = (message) => failures.push(message);
|
||||||
|
|
||||||
|
/* =======================================================================================
|
||||||
|
1. brand-default is complete
|
||||||
|
======================================================================================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stock set is deliberately small. Everything else the site requests — every logo size,
|
||||||
|
* both PWA icons, the apple-touch icon, the favicons and the .ico — is derived at runtime
|
||||||
|
* from `logo.png`, so that an operator rebrands by replacing one file rather than fifteen.
|
||||||
|
* Adding a precomputed derivative here would quietly undo that.
|
||||||
|
*/
|
||||||
|
const REQUIRED = ['brand.json', 'theme.css', 'logo.png', 'wordmark.svg', 'og-image.png'];
|
||||||
|
|
||||||
|
for (const name of REQUIRED) {
|
||||||
|
const file = path.join(DEFAULTS, name);
|
||||||
|
if (!existsSync(file)) {
|
||||||
|
fail(`brand-default/${name} is missing — §7 requires the stock brand to be complete.`);
|
||||||
|
} else if (statSync(file).size === 0) {
|
||||||
|
fail(`brand-default/${name} is empty.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(path.join(DEFAULTS, 'logo.png'))) {
|
||||||
|
const meta = await sharp(path.join(DEFAULTS, 'logo.png')).metadata();
|
||||||
|
if (meta.width !== meta.height) {
|
||||||
|
fail(`brand-default/logo.png is ${meta.width}x${meta.height}; the mark must be square.`);
|
||||||
|
}
|
||||||
|
// 512 is the largest thing anything asks for (icon-512.png). A smaller source would be
|
||||||
|
// upscaled into an installed app icon, which is where it would be most visible.
|
||||||
|
if (meta.width < 512) {
|
||||||
|
fail(`brand-default/logo.png is ${meta.width}px; derivatives go up to 512 and must not upscale.`);
|
||||||
|
}
|
||||||
|
if (!meta.hasAlpha) {
|
||||||
|
fail('brand-default/logo.png has no alpha channel; the mark would carry a background.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================================================================================
|
||||||
|
2. Every /brand/* URL in the source resolves
|
||||||
|
======================================================================================= */
|
||||||
|
|
||||||
|
const SCAN_EXT = new Set(['.astro', '.ts', '.tsx', '.js', '.mjs', '.css', '.md', '.mdx', '.json']);
|
||||||
|
|
||||||
|
async function* walk(dir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenced = new Map(); // name -> [where]
|
||||||
|
|
||||||
|
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
const relative = path.relative(ROOT, file);
|
||||||
|
|
||||||
|
for (const match of source.matchAll(/\/brand\/([a-z0-9][a-z0-9._-]*)/g)) {
|
||||||
|
const name = match[1];
|
||||||
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
|
if (!referenced.has(name)) referenced.set(name, []);
|
||||||
|
referenced.get(name).push(`${relative}:${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, sites] of referenced) {
|
||||||
|
// The classifier is imported from the route's own module rather than reimplemented, so
|
||||||
|
// this check cannot drift from what the server will actually do.
|
||||||
|
if (!classify(name)) {
|
||||||
|
fail(
|
||||||
|
`/brand/${name} is requested by ${sites.join(', ')} but the route would 404 it.\n` +
|
||||||
|
` Add it to STATIC_FILES, NAMED_DERIVATIVES or DERIVABLE_SIZES in ` +
|
||||||
|
`src/lib/brandAssets.mjs — or use a size that is already on the list.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================================================================================
|
||||||
|
3. The rewritable brand strings are safe to replace blindly
|
||||||
|
======================================================================================= */
|
||||||
|
|
||||||
|
const brandPath = path.join(DEFAULTS, 'brand.json');
|
||||||
|
let brand = null;
|
||||||
|
|
||||||
|
if (existsSync(brandPath)) {
|
||||||
|
try {
|
||||||
|
brand = JSON.parse(readFileSync(brandPath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
fail(`brand-default/brand.json is not valid JSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brand) {
|
||||||
|
const REQUIRED_FIELDS = [
|
||||||
|
'siteName',
|
||||||
|
'tagline',
|
||||||
|
'contactEmail',
|
||||||
|
'discordInvite',
|
||||||
|
'giteaOrg',
|
||||||
|
'demoUrl',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (typeof brand[field] !== 'string') {
|
||||||
|
fail(`brand.json is missing the string field "${field}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
|
||||||
|
* restating the list. A field added there and forgotten here would be unchecked; a field
|
||||||
|
* added here and forgotten there would be silently build-time only. Either way the two
|
||||||
|
* disagreeing is the bug, so the check is that they agree.
|
||||||
|
*/
|
||||||
|
const applySource = readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8');
|
||||||
|
const declared = applySource.match(/const TEXT_FIELDS = \[([^\]]*)\]/);
|
||||||
|
|
||||||
|
if (!declared) {
|
||||||
|
fail('could not find TEXT_FIELDS in scripts/applyBrand.mjs — has it been renamed?');
|
||||||
|
} else {
|
||||||
|
const rewritable = [...declared[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||||
|
|
||||||
|
for (const field of rewritable) {
|
||||||
|
if (!(field in brand)) {
|
||||||
|
fail(`applyBrand.mjs rewrites "${field}", which brand.json does not define.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = brand[field];
|
||||||
|
|
||||||
|
if (value.length < 8) {
|
||||||
|
fail(
|
||||||
|
`brand.json's "${field}" is ${JSON.stringify(value)} — under 8 characters.\n` +
|
||||||
|
` applyBrand.mjs replaces this string across every built page at boot; a short\n` +
|
||||||
|
` value will match unrelated markup and corrupt the output.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[<>]|="/.test(value)) {
|
||||||
|
fail(`brand.json's "${field}" contains markup characters, which the boot rewrite cannot survive.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value that occurs inside another value is the subtler failure: replacing the
|
||||||
|
// shorter one first leaves the longer one half-rewritten, and which runs first is an
|
||||||
|
// accident of declaration order.
|
||||||
|
for (const other of rewritable) {
|
||||||
|
if (other === field) continue;
|
||||||
|
if (typeof brand[other] === 'string' && brand[other].includes(value)) {
|
||||||
|
fail(
|
||||||
|
`brand.json's "${field}" (${JSON.stringify(value)}) occurs inside "${other}".\n` +
|
||||||
|
` The boot rewrite would corrupt one while replacing the other.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// demoUrl is gated by markup rather than replaced as text (see applyBrand.mjs), so it
|
||||||
|
// is correct for it NOT to be in TEXT_FIELDS. Saying so out loud, because "the demo URL
|
||||||
|
// is missing from the rewrite list" is an easy and wrong thing to conclude.
|
||||||
|
if (rewritable.includes('demoUrl')) {
|
||||||
|
fail(
|
||||||
|
'demoUrl must not be in TEXT_FIELDS: its default is the empty string, which cannot\n' +
|
||||||
|
' be string-replaced. It is handled by the data-attribute gate instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================================= */
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('\ncheckBrand: the branding pipeline has problems.\n');
|
||||||
|
for (const failure of failures) console.error(` - ${failure}`);
|
||||||
|
console.error('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||||
|
`and every rewritable string is safe to replace.`
|
||||||
|
);
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Runic Gateway">
|
|
||||||
<!--
|
|
||||||
A placeholder gateway glyph: concentric rings around an open portal, drawn from the
|
|
||||||
emblem's geometry so the header is not empty before phase 2.
|
|
||||||
|
|
||||||
Phase 2 (PLAN.md §7, §11) replaces this with the real derivatives of runic-emblem.png —
|
|
||||||
WebP and AVIF at header, hero and OG sizes, a multi-resolution favicon.ico, the 192/512
|
|
||||||
PWA icons, and the horizontal lockup — all of them in /app/brand-default so the org lead
|
|
||||||
can swap any of them with a file copy.
|
|
||||||
|
|
||||||
Everything is currentColor on purpose: no colour literal, so the mark inherits --gold
|
|
||||||
from the token file and a mounted theme.css recolours it for free.
|
|
||||||
-->
|
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round">
|
|
||||||
<circle cx="32" cy="32" r="26" stroke-width="3" opacity="0.95" />
|
|
||||||
<circle cx="32" cy="32" r="20" stroke-width="1.25" opacity="0.55" />
|
|
||||||
<circle cx="32" cy="32" r="12.5" stroke-width="2" opacity="0.9" />
|
|
||||||
<path d="M32 6v9M32 49v9M6 32h9M49 32h9" stroke-width="2.5" opacity="0.8" />
|
|
||||||
<path d="M13.6 13.6l6.4 6.4M44 44l6.4 6.4M50.4 13.6L44 20M20 44l-6.4 6.4"
|
|
||||||
stroke-width="1.25" opacity="0.4" />
|
|
||||||
</g>
|
|
||||||
<circle cx="32" cy="32" r="5.5" fill="currentColor" opacity="0.22" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
41
src/components/DocsHead.astro
Normal file
41
src/components/DocsHead.astro
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
import Default from '@astrojs/starlight/components/Head.astro';
|
||||||
|
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides Starlight's `Head` so the documentation carries the same brand wiring as the
|
||||||
|
* marketing pages (§7).
|
||||||
|
*
|
||||||
|
* Starlight builds its own head, and without this the docs were a different site: they
|
||||||
|
* linked `/favicon.svg` — a Starlight default that does not exist here, so every docs page
|
||||||
|
* requested a 404 — carried no manifest, no OG card, and crucially no `/brand/theme.css`,
|
||||||
|
* which meant a mounted theme recoloured the marketing pages and left the documentation
|
||||||
|
* stock. Half a rebrand is arguably worse than none, because it looks like a bug in the
|
||||||
|
* product rather than a step somebody missed.
|
||||||
|
*
|
||||||
|
* Starlight's own `favicon` option handles the .ico (see `astro.config.mjs`); everything
|
||||||
|
* that option cannot express is here.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<Default><slot /></Default>
|
||||||
|
|
||||||
|
<link rel="icon" href="/brand/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
|
<meta property="og:image" content={new URL('/brand/og-image.png', Astro.site)} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content={`${brand.siteName} — ${brand.tagline}`} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The operator's stylesheet, last (§7). Its position no longer decides whether it wins —
|
||||||
|
`tokens.css` lives in `@layer tokens` and this file is unlayered, so it takes precedence
|
||||||
|
wherever the browser encounters it. That is deliberate: Astro emits its bundled
|
||||||
|
stylesheets after the head markup, and an ordering-based mechanism silently stopped
|
||||||
|
working the moment it did.
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet" href="/brand/theme.css" />
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
---
|
---
|
||||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
|
||||||
import { brand } from '../lib/brand.mjs';
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
||||||
* the marketing header. One product, two chromes, one mark.
|
* the marketing header. One product, two chromes, one mark.
|
||||||
*
|
*
|
||||||
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
|
* The override still earns its place now that the mark is a raster image and Starlight's
|
||||||
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
|
* own `logo` option would also render an `<img>`: that option takes an asset IMPORTED
|
||||||
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
|
* through Vite, which fingerprints the filename into the build. A fingerprinted logo is one
|
||||||
* document — `currentColor` has nothing to inherit from there, and the mark renders black
|
* the bind mount can never replace (§7), which is the whole point of `/brand/*`. Pointing
|
||||||
* on black. Inlining it is what makes the token reach the artwork.
|
* at the stable URL is what keeps the docs header swappable along with everything else.
|
||||||
*
|
|
||||||
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
|
|
||||||
* working, because what it needs is markup rather than a file.
|
|
||||||
*/
|
*/
|
||||||
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||||
---
|
---
|
||||||
|
|
||||||
<a href={siteTitleHref} class="site-title sl-flex">
|
<a href={siteTitleHref} class="site-title sl-flex">
|
||||||
<span class="docs-mark" set:html={Mark} aria-hidden="true" />
|
<img
|
||||||
|
class="docs-mark"
|
||||||
|
src="/brand/logo-32.webp"
|
||||||
|
srcset="/brand/logo-32.webp 1x, /brand/logo-64.webp 2x, /brand/logo-96.webp 3x"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<span translate="no">{siteTitle || brand.siteName}</span>
|
<span translate="no">{siteTitle || brand.siteName}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -37,15 +40,10 @@ const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-mark {
|
.docs-mark {
|
||||||
display: inline-flex;
|
display: block;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 30px;
|
width: 32px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
color: var(--gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(:root[data-theme='light']) .docs-mark {
|
|
||||||
color: var(--light-gold);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span:last-child {
|
span:last-child {
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
---
|
---
|
||||||
import { brand } from '../lib/brand.mjs';
|
import { brand } from '../lib/brand.mjs';
|
||||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The marketing header. The docs get Starlight's own header, themed to match in
|
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||||
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
|
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
|
||||||
*
|
*
|
||||||
|
* The mark is the product's real emblem (D11), served from the brand mount rather than
|
||||||
|
* imported: the same artwork as the website's own logo and the Android launcher icon, so
|
||||||
|
* the three surfaces read as one product. Phase 1's placeholder glyph is gone.
|
||||||
|
*
|
||||||
|
* It is an `<img>`, not an inline SVG, and that costs something worth naming. The emblem is
|
||||||
|
* raster illustration, so a mounted `theme.css` cannot recolour it the way it recolours
|
||||||
|
* everything else — replacing the mark means replacing `logo.png`. That is the trade D11
|
||||||
|
* makes: a mark that already carries recognition, against a simpler one that would follow
|
||||||
|
* the palette.
|
||||||
|
*
|
||||||
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
|
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
|
||||||
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
|
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +34,15 @@ const isCurrent = (href: string) =>
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="page site-header__inner">
|
<div class="page site-header__inner">
|
||||||
<a class="brand-lockup" href="/">
|
<a class="brand-lockup" href="/">
|
||||||
<span class="brand-lockup__mark" set:html={Mark} />
|
<img
|
||||||
|
class="brand-lockup__mark"
|
||||||
|
src="/brand/logo-40.webp"
|
||||||
|
srcset="/brand/logo-40.webp 1x, /brand/logo-80.webp 2x, /brand/logo-120.webp 3x"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
alt=""
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
<span class="brand-lockup__name">{brand.siteName}</span>
|
<span class="brand-lockup__name">{brand.siteName}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* `alt=""` above is deliberate: the mark sits beside the site name in the same link, so
|
||||||
|
announcing it would make a screen reader say the product's name twice. */
|
||||||
.brand-lockup__mark {
|
.brand-lockup__mark {
|
||||||
display: inline-flex;
|
display: block;
|
||||||
color: var(--gold);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
|||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:url" content={canonical} />
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={new URL('/brand/og-image.png', Astro.site)} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content={`${brand.siteName} — ${brand.tagline}`} />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -43,11 +47,35 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
|||||||
`default-src 'self'` holds with no exception to argue about. The CSP header itself
|
`default-src 'self'` holds with no exception to argue about. The CSP header itself
|
||||||
is set at the adapter in phase 10; this comment is here so nobody adds a CDN link
|
is set at the adapter in phase 10; this comment is here so nobody adds a CDN link
|
||||||
in the meantime and quietly breaks the promise.
|
in the meantime and quietly breaks the promise.
|
||||||
|
|
||||||
Favicons and the OG image are served from the brand mount (`/brand/*`) in phase 2.
|
|
||||||
-->
|
-->
|
||||||
<meta name="theme-color" content={token('--bg')} />
|
<meta name="theme-color" content={token('--bg')} />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Icons, like every other brand asset, come from the mount (§7). Only `favicon.ico` is
|
||||||
|
strictly needed — browsers ask for it at that exact path whether or not a page links
|
||||||
|
it, which is why the route derives one rather than 404ing — but naming the PNG and
|
||||||
|
the apple-touch icon explicitly means a device picks the size it wants instead of
|
||||||
|
downscaling a 48px .ico.
|
||||||
|
|
||||||
|
None of these files has to exist in `brand-default/`. They are derived on request
|
||||||
|
from whichever `logo.png` is in force, so an operator replaces exactly one file.
|
||||||
|
-->
|
||||||
|
<link rel="icon" href="/brand/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||||
|
<link rel="icon" href="/brand/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Last in the head, and that position is the mechanism (§7). This is the operator's
|
||||||
|
stylesheet: it only ever redefines custom properties, and a custom property
|
||||||
|
redefined on `:root` wins by being later, not by being more specific. Moving this
|
||||||
|
above the site's own stylesheet would silently turn every override into a no-op.
|
||||||
|
|
||||||
|
The stock copy declares nothing, so this costs one 304 and changes no pixels until
|
||||||
|
somebody mounts a real one.
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet" href="/brand/theme.css" />
|
||||||
|
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,31 @@ import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The single accessor for brand text (§7). Every template reads brand through here and
|
* The single accessor for brand text (§7). Every template reads brand through here and
|
||||||
* never imports `brand.json` directly, so phase 2 can change WHERE the values come from
|
* never imports `brand.json` directly.
|
||||||
* without touching a single call site.
|
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* A tension phase 2 has to resolve, recorded here so it is not discovered late
|
* WHAT THIS RETURNS, AND HOW THE MOUNT STILL WINS
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* §7 promises that changing the site name, the Discord invite or the contact address is a
|
* These are the STOCK values, read from `brand-default/brand.json` at build time, and they
|
||||||
* file edit on the bind mount plus a restart — the same class of change as swapping a
|
* are what gets baked into the prerendered HTML. That is correct and complete for a stock
|
||||||
* logo. But §6 prerenders the pages at build time, and a value read at build time is baked
|
* deployment, which is the common case.
|
||||||
* into the HTML, where no mounted file can reach it.
|
|
||||||
*
|
*
|
||||||
* Assets are fine: they are served by `GET /brand/*` at runtime, which reads the mount per
|
* The mount reaches the text afterwards, from outside this module. Phase 1 recorded the
|
||||||
* request. Text is not, and phase 2 owns the fix. The options, in the order they are worth
|
* conflict here — §7 promises that renaming the product or changing the Discord invite is
|
||||||
* trying:
|
* a file edit plus a restart, while §6 prerenders every page, so a build-time value is
|
||||||
|
* baked where no mounted file can reach it. The org lead settled it on 2026-08-20:
|
||||||
|
* `scripts/applyBrand.mjs` rewrites the built HTML at boot, before the server opens a
|
||||||
|
* socket, replacing what was baked with what the mount says. Every page stays prerendered,
|
||||||
|
* the docs are covered by the same pass, and Pagefind still has static HTML to index.
|
||||||
*
|
*
|
||||||
* 1. A response-time rewrite in the Node adapter's middleware, substituting a small set
|
* Two consequences for anyone adding a field here:
|
||||||
* of placeholder tokens in the prerendered HTML. Keeps every page static and the
|
|
||||||
* mount authoritative. Costs one pass over the response body.
|
|
||||||
* 2. Mark the handful of pages that show brand text as `prerender = false`. Simple, but
|
|
||||||
* it spreads: the footer is on every page, so "the handful" is all of them.
|
|
||||||
* 3. Accept that text is build-time and only assets are mounted. Cheapest, and it
|
|
||||||
* contradicts the sentence in §7 that says otherwise — so it needs the org lead's
|
|
||||||
* agreement, not a quiet decision here.
|
|
||||||
*
|
*
|
||||||
* Until then this returns the stock values, which is the correct behaviour for an empty
|
* - A new brand string is not automatically rewritable. Add it to `TEXT_FIELDS` in
|
||||||
* mount either way.
|
* `applyBrand.mjs`, or it is build-time only and §7 quietly stops being true for it.
|
||||||
|
* - The rewrite is a plain string replacement, so a default that is short or that occurs
|
||||||
|
* in ordinary markup is unsafe. `scripts/checkBrand.mjs` fails the build for one.
|
||||||
|
*
|
||||||
|
* Assets never had this problem: `GET /brand/*` reads the mount per request.
|
||||||
*/
|
*/
|
||||||
export const brand = Object.freeze({ ...brandDefault });
|
export const brand = Object.freeze({ ...brandDefault });
|
||||||
|
|
||||||
|
|||||||
335
src/lib/brandAssets.mjs
Normal file
335
src/lib/brandAssets.mjs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* brandAssets.mjs — the brand mount, resolved (PLAN.md §7)
|
||||||
|
*
|
||||||
|
* Two directories. `brand-default/` is baked into the image and always complete.
|
||||||
|
* `brand/` is the bind mount and may be empty, partial or full. Every file resolves
|
||||||
|
* against the mount first and the defaults second, PER FILE, so overriding only
|
||||||
|
* `theme.css` leaves every logo stock and an empty mount produces exactly the stock site.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* WHY THIS IS A RUNTIME MODULE AND NOT AN ASSET IMPORT
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* Nothing here goes through Vite. Vite would fingerprint the filename into the build —
|
||||||
|
* `logo.a1b2c3.png` — and a mounted file could then never replace it, because no page
|
||||||
|
* would ever ask for the name the operator wrote. Stable, unhashed URLs are the mechanism;
|
||||||
|
* the ETag below is what buys back the caching that fingerprinting would have given.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* ONE FILE IS THE WHOLE REBRAND
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* §7's promise is that swapping a logo is "a file copy". The site asks for about fifteen
|
||||||
|
* images — header at three pixel ratios, hero, two PWA icons, an apple-touch icon, three
|
||||||
|
* favicon sizes and an .ico. If those were fifteen files in `brand-default/`, keeping the
|
||||||
|
* promise would mean an operator producing fifteen files, and the realistic outcome is a
|
||||||
|
* deployment with a new header mark and the old favicon.
|
||||||
|
*
|
||||||
|
* So the defaults hold exactly one raster — `logo.png` — and everything else is derived
|
||||||
|
* from whichever `logo.png` is in force, cached in memory after the first request. Drop in
|
||||||
|
* one file, restart, and the header, the browser tab, the installed icon and the hero all
|
||||||
|
* change together.
|
||||||
|
*
|
||||||
|
* Derivation is limited to an allowlist of sizes. That is not tidiness: an open size
|
||||||
|
* parameter is an invitation to make the container resize an image ten thousand times.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both directories are resolved from the working directory, which is `/app` in the
|
||||||
|
* container and the repository root in development — so the defaults are correct in both
|
||||||
|
* places and the env vars exist for the third case nobody has hit yet.
|
||||||
|
*/
|
||||||
|
const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
|
||||||
|
const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
|
||||||
|
|
||||||
|
const CONTENT_TYPES = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files that may be served verbatim from either directory.
|
||||||
|
*
|
||||||
|
* An allowlist rather than "whatever is in the directory", because the mount is operator
|
||||||
|
* data: without this, dropping a stray file into `brand/` would publish it, and a mount
|
||||||
|
* pointed at the wrong directory by a typo in a compose file would publish that instead.
|
||||||
|
* Serving only names the site actually asks for keeps the blast radius of a mistake to a
|
||||||
|
* missing logo.
|
||||||
|
*/
|
||||||
|
const STATIC_FILES = new Set([
|
||||||
|
'brand.json',
|
||||||
|
'theme.css',
|
||||||
|
'logo.png',
|
||||||
|
'logo.svg',
|
||||||
|
'wordmark.svg',
|
||||||
|
'og-image.png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Sizes the site actually uses, at 1x, 2x and 3x where it uses them. */
|
||||||
|
const DERIVABLE_SIZES = new Set([
|
||||||
|
16, 32, 40, 48, 64, 80, 96, 120, 128, 160, 180, 192, 240, 256, 320, 384, 512,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NAMED_DERIVATIVES = {
|
||||||
|
// Derivable, not static, even though §7's table lists it as a file: a mounted
|
||||||
|
// `favicon.ico` still wins, because `locate` runs before derivation for every name. The
|
||||||
|
// distinction that matters is what happens when NOBODY supplies one, and the answer has
|
||||||
|
// to be "derive it from the logo" rather than "404" — browsers request `/favicon.ico`
|
||||||
|
// whether or not a page links it.
|
||||||
|
'favicon.ico': { size: 48, format: 'ico' },
|
||||||
|
'icon-192.png': { size: 192, format: 'png' },
|
||||||
|
'icon-512.png': { size: 512, format: 'png' },
|
||||||
|
'apple-touch-icon.png': { size: 180, format: 'png' },
|
||||||
|
'favicon-16.png': { size: 16, format: 'png' },
|
||||||
|
'favicon-32.png': { size: 32, format: 'png' },
|
||||||
|
'favicon-48.png': { size: 48, format: 'png' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `logo-<size>.<format>` — the header and hero variants. */
|
||||||
|
const SIZED = /^logo-(\d{1,4})\.(webp|avif|png)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes what a requested name means, or returns null if it means nothing.
|
||||||
|
* Names are flat by construction: a `/` or a `..` never reaches here (see `parseName`).
|
||||||
|
*/
|
||||||
|
export function classify(name) {
|
||||||
|
if (STATIC_FILES.has(name)) return { kind: 'static', name };
|
||||||
|
if (NAMED_DERIVATIVES[name]) return { kind: 'derived', name, ...NAMED_DERIVATIVES[name] };
|
||||||
|
|
||||||
|
const sized = SIZED.exec(name);
|
||||||
|
if (sized) {
|
||||||
|
const size = Number(sized[1]);
|
||||||
|
if (DERIVABLE_SIZES.has(size)) return { kind: 'derived', name, size, format: sized[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects anything that is not a single flat filename.
|
||||||
|
*
|
||||||
|
* Path traversal is the obvious reason, and it is not the only one: this route reads from
|
||||||
|
* a directory an operator controls but does not audit, so "one segment, lowercase, from
|
||||||
|
* the allowlist" is a much smaller thing to be sure of than "no `..` anywhere".
|
||||||
|
*/
|
||||||
|
export function parseName(rest) {
|
||||||
|
const name = (rest || '').replace(/^\/+/, '');
|
||||||
|
if (!name || !/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.includes('..')) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statOrNull(file) {
|
||||||
|
try {
|
||||||
|
const info = await stat(file);
|
||||||
|
return info.isFile() ? info : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a given file comes from, mount first. Returns null when neither directory has it —
|
||||||
|
* which for a derivable name is not an error, it just means "derive it".
|
||||||
|
*/
|
||||||
|
async function locate(name) {
|
||||||
|
const mounted = path.join(MOUNT_DIR, name);
|
||||||
|
const info = await statOrNull(mounted);
|
||||||
|
if (info) return { file: mounted, info, source: 'mount' };
|
||||||
|
|
||||||
|
const fallback = path.join(DEFAULT_DIR, name);
|
||||||
|
const defaultInfo = await statOrNull(fallback);
|
||||||
|
if (defaultInfo) return { file: fallback, info: defaultInfo, source: 'default' };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raster every derivative descends from.
|
||||||
|
*
|
||||||
|
* `logo.svg` is second rather than first because it is the rarer case and the PNG is what
|
||||||
|
* §7's table calls the emblem; an operator who mounts both means the PNG. An operator who
|
||||||
|
* mounts only the SVG gets it rasterised, which is better than getting the stock mark.
|
||||||
|
*/
|
||||||
|
async function locateSource() {
|
||||||
|
for (const candidate of ['logo.png', 'logo.svg']) {
|
||||||
|
const mounted = path.join(MOUNT_DIR, candidate);
|
||||||
|
const info = await statOrNull(mounted);
|
||||||
|
if (info) return { file: mounted, info, source: 'mount' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = path.join(DEFAULT_DIR, 'logo.png');
|
||||||
|
const info = await statOrNull(fallback);
|
||||||
|
return info ? { file: fallback, info, source: 'default' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cache key that changes when the file behind it changes.
|
||||||
|
*
|
||||||
|
* §7 says a rebrand is a file copy and a restart, and a restart empties this map — so
|
||||||
|
* strictly the mtime is redundant. It is here because the failure it prevents is the
|
||||||
|
* confusing one: an operator who copies a new logo in without restarting should see either
|
||||||
|
* the old mark or the new one, never a header showing the new mark beside a favicon still
|
||||||
|
* derived from the old.
|
||||||
|
*/
|
||||||
|
function signature({ file, info }) {
|
||||||
|
return `${file}:${info.mtimeMs}:${info.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** name -> { bytes, etag, type, source } */
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
const etagOf = (bytes) => `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 24)}"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal ICO container.
|
||||||
|
*
|
||||||
|
* `favicon.ico` is in §7's table and sharp cannot write the format, but an .ico is barely a
|
||||||
|
* format: a six-byte header, a sixteen-byte directory entry per image, and — since Vista —
|
||||||
|
* ordinary PNG payloads. Writing those forty bytes is cheaper than a dependency, and it is
|
||||||
|
* what lets `/favicon.ico`, which browsers request whether or not a page links it, answer
|
||||||
|
* with the operator's mark rather than a 404.
|
||||||
|
*/
|
||||||
|
function encodeIco(images) {
|
||||||
|
const header = Buffer.alloc(6);
|
||||||
|
header.writeUInt16LE(0, 0); // reserved
|
||||||
|
header.writeUInt16LE(1, 2); // 1 = icon
|
||||||
|
header.writeUInt16LE(images.length, 4);
|
||||||
|
|
||||||
|
const directory = Buffer.alloc(16 * images.length);
|
||||||
|
let offset = header.length + directory.length;
|
||||||
|
|
||||||
|
images.forEach(({ size, bytes }, i) => {
|
||||||
|
const at = i * 16;
|
||||||
|
directory.writeUInt8(size >= 256 ? 0 : size, at); // 0 means 256
|
||||||
|
directory.writeUInt8(size >= 256 ? 0 : size, at + 1);
|
||||||
|
directory.writeUInt8(0, at + 2); // palette size, 0 for truecolour
|
||||||
|
directory.writeUInt8(0, at + 3); // reserved
|
||||||
|
directory.writeUInt16LE(1, at + 4); // colour planes
|
||||||
|
directory.writeUInt16LE(32, at + 6); // bits per pixel
|
||||||
|
directory.writeUInt32LE(bytes.length, at + 8);
|
||||||
|
directory.writeUInt32LE(offset, at + 12);
|
||||||
|
offset += bytes.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.concat([header, directory, ...images.map((image) => image.bytes)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterise at the target size. An SVG source needs the density raised to match, otherwise
|
||||||
|
* librsvg renders it at its nominal size and sharp scales the result up.
|
||||||
|
*/
|
||||||
|
async function rasterise(source, size) {
|
||||||
|
const isSvg = source.file.endsWith('.svg');
|
||||||
|
const bytes = await readFile(source.file);
|
||||||
|
|
||||||
|
if (!isSvg) return sharp(bytes).resize(size, size, { fit: 'contain', background: TRANSPARENT });
|
||||||
|
|
||||||
|
const nominal = (await sharp(bytes).metadata()).width || size;
|
||||||
|
return sharp(bytes, { density: Math.min(2400, Math.max(72, (72 * size) / nominal)) })
|
||||||
|
.resize(size, size, { fit: 'contain', background: TRANSPARENT });
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
|
||||||
|
|
||||||
|
async function derive(spec, source) {
|
||||||
|
if (spec.name === 'favicon.ico') {
|
||||||
|
const images = await Promise.all(
|
||||||
|
[16, 32, 48].map(async (size) => ({
|
||||||
|
size,
|
||||||
|
bytes: await (await rasterise(source, size)).png({ compressionLevel: 9 }).toBuffer(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return encodeIco(images);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = await rasterise(source, spec.size);
|
||||||
|
|
||||||
|
if (spec.format === 'webp') return pipeline.webp({ quality: 90, effort: 5 }).toBuffer();
|
||||||
|
if (spec.format === 'avif') return pipeline.avif({ quality: 62, effort: 4 }).toBuffer();
|
||||||
|
return pipeline.png({ compressionLevel: 9 }).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a brand file to bytes: mount, then defaults, then derivation.
|
||||||
|
*
|
||||||
|
* Returns null for a name that is not a brand file at all, so the caller answers 404
|
||||||
|
* rather than leaking which of the three steps failed.
|
||||||
|
*/
|
||||||
|
export async function resolveBrandFile(name) {
|
||||||
|
const spec = classify(name);
|
||||||
|
if (!spec) return null;
|
||||||
|
|
||||||
|
const found = await locate(name);
|
||||||
|
|
||||||
|
// A mounted or stock file always wins over a derivation. An operator who has produced a
|
||||||
|
// hand-tuned 32px favicon should get theirs, not one this code resized.
|
||||||
|
if (found) {
|
||||||
|
const key = `file:${signature(found)}`;
|
||||||
|
const hit = cache.get(name);
|
||||||
|
if (hit?.key === key) return hit;
|
||||||
|
|
||||||
|
const bytes = await readFile(found.file);
|
||||||
|
const entry = {
|
||||||
|
key,
|
||||||
|
bytes,
|
||||||
|
etag: etagOf(bytes),
|
||||||
|
type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
|
||||||
|
source: found.source,
|
||||||
|
};
|
||||||
|
cache.set(name, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.kind !== 'derived') return null;
|
||||||
|
|
||||||
|
const source = await locateSource();
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const key = `derive:${signature(source)}`;
|
||||||
|
const hit = cache.get(name);
|
||||||
|
if (hit?.key === key) return hit;
|
||||||
|
|
||||||
|
const bytes = await derive(spec, source);
|
||||||
|
const entry = {
|
||||||
|
key,
|
||||||
|
bytes,
|
||||||
|
etag: etagOf(bytes),
|
||||||
|
type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
|
||||||
|
source: `derived:${source.source}`,
|
||||||
|
};
|
||||||
|
cache.set(name, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variants every page requests, derived ahead of the first visitor.
|
||||||
|
*
|
||||||
|
* Called when the route module is first loaded, not at process start — Astro loads a route
|
||||||
|
* lazily — so in practice the first request to anything under `/brand/` pays for its own
|
||||||
|
* file and warms the rest in the background. That is the difference between one slow
|
||||||
|
* request and eight.
|
||||||
|
*/
|
||||||
|
export function warmCache() {
|
||||||
|
const names = [
|
||||||
|
'logo-40.webp',
|
||||||
|
'logo-80.webp',
|
||||||
|
'logo-120.webp',
|
||||||
|
'favicon-32.png',
|
||||||
|
'favicon.ico',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
];
|
||||||
|
return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the checks and the smoke test, which assert on what a request WOULD produce. */
|
||||||
|
export const brandDirs = { mount: MOUNT_DIR, defaults: DEFAULT_DIR };
|
||||||
72
src/pages/brand/[...file].ts
Normal file
72
src/pages/brand/[...file].ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
import { parseName, resolveBrandFile, warmCache } from '../../lib/brandAssets.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /brand/*` — the bind-mounted branding (PLAN.md §7).
|
||||||
|
*
|
||||||
|
* This is one of exactly two routes on the site that execute per request; the other is the
|
||||||
|
* beta signup in phase 5. Everything else is prerendered, which is why the config comment
|
||||||
|
* in `astro.config.mjs` describes opting OUT rather than in.
|
||||||
|
*
|
||||||
|
* It has to be dynamic. The whole point of §7 is that these bytes come from a directory
|
||||||
|
* that did not exist when the image was built, so there is nothing to prerender: a build
|
||||||
|
* that baked them in would be a build that has to be repeated to change a logo.
|
||||||
|
*/
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Warm the variants every page asks for, so the first visitor pays for one derivation
|
||||||
|
// rather than eight. Fire and forget: a failure here is a cache miss, not an error.
|
||||||
|
void warmCache();
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params, request }) => {
|
||||||
|
const name = parseName(params.file);
|
||||||
|
if (!name) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = await resolveBrandFile(name);
|
||||||
|
} catch (error) {
|
||||||
|
// A mounted file that is not what it claims to be — a truncated PNG, a text file named
|
||||||
|
// logo.png — must not take the page down with it. The brand is decoration; the site
|
||||||
|
// still works without it, and the operator gets a log line naming the file.
|
||||||
|
console.error(`[brand] could not serve ${name}:`, error);
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
|
// Revalidation is what makes the short TTL affordable: browsers keep the bytes and ask
|
||||||
|
// only whether they changed, so the common case is a 304 with no body.
|
||||||
|
if (request.headers.get('if-none-match') === file.etag) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: { ETag: file.etag, 'Cache-Control': CACHE_CONTROL },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(file.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type,
|
||||||
|
'Content-Length': String(file.bytes.length),
|
||||||
|
ETag: file.etag,
|
||||||
|
'Cache-Control': CACHE_CONTROL,
|
||||||
|
// Which of the three resolution steps answered. The mount is the one part of this
|
||||||
|
// site an operator configures by hand and cannot see the result of from the outside;
|
||||||
|
// this turns "the logo did not change" from a guess into one curl.
|
||||||
|
'X-Brand-Source': file.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Five minutes, not a year.
|
||||||
|
*
|
||||||
|
* These URLs are deliberately unhashed (§7 — a fingerprinted filename could never be
|
||||||
|
* replaced by a mounted file), so a long max-age would mean an operator swapping a logo and
|
||||||
|
* being told by every already-warm browser that nothing had happened. Five minutes plus
|
||||||
|
* revalidation costs one conditional request per asset per five minutes and bounds how
|
||||||
|
* wrong a stale cache can be.
|
||||||
|
*/
|
||||||
|
const CACHE_CONTROL = 'public, max-age=300, must-revalidate';
|
||||||
45
src/pages/manifest.webmanifest.ts
Normal file
45
src/pages/manifest.webmanifest.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
import { token } from '../lib/tokens.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web app manifest.
|
||||||
|
*
|
||||||
|
* It exists because §7's table lists `icon-192.png` and `icon-512.png` as brand files, and
|
||||||
|
* without a manifest nothing ever asks for them — an installed-icon size that no document
|
||||||
|
* references is a file the operator maintains for nobody.
|
||||||
|
*
|
||||||
|
* Prerendered like every other page: the icon URLs it points at are stable and unhashed, so
|
||||||
|
* the manifest does not change when the icons behind them do. The two strings that CAN
|
||||||
|
* change — the name and the description — are handled the same way as the HTML, by
|
||||||
|
* `scripts/applyBrand.mjs` at boot, which is why that script rewrites `.webmanifest` as
|
||||||
|
* well as `.html`.
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: brand.siteName,
|
||||||
|
short_name: brand.siteName,
|
||||||
|
description: brand.tagline,
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: token('--bg'),
|
||||||
|
theme_color: token('--bg'),
|
||||||
|
icons: [
|
||||||
|
{ src: '/brand/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/brand/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
// `purpose: maskable` is a promise that the mark survives being cropped to a
|
||||||
|
// circle or a squircle. `buildBrandAssets.mjs` insets the emblem inside its
|
||||||
|
// canvas for exactly this, but the promise is only true for the STOCK logo — an
|
||||||
|
// operator who mounts edge-to-edge artwork would get it clipped on Android, so
|
||||||
|
// the declaration stays off until the site can know what it is shipping.
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
{ headers: { 'Content-Type': 'application/manifest+json; charset=utf-8' } }
|
||||||
|
);
|
||||||
@@ -168,8 +168,8 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup__mark {
|
.brand-lockup__mark {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,3 +332,22 @@ svg {
|
|||||||
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
|
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
|
||||||
color: var(--mode-live);
|
color: var(--mode-live);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The demo slot -------------------------------------------------------
|
||||||
|
PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
|
||||||
|
the site is built so that gaining one is a line in the mounted brand.json
|
||||||
|
rather than a rebuild — the same class of change as swapping a logo (§7).
|
||||||
|
|
||||||
|
Anything carrying `data-demo-url` is hidden while that attribute is empty,
|
||||||
|
which is the state a stock build ships in. `scripts/applyBrand.mjs` fills
|
||||||
|
both the attribute and the adjacent empty `href` at boot when the mount sets
|
||||||
|
`demoUrl`, and the element appears. The markup contract is:
|
||||||
|
|
||||||
|
<a class="demo-cta" href="" data-demo-url="">See it running</a>
|
||||||
|
|
||||||
|
Written here, before phase 3 writes that markup, because the rule and the
|
||||||
|
rewrite have to agree and they live in different files. */
|
||||||
|
|
||||||
|
[data-demo-url=''] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,28 @@
|
|||||||
concepts line up, so a theme written for a Runic Gateway deployment is
|
concepts line up, so a theme written for a Runic Gateway deployment is
|
||||||
legible here and vice versa (§7, §11).
|
legible here and vice versa (§7, §11).
|
||||||
|
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
WHY EVERYTHING BELOW IS INSIDE `@layer tokens`
|
||||||
|
|
||||||
|
The mounted `theme.css` beats these definitions because they are in a cascade
|
||||||
|
layer and it is not: unlayered CSS wins over layered CSS no matter which one
|
||||||
|
the browser saw first.
|
||||||
|
|
||||||
|
The first version relied on the `<link>` order instead, and it did not work.
|
||||||
|
Astro emits its own bundled stylesheet AFTER the links written in the page's
|
||||||
|
head, so the site's tokens landed after the operator's and every override was
|
||||||
|
silently a no-op. Depending on the order of two `:root` blocks of identical
|
||||||
|
specificity was the fragile part; the layer removes the dependency.
|
||||||
|
|
||||||
|
Only this file is layered. The rest of the stylesheet consumes these values
|
||||||
|
through `var()` and never competes with them.
|
||||||
|
----------------------------------------------------------------------------
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
:root {
|
@layer tokens {
|
||||||
|
|
||||||
|
:root {
|
||||||
/* ---- Ground and panels -------------------------------------------------
|
/* ---- Ground and panels -------------------------------------------------
|
||||||
Taken unchanged from the product's token file. Same bytes, same names. */
|
Taken unchanged from the product's token file. Same bytes, same names. */
|
||||||
--bg: #0e1318;
|
--bg: #0e1318;
|
||||||
@@ -102,9 +121,9 @@
|
|||||||
--page-max: 1180px;
|
--page-max: 1180px;
|
||||||
--gutter: 24px;
|
--gutter: 24px;
|
||||||
--header-h: 68px;
|
--header-h: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Light mode, docs only ----------------------------------------------
|
/* ---- Light mode, docs only ----------------------------------------------
|
||||||
§11: marketing pages are single-theme by design; the docs honour the
|
§11: marketing pages are single-theme by design; the docs honour the
|
||||||
reader's light/dark preference. Starlight ships an accessible light theme,
|
reader's light/dark preference. Starlight ships an accessible light theme,
|
||||||
so this is not a second palette — it is the four brand colours restated at
|
so this is not a second palette — it is the four brand colours restated at
|
||||||
@@ -115,7 +134,7 @@
|
|||||||
token file, because that is the rule: a literal anywhere else fails
|
token file, because that is the rule: a literal anywhere else fails
|
||||||
`checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
|
`checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
|
||||||
these too. */
|
these too. */
|
||||||
:root {
|
:root {
|
||||||
--light-bg: #f6f8fb;
|
--light-bg: #f6f8fb;
|
||||||
--light-panel: #ffffff;
|
--light-panel: #ffffff;
|
||||||
--light-line: #d6dee9;
|
--light-line: #d6dee9;
|
||||||
@@ -126,4 +145,5 @@
|
|||||||
--light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
|
--light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
|
||||||
--light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
|
--light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
|
||||||
--light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
|
--light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user