diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ce205c7..09fbb8a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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; } diff --git a/apps/web/src/components/landing/hero-splash.tsx b/apps/web/src/components/landing/hero-splash.tsx index afa99e7..6388f3d 100644 --- a/apps/web/src/components/landing/hero-splash.tsx +++ b/apps/web/src/components/landing/hero-splash.tsx @@ -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]); diff --git a/apps/web/src/styles/brand.css b/apps/web/src/styles/brand.css new file mode 100644 index 0000000..adb7380 --- /dev/null +++ b/apps/web/src/styles/brand.css @@ -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); +}