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 components;
@tailwind utilities;
@@ -290,10 +295,11 @@
and the symmetric cubic-bezier easing removes the hard turnaround the old ease-in-out had
at each end of the travel. */
/* 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
same hues lifted for contrast on near-black -- from packs/such-software/pack.json. */
These were four hand-typed hexes per theme, copied out of the pack and free to drift
from it. They now come from the generated tokens above, so the pack is the only place
the values exist. */
.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;
-webkit-background-clip: text;
background-clip: text;
@@ -301,16 +307,16 @@
animation: hero-sheen 18s cubic-bezier(0.37, 0, 0.63, 1) infinite;
}
.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 {
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;
background-clip: text;
color: transparent;
}
.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 {
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
// 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
// frame. See such-graphics packs/such-software/pack.json.
const SPLASH_MS = 1337;
// frame.
//
// 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
// 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.
* 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
* 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.
useEffect(() => {
if (leaving) return;
const id = window.setTimeout(() => enterRef.current(), SPLASH_MS);
const id = window.setTimeout(() => enterRef.current(), splashMs());
return () => window.clearTimeout(id);
}, [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);
}