feat(web): consume the brand pack instead of retyping it

The site had been copying brand facts out of a generator that could emit them.
The four glyph hexes were hand-typed into globals.css in both themes, and the
splash duration sat in a TypeScript constant under a comment asking the next
person to keep two repositories in agreement by reading pack.json. That comment
was doing the job a build should do.

styles/brand.css is now the generated projection, committed here because this
repository cannot run the generator, and imported ahead of Tailwind. The four
gradients read var(--sg-glyph-*); there are zero hardcoded glyph hexes left in
globals.css. hero-splash reads --sg-dur-splash rather than declaring 1337, and
the literal that remains is a fallback for a missing stylesheet, announced in
the console, not a second copy of the value.

The file is held to the pack by lane 3 of check_consumer_projections in
such-graphics, default closed behind SG_CONSUMER_ROOT_SUCH_SOFTWARE_SITE. Do not
hand-edit it: regenerate with `such-graphics project-brand` and copy the result.

Verified in the built stylesheet rather than assumed: 84 --sg-* definitions land,
--sg-dur-splash:1337ms among them, and .hero-title compiles to the var() form.
This commit is contained in:
jwinterm
2026-09-06 01:02:57 -04:00
parent 2aead35842
commit 7400fa30e4
3 changed files with 127 additions and 10 deletions
+12 -6
View File
@@ -1,3 +1,8 @@
/* The generated brand tokens. Source of truth is such-graphics
packs/such-software/pack.json; regenerate with `such-graphics project-brand` and copy
the result here. Never hand-edit styles/brand.css: the pack gate compares it. */
@import "../styles/brand.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -290,10 +295,11 @@
and the symmetric cubic-bezier easing removes the hard turnaround the old ease-in-out had and the symmetric cubic-bezier easing removes the hard turnaround the old ease-in-out had
at each end of the travel. */ at each end of the travel. */
/* The brand's four-colour system (S green, U magenta, C blue, H teal), one per glyph. /* The brand's four-colour system (S green, U magenta, C blue, H teal), one per glyph.
Light theme uses the light palette; .dark swaps in the dark palette, which is the These were four hand-typed hexes per theme, copied out of the pack and free to drift
same hues lifted for contrast on near-black -- from packs/such-software/pack.json. */ from it. They now come from the generated tokens above, so the pack is the only place
the values exist. */
.hero-title { .hero-title {
background-image: linear-gradient(110deg, #1a5c38 0%, #b83670 30%, #176a92 55%, #12857e 80%, #1a5c38 100%); background-image: linear-gradient(110deg, var(--sg-glyph-s) 0%, var(--sg-glyph-u) 30%, var(--sg-glyph-c) 55%, var(--sg-glyph-h) 80%, var(--sg-glyph-s) 100%);
background-size: 300% auto; background-size: 300% auto;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
@@ -301,16 +307,16 @@
animation: hero-sheen 18s cubic-bezier(0.37, 0, 0.63, 1) infinite; animation: hero-sheen 18s cubic-bezier(0.37, 0, 0.63, 1) infinite;
} }
.dark .hero-title { .dark .hero-title {
background-image: linear-gradient(110deg, #35925c 0%, #e8689e 30%, #4aade0 55%, #3ad4c6 80%, #35925c 100%); background-image: linear-gradient(110deg, var(--sg-glyph-s-dark) 0%, var(--sg-glyph-u-dark) 30%, var(--sg-glyph-c-dark) 55%, var(--sg-glyph-h-dark) 80%, var(--sg-glyph-s-dark) 100%);
} }
.brand-gradient-text { .brand-gradient-text {
background-image: linear-gradient(90deg, #1a5c38, #b83670, #176a92, #12857e); background-image: linear-gradient(90deg, var(--sg-glyph-s), var(--sg-glyph-u), var(--sg-glyph-c), var(--sg-glyph-h));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
} }
.dark .brand-gradient-text { .dark .brand-gradient-text {
background-image: linear-gradient(90deg, #35925c, #e8689e, #4aade0, #3ad4c6); background-image: linear-gradient(90deg, var(--sg-glyph-s-dark), var(--sg-glyph-u-dark), var(--sg-glyph-c-dark), var(--sg-glyph-h-dark));
} }
@keyframes hero-sheen { @keyframes hero-sheen {
0%, 100% { background-position: 0% center; } 0%, 100% { background-position: 0% center; }
@@ -5,8 +5,31 @@ import { useEffect, useRef, useState } from "react";
// How long the splash holds before it hands over. This MUST match the pack's // How long the splash holds before it hands over. This MUST match the pack's
// motion.splash.ms: the morph asset is generated to end exactly on this number, so a gate // motion.splash.ms: the morph asset is generated to end exactly on this number, so a gate
// that closes early cuts the last letter mid-draw and one that closes late holds a still // that closes early cuts the last letter mid-draw and one that closes late holds a still
// frame. See such-graphics packs/such-software/pack.json. // frame.
const SPLASH_MS = 1337; //
// It used to say that, and then hardcode 1337 next to a comment asking the next person to
// keep two repositories in agreement by hand. The pack has always been able to emit the
// number: it reaches us as `--sg-dur-splash` in the generated brand.css, so now we read it.
// The literal below is a fallback for the case where the stylesheet did not load, not a
// second copy of the value.
const SPLASH_FALLBACK_MS = 1337;
function splashMs(): number {
if (typeof window === "undefined") return SPLASH_FALLBACK_MS;
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sg-dur-splash")
.trim();
const value = Number.parseFloat(raw);
if (!Number.isFinite(value) || value <= 0) {
console.warn(
`hero-splash: --sg-dur-splash is ${raw || "unset"}, so the generated brand tokens are ` +
`not loaded; holding for ${SPLASH_FALLBACK_MS}ms instead.`,
);
return SPLASH_FALLBACK_MS;
}
// CSS times are "1337ms" or "1.337s"; only the latter needs scaling.
return raw.endsWith("ms") ? value : value * 1000;
}
// Long enough to read as a cross-fade, short enough that it does not feel like a second // Long enough to read as a cross-fade, short enough that it does not feel like a second
// gate stacked on the first. // gate stacked on the first.
@@ -27,7 +50,7 @@ const BURST_CELLS = [
* *
* Four dots become the four pillar symbols become the word SUCH, then the page takes over. * Four dots become the four pillar symbols become the word SUCH, then the page takes over.
* The whole thing is one generated SVG (morph_splash*.svg) whose CSS timeline is scaled to * The whole thing is one generated SVG (morph_splash*.svg) whose CSS timeline is scaled to
* land on SPLASH_MS, so this component only has to hold it and then get out of the way. * land on that duration, so this component only has to hold it and then get out of the way.
* *
* It is a FIXED overlay from its first paint, never a block in document flow. That is not * It is a FIXED overlay from its first paint, never a block in document flow. That is not
* cosmetic: in flow, removing it pulled a viewport of height out of the page and shifted * cosmetic: in flow, removing it pulled a viewport of height out of the page and shifted
@@ -63,7 +86,7 @@ export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?
// The gate: hand over when the morph ends. // The gate: hand over when the morph ends.
useEffect(() => { useEffect(() => {
if (leaving) return; if (leaving) return;
const id = window.setTimeout(() => enterRef.current(), SPLASH_MS); const id = window.setTimeout(() => enterRef.current(), splashMs());
return () => window.clearTimeout(id); return () => window.clearTimeout(id);
}, [leaving]); }, [leaving]);
+88
View File
@@ -0,0 +1,88 @@
/* pack=such-software version=3.0.0 source=02fb904f79a118d7e33d470cbbf51aa1a3ed0123 */
:root {
/* GENERATED by such_graphics.style.tokens from the style pack -- do not edit */
--sg-accent: #e8b23a;
--sg-bg: #f7f8fa;
--sg-bg-dark: #0a0a0a;
--sg-crit: #b83670;
--sg-faint: #aab2bf;
--sg-field: #1a5c38;
--sg-field-dark: #45c99e;
--sg-field-hi: #2f8f5e;
--sg-field-hi-dark: #7ddfbe;
--sg-frame: #1a5c38;
--sg-frame-dark: #45c99e;
--sg-glyph-c: #176a92;
--sg-glyph-c-dark: #4aade0;
--sg-glyph-h: #12857e;
--sg-glyph-h-dark: #3ad4c6;
--sg-glyph-s: #1a5c38;
--sg-glyph-s-dark: #35925c;
--sg-glyph-u: #b83670;
--sg-glyph-u-dark: #e8689e;
--sg-ink: #0a0a14;
--sg-ink-dark: #f7f8fa;
--sg-muted: #5b6472;
--sg-ok: #237456;
--sg-spark: #0a0a14;
--sg-spark-dark: #ffffff;
--sg-warn: #b8863a;
--sg-font-body: 'Manrope', 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--sg-font-display: 'Manrope', 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--sg-font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
--sg-fs-body: 13px;
--sg-fs-caption: 11px;
--sg-fs-label: 12px;
--sg-fs-subhead: 15px;
--sg-fs-title: 34px;
--sg-fs-wordmark: 78px;
--sg-dur-draw: 1600ms;
--sg-motion-draw-curve-name: ease-in-out;
--sg-motion-draw-curve: ease-in-out;
--sg-dur-fade: 400ms;
--sg-motion-fade-curve-name: ease-out;
--sg-motion-fade-curve: ease-out;
--sg-dur-frame: 900ms;
--sg-motion-frame-curve-name: ease-out;
--sg-motion-frame-curve: ease-out;
--sg-dur-grow: 520ms;
--sg-motion-grow-curve-name: ease-out-back;
--sg-motion-grow-curve: cubic-bezier(0.34, 1.56, 0.64, 1);
--sg-dur-morph: 900ms;
--sg-motion-morph-curve-name: ease-in-out;
--sg-motion-morph-curve: ease-in-out;
--sg-dur-pulse: 3000ms;
--sg-motion-pulse-curve-name: linear;
--sg-motion-pulse-curve: linear;
--sg-dur-settle: 420ms;
--sg-motion-settle-curve-name: ease-in-out;
--sg-motion-settle-curve: ease-in-out;
--sg-dur-splash: 1337ms;
--sg-motion-splash-curve-name: ease-in-out;
--sg-motion-splash-curve: ease-in-out;
--sg-dur-symbol-hold: 1100ms;
--sg-motion-symbol-hold-curve-name: linear;
--sg-motion-symbol-hold-curve: linear;
--sg-corner-lg: 24px;
--sg-corner-md: 16px;
--sg-corner-sm: 8px;
--sg-touch-min: 48px;
--sg-role-accent-color: var(--sg-glyph-u);
--sg-role-accent-font-family: var(--sg-font-display);
--sg-role-accent-font-size: var(--sg-fs-subhead);
--sg-role-body-color: var(--sg-ink);
--sg-role-body-font-family: var(--sg-font-body);
--sg-role-body-font-size: var(--sg-fs-body);
--sg-role-caption-color: var(--sg-muted);
--sg-role-caption-font-family: var(--sg-font-mono);
--sg-role-caption-font-size: var(--sg-fs-caption);
--sg-role-subtitle-color: var(--sg-muted);
--sg-role-subtitle-font-family: var(--sg-font-display);
--sg-role-subtitle-font-size: var(--sg-fs-subhead);
--sg-role-title-color: var(--sg-ink);
--sg-role-title-font-family: var(--sg-font-display);
--sg-role-title-font-size: var(--sg-fs-title);
--sg-role-wordmark-color: var(--sg-ink);
--sg-role-wordmark-font-family: var(--sg-font-display);
--sg-role-wordmark-font-size: var(--sg-fs-wordmark);
}