feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §7: swapping a logo or recolouring the site is a file copy and a container restart, never a rebuild. Phase 2 builds the mechanism and the checks that keep it true. GET /brand/* resolves every file against the mount first and the baked-in defaults second, per file, at stable unhashed URLs with an ETag and a five minute TTL. Nothing goes through Vite, which would fingerprint the names out of the mount's reach. An X-Brand-Source header says which step answered. Three decisions were taken with the org lead (recorded as D14-D16 in §7): D14 — one raster in, every size out. brand-default holds a single logo.png; the header mark at three pixel ratios, both install icons, the apple-touch icon, the favicons and a real multi-resolution favicon.ico are derived on request from whichever logo.png is in force, cached, and limited to an allowlist of sizes. Shipping fifteen precomputed files would have meant an operator producing fifteen to change a mark — and getting a new header with the old favicon. D15 — brand text is applied at boot. Pages are prerendered, so §7's promise about the site name, tagline and links could not hold at render time. npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML from what it last applied to what the mount says. It rewrites from a record in dist/.brand-applied.json rather than from the defaults, because the naive version works exactly once and then silently ignores every later edit. An empty mount is a no-op; removing a mount restores the stock build byte for byte. Verified both ways, plus a second rename. D16 — the header shows the real emblem, replacing phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one mark. It is raster art, so theme.css cannot recolour it; replacing logo.png is how the mark changes. Two defects found and fixed while proving it: The mounted theme.css did not win. Astro emits its own stylesheet after the head markup, so linking the operator's last was not enough and every override was silently a no-op. tokens.css now lives in @layer tokens and the mounted file is unlayered, which takes order out of the mechanism entirely. The documentation was a different site. Starlight builds its own head, so the docs linked a Starlight default /favicon.svg that does not exist here, carried no manifest or OG card, and never loaded the brand stylesheet — a mounted theme recoloured the marketing pages and left the docs stock. A Head override fixes it; half a rebrand looks like a product bug rather than a missed step. brand-default/wordmark.svg and og-image.png are generated by scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are committed, so CI needs neither the artwork nor a font. Type is converted to paths, because an SVG in an <img> can see neither the page's @font-face rules nor fontconfig — the same isolation that broke currentColor in phase 1. Its glyphs are drawn at the origin and translated: opentype.js emits NaN coordinates at a non-zero origin for some glyphs, and a path parser stops at the first malformed command, so the first lockup read "Runic Gate" and looked like a typo rather than a bug. scripts/checkBrand.mjs is the mechanism for the two failures that are otherwise silent: it puts every literal /brand/... URL in the source through the route's own classifier, so a size that is not on the allowlist fails the build instead of 404ing in a browser, and it rejects a brand string short enough that a blind replacement at boot could corrupt a page. Negative-tested three ways before being trusted. It runs in CI ahead of the type check. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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';
|
||||
|
||||
/**
|
||||
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
||||
* the marketing header. One product, two chromes, one mark.
|
||||
*
|
||||
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
|
||||
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
|
||||
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
|
||||
* document — `currentColor` has nothing to inherit from there, and the mark renders black
|
||||
* on black. Inlining it is what makes the token reach the artwork.
|
||||
*
|
||||
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
|
||||
* working, because what it needs is markup rather than a file.
|
||||
* The override still earns its place now that the mark is a raster image and Starlight's
|
||||
* own `logo` option would also render an `<img>`: that option takes an asset IMPORTED
|
||||
* through Vite, which fingerprints the filename into the build. A fingerprinted logo is one
|
||||
* the bind mount can never replace (§7), which is the whole point of `/brand/*`. Pointing
|
||||
* at the stable URL is what keeps the docs header swappable along with everything else.
|
||||
*/
|
||||
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||
---
|
||||
|
||||
<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>
|
||||
</a>
|
||||
|
||||
@@ -37,15 +40,10 @@ const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||
}
|
||||
|
||||
.docs-mark {
|
||||
display: inline-flex;
|
||||
display: block;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
:global(:root[data-theme='light']) .docs-mark {
|
||||
color: var(--light-gold);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
---
|
||||
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
|
||||
* `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
|
||||
* 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">
|
||||
<div class="page site-header__inner">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
|
||||
</header>
|
||||
|
||||
<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 {
|
||||
display: inline-flex;
|
||||
color: var(--gold);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user