From 33d75f8292140cfdecb781f7511cf3ccab4d105a Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:21:20 +0000 Subject: [PATCH 01/21] Export the brand font weights as variables and Tailwind weights tokens.json has carried fontWeight (regular 400, medium 500, semibold 600) since v1.0.0, but no output exposed it, so consumers hand-wrote their own aliases. - brand.css and the supergraphics prelude: --mfb-weight-regular/-medium/-semibold - theme.css (Tailwind 4): --font-weight-regular/-medium/-semibold, plus --font-weight-normal with the regular value (Tailwind's own name for 400) - tailwind.js (Tailwind 3): theme.extend.fontWeight with the same names; it extends Tailwind's scale, so font-normal and font-bold keep working - index.js: export fontWeight Co-Authored-By: Claude Opus 5.5 --- brand.css | 3 +++ index.js | 5 +++++ scripts/build-design-package.mjs | 14 ++++++++++++++ supergraphics.css | 3 +++ tailwind.js | 6 ++++++ theme.css | 4 ++++ 6 files changed, 35 insertions(+) diff --git a/brand.css b/brand.css index 1c52eed..20bbc3a 100644 --- a/brand.css +++ b/brand.css @@ -31,6 +31,9 @@ --mfb-size-body: 18px; --mfb-size-label: 22px; --mfb-size-quote: 40px; + --mfb-weight-regular: 400; + --mfb-weight-medium: 500; + --mfb-weight-semibold: 600; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; --sg-aspect: 1.5; diff --git a/index.js b/index.js index 5faead3..decf6e0 100644 --- a/index.js +++ b/index.js @@ -57,4 +57,9 @@ export const fontSize = { "label": "22px", "quote": "40px" }; +export const fontWeight = { + "regular": 400, + "medium": 500, + "semibold": 600 +}; export const gradientBrand = "linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)"; diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 134b949..d53ef41 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -103,6 +103,15 @@ for (const [k, tok] of Object.entries(tokens.fontFamily || {})) fontFamily[k] = if (fontFamily.body) fontFamily.sans = fontFamily.body; const fontSize = {}; for (const [k, tok] of Object.entries(tokens.fontSize || {})) fontSize[k] = tok.$value; +// Font weights as numbers (400, 500, 600). Tailwind calls 400 `normal`, tokens.json calls it +// `regular`: the Tailwind outputs carry both names with the same value (as `sans` mirrors +// `body` above), so `font-regular` and `font-normal` both give the brand's regular weight. +// The weights EXTEND Tailwind's scale; they never replace it, so font-bold and the rest keep working. +const fontWeight = {}; +for (const [k, tok] of Object.entries(tokens.fontWeight || {})) fontWeight[k] = tok.$value; +const twFontWeight = {}; +for (const [k, v] of Object.entries(fontWeight)) twFontWeight[k] = String(v); +if (fontWeight.regular !== undefined && !('normal' in fontWeight)) twFontWeight.normal = String(fontWeight.regular); const gradient = tokens.gradient?.brand?.$value; const ff = (arr) => arr.map((f) => (/\s/.test(f) ? `"${f}"` : f)).join(', '); @@ -113,6 +122,7 @@ const preset = { colors: { ...mfb, mfb }, // both conventions: bg-purple-400 and bg-mfb-purple-400 fontFamily, fontSize, + fontWeight: twFontWeight, ...(gradient ? { backgroundImage: { 'brand-gradient': gradient } } : {}), }, }, @@ -130,6 +140,7 @@ for (const [name, tok] of Object.entries(tokens.color)) { } for (const [k, v] of Object.entries(fontFamily)) theme += ` --font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) theme += ` --text-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(twFontWeight)) theme += ` --font-weight-${k}: ${v};\n`; theme += '}\n'; write('theme.css', theme); @@ -139,6 +150,7 @@ for (const [name, tok] of Object.entries(tokens.color)) css += ` --mfb-${name}: if (gradient) css += ` --mfb-gradient-brand: ${gradient};\n`; for (const [k, v] of Object.entries(fontFamily)) css += ` --mfb-font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) css += ` --mfb-size-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(fontWeight)) css += ` --mfb-weight-${k}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) css += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; css += '}\n'; write('brand.css', css); @@ -162,6 +174,7 @@ let sgPrelude = ':root {\n'; for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; +for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { sgPrelude += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; } @@ -176,6 +189,7 @@ write( `export const colors = ${JSON.stringify(mfb, null, 2)};\n` + `export const fontFamily = ${JSON.stringify(fontFamily, null, 2)};\n` + `export const fontSize = ${JSON.stringify(fontSize, null, 2)};\n` + + `export const fontWeight = ${JSON.stringify(fontWeight, null, 2)};\n` + (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') ); diff --git a/supergraphics.css b/supergraphics.css index 4fffecb..a74efd8 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -20,6 +20,9 @@ --mfb-gray-200: #EAEAF4; --mfb-gray-100: #F3F3FA; --mfb-gradient: linear-gradient(135deg, #2B1C58 0%, #5E378E 100%); + --mfb-weight-regular: 400; + --mfb-weight-medium: 500; + --mfb-weight-semibold: 600; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; --sg-aspect: 1.5; diff --git a/tailwind.js b/tailwind.js index 47ea119..53a7e69 100644 --- a/tailwind.js +++ b/tailwind.js @@ -85,6 +85,12 @@ export default { "label": "22px", "quote": "40px" }, + "fontWeight": { + "regular": "400", + "medium": "500", + "semibold": "600", + "normal": "400" + }, "backgroundImage": { "brand-gradient": "linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)" } diff --git a/theme.css b/theme.css index e1796cc..0584ae6 100644 --- a/theme.css +++ b/theme.css @@ -47,4 +47,8 @@ --text-body: 18px; --text-label: 22px; --text-quote: 40px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-normal: 400; } From dbdc1d36cffd72db37c215c59f86f1028857d8f8 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:21:42 +0000 Subject: [PATCH 02/21] Export the halftone colours and read them in .sg-halftone-cutout The Brand Book Halftones slide (918:3730, text 918:3737) gives two highlight caps: #F7E6FF for cutout portraits and #EEEEEE for full portraits, both with shadow #45265B. tokens.json had the cutout pair only. - tokens.json: geometry.halftone-highlight-full #EEEEEE (prose, figma:918:3730) - brand.css and the supergraphics prelude: --mfb-halftone-highlight, --mfb-halftone-highlight-full, --mfb-halftone-shadow (kept out of the palette and the Tailwind colour utilities, as the tokens say) - canon: .sg-halftone-cutout reads the variables instead of hex literals - brand-spec Rule 4: the full-portrait highlight comes from the token Co-Authored-By: Claude Opus 5.5 --- brand.css | 3 +++ scripts/brand-spec.mjs | 1 + scripts/build-design-package.mjs | 8 ++++++++ src/brand-spec.template.md | 2 +- src/supergraphics.canon.css | 6 +++--- supergraphics.css | 9 ++++++--- tokens.json | 13 +++++++++++++ 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/brand.css b/brand.css index 20bbc3a..dedf6ba 100644 --- a/brand.css +++ b/brand.css @@ -34,6 +34,9 @@ --mfb-weight-regular: 400; --mfb-weight-medium: 500; --mfb-weight-semibold: 600; + --mfb-halftone-highlight: #F7E6FF; + --mfb-halftone-highlight-full: #EEEEEE; + --mfb-halftone-shadow: #45265B; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; --sg-aspect: 1.5; diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index b6e0472..64dfb86 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -75,6 +75,7 @@ const values = { ANGLE: need('geometry', 'angle-base').replace('deg', DEGREE), BASE_RATIO: need('geometry', 'base-h-ratio'), HALFTONE_HI: need('geometry', 'halftone-highlight'), + HALFTONE_HI_FULL: need('geometry', 'halftone-highlight-full'), HALFTONE_LO: need('geometry', 'halftone-shadow'), ORANGE: need('color', 'orange-300'), GRAY900: need('color', 'gray-900'), diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index d53ef41..e7875f5 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -113,6 +113,12 @@ const twFontWeight = {}; for (const [k, v] of Object.entries(fontWeight)) twFontWeight[k] = String(v); if (fontWeight.regular !== undefined && !('normal' in fontWeight)) twFontWeight.normal = String(fontWeight.regular); const gradient = tokens.gradient?.brand?.$value; +// Halftone colours (geometry group, $type color): book-specified values that are deliberately +// NOT palette tokens, so they get their own --mfb-halftone-* names and stay out of the +// Tailwind colour utilities. supergraphics.css reads them in .sg-halftone-cutout. +const halftone = Object.entries(tokens.geometry || {}) + .filter(([k, tok]) => k.startsWith('halftone-') && tok.$type === 'color') + .map(([k, tok]) => [`--mfb-${k}`, tok.$value]); const ff = (arr) => arr.map((f) => (/\s/.test(f) ? `"${f}"` : f)).join(', '); // ---- 1. tailwind.js - Tailwind 3 preset (also usable in TW4 via @config) ---- @@ -151,6 +157,7 @@ if (gradient) css += ` --mfb-gradient-brand: ${gradient};\n`; for (const [k, v] of Object.entries(fontFamily)) css += ` --mfb-font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) css += ` --mfb-size-${k}: ${tok.$value};\n`; for (const [k, v] of Object.entries(fontWeight)) css += ` --mfb-weight-${k}: ${v};\n`; +for (const [cssVar, v] of halftone) css += ` ${cssVar}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) css += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; css += '}\n'; write('brand.css', css); @@ -175,6 +182,7 @@ let sgPrelude = for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; +for (const [cssVar, v] of halftone) sgPrelude += ` ${cssVar}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { sgPrelude += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; } diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 0b13475..bacd56c 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -107,7 +107,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font **Do:** - Apply in Canva Apps → Halftone Scale 2.5 → Duotone Image Edit (the app name says "Duotone" but the output is halftone dots) - **Cutout portraits** (subject isolated on colored bg): highlights `{{HALFTONE_HI}}`, shadows `{{HALFTONE_LO}}`, on purple-300 or orange-300 bg -- **Full portraits** (subject fills frame): highlights `#EEEEEE`, shadows `{{HALFTONE_LO}}`, on purple-300 bg +- **Full portraits** (subject fills frame): highlights `{{HALFTONE_HI_FULL}}`, shadows `{{HALFTONE_LO}}`, on purple-300 bg - Place the halftoned image INSIDE a Spotlight Frame (angular wedge) or Parallelogram Frame, never a rectangular full-bleed **Don't:** diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index b897d9e..5acb182 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -282,7 +282,7 @@ content: ''; position: absolute; inset: 0; - background: #45265B; /* book-specified shadow value (not a palette token) */ + background: var(--mfb-halftone-shadow); /* book-specified shadow value (not a palette token) */ clip-path: var(--sg-wedge); } @@ -303,8 +303,8 @@ inset: 0; /* highlight cap + dot texture: whites multiply down to #F7E6FF, dot grid adds the newspaper-print texture (Halftone Scale ~2.5) */ - background-color: #F7E6FF; /* book-specified highlight value */ - background-image: radial-gradient(circle, #45265B 0.8px, transparent 1px); + background-color: var(--mfb-halftone-highlight); /* book-specified highlight value */ + background-image: radial-gradient(circle, var(--mfb-halftone-shadow) 0.8px, transparent 1px); background-size: 5px 5px; mix-blend-mode: multiply; clip-path: var(--sg-wedge); diff --git a/supergraphics.css b/supergraphics.css index a74efd8..bb130fa 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -23,6 +23,9 @@ --mfb-weight-regular: 400; --mfb-weight-medium: 500; --mfb-weight-semibold: 600; + --mfb-halftone-highlight: #F7E6FF; + --mfb-halftone-highlight-full: #EEEEEE; + --mfb-halftone-shadow: #45265B; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; --sg-aspect: 1.5; @@ -314,7 +317,7 @@ content: ''; position: absolute; inset: 0; - background: #45265B; /* book-specified shadow value (not a palette token) */ + background: var(--mfb-halftone-shadow); /* book-specified shadow value (not a palette token) */ clip-path: var(--sg-wedge); } @@ -335,8 +338,8 @@ inset: 0; /* highlight cap + dot texture: whites multiply down to #F7E6FF, dot grid adds the newspaper-print texture (Halftone Scale ~2.5) */ - background-color: #F7E6FF; /* book-specified highlight value */ - background-image: radial-gradient(circle, #45265B 0.8px, transparent 1px); + background-color: var(--mfb-halftone-highlight); /* book-specified highlight value */ + background-image: radial-gradient(circle, var(--mfb-halftone-shadow) 0.8px, transparent 1px); background-size: 5px 5px; mix-blend-mode: multiply; clip-path: var(--sg-wedge); diff --git a/tokens.json b/tokens.json index 7c3b912..8a6d0f3 100644 --- a/tokens.json +++ b/tokens.json @@ -298,6 +298,19 @@ } } }, + "halftone-highlight-full": { + "$type": "color", + "$value": "#EEEEEE", + "$description": "Halftone highlight cap for full portraits (the subject fills the frame). Cutout portraits use halftone-highlight. NOT a palette token.", + "$extensions": { + "mfb": { + "source": "figma:918:3730", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Brand Book Halftones slide, text node 918:3737: 'Cutout portraits: Highlights #F7E6FF, Shadows #45265B. Full portraits: Highlights #EEEEEE, Shadows #45265B'. Read through the Figma MCP on 2026-10-06. The shadow is the same halftone-shadow for both. Kept out of the palette, like halftone-highlight." + } + } + }, "halftone-shadow": { "$type": "color", "$value": "#45265B", From f15d6d281bd9a43ba89512143c676de5bbd1b907 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:22:02 +0000 Subject: [PATCH 03/21] Canon type classes read the font, weight and size variables .sg-headline, .sg-body, .sg-label, .sg-cta, .sg-info-bar (and its date and location) and .sg-cover__title wrote 'IBM Plex Sans', sans-serif and numeric weights by hand. They now read var(--mfb-font-heading|body) and var(--mfb-weight-*), so the Brand Book's system fallback, Arial (918:2375), applies and consumers no longer need local font-family overrides. .sg-cover__title read var(--mfb-font-h2, 58px), a variable that never existed, so it always fell back to the literal. It now reads var(--mfb-size-h2) (58px, the same rendered size). The supergraphics prelude now carries the non-deprecated font families and the font sizes, so the file stays standalone. Mono is left out of it. Co-Authored-By: Claude Opus 5.5 --- scripts/build-design-package.mjs | 9 +++++++ src/supergraphics.canon.css | 30 +++++++++++------------ supergraphics.css | 42 ++++++++++++++++++++------------ 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index e7875f5..e35050c 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -181,6 +181,15 @@ let sgPrelude = ':root {\n'; for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; +// The canon's type classes (.sg-headline, .sg-cta ...) read the font, size and weight +// variables, so the prelude carries them. Deprecated families (mono) are left out: nothing in +// the canon uses them, and brand.css still serves them for compatibility. +const isDeprecated = (tok) => /^DEPRECATED/i.test(String(tok?.$status || '')); +for (const [k, v] of Object.entries(fontFamily)) { + const tok = tokens.fontFamily[k] || tokens.fontFamily.body; // `sans` mirrors body + if (!isDeprecated(tok)) sgPrelude += ` --mfb-font-${k}: ${ff(v)};\n`; +} +for (const [k, tok] of Object.entries(tokens.fontSize || {})) sgPrelude += ` --mfb-size-${k}: ${tok.$value};\n`; for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; for (const [cssVar, v] of halftone) sgPrelude += ` ${cssVar}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 5acb182..7cb39ae 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -335,7 +335,7 @@ justify-content: space-between; align-items: flex-end; padding: 20px 48px; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--mfb-font-body); border-top: 3px solid var(--mfb-orange-300); z-index: 10; } @@ -350,14 +350,14 @@ } .sg-info-bar__date { font-size: 32px; - font-weight: 600; /* book allows max 600 */ + font-weight: var(--mfb-weight-semibold); /* book allows max 600 */ color: var(--mfb-orange-300); line-height: 1.1; } .sg-info-bar__date small { display: block; font-size: 15px; - font-weight: 400; + font-weight: var(--mfb-weight-regular); color: inherit; opacity: 0.75; margin-top: 2px; @@ -365,7 +365,7 @@ .sg-info-bar__location { text-align: right; font-size: 17px; - font-weight: 400; + font-weight: var(--mfb-weight-regular); line-height: 1.3; } @@ -386,19 +386,19 @@ ═══════════════════════════════════════════════════════════════════ */ .sg-headline { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; + font-family: var(--mfb-font-heading); + font-weight: var(--mfb-weight-medium); line-height: 1.05; letter-spacing: -0.03em; /* book heading tracking */ } .sg-body { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 400; + font-family: var(--mfb-font-body); + font-weight: var(--mfb-weight-regular); line-height: 1.2; /* book specimen body LH */ } .sg-label { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; + font-family: var(--mfb-font-body); + font-weight: var(--mfb-weight-medium); font-size: 18px; /* no text-transform — brand book p.26 forbids uppercase */ letter-spacing: 1.5px; @@ -427,9 +427,9 @@ color: var(--mfb-white); padding: 14px 40px; border-radius: 40px; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--mfb-font-body); font-size: 20px; - font-weight: 600; + font-weight: var(--mfb-weight-semibold); /* no text-transform — brand book p.26 forbids uppercase */ letter-spacing: 1px; } @@ -487,9 +487,9 @@ right: 18%; margin: 0; color: inherit; /* beat brand.css h1 color */ - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; - font-size: var(--mfb-font-h2, 58px); + font-family: var(--mfb-font-heading); + font-weight: var(--mfb-weight-medium); + font-size: var(--mfb-size-h2); line-height: 1.05; letter-spacing: -0.03em; /* Title Case, short — no text-transform (p.26 forbids uppercase) */ diff --git a/supergraphics.css b/supergraphics.css index bb130fa..097f8ae 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -20,6 +20,18 @@ --mfb-gray-200: #EAEAF4; --mfb-gray-100: #F3F3FA; --mfb-gradient: linear-gradient(135deg, #2B1C58 0%, #5E378E 100%); + --mfb-font-heading: "IBM Plex Sans", Arial, sans-serif; + --mfb-font-body: "IBM Plex Sans", Arial, sans-serif; + --mfb-font-sans: "IBM Plex Sans", Arial, sans-serif; + --mfb-size-h1: 70px; + --mfb-size-h2: 58px; + --mfb-size-h3: 48px; + --mfb-size-h4: 40px; + --mfb-size-h5: 36px; + --mfb-size-h6: 32px; + --mfb-size-body: 18px; + --mfb-size-label: 22px; + --mfb-size-quote: 40px; --mfb-weight-regular: 400; --mfb-weight-medium: 500; --mfb-weight-semibold: 600; @@ -370,7 +382,7 @@ justify-content: space-between; align-items: flex-end; padding: 20px 48px; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--mfb-font-body); border-top: 3px solid var(--mfb-orange-300); z-index: 10; } @@ -385,14 +397,14 @@ } .sg-info-bar__date { font-size: 32px; - font-weight: 600; /* book allows max 600 */ + font-weight: var(--mfb-weight-semibold); /* book allows max 600 */ color: var(--mfb-orange-300); line-height: 1.1; } .sg-info-bar__date small { display: block; font-size: 15px; - font-weight: 400; + font-weight: var(--mfb-weight-regular); color: inherit; opacity: 0.75; margin-top: 2px; @@ -400,7 +412,7 @@ .sg-info-bar__location { text-align: right; font-size: 17px; - font-weight: 400; + font-weight: var(--mfb-weight-regular); line-height: 1.3; } @@ -421,19 +433,19 @@ ═══════════════════════════════════════════════════════════════════ */ .sg-headline { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; + font-family: var(--mfb-font-heading); + font-weight: var(--mfb-weight-medium); line-height: 1.05; letter-spacing: -0.03em; /* book heading tracking */ } .sg-body { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 400; + font-family: var(--mfb-font-body); + font-weight: var(--mfb-weight-regular); line-height: 1.2; /* book specimen body LH */ } .sg-label { - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; + font-family: var(--mfb-font-body); + font-weight: var(--mfb-weight-medium); font-size: 18px; /* no text-transform — brand book p.26 forbids uppercase */ letter-spacing: 1.5px; @@ -462,9 +474,9 @@ color: var(--mfb-white); padding: 14px 40px; border-radius: 40px; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--mfb-font-body); font-size: 20px; - font-weight: 600; + font-weight: var(--mfb-weight-semibold); /* no text-transform — brand book p.26 forbids uppercase */ letter-spacing: 1px; } @@ -522,9 +534,9 @@ right: 18%; margin: 0; color: inherit; /* beat brand.css h1 color */ - font-family: 'IBM Plex Sans', sans-serif; - font-weight: 500; - font-size: var(--mfb-font-h2, 58px); + font-family: var(--mfb-font-heading); + font-weight: var(--mfb-weight-medium); + font-size: var(--mfb-size-h2); line-height: 1.05; letter-spacing: -0.03em; /* Title Case, short — no text-transform (p.26 forbids uppercase) */ From e8a9dd731f5dbbb18f26f09f3c0e3bd9b98c112e Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:23:49 +0000 Subject: [PATCH 04/21] Add the base highlighter, measured from the Brand Book The Brand Book Highlighter slide (918:2874) says: one word per heading, "covering up to 80% of the word length. Do not alter its appearance". Its vectors draw a thin tapered line at the baseline, behind the glyphs, starting a little in from the first letter. The package had no base class, and the written rule (a linear-gradient band) painted a band about twice as thick, lower and across the whole word. - tokens.json, geometry: highlighter-coverage 80% (prose, figma:918:2874); highlighter-aspect 26.6, highlighter-offset 0.03em and highlighter-shape (measured from the vectors 918:2883, 918:2887, 918:2893, 918:2898 and the Brand in Use copies; node ids and ranges in each note). The $verification text defines the new sourceKind "measured". - build: the four values join the geometry gate, so the build refuses when the canon's --sg-highlighter-* declarations disagree with tokens.json, and they are emitted into brand.css and the supergraphics prelude. - canon: a base .highlighter (inline-block) whose ::after is an inline-block line sitting on the baseline: width 80% of the word, centred by a negative margin, thickness from aspect-ratio (so it scales with the word, as in the book), shape from clip-path, moved down by the measured offset, painted behind the text. Orange-300 by default; white inside the existing orange contexts (.sg-spotlight--orange, .sg-bg-orange, which keep their white text), .sg-cover--orange and .sg-bg-gray. --sg-highlighter-color overrides it on other orange or grey surfaces. - brand-spec Rule 2 and checklist item 4 describe the measured construction. - verify-figma.mjs lists measured tokens in its coverage census. Co-Authored-By: Claude Opus 5.5 --- brand-spec.md | 22 ++++++++----- brand.css | 4 +++ scripts/brand-spec.mjs | 4 +++ scripts/build-design-package.mjs | 4 +++ scripts/verify-figma.mjs | 6 ++-- src/brand-spec.template.md | 22 ++++++++----- src/supergraphics.canon.css | 35 +++++++++++++++++++-- supergraphics.css | 39 +++++++++++++++++++++-- tokens.json | 54 +++++++++++++++++++++++++++++++- 9 files changed, 169 insertions(+), 21 deletions(-) diff --git a/brand-spec.md b/brand-spec.md index 89050f7..d978b01 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -100,19 +100,27 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font - `text-transform: uppercase` or setting `.characters = "SOME LABEL"` in generators; re-case the source characters instead - Mix Title Case and sentence case within a single role (all kickers Title Case, all captions sentence case) -### Rule 2. Highlighter: ONE word, thin line +### Rule 2. Highlighter: ONE word, a thin line at the baseline -**Source:** Brand Book *Highlighter* (`918:2874`): "Apply it to only one word per heading, covering up to 80% of the word length." +**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance." + +**What it is:** a thin line drawn behind one word, at its baseline. It is not a band behind the letters. Measured from the Brand Book's own vectors (all twelve highlighters in it are one shape, copied and scaled): + +- **Length:** 80% of the word, centred, so it starts a little in from the first letter and stops short of the last +- **Thickness:** the line's length divided by 26.6, about 3% of the word's width. It grows with the word, not with the font size +- **Position:** its centre sits 0.03em below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it +- **Shape:** a filled quad that tapers from 6/7 of its height at the start to 4/7 at the end, rises slightly to the right, with slant-cut ends (as a CSS clip-path: `polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%)`) +- **Colour:** orange-300 on purple and on white; white on grey and on orange **Do:** -- CSS: `background-image: linear-gradient(transparent 80%, var(--mfb-orange-300) 80%)`, a thin band of about 20% of the x-height -- Put that style on a `highlighter` class so it stays bound to the word (this package's `supergraphics.css` turns it white inside its orange spotlight and orange background classes) +- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line above, orange-300 by default and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`. On another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section +- Where CSS cannot be used (print, canvas), draw the same construction from the geometry tokens `highlighter-coverage`, `highlighter-aspect`, `highlighter-offset` and `highlighter-shape` - End the highlight at the word boundary, with no trailing punctuation inside it (`Foundations` ✓ · `Foundations.` ✗) **Don't:** - Highlight two or more words, an italic phrase, or a whole heading -- Use a tall "highlighter block" that covers 40%+ of the x-height (the legacy `transparent 60%` CSS reads too tall; use `transparent 80%` for the thin line) -- Create an `hl-bar` rectangle or fixed-width stripe behind the text; it must bind to the word via the text-decoration style +- Paint a band behind the letters: the old `linear-gradient(transparent 80%, ...)` CSS draws a band about 0.26em thick under the whole word, about twice as thick, lower and wider than the Brand Book's line +- Stretch it over the whole word or past it, tilt it, round it, or give it another colour than the pairs above ("Do not alter its appearance") ### Rule 3. No translucent colors on brand shapes @@ -295,7 +303,7 @@ Real people in real educational settings, warm natural lighting, diverse represe 1. **Screenshot the relevant Brand Book page** in `mFIc75UUSyftaqnNUQgjLX` (for example with the Figma MCP `get_screenshot` tool): the rule page node for the element you're about to create 2. **Pick the supergraphic by content type**: educational (parallelogram) vs lifestyle (spotlight/book) per Rule 5 3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1) -4. **Highlighter = one word, thin**: if your headline has multiple words you want to emphasize, pick one (Rule 2) +4. **Highlighter = one word, a thin line at the baseline under 80% of it**: if your headline has multiple words you want to emphasize, pick one (Rule 2) 5. **No translucent fills, no duotone photos**: solid colors, halftone-inside-a-frame for portraits (Rules 3 & 4) 6. **Verify every name and number** against source systems (network records, finance records, surveys) before typing (Rule 10) 7. **This file is a mirror**: the Brand Book Figma wins every conflict (Section 0) diff --git a/brand.css b/brand.css index dedf6ba..f27ce50 100644 --- a/brand.css +++ b/brand.css @@ -42,4 +42,8 @@ --sg-aspect: 1.5; --sg-logo-icon-h: 40px; --sg-base-h-ratio: 2; + --sg-highlighter-coverage: 80%; + --sg-highlighter-aspect: 26.6; + --sg-highlighter-offset: 0.03em; + --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index 64dfb86..07f86cd 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -77,6 +77,10 @@ const values = { HALFTONE_HI: need('geometry', 'halftone-highlight'), HALFTONE_HI_FULL: need('geometry', 'halftone-highlight-full'), HALFTONE_LO: need('geometry', 'halftone-shadow'), + HL_COVERAGE: need('geometry', 'highlighter-coverage'), + HL_ASPECT: need('geometry', 'highlighter-aspect'), + HL_OFFSET: need('geometry', 'highlighter-offset'), + HL_SHAPE: need('geometry', 'highlighter-shape'), ORANGE: need('color', 'orange-300'), GRAY900: need('color', 'gray-900'), }; diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index e35050c..d62d16d 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -55,6 +55,10 @@ const GEOM_VARS = { 'aspect': '--sg-aspect', 'logo-icon-h': '--sg-logo-icon-h', 'base-h-ratio': '--sg-base-h-ratio', + 'highlighter-coverage': '--sg-highlighter-coverage', + 'highlighter-aspect': '--sg-highlighter-aspect', + 'highlighter-offset': '--sg-highlighter-offset', + 'highlighter-shape': '--sg-highlighter-shape', }; const geomMismatch = []; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { diff --git a/scripts/verify-figma.mjs b/scripts/verify-figma.mjs index a5d251c..fb334a0 100644 --- a/scripts/verify-figma.mjs +++ b/scripts/verify-figma.mjs @@ -71,7 +71,7 @@ for (const [name, tok] of Object.entries(tokens.color)) tokenHex[tok.$value.toUp // It reports rather than fails, because most tokens are honestly unverifiable: the Brand Book // states the 13 degree angle as English prose, and no API returns that as data. const TOKEN_GROUPS = Object.keys(tokens).filter((k) => !k.startsWith('$')); -const census = { byKind: {}, unverifiable: [], declared: [], noProvenance: [] }; +const census = { byKind: {}, unverifiable: [], measured: [], declared: [], noProvenance: [] }; for (const group of TOKEN_GROUPS) { for (const [name, tok] of Object.entries(tokens[group])) { const mfb = (tok.$extensions || {}).mfb; @@ -79,6 +79,7 @@ for (const group of TOKEN_GROUPS) { census.byKind[kind] = (census.byKind[kind] || 0) + 1; const ref = `${group}.${name}`; if (kind === 'prose') census.unverifiable.push(`${ref} (${mfb.source})`); + else if (kind === 'measured') census.measured.push(`${ref} (${mfb.source})`); else if (kind === 'declared') census.declared.push(ref); else if (kind === 'unverified' || kind === 'no-provenance') census.noProvenance.push(ref); } @@ -188,7 +189,7 @@ const result = { coverage: census, knownOpenQuestions: [ `coverage: ${census.machineCheckable}/${census.total} tokens are machine-checkable against Figma; ` + - `${census.unverifiable.length} are prose in the brand book, ${census.declared.length} are declared ` + + `${census.unverifiable.length} are prose in the brand book, ${census.measured.length} are measured from its vectors, ${census.declared.length} are declared ` + `downstream (what Figma owes), ${census.noProvenance.length} have no established provenance`, 'IBM Plex Mono (fontFamily.mono) is deprecated in tokens.json: absent from the Brand Book, kept for compatibility, phase out', 'Brand gradient (gradient.brand) is deprecated in tokens.json: absent from the Brand Book, kept for compatibility, phase out', @@ -212,6 +213,7 @@ if (flags.json) { // verified; this line says how much of it actually was. console.log(` Coverage: ${census.machineCheckable}/${census.total} tokens machine-checkable against Figma`); console.log(` ${census.unverifiable.length} prose (stated in the brand book as English; no API returns these)`); + console.log(` ${census.measured.length} measured from the brand book's vectors by a person (re-measure if those slides change)`); console.log(` ${census.declared.length} declared downstream, which is what Figma owes: ${census.declared.join(', ') || 'none'}`); if (census.noProvenance.length) console.log(` ${census.noProvenance.length} with NO established provenance: ${census.noProvenance.slice(0, 6).join(', ')}${census.noProvenance.length > 6 ? ' ...' : ''}`); diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index bacd56c..3240e4f 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -72,19 +72,27 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font - `text-transform: uppercase` or setting `.characters = "SOME LABEL"` in generators; re-case the source characters instead - Mix Title Case and sentence case within a single role (all kickers Title Case, all captions sentence case) -### Rule 2. Highlighter: ONE word, thin line +### Rule 2. Highlighter: ONE word, a thin line at the baseline -**Source:** Brand Book *Highlighter* (`918:2874`): "Apply it to only one word per heading, covering up to 80% of the word length." +**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance." + +**What it is:** a thin line drawn behind one word, at its baseline. It is not a band behind the letters. Measured from the Brand Book's own vectors (all twelve highlighters in it are one shape, copied and scaled): + +- **Length:** {{HL_COVERAGE}} of the word, centred, so it starts a little in from the first letter and stops short of the last +- **Thickness:** the line's length divided by {{HL_ASPECT}}, about 3% of the word's width. It grows with the word, not with the font size +- **Position:** its centre sits {{HL_OFFSET}} below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it +- **Shape:** a filled quad that tapers from 6/7 of its height at the start to 4/7 at the end, rises slightly to the right, with slant-cut ends (as a CSS clip-path: `{{HL_SHAPE}}`) +- **Colour:** orange-300 on purple and on white; white on grey and on orange **Do:** -- CSS: `background-image: linear-gradient(transparent 80%, var(--mfb-orange-300) 80%)`, a thin band of about 20% of the x-height -- Put that style on a `highlighter` class so it stays bound to the word (this package's `supergraphics.css` turns it white inside its orange spotlight and orange background classes) +- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line above, orange-300 by default and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`. On another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section +- Where CSS cannot be used (print, canvas), draw the same construction from the geometry tokens `highlighter-coverage`, `highlighter-aspect`, `highlighter-offset` and `highlighter-shape` - End the highlight at the word boundary, with no trailing punctuation inside it (`Foundations` ✓ · `Foundations.` ✗) **Don't:** - Highlight two or more words, an italic phrase, or a whole heading -- Use a tall "highlighter block" that covers 40%+ of the x-height (the legacy `transparent 60%` CSS reads too tall; use `transparent 80%` for the thin line) -- Create an `hl-bar` rectangle or fixed-width stripe behind the text; it must bind to the word via the text-decoration style +- Paint a band behind the letters: the old `linear-gradient(transparent 80%, ...)` CSS draws a band about 0.26em thick under the whole word, about twice as thick, lower and wider than the Brand Book's line +- Stretch it over the whole word or past it, tilt it, round it, or give it another colour than the pairs above ("Do not alter its appearance") ### Rule 3. No translucent colors on brand shapes @@ -267,7 +275,7 @@ Real people in real educational settings, warm natural lighting, diverse represe 1. **Screenshot the relevant Brand Book page** in `{{FIGMA_FILE}}` (for example with the Figma MCP `get_screenshot` tool): the rule page node for the element you're about to create 2. **Pick the supergraphic by content type**: educational (parallelogram) vs lifestyle (spotlight/book) per Rule 5 3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1) -4. **Highlighter = one word, thin**: if your headline has multiple words you want to emphasize, pick one (Rule 2) +4. **Highlighter = one word, a thin line at the baseline under 80% of it**: if your headline has multiple words you want to emphasize, pick one (Rule 2) 5. **No translucent fills, no duotone photos**: solid colors, halftone-inside-a-frame for portraits (Rules 3 & 4) 6. **Verify every name and number** against source systems (network records, finance records, surveys) before typing (Rule 10) 7. **This file is a mirror**: the Brand Book Figma wins every conflict (Section 0) diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 7cb39ae..741cd8d 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -443,14 +443,45 @@ HIGHLIGHTER VARIANT — for orange backgrounds where default is invisible ═══════════════════════════════════════════════════════════════════ */ +:root { + --sg-highlighter-coverage: 80%; + --sg-highlighter-aspect: 26.6; + --sg-highlighter-offset: 0.03em; + --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); +} + +.highlighter { + display: inline-block; + position: relative; + isolation: isolate; +} +.highlighter::after { + content: ''; + display: inline-block; + position: relative; + z-index: -1; + vertical-align: baseline; + width: var(--sg-highlighter-coverage); + aspect-ratio: var(--sg-highlighter-aspect); + margin-left: calc(-0.5 * (100% + var(--sg-highlighter-coverage))); + background: var(--sg-highlighter-color, var(--mfb-orange-300)); + clip-path: var(--sg-highlighter-shape); + transform: translateY(calc(50% + var(--sg-highlighter-offset))); + pointer-events: none; +} + .sg-spotlight--orange .highlighter { - background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + --sg-highlighter-color: var(--mfb-white); color: var(--mfb-white); } .sg-bg-orange .highlighter { - background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + --sg-highlighter-color: var(--mfb-white); color: var(--mfb-white); } +.sg-cover--orange .highlighter, +.sg-bg-gray .highlighter { + --sg-highlighter-color: var(--mfb-white); +} /* ═══════════════════════════════════════════════════════════════════ COVER — Brand book Rule 8 (cover master): diff --git a/supergraphics.css b/supergraphics.css index 097f8ae..bcdb2e5 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -43,6 +43,10 @@ --sg-aspect: 1.5; --sg-logo-icon-h: 40px; --sg-base-h-ratio: 2; + --sg-highlighter-coverage: 80%; + --sg-highlighter-aspect: 26.6; + --sg-highlighter-offset: 0.03em; + --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } /* MFB Supergraphics — Brand Visual Elements Layer */ @@ -490,14 +494,45 @@ HIGHLIGHTER VARIANT — for orange backgrounds where default is invisible ═══════════════════════════════════════════════════════════════════ */ +:root { + --sg-highlighter-coverage: 80%; + --sg-highlighter-aspect: 26.6; + --sg-highlighter-offset: 0.03em; + --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); +} + +.highlighter { + display: inline-block; + position: relative; + isolation: isolate; +} +.highlighter::after { + content: ''; + display: inline-block; + position: relative; + z-index: -1; + vertical-align: baseline; + width: var(--sg-highlighter-coverage); + aspect-ratio: var(--sg-highlighter-aspect); + margin-left: calc(-0.5 * (100% + var(--sg-highlighter-coverage))); + background: var(--sg-highlighter-color, var(--mfb-orange-300)); + clip-path: var(--sg-highlighter-shape); + transform: translateY(calc(50% + var(--sg-highlighter-offset))); + pointer-events: none; +} + .sg-spotlight--orange .highlighter { - background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + --sg-highlighter-color: var(--mfb-white); color: var(--mfb-white); } .sg-bg-orange .highlighter { - background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + --sg-highlighter-color: var(--mfb-white); color: var(--mfb-white); } +.sg-cover--orange .highlighter, +.sg-bg-gray .highlighter { + --sg-highlighter-color: var(--mfb-white); +} /* ═══════════════════════════════════════════════════════════════════ COVER — Brand book Rule 8 (cover master): diff --git a/tokens.json b/tokens.json index 8a6d0f3..4d160f2 100644 --- a/tokens.json +++ b/tokens.json @@ -323,6 +323,58 @@ "note": "See halftone-highlight. Do not fold into the palette." } } + }, + "highlighter-coverage": { + "$type": "dimension", + "$value": "80%", + "$description": "Share of the word's width the highlighter line covers, centred, so it starts a little in from the first letter (10%) and stops short of the last (90%).", + "$extensions": { + "mfb": { + "source": "figma:918:2874", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Brand Book Highlighter slide: 'Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance.' Measured on the same slide (Highlighter slide 918:2874, lines 918:2883 (on purple-300), 918:2887 (on orange-300), 918:2893 (on grey), 918:2898 (on white)): 79.8%, 82.5% and 82.7% of the ink width, 77.0% to 81.1% of the advance width, starting 8.1% to 9.4% in from the left ink edge. Brand in Use (918:3514, 918:4458) departs from this (70% to 88%); the slide is treated as normative." + } + } + }, + "highlighter-aspect": { + "$type": "number", + "$value": 26.6, + "$description": "Highlighter line length over its thickness (bounding height). The Brand Book draws the thickness in proportion to the line's length, not to the font size: at 80% coverage the line is about 3% of the word's width thick.", + "$extensions": { + "mfb": { + "source": "figma:918:2883", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Measured from the vector geometry through the Figma MCP (read only). 26.6 on 918:2883 and 918:2898 (Education, the same geometry), 26.2 on 918:2887 (convince), 27.7 on 918:2893 (Unconference); 28.1 to 29.3 in Brand in Use. A fixed em thickness would read too thin under long words (Unconference is 0.187em)." + } + } + }, + "highlighter-offset": { + "$type": "dimension", + "$value": "0.03em", + "$description": "Distance of the highlighter line's centre below the text baseline. The line straddles the baseline and sits mostly below it, behind the glyphs.", + "$extensions": { + "mfb": { + "source": "figma:918:2883", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Measured from node geometry with IBM Plex Sans metrics (baseline 0.875em below the line top at 100% line height), checked against the renders within 0.015em. Centre 0.034em, 0.029em and 0.020em below the baseline on 918:2883, 918:2887 and 918:2893; 0.020em to 0.047em across all 12 instances in the Brand Book (median 0.032em)." + } + } + }, + "highlighter-shape": { + "$type": "string", + "$value": "polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%)", + "$description": "Outline of the highlighter line in its bounding box, as a CSS clip-path: a filled quad that tapers from 6/7 of the box height at the start to 4/7 at the end, rises slightly to the right and has slant-cut ends.", + "$extensions": { + "mfb": { + "source": "figma:918:2883", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "All 12 highlighters in the Brand Book are one vector ('Vector 384') copied and scaled, drawn beneath the text in layer order. Corners taken from the SVG paths of 918:2883, 918:2887, 918:2893, 918:2898, 918:3518, 918:3574, 918:4475, 918:4501 and 918:4509. Variants: 918:3537 and 918:3559 put the top-left x at 0.71%; 918:4463 has y fractions 0.20 and 0.53." + } + } } }, "fontFamily": { @@ -535,5 +587,5 @@ } } }, - "$verification": "Colour and the two IBM Plex Sans families are checked weekly against Figma page 262:2 by verify-brand-tokens.mjs, which counts rendered solid fills. That is the ONLY machine-checkable provenance in this file. Every token now carries $extensions.mfb.sourceKind: rendered-fill (checkable), prose (stated in the brand book as English, a human transcribed it, unverifiable by machine), declared (exists only downstream; this is the list of what Figma owes), or unverified (provenance never established). Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." + "$verification": "Colour and the two IBM Plex Sans families are checked weekly against Figma page 262:2 by verify-brand-tokens.mjs, which counts rendered solid fills. That is the ONLY machine-checkable provenance in this file. Every token now carries $extensions.mfb.sourceKind: rendered-fill (checkable), prose (stated in the brand book as English, a human transcribed it, unverifiable by machine), declared (exists only downstream; this is the list of what Figma owes), measured (read off the brand book's vector geometry by a person, with the node ids in the note; no machine re-checks it), or unverified (provenance never established). Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." } From a4909d9d5c6a00b873f6784fb864043914608203 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:24:29 +0000 Subject: [PATCH 05/21] Canon geometry: unclipped pattern rows and a frame scale that covers Both corrections derive only from --sg-angle-base and --sg-aspect; no new numbers. - .sg-para-pattern: each .sg-para is skewed about its centre, so the first shape of a row reaches tan(angle) x row height / 2 past the row's left edge, where the pattern's overflow: hidden cut its corner off. Rows now take that inset as padding-left. Row height moves to --sg-row-h so the progressive rows (x1.25, x1.55) get their own inset. - .sg-para-frame: the counter-skewed photo used a fixed scale(1.12), which covers the parallelogram only when the frame is at least 1.92 times as wide as it is tall; at the default aspect 1.5, and on square or portrait frames, the corners showed through. The scale is now 1 + tan(angle) / --sg-aspect, the smallest that covers the frame, so --sg-aspect must be the frame's real width over height (as for the spotlight, book and halftone frames). Co-Authored-By: Claude Opus 5.5 --- src/supergraphics.canon.css | 10 ++++++---- supergraphics.css | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 741cd8d..f43e7a3 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -31,7 +31,7 @@ } .sg-para-frame img, .sg-para-frame .sg-photo-zone { - transform: skewX(var(--sg-angle-base)) scale(1.12); + transform: skewX(var(--sg-angle-base)) scale(calc(1 + tan(var(--sg-angle-base)) / var(--sg-aspect))); display: block; width: 100%; height: 100%; @@ -552,7 +552,9 @@ .sg-para-pattern__row { display: flex; align-items: stretch; - height: var(--sg-base-h); + --sg-row-h: var(--sg-base-h); + height: var(--sg-row-h); + padding-left: calc(tan(var(--sg-angle-base)) * var(--sg-row-h) / 2); } .sg-para-pattern__row > .sg-para { background: var(--mfb-purple-200); @@ -565,5 +567,5 @@ .sg-para-pattern__row:nth-child(2n) { margin-left: 26px; } .sg-para-pattern__row:nth-child(3n) { margin-left: 52px; } /* progressive scale option (book: "rows can scale progressively") */ -.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(2) { height: calc(var(--sg-base-h) * 1.25); } -.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(3) { height: calc(var(--sg-base-h) * 1.55); } +.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(2) { --sg-row-h: calc(var(--sg-base-h) * 1.25); } +.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(3) { --sg-row-h: calc(var(--sg-base-h) * 1.55); } diff --git a/supergraphics.css b/supergraphics.css index bcdb2e5..04189fc 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -82,7 +82,7 @@ } .sg-para-frame img, .sg-para-frame .sg-photo-zone { - transform: skewX(var(--sg-angle-base)) scale(1.12); + transform: skewX(var(--sg-angle-base)) scale(calc(1 + tan(var(--sg-angle-base)) / var(--sg-aspect))); display: block; width: 100%; height: 100%; @@ -603,7 +603,9 @@ .sg-para-pattern__row { display: flex; align-items: stretch; - height: var(--sg-base-h); + --sg-row-h: var(--sg-base-h); + height: var(--sg-row-h); + padding-left: calc(tan(var(--sg-angle-base)) * var(--sg-row-h) / 2); } .sg-para-pattern__row > .sg-para { background: var(--mfb-purple-200); @@ -616,5 +618,5 @@ .sg-para-pattern__row:nth-child(2n) { margin-left: 26px; } .sg-para-pattern__row:nth-child(3n) { margin-left: 52px; } /* progressive scale option (book: "rows can scale progressively") */ -.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(2) { height: calc(var(--sg-base-h) * 1.25); } -.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(3) { height: calc(var(--sg-base-h) * 1.55); } +.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(2) { --sg-row-h: calc(var(--sg-base-h) * 1.25); } +.sg-para-pattern--progressive .sg-para-pattern__row:nth-child(3) { --sg-row-h: calc(var(--sg-base-h) * 1.55); } From 25ce1f9cd9e5f57f9dfff17571428d5dcd4b7e0c Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:24:42 +0000 Subject: [PATCH 06/21] Spec: links follow the contrast rule; list the new variables The colour quick reference said "Links: orange-300". The Brand Book's Colour contrast slide (918:2588) says light text goes on dark colours and dark text on light colours, and orange is a light colour. Links on light backgrounds now use purple-300 with an orange-300 underline; on dark backgrounds, orange-300 or white. Heading colours are not changed here (see the pull request description). The CSS Variables paragraph now lists the weight, halftone and highlighter variables. Co-Authored-By: Claude Opus 5.5 --- brand-spec.md | 7 ++++--- src/brand-spec.template.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/brand-spec.md b/brand-spec.md index d978b01..b1e8ea1 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -80,7 +80,7 @@ the Brand Book Figma file mFIc75UUSyftaqnNUQgjLX. To regenerate: node scripts/br ### CSS Variables -This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, and the geometry as `var(--sg-angle-base)` and its siblings. `theme.css` (Tailwind 4) carries the same colors, font families and font sizes, and `tailwind.js` (Tailwind 3) carries those plus the brand gradient; the geometry variables are only in `brand.css` and `supergraphics.css`. +This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colours as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colours are deliberately not colour utilities. ## 2. Binding Rules (The 12) @@ -278,10 +278,11 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rules -- **Links:** orange-300, underline on hover +- **Links on light backgrounds:** a dark brand colour (purple-300) with an orange-300 underline. Orange is a light colour, so orange link text on white breaks the Brand Book's *Colour contrast* rule (`918:2588`: "Light text should be placed on dark colors, and dark text on light colors") +- **Links on dark backgrounds:** orange-300 or white - **Text on dark backgrounds:** white - **Text on light backgrounds:** gray-900 (`#25252B`), never pure black -- **Orange is for accents only** (CTAs, links, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes +- **Orange is for accents only** (CTAs, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes - **Gradient:** the brand gradient is deprecated (Section 1): do not use it in new designs. Where an existing page still uses it, keep its 135deg direction (top-left to bottom-right), and never use it on covers. ### Logo files diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 3240e4f..820631a 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -52,7 +52,7 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec ### CSS Variables -This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, and the geometry as `var(--sg-angle-base)` and its siblings. `theme.css` (Tailwind 4) carries the same colors, font families and font sizes, and `tailwind.js` (Tailwind 3) carries those plus the brand gradient; the geometry variables are only in `brand.css` and `supergraphics.css`. +This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colours as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colours are deliberately not colour utilities. ## 2. Binding Rules (The 12) @@ -250,10 +250,11 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rules -- **Links:** orange-300, underline on hover +- **Links on light backgrounds:** a dark brand colour (purple-300) with an orange-300 underline. Orange is a light colour, so orange link text on white breaks the Brand Book's *Colour contrast* rule (`918:2588`: "Light text should be placed on dark colors, and dark text on light colors") +- **Links on dark backgrounds:** orange-300 or white - **Text on dark backgrounds:** white - **Text on light backgrounds:** gray-900 (`{{GRAY900}}`), never pure black -- **Orange is for accents only** (CTAs, links, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes +- **Orange is for accents only** (CTAs, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes - **Gradient:** the brand gradient is deprecated (Section 1): do not use it in new designs. Where an existing page still uses it, keep its 135deg direction (top-left to bottom-right), and never use it on covers. ### Logo files From 8a0a06f7b4ab8589de8b4bbe9ff3c9997ccd65cb Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:24:57 +0000 Subject: [PATCH 07/21] Ship brand-spec.md in the package brand-spec.md was public in the repository but not installed, so consumer projects and their AI tools had to fetch the rules from GitHub, at whatever version master happened to be. It is now in package.json's files and in the exports map as ./brand-spec.md, so it arrives in node_modules at the pinned version. CONTRIBUTING's table and install list, the spec generator's header, the spec's Section 5 and the workflow's published-file lists (version-check and release warnings) say so. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/design.yml | 4 ++-- CONTRIBUTING.md | 8 +++++--- brand-spec.md | 2 ++ package.json | 6 ++++-- scripts/brand-spec.mjs | 5 +++-- src/brand-spec.template.md | 2 ++ 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml index 40c94db..a392cee 100644 --- a/.github/workflows/design.yml +++ b/.github/workflows/design.yml @@ -78,7 +78,7 @@ jobs: fi echo "Version bump to $head_version: merging this pull request releases v$head_version." else - changed=$(git diff --name-only "$base" HEAD -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$base" HEAD -- README.md brand-spec.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ -n "$changed" ]; then echo "::warning::Published files change without a version bump, so merging releases nothing: $changed" else @@ -130,7 +130,7 @@ jobs: tag_sha=$(printf '%s\n' "$tag_lines" | tail -n 1 | cut -f1) if [ "$tag_sha" = "$GITHUB_SHA" ] || [ "$before_version" = "$version" ]; then echo "v$version is already tagged: nothing to release." - changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand-spec.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ "$tag_sha" != "$GITHUB_SHA" ] && [ -n "$changed" ]; then echo "::warning::Published files changed without a version bump, so they are in no release until the next one: $changed" fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f803748..d263026 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,11 +18,13 @@ contributions, by people and AI assistants alike. | `src/brand-spec.template.md` | The prose of the brand specification | Yes | | `scripts/` | Build, spec generator and checks | Yes | | `README.md`, `brand.css`, `theme.css`, `tailwind.js`, `index.js`, `supergraphics.css` | Generated package files (published) | No: rebuild them | -| `brand-spec.md` | Generated brand specification (public, not part of the package) | No: regenerate it | +| `brand-spec.md` | Generated brand specification (published, so consumers and their AI tools read the rules from `node_modules`) | No: regenerate it | What consumers install is exactly the list in `package.json`'s `files`, plus `package.json` -itself: `README.md`, `brand.css`, `index.js`, `supergraphics.css`, `tailwind.js`, `theme.css` -and `tokens.json`. Nothing else in this repository reaches them. +itself: `README.md`, `brand-spec.md`, `brand.css`, `index.js`, `supergraphics.css`, +`tailwind.js`, `theme.css` and `tokens.json`. Nothing else in this repository reaches them. +`brand-spec.md` is there so that a consumer, and the AI tools working in its repository, read +the brand rules at `node_modules/@myfirstbitcoin/design/brand-spec.md`, at the version they pin. `package.json` must never get `scripts` (including `prepare` or `postinstall`) or any kind of dependencies. Consumers install this package as a git dependency, and npm runs a git diff --git a/brand-spec.md b/brand-spec.md index b1e8ea1..464be47 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -320,3 +320,5 @@ node scripts/brand-spec.mjs ``` `node scripts/check.mjs` fails when this file is out of date, and so does the rebuild check on every pull request. + +This file ships in the `@myfirstbitcoin/design` package. A project that installs the package can read it at `node_modules/@myfirstbitcoin/design/brand-spec.md` (or import `@myfirstbitcoin/design/brand-spec.md`), at the version it pins. diff --git a/package.json b/package.json index 0cead2f..1510d94 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "./theme.css": "./theme.css", "./brand.css": "./brand.css", "./supergraphics.css": "./supergraphics.css", - "./tokens.json": "./tokens.json" + "./tokens.json": "./tokens.json", + "./brand-spec.md": "./brand-spec.md" }, "files": [ "index.js", @@ -19,7 +20,8 @@ "brand.css", "supergraphics.css", "tokens.json", - "README.md" + "README.md", + "brand-spec.md" ], "repository": { "type": "git", diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index 07f86cd..70524f7 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -9,8 +9,9 @@ // template. Rules cannot be generated and should not be; values must be, so the spec can never // disagree with the tokens it says it comes from. // -// brand-spec.md is committed and public, but it is NOT in package.json's `files`, so it is not -// part of what consumers install. +// brand-spec.md is committed and public, and it is in package.json's `files` (and exports, as +// ./brand-spec.md), so consumers install it with the package and read the rules at the version +// they pin. // // It refuses to write when: // - a token the prose depends on is absent (a blank where a brand value belongs); diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 820631a..29707c3 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -292,3 +292,5 @@ node scripts/brand-spec.mjs ``` `node scripts/check.mjs` fails when this file is out of date, and so does the rebuild check on every pull request. + +This file ships in the `@myfirstbitcoin/design` package. A project that installs the package can read it at `node_modules/@myfirstbitcoin/design/brand-spec.md` (or import `@myfirstbitcoin/design/brand-spec.md`), at the version it pins. From 0a8782142a9161b1bcdaacfa49eb50a229ef9205 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:26:08 +0000 Subject: [PATCH 08/21] README: say how the package is built; document the new variables The README said the package was generated "Figma -> sync-brand.js". Since the build moved into this repository (the point of the closed PR #4), it is scripts/build-design-package.mjs here, from tokens.json. The README now says so, and: - documents the weight, halftone and highlighter variables and the geometry list, read from tokens.json (angles, weights, coverage) rather than written by hand - explains the base highlighter and that a consumer's own .highlighter band must go when it upgrades, or the two draw on top of each other - states the --sg-aspect rule: spotlight, book, halftone and parallelogram frames need it set to the frame's real width over height - marks as deprecated, without removing anything, the brand gradient (--mfb-gradient-brand, --mfb-gradient, bg-brand-gradient, .sg-bg-gradient: use a solid palette fill), .sg-photo-zone and the mono font (use the brand sans, whose fallback is Arial per Brand Book 918:2375) - points to brand-spec.md in node_modules This is a release pull request, so the README is reworded to the public conventions: no em-dash, "My First Bitcoin" in full. CONTRIBUTING says so. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 7 +- README.md | 41 +++++++++- scripts/build-design-package.mjs | 129 ++++++++++++++++++++++++------- 3 files changed, 141 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d263026..a706dd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,9 +102,10 @@ Keep it free of added comments: any byte added here changes the published file. here, by pull request; the internal copy is then brought into line, and a weekly check compares the two. -The canon, `tokens.json` and the README template keep the punctuation they were published with, -em-dashes included, because rewording them changes published files. Such rewording belongs in a -release pull request. +The canon and `tokens.json` keep the punctuation they were published with, em-dashes included, +because rewording them changes published files. Such rewording belongs in a release pull request. +The README template (in `scripts/build-design-package.mjs`) was reworded in the v1.4.0 release +and follows the conventions in [AGENTS.md](AGENTS.md): new published prose carries no em-dash. ## Where the history lives diff --git a/README.md b/README.md index f226174..f98da09 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @myfirstbitcoin/design -The My First Bitcoin brand as code: a Tailwind preset, CSS variables, and raw design tokens. Generated from the canonical `tokens.json` (Figma → `sync-brand.js`). Do not edit generated files by hand. +The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the supergraphics primitives, the raw design tokens and the written brand specification. Every file is generated by `scripts/build-design-package.mjs` in [MyFirstBitcoin/mfb-design](https://github.com/MyFirstBitcoin/mfb-design), from that repository's `tokens.json`, which mirrors the Brand Book in Figma and changes only by pull request. Do not edit generated files by hand. ## Install (git dependency) @@ -8,6 +8,8 @@ The My First Bitcoin brand as code: a Tailwind preset, CSS variables, and raw de "dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0" } ``` +Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile). + ## Use - Tailwind 3 ```js @@ -26,7 +28,7 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; ## Use - plain CSS variables ```css -@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-gradient-brand) ... */ +@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-weight-medium) ... */ ``` ## Use - supergraphics (brand geometry primitives) @@ -35,9 +37,40 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; @import '@myfirstbitcoin/design/supergraphics.css'; ``` -The signature MFB shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, `sg-para-pattern`, `highlighter` band. **Never hand-roll these shapes in a page** — the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. +The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-para-pattern`, `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, and the `highlighter`. **Never hand-roll these shapes in a page**: the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. + +### Set --sg-aspect on every frame + +`sg-spotlight-frame`, `sg-book-frame`, `sg-halftone-cutout` and `sg-para-frame` compute their cuts (and the parallelogram frame its photo scale) from `--sg-aspect`, the frame's width divided by its height. The default, 1.5, is right only for a frame of that shape. Set it to the frame's real ratio, for example `style="--sg-aspect: 0.75"` on a 3:4 frame; otherwise the cuts are not at 13° and 24°, and a parallelogram frame's photo may not cover the frame. + +### The highlighter + +Wrap the one word you emphasize in a heading: `

Open Source Education

`. It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, behind the letters, under 80% of the word, centred. It is orange-300, and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`; on another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section. One word per heading, never more. The span becomes `inline-block`. If your project draws its own `.highlighter` band, delete it when you move to this version, or the two draw on top of each other. Its measurements are the `--sg-highlighter-*` variables (coverage, aspect, offset, shape), from the geometry tokens of the same names. + +## Variables + +Plain CSS variables are namespaced `--mfb-*` (brand values) and `--sg-*` (geometry) to avoid collisions. `brand.css` has all of them; `supergraphics.css` repeats what its classes need. + +- **Colours:** `--mfb-`, for example `--mfb-purple-300`, `--mfb-orange-300`, `--mfb-gray-900` +- **Font families:** `--mfb-font-heading`, `--mfb-font-body`, `--mfb-font-sans` (IBM Plex Sans, then Arial, the Brand Book's system fallback) +- **Font sizes:** `--mfb-size-`, for example `--mfb-size-h1`, `--mfb-size-body` +- **Font weights:** `--mfb-weight-regular` (400), `--mfb-weight-medium` (500), `--mfb-weight-semibold` (600). Tailwind 4 (`theme.css`) and Tailwind 3 (the preset) get the same names as `font-regular`, `font-medium` and `font-semibold`, plus `font-normal` with the regular value; they extend Tailwind's scale rather than replace it +- **Halftone colours:** `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow` (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colours, so they have no Tailwind utilities +- **Geometry:** `--sg-angle-base`, `--sg-angle-alt`, `--sg-aspect`, `--sg-logo-icon-h`, `--sg-base-h-ratio`, `--sg-highlighter-coverage`, `--sg-highlighter-aspect`, `--sg-highlighter-offset`, `--sg-highlighter-shape` + +Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. `bg-purple-400`, `text-orange-300`, `text-gray-900`, `text-h1`), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched. + +## Deprecated (still shipped for compatibility) + +These are not in the Brand Book. They stay until a major release so that pages using them keep working; do not use them in new work. + +- **The brand gradient:** `--mfb-gradient-brand` (brand.css), `--mfb-gradient` (supergraphics.css), `bg-brand-gradient` (Tailwind 3 preset) and `.sg-bg-gradient`. Use a solid palette fill instead, for example purple-300 or purple-400 (`.sg-bg-purple`). +- **`.sg-photo-zone`:** a gradient placeholder. Put the photo itself (an `img`) in the frame, or a solid palette fill while it is missing. +- **The mono font:** `--mfb-font-mono`, `--font-mono` (Tailwind 4) and `font-mono` (Tailwind 3). IBM Plex Mono is not in the Brand Book. Use the brand sans (`--mfb-font-body`, `font-sans`), whose system fallback is Arial (Brand Book 918:2375). + +## Brand rules -Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. `bg-purple-400`, `text-orange-300`, `text-gray-900`, `text-h1`), overriding Tailwind's default purple/orange/gray with MFB brand values. Other defaults (red, blue, etc.) are untouched. Plain CSS variables are namespaced `--mfb-*` to avoid collisions. +`brand-spec.md` ships in this package: the written brand specification, generated from the same tokens. Read it at `node_modules/@myfirstbitcoin/design/brand-spec.md`. The Brand Book in Figma wins any conflict with it. ## Heavy brand assets diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index d62d16d..9bc9aa6 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -18,8 +18,8 @@ // pull request, and a version bump is what releases (see .github/workflows/design.yml). // // The emitted header strings ("by build-design-package.mjs", "Canon: shared/supergraphics.css") -// are kept exactly as they were before the build moved into this repository, so the published -// files stay byte-identical. +// are kept exactly as they were before the build moved into this repository, so a release +// changes only what it means to change. import fs from 'fs'; import path from 'path'; @@ -217,33 +217,104 @@ write( // ---- 6. package.json is hand-maintained and no longer written here. ---- // ---- 7. README.md ---- -// This template is unchanged from before the move, so the published README stays -// byte-identical. Its one dash in the supergraphics paragraph is written as a unicode -// escape (backslash, u, 2014) so that this source file itself carries no literal em-dash; rewording it is a -// published change and belongs in a release pull request. -write( - 'README.md', - `# @myfirstbitcoin/design\n\n` + - `The My First Bitcoin brand as code: a Tailwind preset, CSS variables, and raw design tokens. ` + - `Generated from the canonical \`tokens.json\` (Figma → \`sync-brand.js\`). Do not edit generated files by hand.\n\n` + - `## Install (git dependency)\n\n` + - `\`\`\`json\n"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v${VERSION}" }\n\`\`\`\n\n` + - `## Use - Tailwind 3\n\n` + - `\`\`\`js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n\`\`\`\n\n` + - `## Use - Tailwind 4\n\n` + - `\`\`\`css\n/* global.css */\n@import '@myfirstbitcoin/design/theme.css';\n\`\`\`\n\n` + - `## Use - plain CSS variables\n\n` + - `\`\`\`css\n@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-gradient-brand) ... */\n\`\`\`\n\n` + - `## Use - supergraphics (brand geometry primitives)\n\n` + - `\`\`\`css\n@import '@myfirstbitcoin/design/supergraphics.css';\n\`\`\`\n\n` + - `The signature MFB shapes as ready-made classes: \`sg-para\` / \`sg-para-frame\` (13° parallelograms), ` + - `\`sg-spotlight\` / \`sg-spotlight-frame\` (13°+24° corner cuts), \`sg-book\` / \`sg-book-frame\` / \`sg-book-stack\`, ` + - `\`sg-cover\`, \`sg-halftone-cutout\`, \`sg-para-pattern\`, \`highlighter\` band. ` + - `**Never hand-roll these shapes in a page** \u2014 the brand angle is exactly 13° (\`--sg-angle-base\`) and hand-rolled copies drift. ` + - `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.\n\n` + - `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. \`bg-purple-400\`, \`text-orange-300\`, \`text-gray-900\`, \`text-h1\`), overriding Tailwind's default purple/orange/gray with MFB brand values. Other defaults (red, blue, etc.) are untouched. Plain CSS variables are namespaced \`--mfb-*\` to avoid collisions.\n\n` + - `## Heavy brand assets\n\nLogos, badges, and the brand book PDF live in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand).\n` -); +// The README is published, so its prose follows the public conventions in AGENTS.md (no +// em-dashes, "My First Bitcoin" in full). Brand values in it (angles, weights) are read from +// tokens.json, like every other output, so the README cannot disagree with the tokens. +const deg = (name) => String(tokens.geometry[name].$value).replace('deg', '°'); +const ANGLE = deg('angle-base'); +const ANGLE_ALT = deg('angle-alt'); +const FENCE = '```'; +const code = (s) => '`' + s + '`'; +const weightList = Object.entries(fontWeight) + .map(([k, v]) => `${code(`--mfb-weight-${k}`)} (${v})`) + .join(', '); +const halftoneList = halftone.map(([cssVar]) => code(cssVar)).join(', '); +const readme = [ + `# @myfirstbitcoin/design`, + ``, + `The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the supergraphics primitives, the raw design tokens and the written brand specification. ` + + `Every file is generated by ${code('scripts/build-design-package.mjs')} in [MyFirstBitcoin/mfb-design](https://github.com/MyFirstBitcoin/mfb-design), ` + + `from that repository's ${code('tokens.json')}, which mirrors the Brand Book in Figma and changes only by pull request. Do not edit generated files by hand.`, + ``, + `## Install (git dependency)`, + ``, + `${FENCE}json\n"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v${VERSION}" }\n${FENCE}`, + ``, + `Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile).`, + ``, + `## Use - Tailwind 3`, + ``, + `${FENCE}js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n${FENCE}`, + ``, + `## Use - Tailwind 4`, + ``, + `${FENCE}css\n/* global.css */\n@import '@myfirstbitcoin/design/theme.css';\n${FENCE}`, + ``, + `## Use - plain CSS variables`, + ``, + `${FENCE}css\n@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-weight-medium) ... */\n${FENCE}`, + ``, + `## Use - supergraphics (brand geometry primitives)`, + ``, + `${FENCE}css\n@import '@myfirstbitcoin/design/supergraphics.css';\n${FENCE}`, + ``, + `The signature My First Bitcoin shapes as ready-made classes: ${code('sg-para')} / ${code('sg-para-frame')} (${ANGLE} parallelograms), ${code('sg-para-pattern')}, ` + + `${code('sg-spotlight')} / ${code('sg-spotlight-frame')} (${ANGLE}+${ANGLE_ALT} corner cuts), ${code('sg-book')} / ${code('sg-book-frame')} / ${code('sg-book-stack')}, ` + + `${code('sg-cover')}, ${code('sg-halftone-cutout')}, and the ${code('highlighter')}. ` + + `**Never hand-roll these shapes in a page**: the brand angle is exactly ${ANGLE} (${code('--sg-angle-base')}) and hand-rolled copies drift. ` + + `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.`, + ``, + `### Set --sg-aspect on every frame`, + ``, + `${code('sg-spotlight-frame')}, ${code('sg-book-frame')}, ${code('sg-halftone-cutout')} and ${code('sg-para-frame')} compute their cuts (and the parallelogram frame its photo scale) ` + + `from ${code('--sg-aspect')}, the frame's width divided by its height. The default, ${tokens.geometry.aspect.$value}, is right only for a frame of that shape. ` + + `Set it to the frame's real ratio, for example ${code('style="--sg-aspect: 0.75"')} on a 3:4 frame; otherwise the cuts are not at ${ANGLE} and ${ANGLE_ALT}, ` + + `and a parallelogram frame's photo may not cover the frame.`, + ``, + `### The highlighter`, + ``, + `Wrap the one word you emphasize in a heading: ${code('

Open Source Education

')}. ` + + `It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, behind the letters, under ${tokens.geometry['highlighter-coverage'].$value} of the word, centred. ` + + `It is orange-300, and white inside ${code('sg-bg-orange')}, ${code('sg-spotlight--orange')}, ${code('sg-cover--orange')} and ${code('sg-bg-gray')}; ` + + `on another orange or grey surface, set ${code('--sg-highlighter-color: var(--mfb-white)')} on the section. One word per heading, never more. ` + + `The span becomes ${code('inline-block')}. If your project draws its own ${code('.highlighter')} band, delete it when you move to this version, or the two draw on top of each other. ` + + `Its measurements are the ${code('--sg-highlighter-*')} variables (coverage, aspect, offset, shape), from the geometry tokens of the same names.`, + ``, + `## Variables`, + ``, + `Plain CSS variables are namespaced ${code('--mfb-*')} (brand values) and ${code('--sg-*')} (geometry) to avoid collisions. ${code('brand.css')} has all of them; ${code('supergraphics.css')} repeats what its classes need.`, + ``, + `- **Colours:** ${code('--mfb-')}, for example ${code('--mfb-purple-300')}, ${code('--mfb-orange-300')}, ${code('--mfb-gray-900')}`, + `- **Font families:** ${code('--mfb-font-heading')}, ${code('--mfb-font-body')}, ${code('--mfb-font-sans')} (IBM Plex Sans, then Arial, the Brand Book's system fallback)`, + `- **Font sizes:** ${code('--mfb-size-')}, for example ${code('--mfb-size-h1')}, ${code('--mfb-size-body')}`, + `- **Font weights:** ${weightList}. Tailwind 4 (${code('theme.css')}) and Tailwind 3 (the preset) get the same names as ${code('font-regular')}, ${code('font-medium')} and ${code('font-semibold')}, ` + + `plus ${code('font-normal')} with the regular value; they extend Tailwind's scale rather than replace it`, + `- **Halftone colours:** ${halftoneList} (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colours, so they have no Tailwind utilities`, + `- **Geometry:** ${Object.values(GEOM_VARS).map(code).join(', ')}`, + ``, + `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. ${code('bg-purple-400')}, ${code('text-orange-300')}, ${code('text-gray-900')}, ${code('text-h1')}), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched.`, + ``, + `## Deprecated (still shipped for compatibility)`, + ``, + `These are not in the Brand Book. They stay until a major release so that pages using them keep working; do not use them in new work.`, + ``, + `- **The brand gradient:** ${code('--mfb-gradient-brand')} (brand.css), ${code('--mfb-gradient')} (supergraphics.css), ${code('bg-brand-gradient')} (Tailwind 3 preset) and ${code('.sg-bg-gradient')}. ` + + `Use a solid palette fill instead, for example purple-300 or purple-400 (${code('.sg-bg-purple')}).`, + `- **${code('.sg-photo-zone')}:** a gradient placeholder. Put the photo itself (an ${code('img')}) in the frame, or a solid palette fill while it is missing.`, + `- **The mono font:** ${code('--mfb-font-mono')}, ${code('--font-mono')} (Tailwind 4) and ${code('font-mono')} (Tailwind 3). IBM Plex Mono is not in the Brand Book. ` + + `Use the brand sans (${code('--mfb-font-body')}, ${code('font-sans')}), whose system fallback is Arial (Brand Book 918:2375).`, + ``, + `## Brand rules`, + ``, + `${code('brand-spec.md')} ships in this package: the written brand specification, generated from the same tokens. Read it at ${code('node_modules/@myfirstbitcoin/design/brand-spec.md')}. ` + + `The Brand Book in Figma wins any conflict with it.`, + ``, + `## Heavy brand assets`, + ``, + `Logos, badges, and the brand book PDF live in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand).`, + ``, +].join('\n'); +write('README.md', readme); console.log(`Built @myfirstbitcoin/design v${VERSION} -> ${OUT_DIR}`); console.log('Files written:', written.sort().join(', ')); From 0936cf9a2a2a15d6c649bfceb816008d73d6f2dc Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:26:13 +0000 Subject: [PATCH 09/21] v1.4.0 Raise the version so that merging this pull request releases v1.4.0. All changes since v1.3.0 are additive or corrective; nothing a consumer uses today is removed. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f98da09..d8b4820 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the superg ## Install (git dependency) ```json -"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0" } +"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.4.0" } ``` Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile). diff --git a/package.json b/package.json index 1510d94..4c729d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@myfirstbitcoin/design", - "version": "1.3.0", + "version": "1.4.0", "description": "My First Bitcoin brand design tokens: Tailwind preset (v3 + v4), CSS variables, and raw tokens. Generated from the canonical brand tokens.json.", "type": "module", "license": "UNLICENSED", From 4b6f53561ec778804f19e8d3300eb6beb1662440 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:53:27 +0000 Subject: [PATCH 10/21] Provenance: halftone source slide, full quotes, how measured values are taken halftone-highlight and halftone-shadow cited figma:918:4126, the Parallelogram Frames slide, which has no halftone text. Both now cite the Halftones slide 918:3730 (text node 918:3737), as halftone-highlight-full does, verified 2026-10-06. The quotes in the token notes are now verbatim: the Highlighter sentence goes on past "Do not alter its appearance", and the Halftones text is two lines, not one sentence. The measured source kind said the values were read off "by a person"; they were read through the Figma MCP by an AI session. $verification and the Figma check's coverage line now say so. Co-Authored-By: Claude Opus 5.5 --- scripts/verify-figma.mjs | 2 +- tokens.json | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/verify-figma.mjs b/scripts/verify-figma.mjs index fb334a0..34d4689 100644 --- a/scripts/verify-figma.mjs +++ b/scripts/verify-figma.mjs @@ -213,7 +213,7 @@ if (flags.json) { // verified; this line says how much of it actually was. console.log(` Coverage: ${census.machineCheckable}/${census.total} tokens machine-checkable against Figma`); console.log(` ${census.unverifiable.length} prose (stated in the brand book as English; no API returns these)`); - console.log(` ${census.measured.length} measured from the brand book's vectors by a person (re-measure if those slides change)`); + console.log(` ${census.measured.length} measured from the brand book's vector geometry, by hand or by an AI session through the Figma MCP, node ids in each note; no machine re-checks them (re-measure if those slides change)`); console.log(` ${census.declared.length} declared downstream, which is what Figma owes: ${census.declared.join(', ') || 'none'}`); if (census.noProvenance.length) console.log(` ${census.noProvenance.length} with NO established provenance: ${census.noProvenance.slice(0, 6).join(', ')}${census.noProvenance.length > 6 ? ' ...' : ''}`); diff --git a/tokens.json b/tokens.json index 4d160f2..05313de 100644 --- a/tokens.json +++ b/tokens.json @@ -291,10 +291,10 @@ "$description": "Halftone highlight cap. NOT a palette token.", "$extensions": { "mfb": { - "source": "figma:918:4126", + "source": "figma:918:3730", "sourceKind": "prose", - "verifiedOn": "2026-08-19", - "note": "supergraphics.css:270-274 warns these are book-specified standalone values and must NOT be 'corrected' to purple-200/purple-400. Kept out of the palette deliberately." + "verifiedOn": "2026-10-06", + "note": "supergraphics.css:270-274 warns these are book-specified standalone values and must NOT be 'corrected' to purple-200/purple-400. Kept out of the palette deliberately. Brand Book Halftones slide, text node 918:3737: 'Cutout portraits: Highlights #F7E6FF, Shadows #45265B'. The source was figma:918:4126 until 2026-10-06; that node is the Parallelogram Frames slide, which has no halftone text." } } }, @@ -307,7 +307,7 @@ "source": "figma:918:3730", "sourceKind": "prose", "verifiedOn": "2026-10-06", - "note": "Brand Book Halftones slide, text node 918:3737: 'Cutout portraits: Highlights #F7E6FF, Shadows #45265B. Full portraits: Highlights #EEEEEE, Shadows #45265B'. Read through the Figma MCP on 2026-10-06. The shadow is the same halftone-shadow for both. Kept out of the palette, like halftone-highlight." + "note": "Brand Book Halftones slide, text node 918:3737, on two lines: 'Cutout portraits: Highlights #F7E6FF, Shadows #45265B' and 'Full portraits: Highlights #EEEEEE, Shadows #45265B'. Read through the Figma MCP on 2026-10-06. The shadow is the same halftone-shadow for both. Kept out of the palette, like halftone-highlight." } } }, @@ -317,10 +317,10 @@ "$description": "Halftone shadow underlay and dot grid. NOT a palette token.", "$extensions": { "mfb": { - "source": "figma:918:4126", + "source": "figma:918:3730", "sourceKind": "prose", - "verifiedOn": "2026-08-19", - "note": "See halftone-highlight. Do not fold into the palette." + "verifiedOn": "2026-10-06", + "note": "See halftone-highlight. Do not fold into the palette. Brand Book Halftones slide, text node 918:3737, gives Shadows #45265B for both cutout and full portraits. The source was figma:918:4126 (the Parallelogram Frames slide) until 2026-10-06." } } }, @@ -333,14 +333,14 @@ "source": "figma:918:2874", "sourceKind": "prose", "verifiedOn": "2026-10-06", - "note": "Brand Book Highlighter slide: 'Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance.' Measured on the same slide (Highlighter slide 918:2874, lines 918:2883 (on purple-300), 918:2887 (on orange-300), 918:2893 (on grey), 918:2898 (on white)): 79.8%, 82.5% and 82.7% of the ink width, 77.0% to 81.1% of the advance width, starting 8.1% to 9.4% in from the left ink edge. Brand in Use (918:3514, 918:4458) departs from this (70% to 88%); the slide is treated as normative." + "note": "Brand Book Highlighter slide, text node 918:2878: 'Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance to ensure a clean and cohesive typographic style.' Measured on the same slide (Highlighter slide 918:2874, lines 918:2883 (on purple-300), 918:2887 (on orange-300), 918:2893 (on grey), 918:2898 (on white)): 79.8%, 82.5% and 82.7% of the ink width, 77.0% to 81.1% of the advance width, starting 8.1% to 9.4% in from the left ink edge. Brand in Use (918:3514, 918:4458) departs from this (70% to 88%); the slide is treated as normative." } } }, "highlighter-aspect": { "$type": "number", "$value": 26.6, - "$description": "Highlighter line length over its thickness (bounding height). The Brand Book draws the thickness in proportion to the line's length, not to the font size: at 80% coverage the line is about 3% of the word's width thick.", + "$description": "Highlighter line length over its thickness (bounding height). The Brand Book draws the thickness in proportion to the line's length, so to the word's width, not as a share of the font size: at 80% coverage the line is about 3% of the word's width thick.", "$extensions": { "mfb": { "source": "figma:918:2883", @@ -587,5 +587,5 @@ } } }, - "$verification": "Colour and the two IBM Plex Sans families are checked weekly against Figma page 262:2 by verify-brand-tokens.mjs, which counts rendered solid fills. That is the ONLY machine-checkable provenance in this file. Every token now carries $extensions.mfb.sourceKind: rendered-fill (checkable), prose (stated in the brand book as English, a human transcribed it, unverifiable by machine), declared (exists only downstream; this is the list of what Figma owes), measured (read off the brand book's vector geometry by a person, with the node ids in the note; no machine re-checks it), or unverified (provenance never established). Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." + "$verification": "Colour and the two IBM Plex Sans families are checked weekly against Figma page 262:2 by verify-brand-tokens.mjs, which counts rendered solid fills. That is the ONLY machine-checkable provenance in this file. Every token now carries $extensions.mfb.sourceKind: rendered-fill (checkable), prose (stated in the brand book as English, a human transcribed it, unverifiable by machine), declared (exists only downstream; this is the list of what Figma owes), measured (read off the brand book's vector geometry, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), or unverified (provenance never established). Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." } From 2c437028d66b2ae4fae82b58413f6432f37efec7 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:53:46 +0000 Subject: [PATCH 11/21] Highlighter: aligned in any heading, measured offset, word keeps its color - .highlighter sets text-align: left and text-indent: 0. The line takes 10% less room than the word, so an inherited center or right alignment moved the word right by 5% or 10% of its width and ate the space after it. - The white line no longer applies on .sg-bg-gray: that is gray-200, and a white line on it cannot be seen. The Brand Book's white-on-grey example is a mid grey (close to gray-500); README and spec say to set --sg-highlighter-color there. - The white line now applies on every orange-300 surface of the package: .sg-spotlight-frame--orange, .sg-book-frame--orange and .sg-halftone-cutout--orange-bg join the list. - The orange variants no longer turn the word itself white. On the Highlighter slide the word stays dark on orange and only the line is white ("Do not alter its appearance"; Color Contrast 918:2588). - highlighter-offset is 0.054em: the distance from the baseline to the center of the line's box, which is what the CSS positions, on the cited node 918:2883 (checked against a raster of the slide). 0.03em measured to the shape's average centerline, so the line sat about 1px high at 48px. - Spec Rule 2 wording: the twelve copies have small variants, thickness is proportional to the word's width, and the quote is the full sentence. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- brand-spec.md | 17 +++++++++-------- brand.css | 2 +- scripts/build-design-package.mjs | 9 +++++---- src/brand-spec.template.md | 17 +++++++++-------- src/supergraphics.canon.css | 18 ++++++++---------- supergraphics.css | 20 +++++++++----------- tokens.json | 6 +++--- 8 files changed, 45 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index d8b4820..3329c93 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-par ### The highlighter -Wrap the one word you emphasize in a heading: `

Open Source Education

`. It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, behind the letters, under 80% of the word, centred. It is orange-300, and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`; on another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section. One word per heading, never more. The span becomes `inline-block`. If your project draws its own `.highlighter` band, delete it when you move to this version, or the two draw on top of each other. Its measurements are the `--sg-highlighter-*` variables (coverage, aspect, offset, shape), from the geometry tokens of the same names. +Wrap the one word you emphasize in a heading: `

Open Source Education

`. It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, drawn beneath the glyphs (not a band), under 80% of the word, centered. The line is orange-300, and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. The word keeps the heading's color. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there. One word per heading, never more. The span is an `inline-block`, and the line's length and thickness are computed from the span's own width: do not override its `display` or `text-align`. Its measurements are the `--sg-highlighter-*` variables (coverage, aspect, offset, shape), from the geometry tokens of the same names. ## Variables diff --git a/brand-spec.md b/brand-spec.md index 464be47..7b27809 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -102,25 +102,26 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 2. Highlighter: ONE word, a thin line at the baseline -**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance." +**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance to ensure a clean and cohesive typographic style." -**What it is:** a thin line drawn behind one word, at its baseline. It is not a band behind the letters. Measured from the Brand Book's own vectors (all twelve highlighters in it are one shape, copied and scaled): +**What it is:** a thin line at the baseline of one word, drawn beneath the glyphs. It is not a band behind the letters. Measured from the Brand Book's own vectors (its twelve highlighters are one vector, copied and scaled, with small variants on three copies): -- **Length:** 80% of the word, centred, so it starts a little in from the first letter and stops short of the last -- **Thickness:** the line's length divided by 26.6, about 3% of the word's width. It grows with the word, not with the font size -- **Position:** its centre sits 0.03em below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it +- **Length:** 80% of the word, centered, so it starts a little in from the first letter and stops short of the last +- **Thickness:** the line's length divided by 26.6, about 3% of the word's width. It is proportional to the word's width, so at the same font size a longer word gets a thicker line +- **Position:** the center of its bounding box (half-way between its top and bottom edges) sits 0.054em below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it - **Shape:** a filled quad that tapers from 6/7 of its height at the start to 4/7 at the end, rises slightly to the right, with slant-cut ends (as a CSS clip-path: `polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%)`) -- **Colour:** orange-300 on purple and on white; white on grey and on orange +- **Color:** orange-300 on purple and on white; white on orange and on the mid grey of the Brand Book's example (close to gray-500). The word itself keeps the heading's color **Do:** -- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line above, orange-300 by default and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`. On another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section +- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line described above, orange-300 by default and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there +- Leave the span's `display` and `text-align` alone: the line's length and thickness are computed from the span's own width - Where CSS cannot be used (print, canvas), draw the same construction from the geometry tokens `highlighter-coverage`, `highlighter-aspect`, `highlighter-offset` and `highlighter-shape` - End the highlight at the word boundary, with no trailing punctuation inside it (`Foundations` ✓ · `Foundations.` ✗) **Don't:** - Highlight two or more words, an italic phrase, or a whole heading - Paint a band behind the letters: the old `linear-gradient(transparent 80%, ...)` CSS draws a band about 0.26em thick under the whole word, about twice as thick, lower and wider than the Brand Book's line -- Stretch it over the whole word or past it, tilt it, round it, or give it another colour than the pairs above ("Do not alter its appearance") +- Stretch it over the whole word or past it, tilt it, round it, or give it another color than the pairs above ("Do not alter its appearance") ### Rule 3. No translucent colors on brand shapes diff --git a/brand.css b/brand.css index f27ce50..2455f06 100644 --- a/brand.css +++ b/brand.css @@ -44,6 +44,6 @@ --sg-base-h-ratio: 2; --sg-highlighter-coverage: 80%; --sg-highlighter-aspect: 26.6; - --sg-highlighter-offset: 0.03em; + --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 9bc9aa6..1145291 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -274,10 +274,11 @@ const readme = [ `### The highlighter`, ``, `Wrap the one word you emphasize in a heading: ${code('

Open Source Education

')}. ` + - `It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, behind the letters, under ${tokens.geometry['highlighter-coverage'].$value} of the word, centred. ` + - `It is orange-300, and white inside ${code('sg-bg-orange')}, ${code('sg-spotlight--orange')}, ${code('sg-cover--orange')} and ${code('sg-bg-gray')}; ` + - `on another orange or grey surface, set ${code('--sg-highlighter-color: var(--mfb-white)')} on the section. One word per heading, never more. ` + - `The span becomes ${code('inline-block')}. If your project draws its own ${code('.highlighter')} band, delete it when you move to this version, or the two draw on top of each other. ` + + `It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, drawn beneath the glyphs (not a band), under ${tokens.geometry['highlighter-coverage'].$value} of the word, centered. ` + + `The line is orange-300, and white inside the orange surfaces ${['sg-bg-orange', 'sg-spotlight--orange', 'sg-cover--orange', 'sg-spotlight-frame--orange', 'sg-book-frame--orange'].map(code).join(', ')} and ${code('sg-halftone-cutout--orange-bg')}. The word keeps the heading's color. ` + + `On another orange surface, or on a mid grey such as gray-500, set ${code('--sg-highlighter-color: var(--mfb-white)')} on the section. ` + + `On a light grey such as gray-200 (${code('sg-bg-gray')}) keep the orange line: a white one cannot be seen there. One word per heading, never more. ` + + `The span is an ${code('inline-block')}, and the line's length and thickness are computed from the span's own width: do not override its ${code('display')} or ${code('text-align')}. ` + `Its measurements are the ${code('--sg-highlighter-*')} variables (coverage, aspect, offset, shape), from the geometry tokens of the same names.`, ``, `## Variables`, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 29707c3..c54552c 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -74,25 +74,26 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 2. Highlighter: ONE word, a thin line at the baseline -**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance." +**Source:** Brand Book *Highlighter* (`918:2874`): "The Highlighter is used to emphasize a single word in a heading ... Apply it to only one word per heading, covering up to 80% of the word length. Do not alter its appearance to ensure a clean and cohesive typographic style." -**What it is:** a thin line drawn behind one word, at its baseline. It is not a band behind the letters. Measured from the Brand Book's own vectors (all twelve highlighters in it are one shape, copied and scaled): +**What it is:** a thin line at the baseline of one word, drawn beneath the glyphs. It is not a band behind the letters. Measured from the Brand Book's own vectors (its twelve highlighters are one vector, copied and scaled, with small variants on three copies): -- **Length:** {{HL_COVERAGE}} of the word, centred, so it starts a little in from the first letter and stops short of the last -- **Thickness:** the line's length divided by {{HL_ASPECT}}, about 3% of the word's width. It grows with the word, not with the font size -- **Position:** its centre sits {{HL_OFFSET}} below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it +- **Length:** {{HL_COVERAGE}} of the word, centered, so it starts a little in from the first letter and stops short of the last +- **Thickness:** the line's length divided by {{HL_ASPECT}}, about 3% of the word's width. It is proportional to the word's width, so at the same font size a longer word gets a thicker line +- **Position:** the center of its bounding box (half-way between its top and bottom edges) sits {{HL_OFFSET}} below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it - **Shape:** a filled quad that tapers from 6/7 of its height at the start to 4/7 at the end, rises slightly to the right, with slant-cut ends (as a CSS clip-path: `{{HL_SHAPE}}`) -- **Colour:** orange-300 on purple and on white; white on grey and on orange +- **Color:** orange-300 on purple and on white; white on orange and on the mid grey of the Brand Book's example (close to gray-500). The word itself keeps the heading's color **Do:** -- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line above, orange-300 by default and white inside `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange` and `sg-bg-gray`. On another orange or grey surface, set `--sg-highlighter-color: var(--mfb-white)` on the section +- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line described above, orange-300 by default and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there +- Leave the span's `display` and `text-align` alone: the line's length and thickness are computed from the span's own width - Where CSS cannot be used (print, canvas), draw the same construction from the geometry tokens `highlighter-coverage`, `highlighter-aspect`, `highlighter-offset` and `highlighter-shape` - End the highlight at the word boundary, with no trailing punctuation inside it (`Foundations` ✓ · `Foundations.` ✗) **Don't:** - Highlight two or more words, an italic phrase, or a whole heading - Paint a band behind the letters: the old `linear-gradient(transparent 80%, ...)` CSS draws a band about 0.26em thick under the whole word, about twice as thick, lower and wider than the Brand Book's line -- Stretch it over the whole word or past it, tilt it, round it, or give it another colour than the pairs above ("Do not alter its appearance") +- Stretch it over the whole word or past it, tilt it, round it, or give it another color than the pairs above ("Do not alter its appearance") ### Rule 3. No translucent colors on brand shapes diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index f43e7a3..9ed9dd2 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -446,7 +446,7 @@ :root { --sg-highlighter-coverage: 80%; --sg-highlighter-aspect: 26.6; - --sg-highlighter-offset: 0.03em; + --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } @@ -454,6 +454,8 @@ display: inline-block; position: relative; isolation: isolate; + text-align: left; + text-indent: 0; } .highlighter::after { content: ''; @@ -470,16 +472,12 @@ pointer-events: none; } -.sg-spotlight--orange .highlighter { - --sg-highlighter-color: var(--mfb-white); - color: var(--mfb-white); -} -.sg-bg-orange .highlighter { - --sg-highlighter-color: var(--mfb-white); - color: var(--mfb-white); -} +.sg-spotlight--orange .highlighter, +.sg-bg-orange .highlighter, .sg-cover--orange .highlighter, -.sg-bg-gray .highlighter { +.sg-spotlight-frame--orange .highlighter, +.sg-book-frame--orange .highlighter, +.sg-halftone-cutout--orange-bg .highlighter { --sg-highlighter-color: var(--mfb-white); } diff --git a/supergraphics.css b/supergraphics.css index 04189fc..9ace6f1 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -45,7 +45,7 @@ --sg-base-h-ratio: 2; --sg-highlighter-coverage: 80%; --sg-highlighter-aspect: 26.6; - --sg-highlighter-offset: 0.03em; + --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } @@ -497,7 +497,7 @@ :root { --sg-highlighter-coverage: 80%; --sg-highlighter-aspect: 26.6; - --sg-highlighter-offset: 0.03em; + --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } @@ -505,6 +505,8 @@ display: inline-block; position: relative; isolation: isolate; + text-align: left; + text-indent: 0; } .highlighter::after { content: ''; @@ -521,16 +523,12 @@ pointer-events: none; } -.sg-spotlight--orange .highlighter { - --sg-highlighter-color: var(--mfb-white); - color: var(--mfb-white); -} -.sg-bg-orange .highlighter { - --sg-highlighter-color: var(--mfb-white); - color: var(--mfb-white); -} +.sg-spotlight--orange .highlighter, +.sg-bg-orange .highlighter, .sg-cover--orange .highlighter, -.sg-bg-gray .highlighter { +.sg-spotlight-frame--orange .highlighter, +.sg-book-frame--orange .highlighter, +.sg-halftone-cutout--orange-bg .highlighter { --sg-highlighter-color: var(--mfb-white); } diff --git a/tokens.json b/tokens.json index 05313de..2f7ca3c 100644 --- a/tokens.json +++ b/tokens.json @@ -352,14 +352,14 @@ }, "highlighter-offset": { "$type": "dimension", - "$value": "0.03em", - "$description": "Distance of the highlighter line's centre below the text baseline. The line straddles the baseline and sits mostly below it, behind the glyphs.", + "$value": "0.054em", + "$description": "Distance from the text baseline down to the centre of the highlighter line's bounding box (half-way between its top and bottom edges), which is the point the CSS positions. The line straddles the baseline and sits mostly below it, behind the glyphs.", "$extensions": { "mfb": { "source": "figma:918:2883", "sourceKind": "measured", "verifiedOn": "2026-10-06", - "note": "Measured from node geometry with IBM Plex Sans metrics (baseline 0.875em below the line top at 100% line height), checked against the renders within 0.015em. Centre 0.034em, 0.029em and 0.020em below the baseline on 918:2883, 918:2887 and 918:2893; 0.020em to 0.047em across all 12 instances in the Brand Book (median 0.032em)." + "note": "Measured through the Figma MCP (read only) on 2026-10-06, from node positions and IBM Plex Sans metrics (baseline 0.875em below the top of a line at 100% line height). On 918:2883 (Education, 48px, second line of text node 918:2884) the vector's box runs from 210.39 to 216.79 and the baseline is at 211.00, so the box centre is 2.59px (0.054em) below it; a raster of the slide puts that baseline at the same place. 918:2898 (the same Education on white) gives 0.054em; 918:2893 (Unconference, 48px) 0.036em; 918:2887 (convince, assuming its quote is set at 40px with 100% line height) 0.0375em. Follows the cited source node, as highlighter-aspect does. Replaces 0.03em, which measured to the shape's average centreline rather than to its box centre." } } }, From 7b74b95bfcf848d3f3dd9f167682feb8676b33cd Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 04:53:59 +0000 Subject: [PATCH 12/21] README and spec: upgrade notes, link rule evidence, US spelling - README gains "Upgrading from v1.3.0": delete a local .highlighter band and any display or text-align override on it; remove local padding added around .sg-para-pattern for the slant (rows now inset themselves); set --sg-aspect on parallelogram frames, whose photo scale now depends on it; the :root prelude of supergraphics.css now also sets the font, size, weight and halftone variables; .sg-cover__title reads --mfb-size-h2; and Tailwind 4 projects that reset --font-weight-* must do it before importing theme.css, or the package's weights go too. - The links rule cites the Color Contrast slide's evidence (every orange is paired with dark text) and marks the orange-300 underline as this package's recommendation, not a Brand Book rule. - New prose uses US spelling, as the Brand Book and the rest of the spec do. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++++++++++++++++++-- brand-spec.md | 4 ++-- scripts/build-design-package.mjs | 22 ++++++++++++++++++---- src/brand-spec.template.md | 4 ++-- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3329c93..94c84a4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,23 @@ The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the superg Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile). +## Upgrading from v1.3.0 + +Make these changes in the same commit that raises your pin: + +- **Delete any local `.highlighter` band** (for example a `linear-gradient(transparent 80%, ...)` background on `.highlighter`), and any local rule that changes its `display` or `text-align`. The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color. +- **Remove any padding your project adds around `.sg-para-pattern` to make room for the slant.** Each row now insets itself by half its height times tan(13°), so its first shape is no longer clipped; extra padding moves the rows further right. +- **Set `--sg-aspect` on parallelogram frames too.** The photo scale in `.sg-para-frame` was a fixed 1.12; it is now `1 + tan(--sg-angle-base) / --sg-aspect`, the smallest scale that covers the frame, so it depends on the frame's real shape. +- **`supergraphics.css` sets more variables in `:root`**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing `supergraphics.css`, or import the file into a cascade layer. +- `.sg-cover__title` reads `--mfb-size-h2`. The `--mfb-font-h2` variable it used to read was never defined by this package; set `font-size` on the title instead. +- **Tailwind 4 projects that restrict font weights** with `--font-weight-*: initial` must put that reset in its own `@theme` block before importing `theme.css`: placed after the import, it removes the package's weights too, and no weight utility is generated. + +```css +@import "tailwindcss"; +@theme { --font-weight-*: initial; } +@import "@myfirstbitcoin/design/theme.css"; +``` + ## Use - Tailwind 3 ```js @@ -51,11 +68,11 @@ Wrap the one word you emphasize in a heading: `

Open Source k.startsWith('halftone-') && tok.$type === 'color') .map(([k, tok]) => [`--mfb-${k}`, tok.$value]); @@ -242,6 +242,20 @@ const readme = [ ``, `Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile).`, ``, + `## Upgrading from v1.3.0`, + ``, + `Make these changes in the same commit that raises your pin:`, + ``, + `- **Delete any local ${code('.highlighter')} band** (for example a ${code('linear-gradient(transparent 80%, ...)')} background on ${code('.highlighter')}), and any local rule that changes its ${code('display')} or ${code('text-align')}. ` + + `The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color.`, + `- **Remove any padding your project adds around ${code('.sg-para-pattern')} to make room for the slant.** Each row now insets itself by half its height times tan(${ANGLE}), so its first shape is no longer clipped; extra padding moves the rows further right.`, + `- **Set ${code('--sg-aspect')} on parallelogram frames too.** The photo scale in ${code('.sg-para-frame')} was a fixed 1.12; it is now ${code('1 + tan(--sg-angle-base) / --sg-aspect')}, the smallest scale that covers the frame, so it depends on the frame's real shape.`, + `- **${code('supergraphics.css')} sets more variables in ${code(':root')}**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing ${code('supergraphics.css')}, or import the file into a cascade layer.`, + `- ${code('.sg-cover__title')} reads ${code('--mfb-size-h2')}. The ${code('--mfb-font-h2')} variable it used to read was never defined by this package; set ${code('font-size')} on the title instead.`, + `- **Tailwind 4 projects that restrict font weights** with ${code('--font-weight-*: initial')} must put that reset in its own ${code('@theme')} block before importing ${code('theme.css')}: placed after the import, it removes the package's weights too, and no weight utility is generated.`, + ``, + `${FENCE}css\n@import "tailwindcss";\n@theme { --font-weight-*: initial; }\n@import "@myfirstbitcoin/design/theme.css";\n${FENCE}`, + ``, `## Use - Tailwind 3`, ``, `${FENCE}js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n${FENCE}`, @@ -285,12 +299,12 @@ const readme = [ ``, `Plain CSS variables are namespaced ${code('--mfb-*')} (brand values) and ${code('--sg-*')} (geometry) to avoid collisions. ${code('brand.css')} has all of them; ${code('supergraphics.css')} repeats what its classes need.`, ``, - `- **Colours:** ${code('--mfb-')}, for example ${code('--mfb-purple-300')}, ${code('--mfb-orange-300')}, ${code('--mfb-gray-900')}`, + `- **Colors:** ${code('--mfb-')}, for example ${code('--mfb-purple-300')}, ${code('--mfb-orange-300')}, ${code('--mfb-gray-900')}`, `- **Font families:** ${code('--mfb-font-heading')}, ${code('--mfb-font-body')}, ${code('--mfb-font-sans')} (IBM Plex Sans, then Arial, the Brand Book's system fallback)`, `- **Font sizes:** ${code('--mfb-size-')}, for example ${code('--mfb-size-h1')}, ${code('--mfb-size-body')}`, `- **Font weights:** ${weightList}. Tailwind 4 (${code('theme.css')}) and Tailwind 3 (the preset) get the same names as ${code('font-regular')}, ${code('font-medium')} and ${code('font-semibold')}, ` + `plus ${code('font-normal')} with the regular value; they extend Tailwind's scale rather than replace it`, - `- **Halftone colours:** ${halftoneList} (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colours, so they have no Tailwind utilities`, + `- **Halftone colors:** ${halftoneList} (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colors, so they have no Tailwind utilities`, `- **Geometry:** ${Object.values(GEOM_VARS).map(code).join(', ')}`, ``, `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. ${code('bg-purple-400')}, ${code('text-orange-300')}, ${code('text-gray-900')}, ${code('text-h1')}), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched.`, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index c54552c..513005d 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -52,7 +52,7 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec ### CSS Variables -This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colours as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colours are deliberately not colour utilities. +This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colors as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colors are deliberately not color utilities. ## 2. Binding Rules (The 12) @@ -251,7 +251,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rules -- **Links on light backgrounds:** a dark brand colour (purple-300) with an orange-300 underline. Orange is a light colour, so orange link text on white breaks the Brand Book's *Colour contrast* rule (`918:2588`: "Light text should be placed on dark colors, and dark text on light colors") +- **Links on light backgrounds:** a dark brand color for the text, such as purple-300. The Brand Book's *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. An orange-300 underline is this package's recommendation, not a Brand Book rule - **Links on dark backgrounds:** orange-300 or white - **Text on dark backgrounds:** white - **Text on light backgrounds:** gray-900 (`{{GRAY900}}`), never pure black From 38132582a1203a6ca1a38f4d9712c94b299fe792 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 14:43:32 +0000 Subject: [PATCH 13/21] CONTRIBUTING: the source rule (Brand Book first, the website where it is silent) - Where the Brand Book defines a value, its value is the token, cited by node. - Where the book is silent, the value the live website myfirstbitcoin.org uses may become the token, declared with its exact origin on the site's main branch: sourceKind "declared", source "myfirstbitcoin.org@::". The site was built following the Brand Book, and its CSS is public. - A website value that contradicts the Brand Book is never imported; where the site has several near-duplicates, no token is made until someone picks one; Quentin or Patrick decides, as for any change here. - The source kinds are restated as tokens.json's $verification defines them, with "measured" covering a node's rendered fill as well as its geometry, and "declared" covering a website value or a value this package recommends. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a706dd2..4d8a91e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,36 @@ # Contributing to @myfirstbitcoin/design This repository is the single source of the My First Bitcoin brand as code: the design tokens, -the build that turns them into the published package, the written brand specification, and the -check against the Brand Book in Figma. The Brand Book in Figma is where the brand is decided; -`tokens.json` mirrors it and is edited only to match it. +the build that turns them into the published package, the written brand specification, and +the check against the Brand Book in Figma. The Brand Book in +Figma is where the brand is decided; `tokens.json` mirrors it and is edited only to match it, +under the source rule below. + +## The source rule + +1. **Brand Book first.** Where the Brand Book in Figma (file `mFIc75UUSyftaqnNUQgjLX`, page + `262:2`) defines a value, its value is the token, cited by node (`"source": "figma:"`). +2. **Where the Brand Book is silent**, the value used on the live website, myfirstbitcoin.org, + may become the token. The website was built following the Brand Book, so what it does where + the book says nothing is the best evidence there is. Such a token is declared with its exact + origin on the site's main branch: `"sourceKind": "declared"` and + `"source": "myfirstbitcoin.org@::"`, with the number of uses in its note. + The website's CSS is public, so citing it is fine. +3. **Never a website value that contradicts the Brand Book**, however often the site uses it: a + slant other than the book's angle, a highlighter other than the book's thin line, duotone + photos, a font or a color outside the book, all-caps text, orange text on a light background. + `brand-spec.md` lists the ones found so far under "Not imported from the website". +4. **Where the site has no single value** (several near-duplicates with no convention), no token + is made until someone picks one. +5. **Quentin or Patrick decides** every brand change, as for any pull request here. + +Every token carries `$extensions.mfb.sourceKind`, defined in `$verification` at the end of +`tokens.json`: `rendered-fill` or `rendered-text` (checked by the Figma check), `prose` (stated in +the Brand Book in words), `measured` (read off the Brand Book's vector geometry or a node's +rendered fill, with the node ids in the note), `declared` (not in the Brand Book: a website value +with its site source, or a value this package recommends, with `"source": "declared"` and the +reason in the note), or `unverified`. Color roles and shape tones are aliases of palette colors +(`{color.black}`). Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all contributions, by people and AI assistants alike. @@ -70,7 +97,8 @@ release only when they raise their pin. ## The brand-change loop 1. Patrick changes the Brand Book in Figma and tells Quentin, or the Monday check flags a - difference between Figma and `tokens.json`. + difference between Figma and `tokens.json`, or a value the Brand Book is silent on is + declared from the website under the source rule. 2. The admin session opens a pull request with the change to `tokens.json` and the rebuild. 3. Quentin or Patrick decides. 4. Merging a pull request that raises the version releases it. From 011145597e784c4d4be108b46a8687003208aff6 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 14:43:57 +0000 Subject: [PATCH 14/21] Tokens from the Brand Book and, where it is silent, from the website Under the source rule (CONTRIBUTING), tokens.json gains what the Brand Book defines and the package did not carry, and the values the book is silent on as the live website (main branch at 6368b2a) uses them, each with its source. No existing value, variable, utility or export changes. From the Brand Book (figma provenance, prose or measured): - lineHeight and letterSpacing per type level (specimen 918:2323: 1 for h1 to h4 and quote, 1.1 for h5 and h6, 1.2 for body and label; no tracking on any brand text style). - logo: minimum 120px wide (63px tall) on screen, 25mm in print, aspect 1.904, clear space and corner margin 0.5 x the logo height (918:2681, 918:2714). - colorRole: heading, body, link, muted, border, highlighter and logo colors on light, dark and orange backgrounds, as aliases of palette colors only. Headings and body on light: black (918:2990); on dark: white; on orange: black, CTAs included (918:4183 to 918:4185). Links on light: purple-300 with an orange-300 underline, declared as this package's recommendation; on dark: orange-300, the website's. - shapeTone: a shape one step lighter than its base (918:3604). - CMYK for all 17 colors, and geometry: para-edge-ratio 0.572, pattern-row-step 1.2, pattern-row-gap 0, halftone-scale 2.5. - Provenance: angle-base is measured (13.00deg on 918:4200, the logo icon and five photo frames); base-h-ratio cites the visible 918:4219; logo-icon-h (40px) is declared, as the book states no pixel value. - Color descriptions the book contradicts are corrected: purple-300 is the primary color for surfaces and shapes, not a heading color; text on light backgrounds is black, not gray-900; orange is never text on light. Declared from the website (myfirstbitcoin.org@6368b2a::): spacing scale and semantic spacing (gutter, section, header gap, block end), radius, three shadows tinted with palette colors, easing and durations, container widths, fluid type floors and slopes (the caps are the book's sizes and the build refuses any other), breakpoints, layers, media ratios, the touch target and the link underline offset. Website values that contradict the book (10 degree slant, the hero cut, the thick animated highlighter, duotone, IBM Plex Mono, off-palette tiles, uppercase labels, orange text on light, white text on orange) are not imported. Build: one GROUP_MAP says where each new group is emitted (brand.css as --mfb--; theme.css and tailwind.js with mfb- prefixed keys, type levels or -on- roles; index.js named exports). The build refuses an unmapped group, a geometry token that is neither gated against the canon nor listed as token-only, a key equal to a Tailwind 3 or 4 default, overlapping keys, a role that is not a palette alias, a fluid size whose cap is not the book's, and a hex in a new brand.css variable. Checked against the class names of ten consumer projects: 0 utilities or theme values changed in Tailwind 4.2.2, 4.3.3 and 3.4.19. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 13 +- README.md | 36 +- brand-spec.md | 18 +- brand.css | 99 ++ index.js | 133 +++ scripts/build-design-package.mjs | 341 ++++++- tailwind.js | 87 +- theme.css | 67 ++ tokens.json | 1615 +++++++++++++++++++++++++++++- 9 files changed, 2353 insertions(+), 56 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d8a91e..469d793 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ the Brand Book in words), `measured` (read off the Brand Book's vector geometry rendered fill, with the node ids in the note), `declared` (not in the Brand Book: a website value with its site source, or a value this package recommends, with `"source": "declared"` and the reason in the note), or `unverified`. Color roles and shape tones are aliases of palette colors -(`{color.black}`). +(`{color.black}`), and the build refuses one that is not. Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all contributions, by people and AI assistants alike. @@ -69,8 +69,15 @@ node scripts/check.mjs # the rebuild check that GitHub Actions ``` Commit the source change and the regenerated files together. The build refuses to write -anything if the canon's geometry disagrees with `tokens.json`, and the spec generator refuses if -a token it needs is missing. +anything if the canon's geometry disagrees with `tokens.json`, if a geometry token is neither +gated against the canon nor listed as token-only, if a tokens.json group has no entry in its +`GROUP_MAP` (where each group is emitted), or if a new Tailwind key would equal a Tailwind 3 or 4 +default. The spec generator refuses if a token it needs, or its source, is missing. + +A new token group needs an entry in `GROUP_MAP` in `scripts/build-design-package.mjs`: its +Tailwind 4 namespace, its Tailwind 3 key and its `brand.css` prefix, or `null` where it is not +emitted. New Tailwind keys are `mfb-` prefixed, type levels or color roles +(`-on-`), so that no release changes a utility a project already uses. ## Checks on every pull request diff --git a/README.md b/README.md index 94c84a4..75e4c1d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Make these changes in the same commit that raises your pin: - **Set `--sg-aspect` on parallelogram frames too.** The photo scale in `.sg-para-frame` was a fixed 1.12; it is now `1 + tan(--sg-angle-base) / --sg-aspect`, the smallest scale that covers the frame, so it depends on the frame's real shape. - **`supergraphics.css` sets more variables in `:root`**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing `supergraphics.css`, or import the file into a cascade layer. - `.sg-cover__title` reads `--mfb-size-h2`. The `--mfb-font-h2` variable it used to read was never defined by this package; set `font-size` on the title instead. -- **Tailwind 4 projects that restrict font weights** with `--font-weight-*: initial` must put that reset in its own `@theme` block before importing `theme.css`: placed after the import, it removes the package's weights too, and no weight utility is generated. +- **Tailwind 4 projects that restrict a theme namespace**, for example with `--font-weight-*: initial` or `--radius-*: initial`, must put that reset in its own `@theme` block before importing `theme.css`: placed after the import, it removes the package's values too, and no utility is generated for them. ```css @import "tailwindcss"; @@ -27,6 +27,10 @@ Make these changes in the same commit that raises your pin: @import "@myfirstbitcoin/design/theme.css"; ``` +- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in `tokens.json` now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to. +- **A test that snapshots the `--mfb-*` variables of `brand.css` whose value is a hex** must refresh its snapshot: this release adds `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow`. The other new variables carry no hex; they reference the palette with `var()`. +- **Everything else is added, not changed.** Every new Tailwind utility is `mfb-` prefixed, a type level (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or a color role (`text-heading-on-light`), and none of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below. + ## Use - Tailwind 3 ```js @@ -64,6 +68,36 @@ The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-par Wrap the one word you emphasize in a heading: `

Open Source Education

`. It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, drawn beneath the glyphs (not a band), under 80% of the word, centered. The line is orange-300, and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. The word keeps the heading's color. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there. One word per heading, never more. The span is an `inline-block`, and the line's length and thickness are computed from the span's own width: do not override its `display` or `text-align`. Its measurements are the `--sg-highlighter-*` variables (coverage, aspect, offset, shape), from the geometry tokens of the same names. +## Layout, motion and color roles + +Where the Brand Book defines a value, the token takes it. Where the book is silent (spacing, radius, shadow, motion, widths), the value is declared from the live website, myfirstbitcoin.org, which was built following the Brand Book; each such token's `$extensions.mfb.source` names the commit, file and line. A website value that contradicts the Brand Book is never imported. `brand-spec.md` lists every value with its source. + +| What | brand.css | Tailwind 3 and 4 | +|------|-----------|------------------| +| Color roles | `--mfb--on-`: `heading-on-light`, `body-on-light`, `muted-on-light`, `link-on-light`, `link-underline-on-light`, `border-on-light`, `highlighter-on-light`, `logo-on-light`, `heading-on-dark`, `body-on-dark`, `muted-on-dark`, `link-on-dark`, `highlighter-on-dark`, `logo-on-dark`, `heading-on-orange`, `body-on-orange`, `link-on-orange`, `highlighter-on-orange`, `logo-on-orange` | `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light` ... | +| Line height per level | `--mfb-leading-` | `leading-`, for example `leading-h1` | +| Letter spacing per level | `--mfb-tracking-` | `tracking-` | +| Fluid sizes for web pages | `--mfb-size--fluid` (h1, h2, h3, h4, h5, h6, quote, label) | `text--fluid` | +| Spacing scale | `--mfb-space-1`, `--mfb-space-2`, `--mfb-space-3`, `--mfb-space-4`, `--mfb-space-6`, `--mfb-space-8`, `--mfb-space-12`, `--mfb-space-16`, `--mfb-space-24`, `--mfb-space-32` | Tailwind's own `p-1` to `p-32`: the same values, so the package adds none | +| Semantic spacing | `--mfb-space-heading-to-body`, `--mfb-space-quote-to-label`, `--mfb-space-gutter`, `--mfb-space-section`, `--mfb-space-section-tight`, `--mfb-space-header-gap`, `--mfb-space-block-end` | `py-mfb-section`, `px-mfb-gutter`, `mt-mfb-heading-to-body` ... | +| Radius (interface elements only, never brand shapes) | `--mfb-radius-sm`, `--mfb-radius-md`, `--mfb-radius-lg`, `--mfb-radius-pill` | `rounded-mfb-sm`, `rounded-mfb-md`, `rounded-mfb-lg`; `rounded-full` for the pill | +| Shadow | `--mfb-shadow-media`, `--mfb-shadow-media-control`, `--mfb-shadow-overlay` | `shadow-mfb-media`, `shadow-mfb-media-control`, `shadow-mfb-overlay` | +| Easing | `--mfb-ease-out`, `--mfb-ease-in-out` | `ease-mfb-out`, `ease-mfb-in-out` | +| Duration | `--mfb-duration-fast`, `--mfb-duration-med`, `--mfb-duration-slide`, `--mfb-duration-reveal` | `duration-mfb-fast`, `duration-mfb-med`, `duration-mfb-slide`, `duration-mfb-reveal` | +| Container widths | `--mfb-container-page`, `--mfb-container-wide`, `--mfb-container-measure` (px) | `max-w-mfb-page`, `max-w-mfb-wide`, `max-w-mfb-measure` (rem) | +| Logo size and clear space | `--mfb-logo-min-width`, `--mfb-logo-min-width-print`, `--mfb-logo-aspect`, `--mfb-logo-min-height`, `--mfb-logo-clearspace`, `--mfb-logo-edge-margin` | none | +| Shape tones (a shape one step lighter than its base) | `--mfb-shape-on-purple-300`, `--mfb-shape-on-orange-300`, `--mfb-shape-on-gray-700` | none | +| Breakpoints, for JavaScript and `matchMedia` (a variable cannot be read inside `@media`) | `--mfb-breakpoint-phone`, `--mfb-breakpoint-stack`, `--mfb-breakpoint-nav` | none: the package adds no Tailwind breakpoint | +| Layers | `--mfb-z-header`, `--mfb-z-dropdown`, `--mfb-z-overlay` | none | +| Media ratios | `--mfb-ratio-video`, `--mfb-ratio-photo`, `--mfb-ratio-portrait`, `--mfb-ratio-square` | none (Tailwind has `aspect-video` and `aspect-square`) | +| Interface | `--mfb-ui-touch-target`, `--mfb-ui-link-underline-offset` | none | + +- **New utilities never replace Tailwind's.** Keys are `mfb-` prefixed (`rounded-mfb-md`, not `rounded-md`; `ease-mfb-out`, not `ease-out`, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default. +- **In 1.x, `text-h1` sets only the size.** Add `leading-h1` yourself (and `tracking-h1` where something else sets tracking). Folding line height into `text-h1` would change every existing page, so it waits for a major release. +- **New `brand.css` variables reference the palette with `var()`** and add no hex: roles are `var(--mfb-black)` and the like, shadows `color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)`. `theme.css` and `tailwind.js` carry the same colors as hex, so Tailwind's opacity modifiers work. +- **Tailwind 4 emits a theme variable only when something uses it.** To read `var(--mfb-radius-md)` or another new variable in your own CSS, import `brand.css` as well, or use the utility. +- **index.js** exports `colorRoles`, `shapeTones`, `fontSizeFluid`, `lineHeight`, `letterSpacing`, `logo`, `spacing`, `space`, `radius`, `shadow`, `easing`, `duration`, `container`, `breakpoint`, `zIndex`, `mediaRatio`, `ui` next to the existing `colors`, `fontFamily`, `fontSize` and `fontWeight`. + ## Variables Plain CSS variables are namespaced `--mfb-*` (brand values) and `--sg-*` (geometry) to avoid collisions. `brand.css` has all of them; `supergraphics.css` repeats what its classes need. diff --git a/brand-spec.md b/brand-spec.md index 17c9420..8b5ecef 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -28,18 +28,18 @@ the Brand Book Figma file mFIc75UUSyftaqnNUQgjLX. To regenerate: node scripts/br | Token | Hex | Usage | |-------|-----|-------| -| purple-300 | #422C70 | Primary purple: brand hero color, backgrounds, headers | +| purple-300 | #422C70 | Primary purple: the main brand color for surfaces and shapes (backgrounds, covers, parallelograms, spotlights). Takes white text. Not a heading color on light backgrounds: headings there are black (Type Relationships 918:2990) | | purple-400 | #2B1C58 | Accent dark purple: deep contrast, footer backgrounds | -| purple-200 | #5E378E | Accent light purple: secondary headings, gradient endpoint | -| orange-300 | #F7941F | Primary orange: CTAs, highlights, Bitcoin symbol color | -| orange-400 | #EF7B00 | Accent dark orange: hover/active states, strong emphasis | -| orange-200 | #FBB040 | Accent light orange: soft highlights, secondary accents | -| black | #000000 | Primary black: high-contrast text, bold headlines | +| purple-200 | #5E378E | Accent light purple: supergraphic shapes on purple-300 (one step lighter than the base, 918:3604), halftone cutout backgrounds (918:3730). Takes white text | +| orange-300 | #F7941F | Primary orange: CTA and shape fills with black text (Color Contrast 918:2588), the highlighter line, the Bitcoin symbol, links on dark backgrounds. Never text on light backgrounds | +| orange-400 | #EF7B00 | Accent dark orange: hover and active fills, shapes; takes black text (918:2616). Never text on light backgrounds | +| orange-200 | #FBB040 | Accent light orange: supergraphic shapes on orange-300 (one step lighter than the base, 918:3604), soft accents on dark backgrounds. Takes black text | +| black | #000000 | Primary black: headings and body text on light backgrounds and on orange (Type Relationships 918:2990, Color Contrast 918:2588) | | white | #FFFFFF | Primary white: backgrounds, text on dark surfaces | -| gray-900 | #25252B | Neutral: near-black, body text on light backgrounds | -| gray-800 | #4F4F5D | Neutral: secondary text, subtle headings | +| gray-900 | #25252B | Neutral: the darkest grey, a dark surface that takes white text (918:2588). Text on light backgrounds is black, not gray-900 (918:2990) | +| gray-800 | #4F4F5D | Neutral: a dark grey surface that takes white text (918:2588) | | gray-700 | #6C6C7D | Neutral: muted text, placeholders | -| gray-600 | #88889C | Neutral: disabled text, borders | +| gray-600 | #88889C | Neutral: borders and dividers. As text, large sizes only (3.5:1 on white); as a surface it takes white text at large sizes (918:2588) | | gray-500 | #ADADBE | Neutral: subtle borders, dividers | | gray-400 | #C0C0D0 | Neutral: light borders, inactive elements | | gray-300 | #D8D8E7 | Neutral: card borders, separators | diff --git a/brand.css b/brand.css index 2455f06..d969248 100644 --- a/brand.css +++ b/brand.css @@ -46,4 +46,103 @@ --sg-highlighter-aspect: 26.6; --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); + --mfb-heading-on-light: var(--mfb-black); + --mfb-body-on-light: var(--mfb-black); + --mfb-muted-on-light: var(--mfb-gray-700); + --mfb-link-on-light: var(--mfb-purple-300); + --mfb-link-underline-on-light: var(--mfb-orange-300); + --mfb-border-on-light: var(--mfb-gray-200); + --mfb-highlighter-on-light: var(--mfb-orange-300); + --mfb-logo-on-light: var(--mfb-purple-300); + --mfb-heading-on-dark: var(--mfb-white); + --mfb-body-on-dark: var(--mfb-white); + --mfb-muted-on-dark: var(--mfb-gray-400); + --mfb-link-on-dark: var(--mfb-orange-300); + --mfb-highlighter-on-dark: var(--mfb-orange-300); + --mfb-logo-on-dark: var(--mfb-white); + --mfb-heading-on-orange: var(--mfb-black); + --mfb-body-on-orange: var(--mfb-black); + --mfb-link-on-orange: var(--mfb-black); + --mfb-highlighter-on-orange: var(--mfb-white); + --mfb-logo-on-orange: var(--mfb-black); + --mfb-shape-on-purple-300: var(--mfb-purple-200); + --mfb-shape-on-orange-300: var(--mfb-orange-200); + --mfb-shape-on-gray-700: var(--mfb-gray-600); + --mfb-size-h1-fluid: clamp(44px, 6vw, 70px); + --mfb-size-h2-fluid: clamp(36px, 5vw, 58px); + --mfb-size-h3-fluid: clamp(30px, 4vw, 48px); + --mfb-size-h4-fluid: clamp(26px, 3.2vw, 40px); + --mfb-size-h5-fluid: clamp(22px, 2.6vw, 36px); + --mfb-size-h6-fluid: clamp(20px, 2.2vw, 32px); + --mfb-size-quote-fluid: clamp(22px, 2.8vw, 40px); + --mfb-size-label-fluid: clamp(16px, 1.6vw, 22px); + --mfb-leading-h1: 1; + --mfb-leading-h2: 1; + --mfb-leading-h3: 1; + --mfb-leading-h4: 1; + --mfb-leading-h5: 1.1; + --mfb-leading-h6: 1.1; + --mfb-leading-body: 1.2; + --mfb-leading-label: 1.2; + --mfb-leading-quote: 1; + --mfb-tracking-h1: 0em; + --mfb-tracking-h2: 0em; + --mfb-tracking-h3: 0em; + --mfb-tracking-h4: 0em; + --mfb-tracking-h5: 0em; + --mfb-tracking-h6: 0em; + --mfb-tracking-body: 0em; + --mfb-tracking-label: 0em; + --mfb-tracking-quote: 0em; + --mfb-logo-min-width: 120px; + --mfb-logo-min-width-print: 25mm; + --mfb-logo-aspect: 1.904; + --mfb-logo-min-height: 63px; + --mfb-logo-clearspace: 0.5; + --mfb-logo-edge-margin: 0.5; + --mfb-space-1: 4px; + --mfb-space-2: 8px; + --mfb-space-3: 12px; + --mfb-space-4: 16px; + --mfb-space-6: 24px; + --mfb-space-8: 32px; + --mfb-space-12: 48px; + --mfb-space-16: 64px; + --mfb-space-24: 96px; + --mfb-space-32: 128px; + --mfb-space-heading-to-body: 40px; + --mfb-space-quote-to-label: 48px; + --mfb-space-gutter: clamp(20px, 4vw, 64px); + --mfb-space-section: clamp(64px, 9vw, 128px); + --mfb-space-section-tight: clamp(48px, 6vw, 96px); + --mfb-space-header-gap: clamp(32px, 4vw, 56px); + --mfb-space-block-end: clamp(32px, 4vw, 48px); + --mfb-radius-sm: 4px; + --mfb-radius-md: 8px; + --mfb-radius-lg: 12px; + --mfb-radius-pill: 999px; + --mfb-shadow-media: 0px 20px 60px -20px color-mix(in srgb, var(--mfb-purple-400) 25%, transparent); + --mfb-shadow-media-control: 0px 12px 32px -8px color-mix(in srgb, var(--mfb-black) 40%, transparent); + --mfb-shadow-overlay: 0px 40px 80px -20px color-mix(in srgb, var(--mfb-black) 50%, transparent); + --mfb-ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --mfb-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --mfb-duration-fast: 180ms; + --mfb-duration-med: 320ms; + --mfb-duration-slide: 520ms; + --mfb-duration-reveal: 700ms; + --mfb-container-page: 1280px; + --mfb-container-wide: 1440px; + --mfb-container-measure: 720px; + --mfb-breakpoint-phone: 600px; + --mfb-breakpoint-stack: 720px; + --mfb-breakpoint-nav: 900px; + --mfb-z-header: 100; + --mfb-z-dropdown: 200; + --mfb-z-overlay: 1000; + --mfb-ratio-video: 16 / 9; + --mfb-ratio-photo: 4 / 3; + --mfb-ratio-portrait: 4 / 5; + --mfb-ratio-square: 1 / 1; + --mfb-ui-touch-target: 44px; + --mfb-ui-link-underline-offset: 3px; } diff --git a/index.js b/index.js index decf6e0..507d59b 100644 --- a/index.js +++ b/index.js @@ -63,3 +63,136 @@ export const fontWeight = { "semibold": 600 }; export const gradientBrand = "linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)"; +export const colorRoles = { + "heading-on-light": "#000000", + "body-on-light": "#000000", + "muted-on-light": "#6C6C7D", + "link-on-light": "#422C70", + "link-underline-on-light": "#F7941F", + "border-on-light": "#EAEAF4", + "highlighter-on-light": "#F7941F", + "logo-on-light": "#422C70", + "heading-on-dark": "#FFFFFF", + "body-on-dark": "#FFFFFF", + "muted-on-dark": "#C0C0D0", + "link-on-dark": "#F7941F", + "highlighter-on-dark": "#F7941F", + "logo-on-dark": "#FFFFFF", + "heading-on-orange": "#000000", + "body-on-orange": "#000000", + "link-on-orange": "#000000", + "highlighter-on-orange": "#FFFFFF", + "logo-on-orange": "#000000" +}; +export const shapeTones = { + "purple-300": "#5E378E", + "orange-300": "#FBB040", + "gray-700": "#88889C" +}; +export const fontSizeFluid = { + "h1": "clamp(44px, 6vw, 70px)", + "h2": "clamp(36px, 5vw, 58px)", + "h3": "clamp(30px, 4vw, 48px)", + "h4": "clamp(26px, 3.2vw, 40px)", + "h5": "clamp(22px, 2.6vw, 36px)", + "h6": "clamp(20px, 2.2vw, 32px)", + "quote": "clamp(22px, 2.8vw, 40px)", + "label": "clamp(16px, 1.6vw, 22px)" +}; +export const lineHeight = { + "h1": 1, + "h2": 1, + "h3": 1, + "h4": 1, + "h5": 1.1, + "h6": 1.1, + "body": 1.2, + "label": 1.2, + "quote": 1 +}; +export const letterSpacing = { + "h1": "0em", + "h2": "0em", + "h3": "0em", + "h4": "0em", + "h5": "0em", + "h6": "0em", + "body": "0em", + "label": "0em", + "quote": "0em" +}; +export const logo = { + "min-width": "120px", + "min-width-print": "25mm", + "aspect": 1.904, + "min-height": "63px", + "clearspace": 0.5, + "edge-margin": 0.5 +}; +export const spacing = { + "1": "4px", + "2": "8px", + "3": "12px", + "4": "16px", + "6": "24px", + "8": "32px", + "12": "48px", + "16": "64px", + "24": "96px", + "32": "128px" +}; +export const space = { + "heading-to-body": "40px", + "quote-to-label": "48px", + "gutter": "clamp(20px, 4vw, 64px)", + "section": "clamp(64px, 9vw, 128px)", + "section-tight": "clamp(48px, 6vw, 96px)", + "header-gap": "clamp(32px, 4vw, 56px)", + "block-end": "clamp(32px, 4vw, 48px)" +}; +export const radius = { + "sm": "4px", + "md": "8px", + "lg": "12px", + "pill": "999px" +}; +export const shadow = { + "media": "0px 20px 60px -20px color-mix(in srgb, #2B1C58 25%, transparent)", + "media-control": "0px 12px 32px -8px color-mix(in srgb, #000000 40%, transparent)", + "overlay": "0px 40px 80px -20px color-mix(in srgb, #000000 50%, transparent)" +}; +export const easing = { + "out": "cubic-bezier(0.16, 1, 0.3, 1)", + "in-out": "cubic-bezier(0.65, 0, 0.35, 1)" +}; +export const duration = { + "fast": "180ms", + "med": "320ms", + "slide": "520ms", + "reveal": "700ms" +}; +export const container = { + "page": "1280px", + "wide": "1440px", + "measure": "720px" +}; +export const breakpoint = { + "phone": "600px", + "stack": "720px", + "nav": "900px" +}; +export const zIndex = { + "header": 100, + "dropdown": 200, + "overlay": 1000 +}; +export const mediaRatio = { + "video": "16 / 9", + "photo": "4 / 3", + "portrait": "4 / 5", + "square": "1 / 1" +}; +export const ui = { + "touch-target": "44px", + "link-underline-offset": "3px" +}; diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 8c7b83e..ef7aa52 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -82,6 +82,277 @@ if (geomMismatch.length) { process.exit(1); } +// Geometry the canon does not read yet. These tokens record what the Brand Book draws, but the +// canon still uses other values (a 10px pattern gap, 1.25 and 1.55 row steps), so they are NOT +// emitted as --sg-* variables: a prelude value the canon ignores would be a package that +// disagrees with itself. Each one is listed here with its reason, and the build refuses any +// geometry token that is neither gated against the canon (GEOM_VARS), a halftone color, nor +// listed here, so no new geometry reaches the package unseen. +const GEOM_TOKEN_ONLY = { + 'para-edge-ratio': 'the base shape; the canon sizes parallelograms by their container', + 'pattern-row-step': 'the canon steps progressive rows by 1.25 and 1.55', + 'pattern-row-gap': 'the canon separates pattern rows by 10px', + 'halftone-scale': 'a setting of the tool that makes the halftone, not a CSS value', +}; + +// ---- GROUP MAP AND NAMING GATE ---- +// One table: each tokens.json group added after v1.3.0 -> its Tailwind 4 theme.css prefix -> its +// Tailwind 3 theme.extend key and key prefix -> its brand.css prefix. null means "not emitted +// there". Keys in tokens.json are bare; the prefix is added per output. The build refuses a +// tokens.json group that is in neither this table nor ORIGINAL_GROUPS, so a group added later +// cannot leak into a Tailwind namespace unseen. +// +// Naming rules (they keep every Tailwind default intact in Tailwind 3 and 4): +// - every new utility key starts with mfb-, except type levels (text-h1-fluid, leading-h1, +// tracking-h1) and color roles (text-heading-on-light), whose names no Tailwind default has; +// - no breakpoints, screens, bare roots (--spacing, --radius, --shadow, --container), --default-* +// or sub-properties of an existing key are emitted; +// - existing keys (colors, fontSize, fontWeight) are never changed. +const ORIGINAL_GROUPS = ['color', 'gradient', 'geometry', 'fontFamily', 'fontWeight', 'fontSize']; +const GROUP_MAP = { + // Tailwind 4 namespace Tailwind 3 key key prefix suffix brand.css prefix + lineHeight: { tw4: '--leading-', tw3: 'lineHeight', twKey: '', suffix: '', css: '--mfb-leading-' }, + letterSpacing: { tw4: '--tracking-', tw3: 'letterSpacing', twKey: '', suffix: '', css: '--mfb-tracking-' }, + fontSizeFluid: { tw4: '--text-', tw3: 'fontSize', twKey: '', suffix: '-fluid', css: '--mfb-size-' }, + colorRole: { tw4: '--color-', tw3: 'colors', twKey: '', suffix: '', css: '--mfb-' }, + shapeTone: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-shape-on-' }, + logo: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-logo-' }, + spaceScale: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-space-' }, + space: { tw4: '--spacing-', tw3: 'spacing', twKey: 'mfb-', suffix: '', css: '--mfb-space-' }, + radius: { tw4: '--radius-', tw3: 'borderRadius', twKey: 'mfb-', suffix: '', css: '--mfb-radius-' }, + shadow: { tw4: '--shadow-', tw3: 'boxShadow', twKey: 'mfb-', suffix: '', css: '--mfb-shadow-' }, + easing: { tw4: '--ease-', tw3: 'transitionTimingFunction', twKey: 'mfb-', suffix: '', css: '--mfb-ease-' }, + duration: { tw4: '--transition-duration-', tw3: 'transitionDuration', twKey: 'mfb-', suffix: '', css: '--mfb-duration-' }, + container: { tw4: '--container-', tw3: 'maxWidth', twKey: 'mfb-', suffix: '', css: '--mfb-container-' }, + breakpoint: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-breakpoint-' }, + zIndex: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-z-' }, + mediaRatio: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ratio-' }, + ui: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ui-' }, +}; +// Keys of a mapped group that stay out of Tailwind, with the reason. +const TW_SKIP = { radius: { pill: 'Tailwind already has rounded-full' } }; + +// Tailwind defaults a new key must never equal (Tailwind 4.3.3 theme and Tailwind 3.4.19 config). +const TW4_DEFAULTS = { + '--radius-': 'xs sm md lg xl 2xl 3xl 4xl', + '--shadow-': '2xs xs sm md lg xl 2xl inner', + '--ease-': 'in out in-out', + '--container-': '3xs 2xs xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl', + '--leading-': 'tight snug normal relaxed loose', + '--tracking-': 'tighter tight normal wide wider widest', + '--text-': 'xs sm base lg xl 2xl 3xl 4xl 5xl 6xl 7xl 8xl 9xl', + '--transition-duration-': '', + '--spacing-': '', + '--color-': '', +}; +const TW3_DEFAULTS = { + borderRadius: 'none sm DEFAULT md lg xl 2xl 3xl full', + boxShadow: 'sm DEFAULT md lg xl 2xl inner none', + transitionDuration: 'DEFAULT 0 75 100 150 200 300 500 700 1000', + transitionTimingFunction: 'DEFAULT linear in out in-out', + lineHeight: '3 4 5 6 7 8 9 10 none tight snug normal relaxed loose', + letterSpacing: 'tighter tight normal wide wider widest', + maxWidth: '0 none xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl full min max fit prose', + fontSize: 'xs sm base lg xl 2xl 3xl 4xl 5xl 6xl 7xl 8xl 9xl', + spacing: 'px', + colors: 'inherit current transparent black white', +}; +const STATIC_KEYWORDS = new Set('full none px auto screen min max fit prose DEFAULT inner linear inherit current transparent'.split(' ')); +const TW_COLOR_FAMILIES = ('red orange amber yellow lime green emerald teal cyan sky blue indigo violet purple fuchsia pink ' + + 'rose slate gray zinc neutral stone mauve olive mist taupe').split(' '); + +// Value helpers for the new groups. A role or a shape tone is an alias of a palette color, and a +// shadow's color is too: the build resolves the alias and refuses one that is not in tokens.color. +const gateProblems = []; +const aliasOf = (v) => (typeof v === 'string' ? v.match(/^\{color\.([\w-]+)\}$/)?.[1] : undefined); +const paletteRef = (where, v) => { + const name = aliasOf(v); + if (!name || !tokens.color[name]) { + gateProblems.push(`${where}: "${typeof v === 'string' ? v : JSON.stringify(v)}" is not an alias of a palette color ({color.})`); + return null; + } + return name; +}; +const pct = (opacity) => `${Math.round(Number(opacity) * 100)}%`; +const shadowValue = (where, v, target) => { + const name = paletteRef(`${where}.color`, v?.color); + const o = Number(v?.opacity); + if (!(o > 0 && o <= 1)) gateProblems.push(`${where}.opacity: ${v?.opacity} is not between 0 and 1`); + if (!name) return ''; + const color = target === 'css' ? `var(--mfb-${name})` : tokens.color[name].$value; + return `${v.offsetX} ${v.offsetY} ${v.blur} ${v.spread} color-mix(in srgb, ${color} ${pct(o)}, transparent)`; +}; +const remOf = (px) => { + const m = String(px).match(/^(\d+(?:\.\d+)?)px$/); + return m ? `${Number(m[1]) / 16}rem` : null; +}; +// The value of a token of a mapped group, for one output: 'css' (brand.css: palette by var()), +// 'tw' (theme.css and tailwind.js: palette as a literal hex, containers in rem) or 'raw' +// (index.js: resolved values, containers in px as in tokens.json). +const valueFor = (group, key, tok, target) => { + const where = `${group}.${key}`; + if (group === 'colorRole' || group === 'shapeTone') { + const name = paletteRef(where, tok.$value); + if (!name) return ''; + return target === 'css' ? `var(--mfb-${name})` : tokens.color[name].$value; + } + if (group === 'shadow') return shadowValue(where, tok.$value, target === 'css' ? 'css' : 'tw'); + if (group === 'easing') { + const v = tok.$value; + if (!Array.isArray(v) || v.length !== 4 || v.some((n) => typeof n !== 'number')) { + gateProblems.push(`${where}: a cubicBezier token needs four numbers`); + return ''; + } + return `cubic-bezier(${v.join(', ')})`; + } + if (group === 'container' && target === 'tw') { + const rem = remOf(tok.$value); + if (!rem) gateProblems.push(`${where}: "${tok.$value}" is not a px value (Tailwind 4 sorts px containers before rem ones)`); + return rem || ''; + } + return target === 'raw' && typeof tok.$value === 'number' ? tok.$value : String(tok.$value); +}; + +// R1: every group is known. +for (const group of Object.keys(tokens).filter((k) => !k.startsWith('$'))) { + if (!ORIGINAL_GROUPS.includes(group) && !GROUP_MAP[group]) { + gateProblems.push(`tokens.json group "${group}" has no entry in GROUP_MAP (scripts/build-design-package.mjs): say where it is emitted before adding it`); + } +} +for (const group of Object.keys(GROUP_MAP)) { + if (!tokens[group] || typeof tokens[group] !== 'object') gateProblems.push(`GROUP_MAP names "${group}", which tokens.json lacks`); + for (const [k, tok] of Object.entries(tokens[group] || {})) { + if (k.startsWith('$')) gateProblems.push(`${group}.${k}: groups are flat; no $-keys inside a group`); + else if (!tok || typeof tok !== 'object' || !('$value' in tok)) gateProblems.push(`${group}.${k}: not a token (no $value)`); + } +} +// Every geometry token is gated against the canon, a halftone color, or listed as token-only. +for (const [k, tok] of Object.entries(tokens.geometry || {})) { + const halftoneColor = k.startsWith('halftone-') && tok.$type === 'color'; + if (!GEOM_VARS[k] && !halftoneColor && !GEOM_TOKEN_ONLY[k]) { + gateProblems.push(`geometry.${k}: add it to GEOM_VARS (gated against the canon) or to GEOM_TOKEN_ONLY with the reason it is not emitted`); + } +} +for (const k of Object.keys(GEOM_TOKEN_ONLY)) { + if (!tokens.geometry?.[k]) gateProblems.push(`GEOM_TOKEN_ONLY names geometry.${k}, which tokens.json lacks`); + if (new RegExp(`--sg-${k}\\s*:`).test(sgCanon)) gateProblems.push(`the canon declares --sg-${k}: move geometry.${k} from GEOM_TOKEN_ONLY to GEOM_VARS so it is gated`); +} + +// Type levels: line height, letter spacing and fluid sizes exist only for fontSize levels, and a +// fluid size grows up to the Brand Book size and no further (the cap is the book's, the floor +// and slope are the website's). +const LEVELS = Object.keys(tokens.fontSize || {}); +for (const group of ['lineHeight', 'letterSpacing', 'fontSizeFluid']) { + for (const k of Object.keys(tokens[group] || {})) { + if (!LEVELS.includes(k)) gateProblems.push(`${group}.${k}: not a fontSize level (${LEVELS.join(', ')})`); + } +} +for (const [k, tok] of Object.entries(tokens.fontSizeFluid || {})) { + const m = String(tok.$value).match(/^clamp\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)\)$/); + const cap = tokens.fontSize?.[k]?.$value; + if (!m) gateProblems.push(`fontSizeFluid.${k}: "${tok.$value}" is not clamp(min, preferred, max)`); + else if (m[3].trim() !== cap) gateProblems.push(`fontSizeFluid.${k}: its maximum ${m[3].trim()} is not the Brand Book size fontSize.${k} (${cap})`); +} + +// Emitted names: Tailwind 4 variables, Tailwind 3 keys and brand.css variables. +const tw4Lines = []; // [name, value] +const tw3Extend = {}; // key -> { name: value } +const cssLines = []; // [name, value] +const indexExports = {}; // group -> { key: value } +const emitGroup = (group) => { + const map = GROUP_MAP[group]; + const skip = TW_SKIP[group] || {}; + for (const [k, tok] of Object.entries(tokens[group] || {})) { + cssLines.push([`${map.css}${k}${map.suffix}`, valueFor(group, k, tok, 'css')]); + (indexExports[group] ||= {})[k] = valueFor(group, k, tok, 'raw'); + if (skip[k]) continue; + if (map.tw4) tw4Lines.push([`${map.tw4}${map.twKey}${k}${map.suffix}`, valueFor(group, k, tok, 'tw')]); + if (map.tw3) ((tw3Extend[map.tw3] ||= {})[`${map.twKey}${k}${map.suffix}`] = valueFor(group, k, tok, 'tw')); + } +}; +// One call per group, in the order they appear in the outputs. +emitGroup('colorRole'); +emitGroup('shapeTone'); +emitGroup('fontSizeFluid'); +emitGroup('lineHeight'); +emitGroup('letterSpacing'); +emitGroup('logo'); +emitGroup('spaceScale'); +emitGroup('space'); +emitGroup('radius'); +emitGroup('shadow'); +emitGroup('easing'); +emitGroup('duration'); +emitGroup('container'); +emitGroup('breakpoint'); +emitGroup('zIndex'); +emitGroup('mediaRatio'); +emitGroup('ui'); +for (const group of Object.keys(GROUP_MAP)) { + if (!indexExports[group] && Object.keys(tokens[group] || {}).length) gateProblems.push(`GROUP_MAP group "${group}" is never emitted (add an emitGroup call)`); +} + +// (a), (b), (c): no Tailwind default, static keyword or numeric key; (e): no bare root, breakpoint, +// default or sub-property. +const defaultsFor = (table, prefix) => new Set(String(table[prefix] ?? '').split(' ').filter(Boolean)); +for (const [name] of tw4Lines) { + const ns = Object.keys(TW4_DEFAULTS).find((p) => name.startsWith(p)); + if (!ns) { gateProblems.push(`${name}: not in a Tailwind 4 namespace this build may emit`); continue; } + const key = name.slice(ns.length); + if (!key) gateProblems.push(`${name}: a bare Tailwind 4 root changes a default utility`); + if (defaultsFor(TW4_DEFAULTS, ns).has(key)) gateProblems.push(`${name}: "${key}" is a Tailwind 4 default in ${ns}*`); + if (STATIC_KEYWORDS.has(key) || /^\d/.test(key)) gateProblems.push(`${name}: "${key}" is a static keyword or starts with a digit`); + if (key.includes('--')) gateProblems.push(`${name}: a sub-property (--) changes an existing utility`); + if (/^--(breakpoint|default|animate)-/.test(name)) gateProblems.push(`${name}: breakpoints, defaults and animations are never emitted`); + if (ns === '--color-' && (!/-on-(light|dark|orange)$/.test(key) || TW_COLOR_FAMILIES.some((f) => key.startsWith(`${f}-`)) || tokens.color[key])) { + gateProblems.push(`${name}: a color role is named -on- and is not a palette or Tailwind color`); + } +} +for (const [twKey, entries] of Object.entries(tw3Extend)) { + const defaults = new Set(String(TW3_DEFAULTS[twKey] ?? '').split(' ').filter(Boolean)); + for (const key of Object.keys(entries)) { + if (defaults.has(key)) gateProblems.push(`tailwind.js ${twKey}.${key}: a Tailwind 3 default`); + if (STATIC_KEYWORDS.has(key) || /^\d/.test(key)) gateProblems.push(`tailwind.js ${twKey}.${key}: a static keyword or starts with a digit`); + if (twKey === 'fontSize' && (tokens.fontSize || {})[key]) gateProblems.push(`tailwind.js fontSize.${key}: an existing key is frozen in 1.x`); + if (twKey === 'colors' && (tokens.color[key] || TW_COLOR_FAMILIES.includes(key))) gateProblems.push(`tailwind.js colors.${key}: an existing color key is frozen in 1.x`); + } +} +// (d) disjointness. +const keysOf = (group) => Object.keys(tokens[group] || {}); +const overlap = (a, b) => a.filter((k) => b.includes(k)); +const textKeys = [...LEVELS, ...keysOf('fontSizeFluid').map((k) => `${k}-fluid`)]; +for (const [a, b, why] of [ + [keysOf('colorRole'), textKeys, 'color roles and text sizes'], + [keysOf('colorRole'), keysOf('shadow'), 'color roles and shadows'], + [keysOf('shadow'), Object.keys(tokens.color), 'shadows and palette colors'], + [keysOf('space'), keysOf('container'), 'spacing and containers (Tailwind 4 max-w reads --spacing-* first)'], + [keysOf('space'), keysOf('spaceScale'), 'semantic spacing and the spacing scale (both are --mfb-space-*)'], +]) { + for (const k of overlap(a, b)) gateProblems.push(`"${k}" is a key of both ${why}`); +} +// brand.css names are unique, including against the original variables. +const originalCss = [ + ...Object.keys(tokens.color).map((k) => `--mfb-${k}`), + ...Object.keys(tokens.fontSize || {}).map((k) => `--mfb-size-${k}`), + ...Object.keys(tokens.fontWeight || {}).map((k) => `--mfb-weight-${k}`), + ...Object.keys(tokens.fontFamily || {}).map((k) => `--mfb-font-${k}`), + '--mfb-font-sans', '--mfb-gradient-brand', '--mfb-gradient', + ...Object.keys(tokens.geometry || {}).filter((k) => k.startsWith('halftone-')).map((k) => `--mfb-${k}`), +]; +const seenCss = new Set(originalCss); +for (const [name, value] of cssLines) { + if (seenCss.has(name)) gateProblems.push(`brand.css ${name}: emitted twice`); + seenCss.add(name); + if (name === '--mfb-side') gateProblems.push('brand.css --mfb-side: a name projects define locally'); + if (/#[0-9a-f]{3,8}\b/i.test(value)) gateProblems.push(`brand.css ${name}: a new variable references the palette with var(), never a hex (${value})`); +} + +if (gateProblems.length) { + console.error('REFUSING TO BUILD: the new token groups break a naming or value rule.'); + for (const p of new Set(gateProblems)) console.error(` - ${p}`); + process.exit(1); +} + fs.mkdirSync(OUT_DIR, { recursive: true }); const written = []; const write = (name, content) => { @@ -126,14 +397,17 @@ const halftone = Object.entries(tokens.geometry || {}) const ff = (arr) => arr.map((f) => (/\s/.test(f) ? `"${f}"` : f)).join(', '); // ---- 1. tailwind.js - Tailwind 3 preset (also usable in TW4 via @config) ---- +// The new groups (GROUP_MAP) extend Tailwind's scales under their own keys; colors and fontSize +// keep every existing key and value, and gain only the color roles and the fluid sizes. const preset = { theme: { extend: { - colors: { ...mfb, mfb }, // both conventions: bg-purple-400 and bg-mfb-purple-400 + colors: { ...mfb, mfb, ...(tw3Extend.colors || {}) }, // both conventions: bg-purple-400 and bg-mfb-purple-400; roles at the top level fontFamily, - fontSize, + fontSize: { ...fontSize, ...(tw3Extend.fontSize || {}) }, fontWeight: twFontWeight, ...(gradient ? { backgroundImage: { 'brand-gradient': gradient } } : {}), + ...Object.fromEntries(Object.entries(tw3Extend).filter(([k]) => k !== 'colors' && k !== 'fontSize')), }, }, }; @@ -151,6 +425,7 @@ for (const [name, tok] of Object.entries(tokens.color)) { for (const [k, v] of Object.entries(fontFamily)) theme += ` --font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) theme += ` --text-${k}: ${tok.$value};\n`; for (const [k, v] of Object.entries(twFontWeight)) theme += ` --font-weight-${k}: ${v};\n`; +for (const [name, v] of tw4Lines) theme += ` ${name}: ${v};\n`; theme += '}\n'; write('theme.css', theme); @@ -163,6 +438,7 @@ for (const [k, tok] of Object.entries(tokens.fontSize || {})) css += ` --mfb-si for (const [k, v] of Object.entries(fontWeight)) css += ` --mfb-weight-${k}: ${v};\n`; for (const [cssVar, v] of halftone) css += ` ${cssVar}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) css += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; +for (const [name, v] of cssLines) css += ` ${name}: ${v};\n`; css += '}\n'; write('brand.css', css); @@ -203,6 +479,27 @@ sgPrelude += '}\n\n'; write('supergraphics.css', sgPrelude + sgCanon); // ---- 5. index.js - programmatic access ---- +// Named exports only. colors, fontFamily, fontSize and fontWeight are unchanged; colors stays the +// palette. Colors in colorRoles and shapeTones are resolved hex values; containers stay in px. +const INDEX_EXPORTS = [ + ['colorRoles', 'colorRole'], + ['shapeTones', 'shapeTone'], + ['fontSizeFluid', 'fontSizeFluid'], + ['lineHeight', 'lineHeight'], + ['letterSpacing', 'letterSpacing'], + ['logo', 'logo'], + ['spacing', 'spaceScale'], + ['space', 'space'], + ['radius', 'radius'], + ['shadow', 'shadow'], + ['easing', 'easing'], + ['duration', 'duration'], + ['container', 'container'], + ['breakpoint', 'breakpoint'], + ['zIndex', 'zIndex'], + ['mediaRatio', 'mediaRatio'], + ['ui', 'ui'], +]; write( 'index.js', `// @myfirstbitcoin/design - programmatic access to MFB brand tokens.\n` + @@ -211,7 +508,8 @@ write( `export const fontFamily = ${JSON.stringify(fontFamily, null, 2)};\n` + `export const fontSize = ${JSON.stringify(fontSize, null, 2)};\n` + `export const fontWeight = ${JSON.stringify(fontWeight, null, 2)};\n` + - (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') + (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') + + INDEX_EXPORTS.map(([name, group]) => `export const ${name} = ${JSON.stringify(indexExports[group] || {}, null, 2)};\n`).join('') ); // ---- 6. package.json is hand-maintained and no longer written here. ---- @@ -252,10 +550,14 @@ const readme = [ `- **Set ${code('--sg-aspect')} on parallelogram frames too.** The photo scale in ${code('.sg-para-frame')} was a fixed 1.12; it is now ${code('1 + tan(--sg-angle-base) / --sg-aspect')}, the smallest scale that covers the frame, so it depends on the frame's real shape.`, `- **${code('supergraphics.css')} sets more variables in ${code(':root')}**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing ${code('supergraphics.css')}, or import the file into a cascade layer.`, `- ${code('.sg-cover__title')} reads ${code('--mfb-size-h2')}. The ${code('--mfb-font-h2')} variable it used to read was never defined by this package; set ${code('font-size')} on the title instead.`, - `- **Tailwind 4 projects that restrict font weights** with ${code('--font-weight-*: initial')} must put that reset in its own ${code('@theme')} block before importing ${code('theme.css')}: placed after the import, it removes the package's weights too, and no weight utility is generated.`, + `- **Tailwind 4 projects that restrict a theme namespace**, for example with ${code('--font-weight-*: initial')} or ${code('--radius-*: initial')}, must put that reset in its own ${code('@theme')} block before importing ${code('theme.css')}: placed after the import, it removes the package's values too, and no utility is generated for them.`, ``, `${FENCE}css\n@import "tailwindcss";\n@theme { --font-weight-*: initial; }\n@import "@myfirstbitcoin/design/theme.css";\n${FENCE}`, ``, + `- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in ${code('tokens.json')} now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to.`, + `- **A test that snapshots the ${code('--mfb-*')} variables of ${code('brand.css')} whose value is a hex** must refresh its snapshot: this release adds ${halftoneList}. The other new variables carry no hex; they reference the palette with ${code('var()')}.`, + `- **Everything else is added, not changed.** Every new Tailwind utility is ${code('mfb-')} prefixed, a type level (${code('leading-h1')}, ${code('tracking-h1')}, ${code('text-h1-fluid')}) or a color role (${code('text-heading-on-light')}), and none of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below.`, + ``, `## Use - Tailwind 3`, ``, `${FENCE}js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n${FENCE}`, @@ -295,6 +597,37 @@ const readme = [ `The span is an ${code('inline-block')}, and the line's length and thickness are computed from the span's own width: do not override its ${code('display')} or ${code('text-align')}. ` + `Its measurements are the ${code('--sg-highlighter-*')} variables (coverage, aspect, offset, shape), from the geometry tokens of the same names.`, ``, + `## Layout, motion and color roles`, + ``, + `Where the Brand Book defines a value, the token takes it. Where the book is silent (spacing, radius, shadow, motion, widths), the value is declared from the live website, myfirstbitcoin.org, which was built following the Brand Book; ` + + `each such token's ${code('$extensions.mfb.source')} names the commit, file and line. A website value that contradicts the Brand Book is never imported. ${code('brand-spec.md')} lists every value with its source.`, + ``, + `| What | brand.css | Tailwind 3 and 4 |`, + `|------|-----------|------------------|`, + `| Color roles | ${code('--mfb--on-')}: ${keysOf('colorRole').map((k) => code(k)).join(', ')} | ${code('text-heading-on-light')}, ${code('decoration-link-underline-on-light')}, ${code('border-border-on-light')} ... |`, + `| Line height per level | ${code('--mfb-leading-')} | ${code('leading-')}, for example ${code('leading-h1')} |`, + `| Letter spacing per level | ${code('--mfb-tracking-')} | ${code('tracking-')} |`, + `| Fluid sizes for web pages | ${code('--mfb-size--fluid')} (${keysOf('fontSizeFluid').join(', ')}) | ${code('text--fluid')} |`, + `| Spacing scale | ${keysOf('spaceScale').map((k) => code(`--mfb-space-${k}`)).join(', ')} | Tailwind's own ${code('p-1')} to ${code('p-32')}: the same values, so the package adds none |`, + `| Semantic spacing | ${keysOf('space').map((k) => code(`--mfb-space-${k}`)).join(', ')} | ${code('py-mfb-section')}, ${code('px-mfb-gutter')}, ${code('mt-mfb-heading-to-body')} ... |`, + `| Radius (interface elements only, never brand shapes) | ${keysOf('radius').map((k) => code(`--mfb-radius-${k}`)).join(', ')} | ${keysOf('radius').filter((k) => !(TW_SKIP.radius || {})[k]).map((k) => code(`rounded-mfb-${k}`)).join(', ')}; ${code('rounded-full')} for the pill |`, + `| Shadow | ${keysOf('shadow').map((k) => code(`--mfb-shadow-${k}`)).join(', ')} | ${keysOf('shadow').map((k) => code(`shadow-mfb-${k}`)).join(', ')} |`, + `| Easing | ${keysOf('easing').map((k) => code(`--mfb-ease-${k}`)).join(', ')} | ${keysOf('easing').map((k) => code(`ease-mfb-${k}`)).join(', ')} |`, + `| Duration | ${keysOf('duration').map((k) => code(`--mfb-duration-${k}`)).join(', ')} | ${keysOf('duration').map((k) => code(`duration-mfb-${k}`)).join(', ')} |`, + `| Container widths | ${keysOf('container').map((k) => code(`--mfb-container-${k}`)).join(', ')} (px) | ${keysOf('container').map((k) => code(`max-w-mfb-${k}`)).join(', ')} (rem) |`, + `| Logo size and clear space | ${keysOf('logo').map((k) => code(`--mfb-logo-${k}`)).join(', ')} | none |`, + `| Shape tones (a shape one step lighter than its base) | ${keysOf('shapeTone').map((k) => code(`--mfb-shape-on-${k}`)).join(', ')} | none |`, + `| Breakpoints, for JavaScript and ${code('matchMedia')} (a variable cannot be read inside ${code('@media')}) | ${keysOf('breakpoint').map((k) => code(`--mfb-breakpoint-${k}`)).join(', ')} | none: the package adds no Tailwind breakpoint |`, + `| Layers | ${keysOf('zIndex').map((k) => code(`--mfb-z-${k}`)).join(', ')} | none |`, + `| Media ratios | ${keysOf('mediaRatio').map((k) => code(`--mfb-ratio-${k}`)).join(', ')} | none (Tailwind has ${code('aspect-video')} and ${code('aspect-square')}) |`, + `| Interface | ${keysOf('ui').map((k) => code(`--mfb-ui-${k}`)).join(', ')} | none |`, + ``, + `- **New utilities never replace Tailwind's.** Keys are ${code('mfb-')} prefixed (${code('rounded-mfb-md')}, not ${code('rounded-md')}; ${code('ease-mfb-out')}, not ${code('ease-out')}, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default.`, + `- **In 1.x, ${code('text-h1')} sets only the size.** Add ${code('leading-h1')} yourself (and ${code('tracking-h1')} where something else sets tracking). Folding line height into ${code('text-h1')} would change every existing page, so it waits for a major release.`, + `- **New ${code('brand.css')} variables reference the palette with ${code('var()')}** and add no hex: roles are ${code('var(--mfb-black)')} and the like, shadows ${code('color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)')}. ${code('theme.css')} and ${code('tailwind.js')} carry the same colors as hex, so Tailwind's opacity modifiers work.`, + `- **Tailwind 4 emits a theme variable only when something uses it.** To read ${code('var(--mfb-radius-md)')} or another new variable in your own CSS, import ${code('brand.css')} as well, or use the utility.`, + `- **index.js** exports ${INDEX_EXPORTS.map(([name]) => code(name)).join(', ')} next to the existing ${code('colors')}, ${code('fontFamily')}, ${code('fontSize')} and ${code('fontWeight')}.`, + ``, `## Variables`, ``, `Plain CSS variables are namespaced ${code('--mfb-*')} (brand values) and ${code('--sg-*')} (geometry) to avoid collisions. ${code('brand.css')} has all of them; ${code('supergraphics.css')} repeats what its classes need.`, diff --git a/tailwind.js b/tailwind.js index 53a7e69..2221d85 100644 --- a/tailwind.js +++ b/tailwind.js @@ -51,7 +51,26 @@ export default { "800": "#4F4F5D", "900": "#25252B" } - } + }, + "heading-on-light": "#000000", + "body-on-light": "#000000", + "muted-on-light": "#6C6C7D", + "link-on-light": "#422C70", + "link-underline-on-light": "#F7941F", + "border-on-light": "#EAEAF4", + "highlighter-on-light": "#F7941F", + "logo-on-light": "#422C70", + "heading-on-dark": "#FFFFFF", + "body-on-dark": "#FFFFFF", + "muted-on-dark": "#C0C0D0", + "link-on-dark": "#F7941F", + "highlighter-on-dark": "#F7941F", + "logo-on-dark": "#FFFFFF", + "heading-on-orange": "#000000", + "body-on-orange": "#000000", + "link-on-orange": "#000000", + "highlighter-on-orange": "#FFFFFF", + "logo-on-orange": "#000000" }, "fontFamily": { "heading": [ @@ -83,7 +102,15 @@ export default { "h6": "32px", "body": "18px", "label": "22px", - "quote": "40px" + "quote": "40px", + "h1-fluid": "clamp(44px, 6vw, 70px)", + "h2-fluid": "clamp(36px, 5vw, 58px)", + "h3-fluid": "clamp(30px, 4vw, 48px)", + "h4-fluid": "clamp(26px, 3.2vw, 40px)", + "h5-fluid": "clamp(22px, 2.6vw, 36px)", + "h6-fluid": "clamp(20px, 2.2vw, 32px)", + "quote-fluid": "clamp(22px, 2.8vw, 40px)", + "label-fluid": "clamp(16px, 1.6vw, 22px)" }, "fontWeight": { "regular": "400", @@ -93,6 +120,62 @@ export default { }, "backgroundImage": { "brand-gradient": "linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)" + }, + "lineHeight": { + "h1": "1", + "h2": "1", + "h3": "1", + "h4": "1", + "h5": "1.1", + "h6": "1.1", + "body": "1.2", + "label": "1.2", + "quote": "1" + }, + "letterSpacing": { + "h1": "0em", + "h2": "0em", + "h3": "0em", + "h4": "0em", + "h5": "0em", + "h6": "0em", + "body": "0em", + "label": "0em", + "quote": "0em" + }, + "spacing": { + "mfb-heading-to-body": "40px", + "mfb-quote-to-label": "48px", + "mfb-gutter": "clamp(20px, 4vw, 64px)", + "mfb-section": "clamp(64px, 9vw, 128px)", + "mfb-section-tight": "clamp(48px, 6vw, 96px)", + "mfb-header-gap": "clamp(32px, 4vw, 56px)", + "mfb-block-end": "clamp(32px, 4vw, 48px)" + }, + "borderRadius": { + "mfb-sm": "4px", + "mfb-md": "8px", + "mfb-lg": "12px" + }, + "boxShadow": { + "mfb-media": "0px 20px 60px -20px color-mix(in srgb, #2B1C58 25%, transparent)", + "mfb-media-control": "0px 12px 32px -8px color-mix(in srgb, #000000 40%, transparent)", + "mfb-overlay": "0px 40px 80px -20px color-mix(in srgb, #000000 50%, transparent)" + }, + "transitionTimingFunction": { + "mfb-out": "cubic-bezier(0.16, 1, 0.3, 1)", + "mfb-in-out": "cubic-bezier(0.65, 0, 0.35, 1)" + }, + "transitionDuration": { + "mfb-fast": "180ms", + "mfb-med": "320ms", + "mfb-slide": "520ms", + "mfb-reveal": "700ms" + }, + "maxWidth": { + "mfb-page": "80rem", + "mfb-wide": "90rem", + "mfb-measure": "45rem" } } } diff --git a/theme.css b/theme.css index 0584ae6..d465100 100644 --- a/theme.css +++ b/theme.css @@ -51,4 +51,71 @@ --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-normal: 400; + --color-heading-on-light: #000000; + --color-body-on-light: #000000; + --color-muted-on-light: #6C6C7D; + --color-link-on-light: #422C70; + --color-link-underline-on-light: #F7941F; + --color-border-on-light: #EAEAF4; + --color-highlighter-on-light: #F7941F; + --color-logo-on-light: #422C70; + --color-heading-on-dark: #FFFFFF; + --color-body-on-dark: #FFFFFF; + --color-muted-on-dark: #C0C0D0; + --color-link-on-dark: #F7941F; + --color-highlighter-on-dark: #F7941F; + --color-logo-on-dark: #FFFFFF; + --color-heading-on-orange: #000000; + --color-body-on-orange: #000000; + --color-link-on-orange: #000000; + --color-highlighter-on-orange: #FFFFFF; + --color-logo-on-orange: #000000; + --text-h1-fluid: clamp(44px, 6vw, 70px); + --text-h2-fluid: clamp(36px, 5vw, 58px); + --text-h3-fluid: clamp(30px, 4vw, 48px); + --text-h4-fluid: clamp(26px, 3.2vw, 40px); + --text-h5-fluid: clamp(22px, 2.6vw, 36px); + --text-h6-fluid: clamp(20px, 2.2vw, 32px); + --text-quote-fluid: clamp(22px, 2.8vw, 40px); + --text-label-fluid: clamp(16px, 1.6vw, 22px); + --leading-h1: 1; + --leading-h2: 1; + --leading-h3: 1; + --leading-h4: 1; + --leading-h5: 1.1; + --leading-h6: 1.1; + --leading-body: 1.2; + --leading-label: 1.2; + --leading-quote: 1; + --tracking-h1: 0em; + --tracking-h2: 0em; + --tracking-h3: 0em; + --tracking-h4: 0em; + --tracking-h5: 0em; + --tracking-h6: 0em; + --tracking-body: 0em; + --tracking-label: 0em; + --tracking-quote: 0em; + --spacing-mfb-heading-to-body: 40px; + --spacing-mfb-quote-to-label: 48px; + --spacing-mfb-gutter: clamp(20px, 4vw, 64px); + --spacing-mfb-section: clamp(64px, 9vw, 128px); + --spacing-mfb-section-tight: clamp(48px, 6vw, 96px); + --spacing-mfb-header-gap: clamp(32px, 4vw, 56px); + --spacing-mfb-block-end: clamp(32px, 4vw, 48px); + --radius-mfb-sm: 4px; + --radius-mfb-md: 8px; + --radius-mfb-lg: 12px; + --shadow-mfb-media: 0px 20px 60px -20px color-mix(in srgb, #2B1C58 25%, transparent); + --shadow-mfb-media-control: 0px 12px 32px -8px color-mix(in srgb, #000000 40%, transparent); + --shadow-mfb-overlay: 0px 40px 80px -20px color-mix(in srgb, #000000 50%, transparent); + --ease-mfb-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-mfb-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --transition-duration-mfb-fast: 180ms; + --transition-duration-mfb-med: 320ms; + --transition-duration-mfb-slide: 520ms; + --transition-duration-mfb-reveal: 700ms; + --container-mfb-page: 80rem; + --container-mfb-wide: 90rem; + --container-mfb-measure: 45rem; } diff --git a/tokens.json b/tokens.json index 2f7ca3c..c96eac0 100644 --- a/tokens.json +++ b/tokens.json @@ -4,12 +4,20 @@ "purple-300": { "$type": "color", "$value": "#422C70", - "$description": "Primary purple — brand hero color, backgrounds, headers", + "$description": "Primary purple: the main brand color for surfaces and shapes (backgrounds, covers, parallelograms, spotlights). Takes white text. Not a heading color on light backgrounds: headings there are black (Type Relationships 918:2990)", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 41, + 61, + 0, + 56 + ], + "cmykSource": "figma:918:2258", + "cmykSourceKind": "prose" } } }, @@ -21,67 +29,115 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 51, + 68, + 0, + 65 + ], + "cmykSource": "figma:918:2386", + "cmykSourceKind": "prose" } } }, "purple-200": { "$type": "color", "$value": "#5E378E", - "$description": "Accent light purple — secondary headings, gradient endpoint", + "$description": "Accent light purple: supergraphic shapes on purple-300 (one step lighter than the base, 918:3604), halftone cutout backgrounds (918:3730). Takes white text", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 34, + 61, + 0, + 44 + ], + "cmykSource": "figma:918:2386", + "cmykSourceKind": "prose" } } }, "orange-300": { "$type": "color", "$value": "#F7941F", - "$description": "Primary orange — CTAs, highlights, Bitcoin symbol color", + "$description": "Primary orange: CTA and shape fills with black text (Color Contrast 918:2588), the highlighter line, the Bitcoin symbol, links on dark backgrounds. Never text on light backgrounds", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 0, + 40, + 87, + 3 + ], + "cmykSource": "figma:918:2258", + "cmykSourceKind": "prose" } } }, "orange-400": { "$type": "color", "$value": "#EF7B00", - "$description": "Accent dark orange — hover/active states, strong emphasis", + "$description": "Accent dark orange: hover and active fills, shapes; takes black text (918:2616). Never text on light backgrounds", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 0, + 46, + 100, + 6 + ], + "cmykSource": "figma:918:2386", + "cmykSourceKind": "prose" } } }, "orange-200": { "$type": "color", "$value": "#FBB040", - "$description": "Accent light orange — soft highlights, secondary accents", + "$description": "Accent light orange: supergraphic shapes on orange-300 (one step lighter than the base, 918:3604), soft accents on dark backgrounds. Takes black text", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 0, + 30, + 75, + 2 + ], + "cmykSource": "figma:918:2386", + "cmykSourceKind": "prose" } } }, "black": { "$type": "color", "$value": "#000000", - "$description": "Primary black — high-contrast text, bold headlines", + "$description": "Primary black: headings and body text on light backgrounds and on orange (Type Relationships 918:2990, Color Contrast 918:2588)", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 0, + 0, + 0, + 100 + ], + "cmykSource": "figma:918:2258", + "cmykSourceKind": "prose" } } }, @@ -93,31 +149,55 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 0, + 0, + 0, + 0 + ], + "cmykSource": "figma:918:2258", + "cmykSourceKind": "prose" } } }, "gray-900": { "$type": "color", "$value": "#25252B", - "$description": "Neutral — near-black, body text on light backgrounds", + "$description": "Neutral: the darkest grey, a dark surface that takes white text (918:2588). Text on light backgrounds is black, not gray-900 (918:2990)", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 14, + 14, + 0, + 83 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, "gray-800": { "$type": "color", "$value": "#4F4F5D", - "$description": "Neutral — secondary text, subtle headings", + "$description": "Neutral: a dark grey surface that takes white text (918:2588)", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 15, + 15, + 0, + 64 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -129,19 +209,35 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 14, + 14, + 0, + 51 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, "gray-600": { "$type": "color", "$value": "#88889C", - "$description": "Neutral — disabled text, borders", + "$description": "Neutral: borders and dividers. As text, large sizes only (3.5:1 on white); as a surface it takes white text at large sizes (918:2588)", "$extensions": { "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 13, + 13, + 0, + 39 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -153,7 +249,15 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 9, + 9, + 0, + 25 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -165,7 +269,15 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 8, + 8, + 0, + 18 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -177,7 +289,15 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 6, + 6, + 0, + 9 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -189,7 +309,15 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 4, + 4, + 0, + 4 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } }, @@ -201,7 +329,15 @@ "mfb": { "source": "figma:262:2", "sourceKind": "rendered-fill", - "verifiedOn": "2026-07-14" + "verifiedOn": "2026-07-14", + "cmyk": [ + 3, + 3, + 0, + 2 + ], + "cmykSource": "figma:918:2465", + "cmykSourceKind": "prose" } } } @@ -228,10 +364,10 @@ "$description": "Brand slant, as a MAGNITUDE. The parent skew is negative and the counter-skew on child content is positive: skewX(calc(-1 * var(--sg-angle-base))) on the container, skewX(var(--sg-angle-base)) on the content inside it, or the text comes out slanted. So -13deg in a stylesheet is CORRECT, and the value to be suspicious of is any magnitude that is not 13. Stated here because a reader given only \"13deg\" cannot tell which sign the transform wants, and that ambiguity is the one this whole token set exists to remove.", "$extensions": { "mfb": { - "source": "figma:918:4196", - "sourceKind": "prose", - "verifiedOn": "2026-08-19", - "note": "Stated as English prose in the brand book, not as a transform. Also at 918:4219 and 918:3789. The only machine-readable -13deg rotation in the Figma file is a MISUSE example (918:2783, 'Don't place the logo on an angle'). No machine can verify this." + "source": "figma:918:4200", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). The base parallelogram 918:4200 runs 44.01 over 190.63 (13.00deg from vertical); the logo icon inside instance 918:2708 runs 35.002 over 151.61 (13.00deg); the photo frames 918:4140, 918:4150, 918:4156, 918:4162 and 918:4208 measure 13.01 to 13.02deg. Also stated as prose at 918:4219 (Patterns slide) and 918:3789 ('Both cuts use a 13deg angle'). Parallelograms and the logo icon measure from vertical; the book panel's cuts (918:3777) measure from horizontal. The decorative slivers drawn at 13.71deg (918:4298 and others) are book-internal drift. The only machine-readable -13deg rotation in the file is a misuse example (918:2783, 'Don't place the logo on an angle'). Until 2026-10-06 this cited 918:4196, a node inside a hidden frame, as prose." } } }, @@ -254,9 +390,10 @@ "$description": "Brand base unit. Height of the logo icon.", "$extensions": { "mfb": { - "source": "figma:918:4196", - "sourceKind": "prose", - "verifiedOn": "2026-08-19" + "source": "declared", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "Not in the Brand Book: 918:4196 (inside a hidden frame) states no pixel value. The book's minimum logo size is 120px wide and 63px tall (logo.min-width, logo.min-height); a 40px tall logo is about 76px wide, below it. The canon draws .sg-logo and .sg-cover__logo at this height. Kept as the supergraphics base unit until it is decided. Until 2026-10-06 this cited figma:918:4196 as prose." } } }, @@ -266,10 +403,10 @@ "$description": "Parallelogram base height as a multiple of the logo icon height.", "$extensions": { "mfb": { - "source": "figma:918:4196", + "source": "figma:918:4219", "sourceKind": "prose", - "verifiedOn": "2026-08-19", - "note": "Brand book: 'base height = 2x the height of the logo icon'." + "verifiedOn": "2026-10-06", + "note": "Brand book: 'base height = 2x the height of the logo icon' (the visible copy, 918:4219 on the Patterns slide 918:4212; the copy at 918:4196 sits in a hidden frame). No drawing in the book shows the 2x relation: 918:4200 is drawn at 1.00x the icon of 918:4199, and the Brand in Use slivers are 4.6x and 5.5x the logo height (918:4177 against 918:4186, 918:3820 against 918:3827). The prose stays normative. Until 2026-10-06 this cited 918:4196." } } }, @@ -375,6 +512,58 @@ "note": "All 12 highlighters in the Brand Book are one vector ('Vector 384') copied and scaled, drawn beneath the text in layer order. Corners taken from the SVG paths of 918:2883, 918:2887, 918:2893, 918:2898, 918:3518, 918:3574, 918:4475, 918:4501 and 918:4509. Variants: 918:3537 and 918:3559 put the top-left x at 0.71%; 918:4463 has y fractions 0.20 and 0.53." } } + }, + "para-edge-ratio": { + "$type": "number", + "$value": 0.572, + "$description": "Horizontal edge of the base parallelogram as a multiple of its height: the logo icon's own proportions. Pattern pieces may vary in width; the base shape does not.", + "$extensions": { + "mfb": { + "source": "figma:918:4200", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). 918:4200: edge 109.01 over height 190.63 = 0.5718; the logo icon's edge 86.70 over 151.61 = 0.5718. Pattern pieces vary (0.3415 on the rows of 918:4295), so width is free in patterns." + } + } + }, + "pattern-row-step": { + "$type": "number", + "$value": 1.2, + "$description": "Height ratio between adjacent rows of a progressive parallelogram pattern.", + "$extensions": { + "mfb": { + "source": "figma:918:4295", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rows 918:4297 164.62, 918:4371 197.54, 918:4402 237.05, 918:4348 197.54, 918:4323 164.62: exactly 1.2000 per step. Paired accent slivers are also 1.2 apart (918:4135 and 918:4136, 918:4146 and 918:4147, 918:4177 and 918:4182, 918:3820 and 918:3822). The prose (918:4222) says only 'subtle and consistent'; the purple panel 918:4228 steps 1.254. The canon's .sg-para-pattern--progressive uses 1.25 and 1.55 and does not read this token yet." + } + } + }, + "pattern-row-gap": { + "$type": "dimension", + "$value": "0px", + "$description": "Vertical gap between the rows of a parallelogram pattern: the rows touch.", + "$extensions": { + "mfb": { + "source": "figma:918:4295", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Row edges 84.62 and 84.78, 282.32 and 282.48, 519.52 and 519.11, 716.65 and 716.82 on 918:4295; 81.71 and 81.29, 163 and 163, 228.17 and 227.83 on 918:4228. The canon's .sg-para-pattern uses a 10px gap and does not read this token yet." + } + } + }, + "halftone-scale": { + "$type": "number", + "$value": 2.5, + "$description": "Canva Halftone Scale setting for halftone portraits.", + "$extensions": { + "mfb": { + "source": "figma:918:3736", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Brand Book Halftones slide 918:3730, text node 918:3736. A setting of the tool that makes the halftone, not a CSS value." + } + } } }, "fontFamily": { @@ -587,5 +776,1357 @@ } } }, - "$verification": "Colour and the two IBM Plex Sans families are checked weekly against Figma page 262:2 by verify-brand-tokens.mjs, which counts rendered solid fills. That is the ONLY machine-checkable provenance in this file. Every token now carries $extensions.mfb.sourceKind: rendered-fill (checkable), prose (stated in the brand book as English, a human transcribed it, unverifiable by machine), declared (exists only downstream; this is the list of what Figma owes), measured (read off the brand book's vector geometry, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), or unverified (provenance never established). Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." + "lineHeight": { + "h1": { + "$type": "number", + "$value": 1, + "$description": "Line height of h1, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2335", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2332 and set as the leading of the text style 918:2335, read through the Figma MCP. The website sets headings at 1.05, which is not imported." + } + } + }, + "h2": { + "$type": "number", + "$value": 1, + "$description": "Line height of h2, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2340", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2339 and set as the leading of the text style 918:2340, read through the Figma MCP. The website sets headings at 1.05, which is not imported." + } + } + }, + "h3": { + "$type": "number", + "$value": 1, + "$description": "Line height of h3, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2345", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2344 and set as the leading of the text style 918:2345, read through the Figma MCP. The website sets headings at 1.05, which is not imported." + } + } + }, + "h4": { + "$type": "number", + "$value": 1, + "$description": "Line height of h4, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2350", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2349 and set as the leading of the text style 918:2350, read through the Figma MCP. The website sets headings at 1.05, which is not imported." + } + } + }, + "h5": { + "$type": "number", + "$value": 1.1, + "$description": "Line height of h5, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2356", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2355 and set as the leading of the text style 918:2356, read through the Figma MCP." + } + } + }, + "h6": { + "$type": "number", + "$value": 1.1, + "$description": "Line height of h6, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2361", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2360 and set as the leading of the text style 918:2361, read through the Figma MCP." + } + } + }, + "body": { + "$type": "number", + "$value": 1.2, + "$description": "Line height of body, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2368", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2333 and set as the leading of the text style 918:2368, read through the Figma MCP. Type Relationships (918:2994) draws body at 1.22; the specimen states the number, so it wins. The website sets paragraphs at 1.55, which is not imported." + } + } + }, + "label": { + "$type": "number", + "$value": 1.2, + "$description": "Line height of label, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2374", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2373 and set as the leading of the text style 918:2374, read through the Figma MCP." + } + } + }, + "quote": { + "$type": "number", + "$value": 1, + "$description": "Line height of quote, as a multiple of its font size.", + "$extensions": { + "mfb": { + "source": "figma:918:2351", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Type specimen 918:2323: stated in the label 918:2334 and set as the leading of the text style 918:2351, read through the Figma MCP." + } + } + } + }, + "letterSpacing": { + "h1": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h1: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2335", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "h2": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h2: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2340", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "h3": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h3: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2345", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "h4": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h4: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2350", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "h5": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h5: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2356", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "h6": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of h6: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2361", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "body": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of body: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2368", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "label": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of label: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2374", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + }, + "quote": { + "$type": "dimension", + "$value": "0em", + "$description": "Letter spacing of quote: none.", + "$extensions": { + "mfb": { + "source": "figma:918:2351", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Every brand text style read (918:2335 to 918:2374, and the Type Relationships slide 918:2990) has letter spacing 0. The website's -0.01em heading tracking and 0.12em label tracking are not imported." + } + } + } + }, + "fontSizeFluid": { + "h1": { + "$type": "dimension", + "$value": "clamp(44px, 6vw, 70px)", + "$description": "Fluid h1 for web pages: grows with the viewport up to the Brand Book size (fontSize.h1).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:66", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h1 size and the build refuses any other; the floor and the slope are the website's. 1 uses on the site." + } + } + }, + "h2": { + "$type": "dimension", + "$value": "clamp(36px, 5vw, 58px)", + "$description": "Fluid h2 for web pages: grows with the viewport up to the Brand Book size (fontSize.h2).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:67", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h2 size and the build refuses any other; the floor and the slope are the website's. 7 uses on the site." + } + } + }, + "h3": { + "$type": "dimension", + "$value": "clamp(30px, 4vw, 48px)", + "$description": "Fluid h3 for web pages: grows with the viewport up to the Brand Book size (fontSize.h3).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:68", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h3 size and the build refuses any other; the floor and the slope are the website's. 29 uses on the site." + } + } + }, + "h4": { + "$type": "dimension", + "$value": "clamp(26px, 3.2vw, 40px)", + "$description": "Fluid h4 for web pages: grows with the viewport up to the Brand Book size (fontSize.h4).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:69", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h4 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + } + } + }, + "h5": { + "$type": "dimension", + "$value": "clamp(22px, 2.6vw, 36px)", + "$description": "Fluid h5 for web pages: grows with the viewport up to the Brand Book size (fontSize.h5).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:70", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h5 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + } + } + }, + "h6": { + "$type": "dimension", + "$value": "clamp(20px, 2.2vw, 32px)", + "$description": "Fluid h6 for web pages: grows with the viewport up to the Brand Book size (fontSize.h6).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:71", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h6 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + } + } + }, + "quote": { + "$type": "dimension", + "$value": "clamp(22px, 2.8vw, 40px)", + "$description": "Fluid quote for web pages: grows with the viewport up to the Brand Book size (fontSize.quote).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:72", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's quote size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + } + } + }, + "label": { + "$type": "dimension", + "$value": "clamp(16px, 1.6vw, 22px)", + "$description": "Fluid label for web pages: grows with the viewport up to the Brand Book size (fontSize.label).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:73", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's label size and the build refuses any other; the floor and the slope are the website's. 3 uses on the site." + } + } + } + }, + "colorRole": { + "heading-on-light": { + "$type": "color", + "$value": "{color.black}", + "$description": "Headings on white and light greys (gray-100 to gray-500).", + "$extensions": { + "mfb": { + "source": "figma:918:2335", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rendered text fills: the specimen headings 918:2335, 918:2340, 918:2345, 918:2350, 918:2356, 918:2361; Type Relationships 918:2996, 918:3023, 918:3035; posters 918:3804 and 918:3805. Not purple-300, and not gray-900." + } + } + }, + "body-on-light": { + "$type": "color", + "$value": "{color.black}", + "$description": "Body text on white and light greys.", + "$extensions": { + "mfb": { + "source": "figma:918:2368", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rendered text fills: the specimen body 918:2368; Type Relationships 918:2994, 918:3021, 918:3033; poster meta 918:3806 and 918:3807; the labels on gray-500 to gray-100 (918:2528 to 918:2576). Rule: 'dark text on light colors' (918:2592). 21:1 on white, 19:1 on gray-100." + } + } + }, + "muted-on-light": { + "$type": "color", + "$value": "{color.gray-700}", + "$description": "Secondary text on white and gray-100 (captions, meta, descriptions), never body copy.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:103", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour, defined in 33 blocks. 5.2:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." + } + } + }, + "link-on-light": { + "$type": "color", + "$value": "{color.purple-300}", + "$description": "Link text on white and light greys, underlined.", + "$extensions": { + "mfb": { + "source": "declared", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "Not in the Brand Book: this package's recommendation, which keeps to the contrast rule (918:2588). The Brand Book has no link style; the website's orange link text (src/styles/global.css:166) is 2.3:1 on white and breaks 918:2588, so it is not imported. 11.5:1 on white." + } + } + }, + "link-underline-on-light": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Underline of a link on white and light greys.", + "$extensions": { + "mfb": { + "source": "declared", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "Not in the Brand Book: this package's recommendation, which keeps to the contrast rule (918:2588). The underline is a mark, not text, so orange is allowed there." + } + } + }, + "border-on-light": { + "$type": "color", + "$value": "{color.gray-200}", + "$description": "Card and divider borders on white.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:96", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Defined in 19 blocks. A palette colour; its role comes from the website." + } + } + }, + "highlighter-on-light": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Highlighter line on white and light greys.", + "$extensions": { + "mfb": { + "source": "figma:918:2898", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rendered fill of the highlighter on white, Highlighter slide 918:2874." + } + } + }, + "logo-on-light": { + "$type": "color", + "$value": "{color.purple-300}", + "$description": "Logo on white and on greys up to gray-500.", + "$extensions": { + "mfb": { + "source": "figma:918:2976", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Logo Usage and Variations 918:2972: purple-300 logo on white (918:2976) and on gray-500 (918:2982). The black and greyscale logos (918:2672) are for limited-colour printing." + } + } + }, + "heading-on-dark": { + "$type": "color", + "$value": "{color.white}", + "$description": "Headings on purples and on gray-600 to gray-900.", + "$extensions": { + "mfb": { + "source": "figma:918:3829", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Poster heading 918:3829 (white on purple-300); 'Aa' 918:2623 to 918:2625 on the purples." + } + } + }, + "body-on-dark": { + "$type": "color", + "$value": "{color.white}", + "$description": "Body text on purples and on gray-700 to gray-900 (gray-600: large text only).", + "$extensions": { + "mfb": { + "source": "figma:918:2623", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). 918:2623 to 918:2625 white on purple-400, purple-300, purple-200; 918:2603 to 918:2606 white on gray-900 to gray-600; poster body 918:3830. 11.5:1 on purple-300, 14.9:1 on purple-400, 8.7:1 on purple-200, 3.5:1 on gray-600." + } + } + }, + "muted-on-dark": { + "$type": "color", + "$value": "{color.gray-400}", + "$description": "Secondary text on purples.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:94", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site uses white at 72% (rgba(255, 255, 255, 0.72), 35 uses), which is not a palette colour; gray-400 is the nearest palette colour on purple-300 and purple-400. 6.4:1 on purple-300, 8.3:1 on purple-400, 4.9:1 on purple-200." + } + } + }, + "link-on-dark": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Link text on purples.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:166", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's link colour, which also applies on its purple surfaces. Orange-300 is a light colour in the contrast rule (918:2588), so it is light text on a dark colour, and the book puts orange-300 marks on purple (918:2883). 5.0:1 on purple-300, 6.5:1 on purple-400, 3.8:1 on purple-200 (large text only there). The same rule on light backgrounds is not imported." + } + } + }, + "highlighter-on-dark": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Highlighter line on purples.", + "$extensions": { + "mfb": { + "source": "figma:918:2883", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rendered fill of the highlighter on purple-300, Highlighter slide 918:2874." + } + } + }, + "logo-on-dark": { + "$type": "color", + "$value": "{color.white}", + "$description": "Logo on purples and on photos.", + "$extensions": { + "mfb": { + "source": "figma:918:2974", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). 918:2974 (white on purple-300) and 918:2989 (white on a photo), Logo Usage and Variations 918:2972." + } + } + }, + "heading-on-orange": { + "$type": "color", + "$value": "{color.black}", + "$description": "Headings on orange-200, orange-300 and orange-400.", + "$extensions": { + "mfb": { + "source": "figma:918:4183", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Program card heading 918:4183 on orange-300; 918:2616 black on orange-400; Type Relationships quote 918:3009 on orange-300. 9.2:1 on orange-300; white would be 2.3:1." + } + } + }, + "body-on-orange": { + "$type": "color", + "$value": "{color.black}", + "$description": "Body text on oranges.", + "$extensions": { + "mfb": { + "source": "figma:918:4184", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Program card body 918:4184 on orange-300; label 918:3007; swatch labels 918:2430, 918:2463, 918:2464. 7.5:1 on orange-400, 11.4:1 on orange-200." + } + } + }, + "link-on-orange": { + "$type": "color", + "$value": "{color.black}", + "$description": "Link and call-to-action text on oranges.", + "$extensions": { + "mfb": { + "source": "figma:918:4185", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). The program card's 'Enroll Now' call to action and its URL (918:4185) are black on orange-300." + } + } + }, + "highlighter-on-orange": { + "$type": "color", + "$value": "{color.white}", + "$description": "Highlighter line on oranges (and on mid greys such as gray-500).", + "$extensions": { + "mfb": { + "source": "figma:918:2887", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Rendered fills: 918:2887 (white on orange-300), 918:2893 (white on a mid grey); program card 918:4180." + } + } + }, + "logo-on-orange": { + "$type": "color", + "$value": "{color.black}", + "$description": "Logo on oranges.", + "$extensions": { + "mfb": { + "source": "figma:918:2978", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). 918:2978 (black on orange) and the program card 918:4186. The book also shows a white logo on orange (918:2980); black is the one that keeps to the contrast rule (918:2588)." + } + } + } + }, + "shapeTone": { + "purple-300": { + "$type": "color", + "$value": "{color.purple-200}", + "$description": "Supergraphic shapes on a purple-300 base.", + "$extensions": { + "mfb": { + "source": "figma:918:3612", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Spotlight 918:3612 (purple-200 on purple-300); parallelogram pattern 918:4223. Shapes are one step lighter than their base." + } + } + }, + "orange-300": { + "$type": "color", + "$value": "{color.orange-200}", + "$description": "Supergraphic shapes on an orange-300 base.", + "$extensions": { + "mfb": { + "source": "figma:918:3614", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Spotlight 918:3614 (orange-200 on orange-300); the frames slide slivers 918:4146 to 918:4148." + } + } + }, + "gray-700": { + "$type": "color", + "$value": "{color.gray-600}", + "$description": "Supergraphic shapes on a gray-700 base.", + "$extensions": { + "mfb": { + "source": "figma:918:3616", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Books 918:3616 to 918:3619 (gray-600 on gray-700)." + } + } + } + }, + "logo": { + "min-width": { + "$type": "dimension", + "$value": "120px", + "$description": "Smallest logo width on screen.", + "$extensions": { + "mfb": { + "source": "figma:918:2711", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Size and Clearspace slide 918:2681, text 918:2711: 'not be used at sizes smaller than 120px (digital) or 25mm (print) in width'. The example instance 918:2684 is drawn at 120.00 x 63.03px, labelled '120px / 25mm' (918:2689)." + } + } + }, + "min-width-print": { + "$type": "dimension", + "$value": "25mm", + "$description": "Smallest logo width in print.", + "$extensions": { + "mfb": { + "source": "figma:918:2711", + "sourceKind": "prose", + "verifiedOn": "2026-10-06", + "note": "Same sentence as logo.min-width; label 918:2689." + } + } + }, + "aspect": { + "$type": "number", + "$value": 1.904, + "$description": "Logo width over height. The icon spans the full logo height, so the logo height is the icon height.", + "$extensions": { + "mfb": { + "source": "figma:918:2708", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Instance 918:2708 is 290.216 x 152.43, 918:2684 is 120 x 63.03, and the logo vector's viewBox is 290.216 x 152.482. The icon path spans 151.61 of the 152.48 height." + } + } + }, + "min-height": { + "$type": "dimension", + "$value": "63px", + "$description": "Smallest logo height on screen (logo.min-width divided by logo.aspect).", + "$extensions": { + "mfb": { + "source": "figma:918:2684", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). 918:2684 is drawn 63.03px tall at the 120px minimum width. geometry.logo-icon-h (40px) is below this." + } + } + }, + "clearspace": { + "$type": "number", + "$value": 0.5, + "$description": "Clear space on every side of the logo, as a multiple of the logo height (the book labels the logo height 2x and the clear space x).", + "$extensions": { + "mfb": { + "source": "figma:918:2681", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). The four clearspace boxes 918:2693 to 918:2696 are 76.215px square around logo 918:2708 at 152.43px tall: exactly 0.500. Labels 918:2704 '2x' and 918:2697 'x'. The prose (918:2712) gives no number." + } + } + }, + "edge-margin": { + "$type": "number", + "$value": 0.5, + "$description": "Distance from the page edges to a logo placed in a corner, as a multiple of the logo height (the clear space).", + "$extensions": { + "mfb": { + "source": "figma:918:2714", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Margin box 918:2718 is 31.28px for logo 918:2722 at 61.98px tall (0.505); logos 918:2722 to 918:2725 sit on the inner guide 918:2721; a centred variant is 918:2730; prose 918:2733. Brand in Use agrees: 918:3814 sits 17.0px in at 31.85px tall (0.53), 918:3827 18.9px and 16.0px in at 34.33px (0.55 and 0.47)." + } + } + } + }, + "spaceScale": { + "1": { + "$type": "dimension", + "$value": "4px", + "$description": "Spacing step 1: the same value as Tailwind's p-1, m-1 and gap-1.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:78", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-1, keyed here by Tailwind's step number so that --mfb-space-1 always equals p-1." + } + } + }, + "2": { + "$type": "dimension", + "$value": "8px", + "$description": "Spacing step 2: the same value as Tailwind's p-2, m-2 and gap-2.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:79", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-2, keyed here by Tailwind's step number so that --mfb-space-2 always equals p-2." + } + } + }, + "3": { + "$type": "dimension", + "$value": "12px", + "$description": "Spacing step 3: the same value as Tailwind's p-3, m-3 and gap-3.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:80", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-3, keyed here by Tailwind's step number so that --mfb-space-3 always equals p-3." + } + } + }, + "4": { + "$type": "dimension", + "$value": "16px", + "$description": "Spacing step 4: the same value as Tailwind's p-4, m-4 and gap-4.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:81", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-4, keyed here by Tailwind's step number so that --mfb-space-4 always equals p-4." + } + } + }, + "6": { + "$type": "dimension", + "$value": "24px", + "$description": "Spacing step 6: the same value as Tailwind's p-6, m-6 and gap-6.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:82", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-5, keyed here by Tailwind's step number so that --mfb-space-6 always equals p-6." + } + } + }, + "8": { + "$type": "dimension", + "$value": "32px", + "$description": "Spacing step 8: the same value as Tailwind's p-8, m-8 and gap-8.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:83", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-6, keyed here by Tailwind's step number so that --mfb-space-8 always equals p-8." + } + } + }, + "12": { + "$type": "dimension", + "$value": "48px", + "$description": "Spacing step 12: the same value as Tailwind's p-12, m-12 and gap-12.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:84", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-7, keyed here by Tailwind's step number so that --mfb-space-12 always equals p-12." + } + } + }, + "16": { + "$type": "dimension", + "$value": "64px", + "$description": "Spacing step 16: the same value as Tailwind's p-16, m-16 and gap-16.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:85", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-8, keyed here by Tailwind's step number so that --mfb-space-16 always equals p-16." + } + } + }, + "24": { + "$type": "dimension", + "$value": "96px", + "$description": "Spacing step 24: the same value as Tailwind's p-24, m-24 and gap-24.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:86", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-9, keyed here by Tailwind's step number so that --mfb-space-24 always equals p-24." + } + } + }, + "32": { + "$type": "dimension", + "$value": "128px", + "$description": "Spacing step 32: the same value as Tailwind's p-32, m-32 and gap-32.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:87", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-10, keyed here by Tailwind's step number so that --mfb-space-32 always equals p-32." + } + } + } + }, + "space": { + "heading-to-body": { + "$type": "dimension", + "$value": "40px", + "$description": "Space between a heading and the body text that follows it, for any heading level (at the 18px body size).", + "$extensions": { + "mfb": { + "source": "figma:918:2990", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Type Relationships, text box bottom to text box top: 918:2996 ends at 212, body 918:2994 starts at 252; 918:3035 ends at 232, 918:3033 starts at 272; 918:3023 ends at 140, 918:3021 starts at 180. The book's own copy columns use the same 40px gap (918:2709, 918:2731)." + } + } + }, + "quote-to-label": { + "$type": "dimension", + "$value": "48px", + "$description": "Space between a quote and its label (the attribution).", + "$extensions": { + "mfb": { + "source": "figma:918:3005", + "sourceKind": "measured", + "verifiedOn": "2026-10-06", + "note": "Read through the Figma MCP (read only). Quote 918:3009 (40px, five lines at line height 1) ends at 228; label 918:3007 starts at 276." + } + } + }, + "gutter": { + "$type": "dimension", + "$value": "clamp(20px, 4vw, 64px)", + "$description": "Horizontal page gutter inside the page container.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:97", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container-px, the padding of every .container (50 uses)." + } + } + }, + "section": { + "$type": "dimension", + "$value": "clamp(64px, 9vw, 128px)", + "$description": "Vertical padding of a page section (top and bottom).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:281", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section, used 39 times." + } + } + }, + "section-tight": { + "$type": "dimension", + "$value": "clamp(48px, 6vw, 96px)", + "$description": "Vertical padding of a compact page section.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:282", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section--tight." + } + } + }, + "header-gap": { + "$type": "dimension", + "$value": "clamp(32px, 4vw, 56px)", + "$description": "Space between a section's header (title and intro) and its content.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EventsCalendar.tsx:91", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 13 uses across the site's blocks (FaqAccordion, ImageGallery, ImpactChart, StatsBanner, TeamGrid and others)." + } + } + }, + "block-end": { + "$type": "dimension", + "$value": "clamp(32px, 4vw, 48px)", + "$description": "Space before a section's closing call to action.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:297", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 11 uses across the site's blocks (Countdown, EventsGrid, StatsBanner, TeamGrid and others)." + } + } + } + }, + "radius": { + "sm": { + "$type": "dimension", + "$value": "4px", + "$description": "Small radius: focus rings, small tags.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:90", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp." + } + } + }, + "md": { + "$type": "dimension", + "$value": "8px", + "$description": "Medium radius: inputs, small media, menus.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:91", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The Brand Book's own specimen label chips are drawn with 8px (918:2998): slide chrome, not a rule." + } + } + }, + "lg": { + "$type": "dimension", + "$value": "12px", + "$description": "Large radius: cards and media frames.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:92", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The card radius, 42 uses on the site. The Brand Book's specimen surfaces on Type Relationships (918:2993, 918:3005, 918:3020) use 12px: slide chrome, not a rule." + } + } + }, + "pill": { + "$type": "dimension", + "$value": "999px", + "$description": "Pill radius: buttons and chips. In Tailwind, use rounded-full.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:93", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp." + } + } + } + }, + "shadow": { + "media": { + "$type": "shadow", + "$value": { + "offsetX": "0px", + "offsetY": "20px", + "blur": "60px", + "spread": "-20px", + "color": "{color.purple-400}", + "opacity": 0.25 + }, + "$description": "Shadow under images and video on light backgrounds. None on dark backgrounds.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:114", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(43, 28, 88, 0.25), purple-400 at 25%. 6 exact uses (Features, VideoSplit, SupporterStories, VideoEmbed, ContactCard, Hero), always none on dark surfaces." + } + } + }, + "media-control": { + "$type": "shadow", + "$value": { + "offsetX": "0px", + "offsetY": "12px", + "blur": "32px", + "spread": "-8px", + "color": "{color.black}", + "opacity": 0.4 + }, + "$description": "Shadow of a control that sits on an image or video, such as a play button.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Hero.tsx:551", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.4), black at 40%. 6 uses (Hero, VideoSplit, VideoCarousel, VideoEmbed, ReportEmbed, VideoTile)." + } + } + }, + "overlay": { + "$type": "shadow", + "$value": { + "offsetX": "0px", + "offsetY": "40px", + "blur": "80px", + "spread": "-20px", + "color": "{color.black}", + "opacity": 0.5 + }, + "$description": "Shadow of a modal or lightbox above the page.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1134", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.5), black at 50%. Also JourneyScoreboard and TransparencyFlow." + } + } + } + }, + "easing": { + "out": { + "$type": "cubicBezier", + "$value": [ + 0.16, + 1, + 0.3, + 1 + ], + "$description": "Easing for hovers, presses, color changes and reveals.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:100", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-out, 83 uses. Not Tailwind's ease-out, which is a different curve." + } + } + }, + "in-out": { + "$type": "cubicBezier", + "$value": [ + 0.65, + 0, + 0.35, + 1 + ], + "$description": "Easing for carousel slides.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:101", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-in-out, 7 uses, all carousel tracks. Not Tailwind's ease-in-out, which is a different curve." + } + } + } + }, + "duration": { + "fast": { + "$type": "duration", + "$value": "180ms", + "$description": "Hover, press and color transitions.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:102", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-fast, 78 uses." + } + } + }, + "med": { + "$type": "duration", + "$value": "320ms", + "$description": "Small state changes, such as a carousel pager dot growing.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:103", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-med, 5 uses." + } + } + }, + "slide": { + "$type": "duration", + "$value": "520ms", + "$description": "A carousel track sliding to the next item (with easing.in-out).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:197", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses (BlogCarousel, VideoCarousel, VerticalCarousel, Testimonials, EducatorCarousel, TestimonialCards)." + } + } + }, + "reveal": { + "$type": "duration", + "$value": "700ms", + "$description": "Scroll reveal: an element fades in and rises 24px into place (with easing.out). None under reduced motion.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:533", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .reveal (global.css:530-535) on 105 elements, with a reduced-motion fallback (global.css:553-561). The site's --dur-slow (600ms, global.css:104) is never used and is not imported." + } + } + } + }, + "container": { + "page": { + "$type": "dimension", + "$value": "1280px", + "$description": "Maximum width of the page container, gutter included (content is 1152px wide at the largest gutter).", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:96", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container, 50 uses of .container." + } + } + }, + "wide": { + "$type": "dimension", + "$value": "1440px", + "$description": "Maximum width of full-width frames such as the header, the footer and wide blocks.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlockWrapper.tsx:18", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses (BlockWrapper, Content, Roadmap, Header, Footer, the page hero grid)." + } + } + }, + "measure": { + "$type": "dimension", + "$value": "720px", + "$description": "Maximum width of running text and of a centered section header.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:133", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 19 uses in the site's blocks, plus the article width (global.css:131)." + } + } + } + }, + "breakpoint": { + "phone": { + "$type": "dimension", + "$value": "600px", + "$description": "Phone layouts: max-width 600px.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:749", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses. For JavaScript and matchMedia: a CSS variable cannot be read inside @media, and the package adds no Tailwind breakpoint." + } + } + }, + "stack": { + "$type": "dimension", + "$value": "720px", + "$description": "Multi-column layouts stack into one column: max-width 720px.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:590", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's most used breakpoint: 7 media queries and 3 JavaScript thresholds. For JavaScript and matchMedia, as breakpoint.phone." + } + } + }, + "nav": { + "$type": "dimension", + "$value": "900px", + "$description": "The navigation collapses into a menu: max-width 900px.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:777", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 7 uses (Header, Footer, JourneyScoreboard, the news pages, EducatorCarousel). For JavaScript and matchMedia, as breakpoint.phone." + } + } + } + }, + "zIndex": { + "header": { + "$type": "number", + "$value": 100, + "$description": "Sticky page header.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:381", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book." + } + } + }, + "dropdown": { + "$type": "number", + "$value": 200, + "$description": "Navigation dropdowns, above the header.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:486", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book." + } + } + }, + "overlay": { + "$type": "number", + "$value": 1000, + "$description": "Modals, lightboxes and the mobile menu, above everything.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1115", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 8 uses." + } + } + } + }, + "mediaRatio": { + "video": { + "$type": "string", + "$value": "16 / 9", + "$description": "Video and wide media.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/VideoTile.astro:50", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses." + } + } + }, + "photo": { + "$type": "string", + "$value": "4 / 3", + "$description": "Landscape photos.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/ImageGallery.tsx:168", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + } + } + }, + "portrait": { + "$type": "string", + "$value": "4 / 5", + "$description": "Portrait photos of people.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EducatorCarousel.tsx:107", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + } + } + }, + "square": { + "$type": "string", + "$value": "1 / 1", + "$description": "Square images and avatars.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/TeamGrid.tsx:201", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + } + } + } + }, + "ui": { + "touch-target": { + "$type": "dimension", + "$value": "44px", + "$description": "Smallest width and height of an icon button or any other tap target.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:409", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 9 uses (ImageGallery, JourneyScoreboard, ProgramGrid, TestimonialCards, TransparencyFlow, VerticalCarousel, VideoCarousel, global.css)." + } + } + }, + "link-underline-offset": { + "$type": "dimension", + "$value": "3px", + "$description": "Distance between a link and its underline.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/pages/news/[...slug].astro:342", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Only the offset is imported: the orange-400 link text in the same rule (line 340) breaks the contrast rule (918:2588)." + } + } + } + }, + "$verification": "Colour and the IBM Plex Sans families, sizes and weights are checked against Figma page 262:2 by scripts/verify-figma.mjs, which counts rendered solid fills and text styles. That is the ONLY machine-checkable provenance in this file. Every token carries $extensions.mfb.sourceKind: rendered-fill or rendered-text (checkable), prose (stated in the Brand Book as English, a human transcribed it, unverifiable by machine), measured (read off the Brand Book's vector geometry or a node's rendered fill, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), declared (not in the Brand Book), or unverified (provenance never established). The source rule: where the Brand Book defines a value, it wins. Where it is silent, a value may be declared from the live website, which was built following the Brand Book: sourceKind declared, source myfirstbitcoin.org@:: on the site's main branch. A website value that contradicts the Brand Book is never declared. Any other declared value has source declared and says in its note why it exists. Colour roles (colorRole) and shape tones (shapeTone) are aliases of palette colours, and the build refuses one that is not. Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." } From 3c2492b51eeea5735d27b5f4fe22d0b5d47ae9ed Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 14:44:09 +0000 Subject: [PATCH 15/21] Spec: color roles, layout, radius, shadow and motion with sources; black text on light brand-spec.md gains, each value filled from tokens.json with its source (a Brand Book node, or the website file and line it was declared from): - Color roles by background, shape tones, and CMYK in the color table. - Type relationships: line height, letter spacing and the fluid size per level, weights per role, 40px between a heading and its text and 48px between a quote and its label (measured on 918:2990 and 918:3005). - Logo: minimum size, clear space, corner margin and color by background. - Layout and spacing, radius and shadow, motion, breakpoints, layers, media ratios, and the website values that are not imported because they contradict the Brand Book. Corrections: - Headings and text on light backgrounds are black (Type Relationships 918:2990), not purple-300 headings and gray-900 text; text on orange is black, buttons included. The quick reference says which supergraphics classes still set white text on orange. - Logo files: the purple logo on greys up to gray-500, the black one on orange; clear space is half the logo height, not the height of the B. - Halftone cutouts also sit on purple-200; the Canva scale is a token. - Rule 7 states the measured pattern step (1.2) and row gap (0), and that .sg-para-pattern does not follow them yet. - Slide labels: Parallelogram 918:4189, Patterns 918:4212, Frames 918:4126, Book Panel 918:3757, Spotlights and Books in Use 918:3716. Co-Authored-By: Claude Opus 5.5 --- brand-spec.md | 248 ++++++++++++++++++++++++++++++------- scripts/brand-spec.mjs | 97 ++++++++++++++- src/brand-spec.template.md | 163 +++++++++++++++++++----- 3 files changed, 428 insertions(+), 80 deletions(-) diff --git a/brand-spec.md b/brand-spec.md index 8b5ecef..d5728a4 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -12,13 +12,16 @@ the Brand Book Figma file mFIc75UUSyftaqnNUQgjLX. To regenerate: node scripts/br **The Brand Book Figma (`mFIc75UUSyftaqnNUQgjLX`) is the single source of truth for My First Bitcoin's visual rules.** This markdown is a mirror. If anything here conflicts with the Brand Book, the Brand Book wins. Before generating any brand artifact, screenshot the relevant Brand Book rule page (for example with the Figma MCP server's `get_screenshot` tool) so you are working from the visual, not from a summary. +**Where the Brand Book is silent** (spacing, radius, shadow, motion, page widths), the values below are declared from the live website, myfirstbitcoin.org, which was built following the Brand Book. Each one names the file and line it comes from, on the site's main branch at commit `6368b2a`. A website value that contradicts the Brand Book is never imported (see "Not imported from the website" in Section 1), so do not copy values from the website's code: take them from this package. + **Canonical rule pages (Brand Book node IDs):** -- Type Relationships: `918:2990` · Type Misuse: `918:2841` +- Type specimen: `918:2323` · Type Relationships: `918:2990` · Type Misuse: `918:2841` +- Color Contrast: `918:2588` · Logo Size and Clearspace: `918:2681` · Logo Placement: `918:2714` · Logo Usage and Variations: `918:2972` - Highlighter (p.42): `918:2874` - Halftones: `918:3730` · Halftones in use: `918:3790` -- Spotlights & Books: `918:3604` · Construction: `918:3632` · Frames: `918:3686` · Book Panel: `918:3716` · In use: `918:3757` -- Parallelogram: `918:4212` · Patterns: `918:4126` · Frames: `918:4189` · Misuse: `918:3958` +- Spotlights & Books: `918:3604` · Construction: `918:3632` · Frames: `918:3686` · Book Panel: `918:3757` · In use: `918:3716` +- Parallelogram: `918:4189` · Patterns: `918:4212` · Frames: `918:4126` · Misuse: `918:3958` - Cover master: `918:2798` - Brand-in-use posters: `918:3514` · Program cards: `918:3508` · Merch: `918:3502` @@ -26,25 +29,61 @@ the Brand Book Figma file mFIc75UUSyftaqnNUQgjLX. To regenerate: node scripts/br ### Colors -| Token | Hex | Usage | -|-------|-----|-------| -| purple-300 | #422C70 | Primary purple: the main brand color for surfaces and shapes (backgrounds, covers, parallelograms, spotlights). Takes white text. Not a heading color on light backgrounds: headings there are black (Type Relationships 918:2990) | -| purple-400 | #2B1C58 | Accent dark purple: deep contrast, footer backgrounds | -| purple-200 | #5E378E | Accent light purple: supergraphic shapes on purple-300 (one step lighter than the base, 918:3604), halftone cutout backgrounds (918:3730). Takes white text | -| orange-300 | #F7941F | Primary orange: CTA and shape fills with black text (Color Contrast 918:2588), the highlighter line, the Bitcoin symbol, links on dark backgrounds. Never text on light backgrounds | -| orange-400 | #EF7B00 | Accent dark orange: hover and active fills, shapes; takes black text (918:2616). Never text on light backgrounds | -| orange-200 | #FBB040 | Accent light orange: supergraphic shapes on orange-300 (one step lighter than the base, 918:3604), soft accents on dark backgrounds. Takes black text | -| black | #000000 | Primary black: headings and body text on light backgrounds and on orange (Type Relationships 918:2990, Color Contrast 918:2588) | -| white | #FFFFFF | Primary white: backgrounds, text on dark surfaces | -| gray-900 | #25252B | Neutral: the darkest grey, a dark surface that takes white text (918:2588). Text on light backgrounds is black, not gray-900 (918:2990) | -| gray-800 | #4F4F5D | Neutral: a dark grey surface that takes white text (918:2588) | -| gray-700 | #6C6C7D | Neutral: muted text, placeholders | -| gray-600 | #88889C | Neutral: borders and dividers. As text, large sizes only (3.5:1 on white); as a surface it takes white text at large sizes (918:2588) | -| gray-500 | #ADADBE | Neutral: subtle borders, dividers | -| gray-400 | #C0C0D0 | Neutral: light borders, inactive elements | -| gray-300 | #D8D8E7 | Neutral: card borders, separators | -| gray-200 | #EAEAF4 | Neutral: light backgrounds, alternating rows | -| gray-100 | #F3F3FA | Neutral: page background, subtle tint | +| Token | Hex | CMYK (print) | Usage | +|-------|-----|--------------|-------| +| purple-300 | #422C70 | 41 61 0 56 | Primary purple: the main brand color for surfaces and shapes (backgrounds, covers, parallelograms, spotlights). Takes white text. Not a heading color on light backgrounds: headings there are black (Type Relationships 918:2990) | +| purple-400 | #2B1C58 | 51 68 0 65 | Accent dark purple: deep contrast, footer backgrounds | +| purple-200 | #5E378E | 34 61 0 44 | Accent light purple: supergraphic shapes on purple-300 (one step lighter than the base, 918:3604), halftone cutout backgrounds (918:3730). Takes white text | +| orange-300 | #F7941F | 0 40 87 3 | Primary orange: CTA and shape fills with black text (Color Contrast 918:2588), the highlighter line, the Bitcoin symbol, links on dark backgrounds. Never text on light backgrounds | +| orange-400 | #EF7B00 | 0 46 100 6 | Accent dark orange: hover and active fills, shapes; takes black text (918:2616). Never text on light backgrounds | +| orange-200 | #FBB040 | 0 30 75 2 | Accent light orange: supergraphic shapes on orange-300 (one step lighter than the base, 918:3604), soft accents on dark backgrounds. Takes black text | +| black | #000000 | 0 0 0 100 | Primary black: headings and body text on light backgrounds and on orange (Type Relationships 918:2990, Color Contrast 918:2588) | +| white | #FFFFFF | 0 0 0 0 | Primary white: backgrounds, text on dark surfaces | +| gray-900 | #25252B | 14 14 0 83 | Neutral: the darkest grey, a dark surface that takes white text (918:2588). Text on light backgrounds is black, not gray-900 (918:2990) | +| gray-800 | #4F4F5D | 15 15 0 64 | Neutral: a dark grey surface that takes white text (918:2588) | +| gray-700 | #6C6C7D | 14 14 0 51 | Neutral: muted text, placeholders | +| gray-600 | #88889C | 13 13 0 39 | Neutral: borders and dividers. As text, large sizes only (3.5:1 on white); as a surface it takes white text at large sizes (918:2588) | +| gray-500 | #ADADBE | 9 9 0 25 | Neutral: subtle borders, dividers | +| gray-400 | #C0C0D0 | 8 8 0 18 | Neutral: light borders, inactive elements | +| gray-300 | #D8D8E7 | 6 6 0 9 | Neutral: card borders, separators | +| gray-200 | #EAEAF4 | 4 4 0 4 | Neutral: light backgrounds, alternating rows | +| gray-100 | #F3F3FA | 3 3 0 2 | Neutral: page background, subtle tint | + +CMYK values are the Brand Book's own (`918:2258`, `918:2386`, `918:2465`). + +### Color roles + +Which palette color to use for each element, by background. Every role is a palette color. **Light** backgrounds are white and gray-100 to gray-500; **dark** backgrounds are the purples and gray-600 to gray-900; **orange** backgrounds are orange-200, orange-300 and orange-400. The rule behind them is the Brand Book's *Color Contrast* slide (`918:2588`): "Light text should be placed on dark colors, and dark text on light colors", with dark text on every orange. + +| Role | Color | Use | Source | +|------|-------|-----|--------| +| `heading-on-light` | black (`#000000`) | Headings on white and light greys (gray-100 to gray-500). | Brand Book `918:2335`, measured | +| `body-on-light` | black (`#000000`) | Body text on white and light greys. | Brand Book `918:2368`, measured | +| `muted-on-light` | gray-700 (`#6C6C7D`) | Secondary text on white and gray-100 (captions, meta, descriptions), never body copy. | website `src/components/blocks/Features.tsx:103` | +| `link-on-light` | purple-300 (`#422C70`) | Link text on white and light greys, underlined. | this package's recommendation | +| `link-underline-on-light` | orange-300 (`#F7941F`) | Underline of a link on white and light greys. | this package's recommendation | +| `border-on-light` | gray-200 (`#EAEAF4`) | Card and divider borders on white. | website `src/components/blocks/BlogCarousel.tsx:96` | +| `highlighter-on-light` | orange-300 (`#F7941F`) | Highlighter line on white and light greys. | Brand Book `918:2898`, measured | +| `logo-on-light` | purple-300 (`#422C70`) | Logo on white and on greys up to gray-500. | Brand Book `918:2976`, measured | +| `heading-on-dark` | white (`#FFFFFF`) | Headings on purples and on gray-600 to gray-900. | Brand Book `918:3829`, measured | +| `body-on-dark` | white (`#FFFFFF`) | Body text on purples and on gray-700 to gray-900 (gray-600: large text only). | Brand Book `918:2623`, measured | +| `muted-on-dark` | gray-400 (`#C0C0D0`) | Secondary text on purples. | website `src/components/blocks/BlogCarousel.tsx:94` | +| `link-on-dark` | orange-300 (`#F7941F`) | Link text on purples. | website `src/styles/global.css:166` | +| `highlighter-on-dark` | orange-300 (`#F7941F`) | Highlighter line on purples. | Brand Book `918:2883`, measured | +| `logo-on-dark` | white (`#FFFFFF`) | Logo on purples and on photos. | Brand Book `918:2974`, measured | +| `heading-on-orange` | black (`#000000`) | Headings on orange-200, orange-300 and orange-400. | Brand Book `918:4183`, measured | +| `body-on-orange` | black (`#000000`) | Body text on oranges. | Brand Book `918:4184`, measured | +| `link-on-orange` | black (`#000000`) | Link and call-to-action text on oranges. | Brand Book `918:4185`, measured | +| `highlighter-on-orange` | white (`#FFFFFF`) | Highlighter line on oranges (and on mid greys such as gray-500). | Brand Book `918:2887`, measured | +| `logo-on-orange` | black (`#000000`) | Logo on oranges. | Brand Book `918:2978`, measured | + +In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailwind: `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light`. + +**Shape tones.** A supergraphic shape is one step lighter than the base it sits on (Brand Book `918:3604`): + +- On purple-300: purple-200 (Brand Book `918:3612`, measured) +- On orange-300: orange-200 (Brand Book `918:3614`, measured) +- On gray-700: gray-600 (Brand Book `918:3616`, measured) ### Gradients @@ -78,10 +117,119 @@ the Brand Book Figma file mFIc75UUSyftaqnNUQgjLX. To regenerate: node scripts/br | label | 22px | Labels: IBM Plex Sans Regular, line-height: 1.2 | | quote | 40px | Block quotes: IBM Plex Sans Medium, line-height: 1 | +### Type relationships + +| Level | Size | Line height | Letter spacing | On web pages (fluid) | +|-------|------|-------------|----------------|----------------------| +| h1 | 70px | 1 | 0em | `clamp(44px, 6vw, 70px)` (website `src/styles/global.css:66`) | +| h2 | 58px | 1 | 0em | `clamp(36px, 5vw, 58px)` (website `src/styles/global.css:67`) | +| h3 | 48px | 1 | 0em | `clamp(30px, 4vw, 48px)` (website `src/styles/global.css:68`) | +| h4 | 40px | 1 | 0em | `clamp(26px, 3.2vw, 40px)` (website `src/styles/global.css:69`) | +| h5 | 36px | 1.1 | 0em | `clamp(22px, 2.6vw, 36px)` (website `src/styles/global.css:70`) | +| h6 | 32px | 1.1 | 0em | `clamp(20px, 2.2vw, 32px)` (website `src/styles/global.css:71`) | +| body | 18px | 1.2 | 0em | fixed, 18px | +| label | 22px | 1.2 | 0em | `clamp(16px, 1.6vw, 22px)` (website `src/styles/global.css:73`) | +| quote | 40px | 1 | 0em | `clamp(22px, 2.8vw, 40px)` (website `src/styles/global.css:72`) | + +- **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). +- **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). +- **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size. +- **Space after a heading:** 40px between any heading and the body text under it, at the 18px body size (Brand Book `918:2990`, measured). **Between a quote and its label:** 48px (Brand Book `918:3005`, measured). + +### Logo + +- **Minimum size:** 120px wide on screens (63px tall), 25mm wide in print (Brand Book `918:2681`). The logo is 1.904 times as wide as it is tall. +- **Clear space:** 0.5 times the logo's height on every side (the book labels the logo height 2x and the clear space x, `918:2681`). A logo in a corner sits that same distance from the page edges (0.5 times its height, `918:2714`). +- **Color by background** (Logo Usage and Variations, `918:2972`): white on purples and on photos; purple-300 on white and on greys up to gray-500; black on orange (the book also shows white there; black is the one that keeps to the contrast rule). The black and greyscale logos are for limited-color printing. +- The supergraphics classes draw `.sg-logo` and `.sg-cover__logo` 40px tall, which is below the minimum size: that height is not in the Brand Book, and it is kept until it is decided. + ### CSS Variables This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colors as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colors are deliberately not color utilities. +The values in the next sections are in `brand.css` as `--mfb--` (for example `--mfb-space-section`, `--mfb-radius-lg`, `--mfb-ease-out`, `--mfb-heading-on-light`). In Tailwind (both versions) the new utility names never replace a Tailwind default: they are `mfb-` prefixed (`rounded-mfb-lg`, `shadow-mfb-media`, `ease-mfb-out`, `duration-mfb-fast`, `max-w-mfb-page`, `py-mfb-section`), type levels (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or color roles (`text-heading-on-light`). Breakpoints, layers, media ratios, logo and interface values are in `brand.css` and `index.js` only. + +### Layout and spacing + +Where the Brand Book is silent, these come from the website (Section 0). + +**Spacing scale:** 1: 4px, 2: 8px, 3: 12px, 4: 16px, 6: 24px, 8: 32px, 12: 48px, 16: 64px, 24: 96px, 32: 128px (website `src/styles/global.css:78` to `src/styles/global.css:87`). Each step N is `--mfb-space-N`, the same value as Tailwind's own `p-N`, `m-N` and `gap-N`, so use Tailwind's utilities and the package adds none. Stay on the scale: no 6px, 10px, 14px, 18px, 20px or 28px. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +| `--mfb-space-heading-to-body` | `40px` | Space between a heading and the body text that follows it, for any heading level (at the 18px body size). | Brand Book `918:2990`, measured | +| `--mfb-space-quote-to-label` | `48px` | Space between a quote and its label (the attribution). | Brand Book `918:3005`, measured | +| `--mfb-space-gutter` | `clamp(20px, 4vw, 64px)` | Horizontal page gutter inside the page container. | website `src/styles/global.css:97` | +| `--mfb-space-section` | `clamp(64px, 9vw, 128px)` | Vertical padding of a page section (top and bottom). | website `src/styles/global.css:281` | +| `--mfb-space-section-tight` | `clamp(48px, 6vw, 96px)` | Vertical padding of a compact page section. | website `src/styles/global.css:282` | +| `--mfb-space-header-gap` | `clamp(32px, 4vw, 56px)` | Space between a section's header (title and intro) and its content. | website `src/components/blocks/EventsCalendar.tsx:91` | +| `--mfb-space-block-end` | `clamp(32px, 4vw, 48px)` | Space before a section's closing call to action. | website `src/components/blocks/BlogCarousel.tsx:297` | +| `--mfb-container-page` | `1280px` | Maximum width of the page container, gutter included (content is 1152px wide at the largest gutter). | website `src/styles/global.css:96` | +| `--mfb-container-wide` | `1440px` | Maximum width of full-width frames such as the header, the footer and wide blocks. | website `src/components/blocks/BlockWrapper.tsx:18` | +| `--mfb-container-measure` | `720px` | Maximum width of running text and of a centered section header. | website `src/components/blocks/Features.tsx:133` | + +### Radius and shadow + +Radius is for interface elements (cards, buttons, inputs, images). **Brand shapes keep sharp corners**: never round a parallelogram, a spotlight, a book or the highlighter. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +| `--mfb-radius-sm` | `4px` | Small radius: focus rings, small tags. | website `src/styles/global.css:90` | +| `--mfb-radius-md` | `8px` | Medium radius: inputs, small media, menus. | website `src/styles/global.css:91` | +| `--mfb-radius-lg` | `12px` | Large radius: cards and media frames. | website `src/styles/global.css:92` | +| `--mfb-radius-pill` | `999px` | Pill radius: buttons and chips. In Tailwind, use rounded-full. | website `src/styles/global.css:93` | +| `--mfb-shadow-media` | `0px 20px 60px -20px`, purple-400 at 25% | Shadow under images and video on light backgrounds. None on dark backgrounds. | website `src/components/blocks/Features.tsx:114` | +| `--mfb-shadow-media-control` | `0px 12px 32px -8px`, black at 40% | Shadow of a control that sits on an image or video, such as a play button. | website `src/components/blocks/Hero.tsx:551` | +| `--mfb-shadow-overlay` | `0px 40px 80px -20px`, black at 50% | Shadow of a modal or lightbox above the page. | website `src/styles/global.css:1134` | + +Shadows are tinted with palette colors only, and none is used on dark backgrounds. + +### Motion + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +| `--mfb-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Easing for hovers, presses, color changes and reveals. | website `src/styles/global.css:100` | +| `--mfb-ease-in-out` | `cubic-bezier(0.65, 0, 0.35, 1)` | Easing for carousel slides. | website `src/styles/global.css:101` | +| `--mfb-duration-fast` | `180ms` | Hover, press and color transitions. | website `src/styles/global.css:102` | +| `--mfb-duration-med` | `320ms` | Small state changes, such as a carousel pager dot growing. | website `src/styles/global.css:103` | +| `--mfb-duration-slide` | `520ms` | A carousel track sliding to the next item (with easing.in-out). | website `src/components/blocks/VideoCarousel.tsx:197` | +| `--mfb-duration-reveal` | `700ms` | Scroll reveal: an element fades in and rises 24px into place (with easing.out). None under reduced motion. | website `src/styles/global.css:533` | + +Under `prefers-reduced-motion: reduce`, nothing moves: reveals show at once, marquees and carousels stop, and transitions are instant. Moving text pauses on hover. Never animate the highlighter ("Do not alter its appearance", `918:2874`). + +### Breakpoints, layers, media and interface + +Breakpoints are for JavaScript and `matchMedia` (`index.js` exports them as `breakpoint`): a CSS variable cannot be read inside `@media`, and the package adds no Tailwind breakpoint, so Tailwind's own `sm:`, `md:` and `lg:` stay as they are. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +| `--mfb-breakpoint-phone` | `600px` | Phone layouts: max-width 600px. | website `src/styles/global.css:749` | +| `--mfb-breakpoint-stack` | `720px` | Multi-column layouts stack into one column: max-width 720px. | website `src/styles/global.css:590` | +| `--mfb-breakpoint-nav` | `900px` | The navigation collapses into a menu: max-width 900px. | website `src/components/Header.astro:777` | +| `--mfb-z-header` | `100` | Sticky page header. | website `src/components/Header.astro:381` | +| `--mfb-z-dropdown` | `200` | Navigation dropdowns, above the header. | website `src/components/Header.astro:486` | +| `--mfb-z-overlay` | `1000` | Modals, lightboxes and the mobile menu, above everything. | website `src/styles/global.css:1115` | +| `--mfb-ratio-video` | `16 / 9` | Video and wide media. | website `src/components/VideoTile.astro:50` | +| `--mfb-ratio-photo` | `4 / 3` | Landscape photos. | website `src/components/blocks/ImageGallery.tsx:168` | +| `--mfb-ratio-portrait` | `4 / 5` | Portrait photos of people. | website `src/components/blocks/EducatorCarousel.tsx:107` | +| `--mfb-ratio-square` | `1 / 1` | Square images and avatars. | website `src/components/blocks/TeamGrid.tsx:201` | +| `--mfb-ui-touch-target` | `44px` | Smallest width and height of an icon button or any other tap target. | website `src/components/blocks/VideoCarousel.tsx:409` | +| `--mfb-ui-link-underline-offset` | `3px` | Distance between a link and its underline. | website `src/pages/news/[...slug].astro:342` | + +### Not imported from the website + +The website also carries values that contradict the Brand Book. They are not tokens, and they must not be copied from the website's code: + +- a 10 degree parallelogram slant, a hero cut of about 5 degrees (an 8%/92% polygon) and its mirrored and mobile versions, a 9 degree ribbon, a 45 degree corner triangle, and a logo icon at 15.5 degrees: the angle is 13°, always left to right +- a thick, animated highlighter bar: the highlighter is a thin line under 80% of one word (Rule 2), never animated +- duotone photos and a dot-texture overlay: photos are halftone (Rule 4) +- IBM Plex Mono labels, a Georgia quote mark and weight 700: IBM Plex Sans only, in Regular, Medium and SemiBold +- all-caps labels and 0.12em label tracking: no uppercase (Rule 1), no tracking +- teal, indigo and other off-palette colors, a blue-grey page text color, and translucent white text: palette colors only +- orange text and links on light backgrounds, and white text on orange buttons: dark text on light colors and on every orange (`918:2588`) +- gray-900 headings and body text: black (`918:2990`) +- heading line height 1.05 and body line height 1.55 or 1.7: the Brand Book's line heights (Type relationships above) + ## 2. Binding Rules (The 12) *Written against the Brand Book Figma and the brand design review. Every rule states why, what to do, and what not to do. When in doubt, screenshot the cited Brand Book page.* @@ -129,7 +277,8 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font **Do:** - Parallelograms, spotlights, book panels: solid fills (purple-300, purple-400, purple-200, orange-300, orange-400, orange-200, or a neutral gray) -- For depth, use Parallelogram Patterns' scale progression (Brand Book `918:4126`: "Rows can scale progressively in size to create depth") +- For depth, use Parallelogram Patterns' scale progression (Brand Book `918:4212`: "Rows can scale progressively in size to create depth") +- A shape on a colored base is one step lighter than the base (shape tones, Section 1) **Don't:** - `opacity: 0.x` on brand shape elements @@ -143,7 +292,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font **Do:** - Apply in Canva Apps → Halftone Scale 2.5 → Duotone Image Edit (the app name says "Duotone" but the output is halftone dots) -- **Cutout portraits** (subject isolated on colored bg): highlights `#F7E6FF`, shadows `#45265B`, on purple-300 or orange-300 bg +- **Cutout portraits** (subject isolated on colored bg): highlights `#F7E6FF`, shadows `#45265B`, on purple-300, purple-200 or orange-300 bg - **Full portraits** (subject fills frame): highlights `#EEEEEE`, shadows `#45265B`, on purple-300 bg - Place the halftoned image INSIDE a Spotlight Frame (angular wedge) or Parallelogram Frame, never a rectangular full-bleed @@ -153,7 +302,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 5. Shape family follows content genre -**Source:** Brand Book *Parallelogram* (`918:4212`) + *Spotlights & Books* (`918:3604`). +**Source:** Brand Book *Parallelogram* (`918:4189`) + *Spotlights & Books* (`918:3604`). - **Parallelogram = EDUCATIONAL content.** Books, curricula, diplomas, certificates, node announcements, program cards. Brand Book: "used exclusively in educational materials." - **Spotlight / Book = LIFESTYLE / EVENTS content.** Community spotlights, unconferences, team photos, documentary stills, social graphics, merch, promotional pieces. Brand Book: "for non-educational content." @@ -169,7 +318,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 6. Spotlight vs Book construction -**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3716`), misuse (`918:2941`). +**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3757`), misuse (`918:2941`). - **Spotlight** = two diagonal cuts at **different** angles → dynamic, directional. Stretches horizontally or vertically. - **Book** = two diagonal cuts at the **same** angle → stable, structured. Stacks vertically at varying heights. @@ -186,13 +335,14 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 7. Parallelogram construction -**Source:** Brand Book *Parallelogram Patterns* (`918:4126`), misuse (`918:3958`). +**Source:** Brand Book *Parallelogram* (`918:4189`), *Parallelogram Patterns* (`918:4212`), misuse (`918:3958`). **Do:** -- 13° angle (same as logo) -- Base height = 2× logo-icon height -- Horizontal or vertical rows with rhythmic offsets -- Rows can scale progressively for depth +- 13° angle (same as logo), measured from vertical +- Base height = 2× logo-icon height (stated on `918:4212`; no drawing in the book shows it) +- The base shape has the logo icon's proportions: its horizontal edge is 0.572 times its height (measured on `918:4200`). Pattern pieces may be wider or narrower +- Horizontal or vertical rows with rhythmic offsets; the rows touch (gap 0px, measured on `918:4295`) +- Rows can scale progressively for depth: each row 1.2 times the height of the one before (measured on `918:4295`). The package's `.sg-para-pattern` does not follow these two measurements yet: it separates rows by 10px and steps them by 1.25 and 1.55 - Staggered or irregular: variety is the point **Don't:** @@ -271,30 +421,34 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Contrast Pairs -| Background | Text | Accent | -|------------|------|--------| -| purple-300 / purple-400 | white | orange-300 | -| white / gray-100 | gray-900 | purple-300 headings, orange-300 CTAs | -| orange-300 | white or gray-900 | none (no secondary accent on orange) | +| Background | Headings and text | Links | Accents | +|------------|-------------------|-------|---------| +| purple-200, purple-300, purple-400, gray-600 to gray-900 | white | orange-300 | orange-300 highlighter; purple-200 shapes on purple-300; gray-400 for secondary text | +| white, gray-100 to gray-500 | black | purple-300, underlined in orange-300 | orange-300 highlighter and fills (with black text); gray-700 for secondary text | +| orange-200, orange-300, orange-400 | black | black | white highlighter; orange-200 shapes on orange-300; no other accent | + +The roles behind this table are in Section 1 (Color roles). ### Rules -- **Links on light backgrounds:** a dark brand color for the text, such as purple-300. The Brand Book's *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. An orange-300 underline is this package's recommendation, not a Brand Book rule -- **Links on dark backgrounds:** orange-300 or white -- **Text on dark backgrounds:** white -- **Text on light backgrounds:** gray-900 (`#25252B`), never pure black -- **Orange is for accents only** (CTAs, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes +- **Headings on light backgrounds:** black (`#000000`), as in the Brand Book's *Type Relationships* (`918:2990`) and its Brand in Use examples. purple-300 is the brand's surface and shape color, not a heading color on white +- **Text on light backgrounds:** black (`#000000`), body copy included (`918:2994`). gray-700 is for secondary text only (captions, meta), on white or gray-100 +- **Headings and text on dark backgrounds:** white +- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them +- **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule +- **Links on dark backgrounds:** orange-300 (or white) +- **Orange is for accents only** (CTA fills with black text, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes, and never text on a light background - **Gradient:** the brand gradient is deprecated (Section 1): do not use it in new designs. Where an existing page still uses it, keep its 135deg direction (top-left to bottom-right), and never use it on covers. ### Logo files The logo files are in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand), under `Logo & Logo Animation/logo/`, as PNG and SVG: -- On dark or purple backgrounds: `mfb_logo_white` -- On orange backgrounds: `mfb_logo_white` -- On light backgrounds: `mfb_logo_purple` -- On grey backgrounds: `mfb_logo_black` -- Minimum clear space around the logo: height of the ₿ symbol +- On dark or purple backgrounds, and on photos: `mfb_logo_white` +- On light backgrounds, and on greys up to gray-500: `mfb_logo_purple` +- On orange backgrounds: `mfb_logo_black` (the Brand Book also shows the white logo there) +- The black and greyscale logos are otherwise for limited-color printing +- Minimum size: 120px wide on screens, 25mm in print. Clear space: 0.5 times the logo's height on every side (Section 1, Logo) ### Photography @@ -304,7 +458,7 @@ Real people in real educational settings, warm natural lighting, diverse represe 1. **Screenshot the relevant Brand Book page** in `mFIc75UUSyftaqnNUQgjLX` (for example with the Figma MCP `get_screenshot` tool): the rule page node for the element you're about to create 2. **Pick the supergraphic by content type**: educational (parallelogram) vs lifestyle (spotlight/book) per Rule 5 -3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1) +3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1); black text on light and orange backgrounds, white on dark (Section 3) 4. **Highlighter = one word, a thin line at the baseline under 80% of it**: if your headline has multiple words you want to emphasize, pick one (Rule 2) 5. **No translucent fills, no duotone photos**: solid colors, halftone-inside-a-frame for portraits (Rules 3 & 4) 6. **Verify every name and number** against source systems (network records, finance records, surveys) before typing (Rule 10) diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index 70524f7..b379ab1 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -83,13 +83,106 @@ const values = { HL_OFFSET: need('geometry', 'highlighter-offset'), HL_SHAPE: need('geometry', 'highlighter-shape'), ORANGE: need('color', 'orange-300'), - GRAY900: need('color', 'gray-900'), }; +// ---- sources ---- +// Every value in the new sections is shown with where it comes from: a Brand Book node, the line +// of the live website it was declared from (where the book is silent), or this package's +// recommendation. tokens.json carries the full note; the spec shows the short form. +const SITE_RE = /^myfirstbitcoin\.org@([0-9a-f]{7,40}):(.+)$/; +const siteCommits = new Set(); +const sourceOf = (group, name) => { + const tok = (tokens[group] || {})[name]; + const m = tok?.$extensions?.mfb; + if (!m?.source) { + missing.push(`${group}.${name} ($extensions.mfb.source)`); + return ''; + } + if (m.source.startsWith('figma:')) return `Brand Book \`${m.source.slice(6)}\`${m.sourceKind === 'measured' ? ', measured' : ''}`; + const site = m.source.match(SITE_RE); + if (site) { + siteCommits.add(site[1]); + return `website \`${site[2]}\``; + } + if (m.source === 'declared') return "this package's recommendation"; + return `\`${m.source}\``; +}; +const paletteName = (v) => (typeof v === 'string' ? v.match(/^\{color\.([\w-]+)\}$/)?.[1] : undefined); +const colorOf = (where, v) => { + const name = paletteName(v); + if (!name || !tokens.color[name]) { + missing.push(`${where} (an alias of a palette color)`); + return ''; + } + return `${name} (\`${tokens.color[name].$value}\`)`; +}; +const shadowText = (where, v) => { + const color = colorOf(`${where}.color`, v?.color).replace(/ \(.*\)$/, ''); + return `\`${v?.offsetX} ${v?.offsetY} ${v?.blur} ${v?.spread}\`, ${color} at ${Math.round(Number(v?.opacity) * 100)}%`; +}; +const rows = (group, required, fmt) => groupEntries(group, required).map(([name, tok]) => fmt(name, tok)).join('\n'); + values.COLOR_ROWS = groupEntries('color', true) - .map(([name, tok]) => `| ${name} | ${tok.$value} | ${cell(tok.$description)} |`) + .map(([name, tok]) => { + const cmyk = tok.$extensions?.mfb?.cmyk; + return `| ${name} | ${tok.$value} | ${Array.isArray(cmyk) ? cmyk.join(' ') : ''} | ${cell(tok.$description)} |`; + }) .join('\n'); +values.BLACK = need('color', 'black'); +values.HALFTONE_SCALE = need('geometry', 'halftone-scale'); +values.PATTERN_STEP = need('geometry', 'pattern-row-step'); +values.PATTERN_GAP = need('geometry', 'pattern-row-gap'); +values.PARA_EDGE = need('geometry', 'para-edge-ratio'); +values.SPACE_HEADING_BODY = need('space', 'heading-to-body'); +values.SPACE_HEADING_BODY_SRC = sourceOf('space', 'heading-to-body'); +values.SPACE_QUOTE_LABEL = need('space', 'quote-to-label'); +values.SPACE_QUOTE_LABEL_SRC = sourceOf('space', 'quote-to-label'); +values.LOGO_MIN_W = need('logo', 'min-width'); +values.LOGO_MIN_W_PRINT = need('logo', 'min-width-print'); +values.LOGO_MIN_H = need('logo', 'min-height'); +values.LOGO_ASPECT = need('logo', 'aspect'); +values.LOGO_CLEAR = need('logo', 'clearspace'); +values.LOGO_MARGIN = need('logo', 'edge-margin'); +values.LOGO_ICON_H = need('geometry', 'logo-icon-h'); +for (const surface of ['light', 'dark', 'orange']) { + values[`LOGO_ON_${surface.toUpperCase()}`] = paletteName(need('colorRole', `logo-on-${surface}`)) || ''; +} + +values.TYPE_ROWS = groupEntries('fontSize', true) + .map(([level, tok]) => { + const lh = need('lineHeight', level); + const ls = need('letterSpacing', level); + const fluid = tokens.fontSizeFluid?.[level]; + return `| ${level} | ${tok.$value} | ${lh} | ${ls} | ${fluid ? `\`${fluid.$value}\` (${sourceOf('fontSizeFluid', level)})` : `fixed, ${tok.$value}`} |`; + }) + .join('\n'); + +values.COLOR_ROLE_ROWS = rows('colorRole', true, (name, tok) => + `| \`${name}\` | ${colorOf(`colorRole.${name}`, tok.$value)} | ${cell(tok.$description)} | ${sourceOf('colorRole', name)} |`); +values.SHAPE_TONE_ITEMS = rows('shapeTone', true, (name, tok) => + `- On ${name}: ${colorOf(`shapeTone.${name}`, tok.$value).replace(/ \(.*\)$/, '')} (${sourceOf('shapeTone', name)})`); + +const valueRow = (group, prefix) => (name, tok) => + `| \`${prefix}${name}\` | \`${tok.$value}\` | ${cell(tok.$description)} | ${sourceOf(group, name)} |`; +values.SPACE_SCALE_ITEMS = groupEntries('spaceScale', true).map(([k, tok]) => `${k}: ${tok.$value}`).join(', '); +const scaleSources = groupEntries('spaceScale', true).map(([k]) => sourceOf('spaceScale', k)); +values.SPACE_SCALE_SRC = scaleSources.length ? `${scaleSources[0]} to ${scaleSources.at(-1).replace(/^website /, '')}` : ''; +values.SPACE_ROWS = rows('space', true, valueRow('space', '--mfb-space-')); +values.CONTAINER_ROWS = rows('container', true, valueRow('container', '--mfb-container-')); +values.RADIUS_ROWS = rows('radius', true, valueRow('radius', '--mfb-radius-')); +values.SHADOW_ROWS = rows('shadow', true, (name, tok) => + `| \`--mfb-shadow-${name}\` | ${shadowText(`shadow.${name}`, tok.$value)} | ${cell(tok.$description)} | ${sourceOf('shadow', name)} |`); +values.EASING_ROWS = rows('easing', true, (name, tok) => + `| \`--mfb-ease-${name}\` | \`cubic-bezier(${[].concat(tok.$value).join(', ')})\` | ${cell(tok.$description)} | ${sourceOf('easing', name)} |`); +values.DURATION_ROWS = rows('duration', true, valueRow('duration', '--mfb-duration-')); +values.BREAKPOINT_ROWS = rows('breakpoint', true, valueRow('breakpoint', '--mfb-breakpoint-')); +values.Z_ROWS = rows('zIndex', true, valueRow('zIndex', '--mfb-z-')); +values.RATIO_ROWS = rows('mediaRatio', true, valueRow('mediaRatio', '--mfb-ratio-')); +values.UI_ROWS = rows('ui', true, valueRow('ui', '--mfb-ui-')); +values.SITE_COMMIT = [...siteCommits].join(', '); +if (siteCommits.size !== 1) missing.push(`website sources: expected one commit across the declared tokens, found ${siteCommits.size} (${values.SITE_COMMIT || 'none'})`); + const gradients = groupEntries('gradient', false); values.GRADIENT_ITEMS = gradients.length ? gradients diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 513005d..66d3287 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -12,13 +12,16 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec **The Brand Book Figma (`{{FIGMA_FILE}}`) is the single source of truth for My First Bitcoin's visual rules.** This markdown is a mirror. If anything here conflicts with the Brand Book, the Brand Book wins. Before generating any brand artifact, screenshot the relevant Brand Book rule page (for example with the Figma MCP server's `get_screenshot` tool) so you are working from the visual, not from a summary. +**Where the Brand Book is silent** (spacing, radius, shadow, motion, page widths), the values below are declared from the live website, myfirstbitcoin.org, which was built following the Brand Book. Each one names the file and line it comes from, on the site's main branch at commit `{{SITE_COMMIT}}`. A website value that contradicts the Brand Book is never imported (see "Not imported from the website" in Section 1), so do not copy values from the website's code: take them from this package. + **Canonical rule pages (Brand Book node IDs):** -- Type Relationships: `918:2990` · Type Misuse: `918:2841` +- Type specimen: `918:2323` · Type Relationships: `918:2990` · Type Misuse: `918:2841` +- Color Contrast: `918:2588` · Logo Size and Clearspace: `918:2681` · Logo Placement: `918:2714` · Logo Usage and Variations: `918:2972` - Highlighter (p.42): `918:2874` - Halftones: `918:3730` · Halftones in use: `918:3790` -- Spotlights & Books: `918:3604` · Construction: `918:3632` · Frames: `918:3686` · Book Panel: `918:3716` · In use: `918:3757` -- Parallelogram: `918:4212` · Patterns: `918:4126` · Frames: `918:4189` · Misuse: `918:3958` +- Spotlights & Books: `918:3604` · Construction: `918:3632` · Frames: `918:3686` · Book Panel: `918:3757` · In use: `918:3716` +- Parallelogram: `918:4189` · Patterns: `918:4212` · Frames: `918:4126` · Misuse: `918:3958` - Cover master: `918:2798` - Brand-in-use posters: `918:3514` · Program cards: `918:3508` · Merch: `918:3502` @@ -26,10 +29,26 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec ### Colors -| Token | Hex | Usage | -|-------|-----|-------| +| Token | Hex | CMYK (print) | Usage | +|-------|-----|--------------|-------| {{COLOR_ROWS}} +CMYK values are the Brand Book's own (`918:2258`, `918:2386`, `918:2465`). + +### Color roles + +Which palette color to use for each element, by background. Every role is a palette color. **Light** backgrounds are white and gray-100 to gray-500; **dark** backgrounds are the purples and gray-600 to gray-900; **orange** backgrounds are orange-200, orange-300 and orange-400. The rule behind them is the Brand Book's *Color Contrast* slide (`918:2588`): "Light text should be placed on dark colors, and dark text on light colors", with dark text on every orange. + +| Role | Color | Use | Source | +|------|-------|-----|--------| +{{COLOR_ROLE_ROWS}} + +In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailwind: `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light`. + +**Shape tones.** A supergraphic shape is one step lighter than the base it sits on (Brand Book `918:3604`): + +{{SHAPE_TONE_ITEMS}} + ### Gradients {{GRADIENT_ITEMS}} @@ -50,10 +69,86 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec |-------|------|-------| {{FONT_SIZE_ROWS}} +### Type relationships + +| Level | Size | Line height | Letter spacing | On web pages (fluid) | +|-------|------|-------------|----------------|----------------------| +{{TYPE_ROWS}} + +- **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). +- **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). +- **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size. +- **Space after a heading:** {{SPACE_HEADING_BODY}} between any heading and the body text under it, at the 18px body size ({{SPACE_HEADING_BODY_SRC}}). **Between a quote and its label:** {{SPACE_QUOTE_LABEL}} ({{SPACE_QUOTE_LABEL_SRC}}). + +### Logo + +- **Minimum size:** {{LOGO_MIN_W}} wide on screens ({{LOGO_MIN_H}} tall), {{LOGO_MIN_W_PRINT}} wide in print (Brand Book `918:2681`). The logo is {{LOGO_ASPECT}} times as wide as it is tall. +- **Clear space:** {{LOGO_CLEAR}} times the logo's height on every side (the book labels the logo height 2x and the clear space x, `918:2681`). A logo in a corner sits that same distance from the page edges ({{LOGO_MARGIN}} times its height, `918:2714`). +- **Color by background** (Logo Usage and Variations, `918:2972`): {{LOGO_ON_DARK}} on purples and on photos; {{LOGO_ON_LIGHT}} on white and on greys up to gray-500; {{LOGO_ON_ORANGE}} on orange (the book also shows white there; black is the one that keeps to the contrast rule). The black and greyscale logos are for limited-color printing. +- The supergraphics classes draw `.sg-logo` and `.sg-cover__logo` {{LOGO_ICON_H}} tall, which is below the minimum size: that height is not in the Brand Book, and it is kept until it is decided. + ### CSS Variables This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colors as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colors are deliberately not color utilities. +The values in the next sections are in `brand.css` as `--mfb--` (for example `--mfb-space-section`, `--mfb-radius-lg`, `--mfb-ease-out`, `--mfb-heading-on-light`). In Tailwind (both versions) the new utility names never replace a Tailwind default: they are `mfb-` prefixed (`rounded-mfb-lg`, `shadow-mfb-media`, `ease-mfb-out`, `duration-mfb-fast`, `max-w-mfb-page`, `py-mfb-section`), type levels (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or color roles (`text-heading-on-light`). Breakpoints, layers, media ratios, logo and interface values are in `brand.css` and `index.js` only. + +### Layout and spacing + +Where the Brand Book is silent, these come from the website (Section 0). + +**Spacing scale:** {{SPACE_SCALE_ITEMS}} ({{SPACE_SCALE_SRC}}). Each step N is `--mfb-space-N`, the same value as Tailwind's own `p-N`, `m-N` and `gap-N`, so use Tailwind's utilities and the package adds none. Stay on the scale: no 6px, 10px, 14px, 18px, 20px or 28px. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +{{SPACE_ROWS}} +{{CONTAINER_ROWS}} + +### Radius and shadow + +Radius is for interface elements (cards, buttons, inputs, images). **Brand shapes keep sharp corners**: never round a parallelogram, a spotlight, a book or the highlighter. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +{{RADIUS_ROWS}} +{{SHADOW_ROWS}} + +Shadows are tinted with palette colors only, and none is used on dark backgrounds. + +### Motion + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +{{EASING_ROWS}} +{{DURATION_ROWS}} + +Under `prefers-reduced-motion: reduce`, nothing moves: reveals show at once, marquees and carousels stop, and transitions are instant. Moving text pauses on hover. Never animate the highlighter ("Do not alter its appearance", `918:2874`). + +### Breakpoints, layers, media and interface + +Breakpoints are for JavaScript and `matchMedia` (`index.js` exports them as `breakpoint`): a CSS variable cannot be read inside `@media`, and the package adds no Tailwind breakpoint, so Tailwind's own `sm:`, `md:` and `lg:` stay as they are. + +| Variable | Value | Use | Source | +|----------|-------|-----|--------| +{{BREAKPOINT_ROWS}} +{{Z_ROWS}} +{{RATIO_ROWS}} +{{UI_ROWS}} + +### Not imported from the website + +The website also carries values that contradict the Brand Book. They are not tokens, and they must not be copied from the website's code: + +- a 10 degree parallelogram slant, a hero cut of about 5 degrees (an 8%/92% polygon) and its mirrored and mobile versions, a 9 degree ribbon, a 45 degree corner triangle, and a logo icon at 15.5 degrees: the angle is {{ANGLE}}, always left to right +- a thick, animated highlighter bar: the highlighter is a thin line under 80% of one word (Rule 2), never animated +- duotone photos and a dot-texture overlay: photos are halftone (Rule 4) +- IBM Plex Mono labels, a Georgia quote mark and weight 700: IBM Plex Sans only, in Regular, Medium and SemiBold +- all-caps labels and 0.12em label tracking: no uppercase (Rule 1), no tracking +- teal, indigo and other off-palette colors, a blue-grey page text color, and translucent white text: palette colors only +- orange text and links on light backgrounds, and white text on orange buttons: dark text on light colors and on every orange (`918:2588`) +- gray-900 headings and body text: black (`918:2990`) +- heading line height 1.05 and body line height 1.55 or 1.7: the Brand Book's line heights (Type relationships above) + ## 2. Binding Rules (The 12) *Written against the Brand Book Figma and the brand design review. Every rule states why, what to do, and what not to do. When in doubt, screenshot the cited Brand Book page.* @@ -101,7 +196,8 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font **Do:** - Parallelograms, spotlights, book panels: solid fills (purple-300, purple-400, purple-200, orange-300, orange-400, orange-200, or a neutral gray) -- For depth, use Parallelogram Patterns' scale progression (Brand Book `918:4126`: "Rows can scale progressively in size to create depth") +- For depth, use Parallelogram Patterns' scale progression (Brand Book `918:4212`: "Rows can scale progressively in size to create depth") +- A shape on a colored base is one step lighter than the base (shape tones, Section 1) **Don't:** - `opacity: 0.x` on brand shape elements @@ -114,8 +210,8 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font **Halftone ≠ duotone.** Halftone is a newspaper dot pattern (visible dots, two-tone). Duotone is a smooth two-color gradient wash. They are NOT the same effect. The brand uses halftone; duotone is a common AI mistake. **Do:** -- Apply in Canva Apps → Halftone Scale 2.5 → Duotone Image Edit (the app name says "Duotone" but the output is halftone dots) -- **Cutout portraits** (subject isolated on colored bg): highlights `{{HALFTONE_HI}}`, shadows `{{HALFTONE_LO}}`, on purple-300 or orange-300 bg +- Apply in Canva Apps → Halftone Scale {{HALFTONE_SCALE}} → Duotone Image Edit (the app name says "Duotone" but the output is halftone dots) +- **Cutout portraits** (subject isolated on colored bg): highlights `{{HALFTONE_HI}}`, shadows `{{HALFTONE_LO}}`, on purple-300, purple-200 or orange-300 bg - **Full portraits** (subject fills frame): highlights `{{HALFTONE_HI_FULL}}`, shadows `{{HALFTONE_LO}}`, on purple-300 bg - Place the halftoned image INSIDE a Spotlight Frame (angular wedge) or Parallelogram Frame, never a rectangular full-bleed @@ -125,7 +221,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 5. Shape family follows content genre -**Source:** Brand Book *Parallelogram* (`918:4212`) + *Spotlights & Books* (`918:3604`). +**Source:** Brand Book *Parallelogram* (`918:4189`) + *Spotlights & Books* (`918:3604`). - **Parallelogram = EDUCATIONAL content.** Books, curricula, diplomas, certificates, node announcements, program cards. Brand Book: "used exclusively in educational materials." - **Spotlight / Book = LIFESTYLE / EVENTS content.** Community spotlights, unconferences, team photos, documentary stills, social graphics, merch, promotional pieces. Brand Book: "for non-educational content." @@ -141,7 +237,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 6. Spotlight vs Book construction -**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3716`), misuse (`918:2941`). +**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3757`), misuse (`918:2941`). - **Spotlight** = two diagonal cuts at **different** angles → dynamic, directional. Stretches horizontally or vertically. - **Book** = two diagonal cuts at the **same** angle → stable, structured. Stacks vertically at varying heights. @@ -158,13 +254,14 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Rule 7. Parallelogram construction -**Source:** Brand Book *Parallelogram Patterns* (`918:4126`), misuse (`918:3958`). +**Source:** Brand Book *Parallelogram* (`918:4189`), *Parallelogram Patterns* (`918:4212`), misuse (`918:3958`). **Do:** -- {{ANGLE}} angle (same as logo) -- Base height = {{BASE_RATIO}}× logo-icon height -- Horizontal or vertical rows with rhythmic offsets -- Rows can scale progressively for depth +- {{ANGLE}} angle (same as logo), measured from vertical +- Base height = {{BASE_RATIO}}× logo-icon height (stated on `918:4212`; no drawing in the book shows it) +- The base shape has the logo icon's proportions: its horizontal edge is {{PARA_EDGE}} times its height (measured on `918:4200`). Pattern pieces may be wider or narrower +- Horizontal or vertical rows with rhythmic offsets; the rows touch (gap {{PATTERN_GAP}}, measured on `918:4295`) +- Rows can scale progressively for depth: each row {{PATTERN_STEP}} times the height of the one before (measured on `918:4295`). The package's `.sg-para-pattern` does not follow these two measurements yet: it separates rows by 10px and steps them by 1.25 and 1.55 - Staggered or irregular: variety is the point **Don't:** @@ -243,30 +340,34 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font ### Contrast Pairs -| Background | Text | Accent | -|------------|------|--------| -| purple-300 / purple-400 | white | orange-300 | -| white / gray-100 | gray-900 | purple-300 headings, orange-300 CTAs | -| orange-300 | white or gray-900 | none (no secondary accent on orange) | +| Background | Headings and text | Links | Accents | +|------------|-------------------|-------|---------| +| purple-200, purple-300, purple-400, gray-600 to gray-900 | white | orange-300 | orange-300 highlighter; purple-200 shapes on purple-300; gray-400 for secondary text | +| white, gray-100 to gray-500 | black | purple-300, underlined in orange-300 | orange-300 highlighter and fills (with black text); gray-700 for secondary text | +| orange-200, orange-300, orange-400 | black | black | white highlighter; orange-200 shapes on orange-300; no other accent | + +The roles behind this table are in Section 1 (Color roles). ### Rules -- **Links on light backgrounds:** a dark brand color for the text, such as purple-300. The Brand Book's *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. An orange-300 underline is this package's recommendation, not a Brand Book rule -- **Links on dark backgrounds:** orange-300 or white -- **Text on dark backgrounds:** white -- **Text on light backgrounds:** gray-900 (`{{GRAY900}}`), never pure black -- **Orange is for accents only** (CTAs, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes +- **Headings on light backgrounds:** black (`{{BLACK}}`), as in the Brand Book's *Type Relationships* (`918:2990`) and its Brand in Use examples. purple-300 is the brand's surface and shape color, not a heading color on white +- **Text on light backgrounds:** black (`{{BLACK}}`), body copy included (`918:2994`). gray-700 is for secondary text only (captions, meta), on white or gray-100 +- **Headings and text on dark backgrounds:** white +- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them +- **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule +- **Links on dark backgrounds:** orange-300 (or white) +- **Orange is for accents only** (CTA fills with black text, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes, and never text on a light background - **Gradient:** the brand gradient is deprecated (Section 1): do not use it in new designs. Where an existing page still uses it, keep its 135deg direction (top-left to bottom-right), and never use it on covers. ### Logo files The logo files are in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand), under `Logo & Logo Animation/logo/`, as PNG and SVG: -- On dark or purple backgrounds: `mfb_logo_white` -- On orange backgrounds: `mfb_logo_white` -- On light backgrounds: `mfb_logo_purple` -- On grey backgrounds: `mfb_logo_black` -- Minimum clear space around the logo: height of the ₿ symbol +- On dark or purple backgrounds, and on photos: `mfb_logo_white` +- On light backgrounds, and on greys up to gray-500: `mfb_logo_purple` +- On orange backgrounds: `mfb_logo_black` (the Brand Book also shows the white logo there) +- The black and greyscale logos are otherwise for limited-color printing +- Minimum size: {{LOGO_MIN_W}} wide on screens, {{LOGO_MIN_W_PRINT}} in print. Clear space: {{LOGO_CLEAR}} times the logo's height on every side (Section 1, Logo) ### Photography @@ -276,7 +377,7 @@ Real people in real educational settings, warm natural lighting, diverse represe 1. **Screenshot the relevant Brand Book page** in `{{FIGMA_FILE}}` (for example with the Figma MCP `get_screenshot` tool): the rule page node for the element you're about to create 2. **Pick the supergraphic by content type**: educational (parallelogram) vs lifestyle (spotlight/book) per Rule 5 -3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1) +3. **Sentence case body, Title Case headings**, never ALL-CAPS (Rule 1); black text on light and orange backgrounds, white on dark (Section 3) 4. **Highlighter = one word, a thin line at the baseline under 80% of it**: if your headline has multiple words you want to emphasize, pick one (Rule 2) 5. **No translucent fills, no duotone photos**: solid colors, halftone-inside-a-frame for portraits (Rules 3 & 4) 6. **Verify every name and number** against source systems (network records, finance records, surveys) before typing (Rule 10) From 8bc11e6d63d985be82852a0d37f16c5bfa39bdb6 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 14:44:31 +0000 Subject: [PATCH 16/21] Ship brand-check.mjs, one brand guard for every project A zero-dependency Node script in the package's files (not a package.json script), which a project runs after its build: node node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] It generalises the guards two of My First Bitcoin's sites already ran, and reads every value it compares with from the tokens.json next to it. It fails on color literals and named colors, font families other than the brand variables, skew, rotate and angle literals that are not quarter turns, hand-rolled clip-path shapes, uppercase, gradients (the deprecated brand gradient included) and smooth color filters, Tailwind color utilities outside the palette and the roles, weights above 600, translucent brand shapes, and raw values that have a token (a spacing, radius, duration, easing, container width, font size, font weight or shadow literal equal to a token value, with the token to use). In the built CSS it fails when --sg-angle-base is missing or not the token value, when a --color-* is not a palette color or a role, and on uppercase or the mono family. An allowlist (each entry with its reason) covers third-party marks; --warn reports a rule without failing, for a step-by-step adoption. scripts/brand-check.test.mjs plants one violation per rule on known lines, a clean file that must pass, an allowlist and --warn; scripts/check.mjs runs it. README documents the guard; CONTRIBUTING lists it with the published files; the workflow counts it as a published file. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/design.yml | 4 +- CONTRIBUTING.md | 26 +- README.md | 31 ++ brand-check.mjs | 535 +++++++++++++++++++++++++++++++ package.json | 3 +- scripts/brand-check.test.mjs | 168 ++++++++++ scripts/build-design-package.mjs | 26 ++ scripts/check.mjs | 12 +- 8 files changed, 795 insertions(+), 10 deletions(-) create mode 100644 brand-check.mjs create mode 100644 scripts/brand-check.test.mjs diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml index a392cee..654b2bc 100644 --- a/.github/workflows/design.yml +++ b/.github/workflows/design.yml @@ -78,7 +78,7 @@ jobs: fi echo "Version bump to $head_version: merging this pull request releases v$head_version." else - changed=$(git diff --name-only "$base" HEAD -- README.md brand-spec.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$base" HEAD -- README.md brand-spec.md brand-check.mjs brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ -n "$changed" ]; then echo "::warning::Published files change without a version bump, so merging releases nothing: $changed" else @@ -130,7 +130,7 @@ jobs: tag_sha=$(printf '%s\n' "$tag_lines" | tail -n 1 | cut -f1) if [ "$tag_sha" = "$GITHUB_SHA" ] || [ "$before_version" = "$version" ]; then echo "v$version is already tagged: nothing to release." - changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand-spec.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand-spec.md brand-check.mjs brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ "$tag_sha" != "$GITHUB_SHA" ] && [ -n "$changed" ]; then echo "::warning::Published files changed without a version bump, so they are in no release until the next one: $changed" fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 469d793..5961b36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing to @myfirstbitcoin/design This repository is the single source of the My First Bitcoin brand as code: the design tokens, -the build that turns them into the published package, the written brand specification, and -the check against the Brand Book in Figma. The Brand Book in +the build that turns them into the published package, the written brand specification, the +brand guard that projects run, and the check against the Brand Book in Figma. The Brand Book in Figma is where the brand is decided; `tokens.json` mirrors it and is edited only to match it, under the source rule below. @@ -43,13 +43,15 @@ contributions, by people and AI assistants alike. | `package.json` | Name, version, exports and the `files` list (published) | Yes: the version is how a release happens | | `src/supergraphics.canon.css` | The supergraphics canon, the origin of the published supergraphics (see below) | Yes, by pull request, with no added comment | | `src/brand-spec.template.md` | The prose of the brand specification | Yes | +| `brand-check.mjs` | The brand guard projects run after their build (published) | Yes, with its test in `scripts/brand-check.test.mjs` | | `scripts/` | Build, spec generator and checks | Yes | | `README.md`, `brand.css`, `theme.css`, `tailwind.js`, `index.js`, `supergraphics.css` | Generated package files (published) | No: rebuild them | | `brand-spec.md` | Generated brand specification (published, so consumers and their AI tools read the rules from `node_modules`) | No: regenerate it | What consumers install is exactly the list in `package.json`'s `files`, plus `package.json` -itself: `README.md`, `brand-spec.md`, `brand.css`, `index.js`, `supergraphics.css`, -`tailwind.js`, `theme.css` and `tokens.json`. Nothing else in this repository reaches them. +itself: `README.md`, `brand-spec.md`, `brand-check.mjs`, `brand.css`, `index.js`, +`supergraphics.css`, `tailwind.js`, `theme.css` and `tokens.json`. Nothing else in this +repository reaches them. `brand-spec.md` is there so that a consumer, and the AI tools working in its repository, read the brand rules at `node_modules/@myfirstbitcoin/design/brand-spec.md`, at the version they pin. @@ -82,8 +84,10 @@ emitted. New Tailwind keys are `mfb-` prefixed, type levels or color roles ## Checks on every pull request - **rebuild-check** (`scripts/check.mjs`): rebuilds into a temporary directory and fails if any - committed output differs from a fresh build, if the build or the spec generator refuses, or if - `package.json` gains scripts or dependencies. It also runs on every push to `master`. + committed output differs from a fresh build, if the build or the spec generator refuses, if + `package.json` gains scripts or dependencies, or if `brand-check.mjs` misses a planted + violation or reports a clean line (`scripts/brand-check.test.mjs`). It also runs on every push + to `master`. - **version-check**: if the pull request changes the version in `package.json`, the new version must be above the newest `v*` tag and must not already be tagged. If published files change without a version bump, it warns: merging would release nothing. @@ -125,6 +129,16 @@ It is not run in GitHub Actions, and no Figma token is stored in this repository check runs it outside GitHub. `--status FILE` writes the full result as JSON for that job, and `--summary` writes a short Markdown summary (to `$GITHUB_STEP_SUMMARY` when that is set). +## The brand guard + +`brand-check.mjs` is published, and projects run it after their build +(`node node_modules/@myfirstbitcoin/design/brand-check.mjs`; the README says how). It needs +Node.js and nothing else, and reads every value it compares with from the `tokens.json` next to +it, so a token change reaches the guard without editing it. It generalises the guards that two +of My First Bitcoin's sites already ran. When you change a rule, add a planted line for it to +`scripts/brand-check.test.mjs` (and a clean line for anything it must not report); +`scripts/check.mjs` runs that test. + ## The supergraphics canon `src/supergraphics.canon.css` is the origin of the supergraphics primitives. It began as a diff --git a/README.md b/README.md index 75e4c1d..388d8b5 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,37 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile - **Tailwind 4 emits a theme variable only when something uses it.** To read `var(--mfb-radius-md)` or another new variable in your own CSS, import `brand.css` as well, or use the utility. - **index.js** exports `colorRoles`, `shapeTones`, `fontSizeFluid`, `lineHeight`, `letterSpacing`, `logo`, `spacing`, `space`, `radius`, `shadow`, `easing`, `duration`, `container`, `breakpoint`, `zIndex`, `mediaRatio`, `ui` next to the existing `colors`, `fontFamily`, `fontSize` and `fontWeight`. +## Brand check + +`brand-check.mjs` ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's `tokens.json`, so it follows the version you pin. + +```sh +node node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json] +``` + +Run it in your build, for example `"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"`. It exits 1 when the source (`--src`, default `src`, comments ignored) has: + +- a color literal (hex, `rgb()`, `rgba()`, `hsl()` and the other color functions), or a named color in a color property +- a font family other than `var(--mfb-font-heading)`, `var(--mfb-font-body)` or `var(--mfb-font-sans)`, a family name, or the deprecated mono family +- a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the 13° slant comes only from the supergraphics classes +- a hand-rolled `clip-path` shape, or a typed `--sg-aspect` +- `text-transform: uppercase`, the `uppercase` class or small caps +- a gradient (the deprecated brand gradient and `.sg-photo-zone` included), or a smooth color filter (Rule 4: halftone, never duotone) +- a Tailwind color utility outside the palette, such as `bg-teal-500` or Tailwind's own `text-purple-500` +- a weight above 600, or opacity on a brand shape +- a raw value that has a token: a spacing, radius, duration, easing, container width, font size or font weight literal equal to a token value (`padding: 24px`, `border-radius: 12px`, `180ms`), with the token to use instead + +and when the built CSS (`--dist`, default `dist`) lacks `--sg-angle-base` with the token value (import `brand.css` or `supergraphics.css`), defines a `--color-*` that is not a palette color or a color role, or sets uppercase or the mono family. `--source-only` skips the built CSS. + +`--allow FILE` (default `brand-check.allow.json` when it exists) lists the exceptions, each with its reason, for example a third-party mark: + +```json +[{ "file": "src/assets/partner-logo.svg", "reason": "the partner's own logo colors" }, + { "file": "src/components/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "the network's own badge color" }] +``` + +An entry without `rule` covers every rule in that file, and one without `match` every finding of the rule. `--warn RULE[,RULE]` reports a rule without failing, for a project that adopts the check step by step (`--warn raw-value`). `--help` lists the rule ids. + ## Variables Plain CSS variables are namespaced `--mfb-*` (brand values) and `--sg-*` (geometry) to avoid collisions. `brand.css` has all of them; `supergraphics.css` repeats what its classes need. diff --git a/brand-check.mjs b/brand-check.mjs new file mode 100644 index 0000000..e17c236 --- /dev/null +++ b/brand-check.mjs @@ -0,0 +1,535 @@ +#!/usr/bin/env node +// brand-check.mjs: the shared brand guard of @myfirstbitcoin/design. +// +// A project runs it after its build, with Node.js 18 or later and nothing else: +// +// node node_modules/@myfirstbitcoin/design/brand-check.mjs [options] +// +// --src DIR source to read (repeatable, or comma-separated; default: src) +// --dist DIR build output whose CSS is read (repeatable; default: dist) +// --source-only skip the build output +// --allow FILE allowlist (default: brand-check.allow.json, when it exists) +// --warn RULES report these rules (comma-separated ids) without failing +// --root DIR project root that the paths above are relative to (default: the current directory) +// --help print the rules and exit +// +// Every brand value comes from one origin, this package. The check reads every value it compares +// with (the palette, the color roles, the angle, the spacing scale and the other tokens) from the +// tokens.json next to this file, so it follows the version a project pins. It fails (exit 1) when +// the source, comments ignored, carries a brand value with another origin, or when the built CSS +// lacks the package's geometry. Exit 2 means it could not run (a bad option or allowlist). +// +// The allowlist is a JSON array of exceptions, each with its reason: +// [{ "file": "src/assets/partner.svg", "reason": "the partner's own logo colors" }, +// { "file": "src/components/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "..." }] +// "file" is a path relative to the root (a path ending in "/" covers a directory); "rule" and +// "match" (text the finding contains) narrow the entry. Unused entries are reported, not fatal. + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const tokens = JSON.parse(readFileSync(join(HERE, 'tokens.json'), 'utf8')); + +const RULES = { + 'color-literal': 'a hex color or a color function (rgb, rgba, hsl, hwb, lab, lch, oklab, oklch, color)', + 'named-color': 'a named CSS color (white, teal...) in a color property or an SVG color attribute', + 'off-palette-utility': 'a Tailwind color utility outside the palette and the color roles (bg-teal-500, text-purple-500)', + gradient: 'a gradient (the Brand Book has none; an alpha-only mask is allowed)', + 'deprecated-gradient': 'the deprecated brand gradient (--mfb-gradient-brand, --mfb-gradient, .sg-bg-gradient, bg-brand-gradient) or .sg-photo-zone', + 'color-filter': 'a smooth color filter (grayscale, sepia, hue-rotate, saturate): photos are halftone, never duotone', + 'font-family': 'a font family other than var(--mfb-font-heading|body|sans), a family name, or the mono or serif family', + uppercase: 'text-transform: uppercase, the uppercase class, or small caps', + weight: 'a font weight above 600', + angle: 'a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the slant comes from the supergraphics classes', + 'clip-path': 'a hand-rolled clip-path shape (shapes come from the supergraphics classes)', + 'translucent-shape': 'opacity or a translucent fill on a brand shape (sg-* or the highlighter)', + 'raw-value': 'a spacing, radius, duration, easing, container, font size, font weight or shadow literal equal to a token value', + 'built-angle': 'the built CSS lacks --sg-angle-base with the token value (import brand.css or supergraphics.css)', + 'built-color': 'the built CSS defines a --color-* that is not a palette color or a color role, or not its value', + 'built-uppercase': 'the built CSS sets uppercase or small caps', + 'built-font': 'the built CSS uses the mono or serif family', + 'built-missing': 'no built CSS was found (build first, or pass --source-only)', +}; + +// ---- options ---- +const args = process.argv.slice(2); +const opt = { src: [], dist: [], allow: null, warn: new Set(), root: process.cwd(), sourceOnly: false }; +const usage = (msg) => { + if (msg) console.error(`brand-check: ${msg}`); + console.error('usage: brand-check.mjs [--src DIR] [--dist DIR] [--source-only] [--allow FILE] [--warn RULES] [--root DIR] [--help]'); + process.exit(2); +}; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = () => (i + 1 < args.length ? args[++i] : usage(`${a} needs a value`)); + const list = (v) => v.split(',').map((s) => s.trim()).filter(Boolean); + if (a === '--help' || a === '-h') { + console.log('brand-check: rules (ids for --warn and the allowlist)'); + for (const [id, why] of Object.entries(RULES)) console.log(` ${id.padEnd(20)} ${why}`); + process.exit(0); + } else if (a === '--src') opt.src.push(...list(next())); + else if (a === '--dist') opt.dist.push(...list(next())); + else if (a === '--allow') opt.allow = next(); + else if (a === '--warn') for (const r of list(next())) { if (!RULES[r]) usage(`--warn: unknown rule "${r}"`); opt.warn.add(r); } + else if (a === '--root') opt.root = resolve(next()); + else if (a === '--source-only') opt.sourceOnly = true; + else usage(`unknown option ${a}`); +} +if (!opt.src.length) opt.src.push('src'); +if (!opt.dist.length) opt.dist.push('dist'); +const ROOT = opt.root; + +// ---- allowlist ---- +let allowlist = []; +const allowPath = opt.allow ? resolve(ROOT, opt.allow) : join(ROOT, 'brand-check.allow.json'); +if (opt.allow && !existsSync(allowPath)) usage(`--allow ${opt.allow}: not found`); +if (existsSync(allowPath)) { + try { + allowlist = JSON.parse(readFileSync(allowPath, 'utf8')); + } catch (e) { + usage(`${relative(ROOT, allowPath)}: not valid JSON (${e.message})`); + } + if (!Array.isArray(allowlist)) usage(`${relative(ROOT, allowPath)}: must be a JSON array`); + allowlist.forEach((e, n) => { + if (!e || typeof e.file !== 'string' || !e.file) usage(`${relative(ROOT, allowPath)} entry ${n + 1}: needs "file"`); + if (typeof e.reason !== 'string' || !e.reason.trim()) usage(`${relative(ROOT, allowPath)} entry ${n + 1}: needs a "reason"`); + if (e.rule !== undefined && !RULES[e.rule]) usage(`${relative(ROOT, allowPath)} entry ${n + 1}: unknown rule "${e.rule}"`); + }); +} +const used = new Set(); +const allowed = (f) => + allowlist.some((e, n) => { + const fileOk = e.file === f.file || (e.file.endsWith('/') && f.file.startsWith(e.file)); + const ruleOk = e.rule === undefined || e.rule === f.rule; + const matchOk = e.match === undefined || String(f.match).toLowerCase().includes(String(e.match).toLowerCase()); + if (fileOk && ruleOk && matchOk) used.add(n); + return fileOk && ruleOk && matchOk; + }); + +// ---- values from tokens.json ---- +const palette = tokens.color; +const roles = tokens.colorRole || {}; +const resolveAlias = (v) => palette[String(v).match(/^\{color\.([\w-]+)\}$/)?.[1]]?.$value; +const familySteps = new Map(); // purple -> {200, 300, 400}; black -> {''} +for (const key of Object.keys(palette)) { + const m = key.match(/^([a-z]+)(?:-(\d+))?$/); + if (!m) continue; + if (!familySteps.has(m[1])) familySteps.set(m[1], new Set()); + familySteps.get(m[1]).add(m[2] ?? ''); +} +const ANGLE = tokens.geometry['angle-base'].$value; + +const px = (v) => { + const m = String(v).trim().match(/^(-?\d*\.?\d+)(px|rem)?$/); + if (!m) return null; + return Math.abs(Number(m[1]) * (m[2] === 'rem' ? 16 : 1)); +}; +const ms = (v) => { + const m = String(v).trim().match(/^(\d*\.?\d+)(ms|s)$/); + return m ? Number(m[1]) * (m[2] === 's' ? 1000 : 1) : null; +}; +const norm = (v) => String(v).toLowerCase().replace(/\s+/g, ' ').replace(/\s*([(),/])\s*/g, '$1').trim(); +const entries = (group) => Object.entries(tokens[group] || {}); +const byPx = (group, hint) => { + const m = new Map(); + for (const [k, t] of entries(group)) { + const n = px(t.$value); + if (n !== null && !m.has(n)) m.set(n, hint(k)); + } + return m; +}; +const byText = (group, hint, map = new Map()) => { + for (const [k, t] of entries(group)) if (!/^-?\d*\.?\d+(px|rem)?$/.test(String(t.$value))) map.set(norm(t.$value), hint(k)); + return map; +}; +// A px literal is compared with the spacing scale only: a semantic spacing token in px (such as +// the 40px between a heading and its text) names a role, and any other 40px is not that role. +const SPACE_PX = byPx('spaceScale', (k) => `var(--mfb-space-${k}) or Tailwind's p-${k}, m-${k}, gap-${k}`); +const SPACE_TEXT = byText('space', (k) => `var(--mfb-space-${k}) or *-mfb-${k}`); +const RADIUS_PX = byPx('radius', (k) => `var(--mfb-radius-${k})${k === 'pill' ? ' or rounded-full' : ` or rounded-mfb-${k}`}`); +const CONTAINER_PX = byPx('container', (k) => `var(--mfb-container-${k}) or max-w-mfb-${k}`); +const SIZE_PX = byPx('fontSize', (k) => `var(--mfb-size-${k}) or text-${k}`); +const SIZE_TEXT = byText('fontSizeFluid', (k) => `var(--mfb-size-${k}-fluid) or text-${k}-fluid`); +const DURATION_MS = new Map(entries('duration').map(([k, t]) => [ms(t.$value), `var(--mfb-duration-${k}) or duration-mfb-${k}`])); +const EASING = new Map(entries('easing').map(([k, t]) => [norm(`cubic-bezier(${[].concat(t.$value).join(',')})`), `var(--mfb-ease-${k}) or ease-mfb-${k}`])); +const WEIGHT = new Map(entries('fontWeight').map(([k, t]) => [String(t.$value), `var(--mfb-weight-${k}) or font-${k}`])); +const SHADOW = new Map(); +for (const [k, t] of entries('shadow')) { + const v = t.$value; + const name = String(v.color).match(/^\{color\.([\w-]+)\}$/)?.[1]; + const pct = `${Math.round(Number(v.opacity) * 100)}%`; + const z = (s) => (String(s) === '0px' ? '0' : String(s)); + for (const color of [`var(--mfb-${name})`, palette[name]?.$value]) { + SHADOW.set(norm(`${z(v.offsetX)} ${z(v.offsetY)} ${z(v.blur)} ${z(v.spread)} color-mix(in srgb, ${color} ${pct}, transparent)`), `var(--mfb-shadow-${k}) or shadow-mfb-${k}`); + } +} + +const TW_COLOR_FAMILIES = new Set(('red orange amber yellow lime green emerald teal cyan sky blue indigo violet purple fuchsia pink ' + + 'rose slate gray zinc neutral stone mauve olive mist taupe black white').split(' ')); +const NAMED_COLORS = new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown ' + + 'burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray ' + + 'darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue ' + + 'darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite ' + + 'forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory ' + + 'khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen ' + + 'lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime ' + + 'limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue ' + + 'mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive ' + + 'olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum ' + + 'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue ' + + 'slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow ' + + 'yellowgreen').split(' ')); +const COLOR_PROP = + '(?:color|background(?:-?color)?|border(?:-?(?:top|right|bottom|left|block|inline)(?:-?(?:start|end))?)?(?:-?color)?|' + + 'outline(?:-?color)?|fill|stroke|stop-?color|flood-?color|lighting-?color|text-?decoration(?:-?color)?|caret-?color|' + + 'accent-?color|column-?rule(?:-?color)?|box-?shadow|text-?shadow|scrollbar-?color)'; + +// ---- files ---- +const SOURCE_EXT = new Set(['.astro', '.css', '.scss', '.sass', '.less', '.pcss', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.html', '.svg', '.vue', '.svelte']); +const CSS_EXT = new Set(['.css', '.scss', '.sass', '.less', '.pcss']); +const SKIP_DIRS = new Set(['node_modules', '.git', '.astro', '.svelte-kit', '.next']); +const distAbs = opt.dist.map((d) => resolve(ROOT, d)); +const rel = (abs) => relative(ROOT, abs).split(sep).join('/'); +function walk(abs, out, exts) { + if (!existsSync(abs)) return out; + const st = statSync(abs); + if (st.isDirectory()) { + if (SKIP_DIRS.has(abs.split(sep).pop())) return out; + for (const n of readdirSync(abs).sort()) walk(join(abs, n), out, exts); + } else if (exts.has(extname(abs)) && !/(^|[\\/])brand-check\.m?js$/.test(abs)) out.push(abs); + return out; +} + +// ---- findings ---- +const findings = []; +let lineStarts = []; +const lineOf = (i) => { + let lo = 0, hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= i) lo = mid; else hi = mid - 1; + } + return lo + 1; +}; +const report = (file, index, rule, why, match) => + findings.push({ file, line: index === null ? null : lineOf(index), rule, why, match: String(match).replace(/\s+/g, ' ').trim().slice(0, 100) }); +const each = (re, text, fn) => { for (const m of text.matchAll(re)) fn(m); }; +const quarterTurn = (value, unit) => Math.abs(({ deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }[unit] * Number(value)) % 90) < 1e-9; + +/** Blank out comments, keeping every newline so line numbers stay true. */ +function stripComments(text, ext) { + const blank = (m) => m.replace(/[^\n]/g, ' '); + let out = text.replace(/\/\*[\s\S]*?\*\//g, blank).replace(//g, blank); + if (!CSS_EXT.has(ext) && ext !== '.html' && ext !== '.svg') { + out = out.replace(/(^|[^:"'`\\])(\/\/[^\n]*)/g, (m, pre, c) => pre + blank(c)); + } + return out; +} + +/** + * The value after a "prop:" at index i: a quoted string, or the text up to ; } or the end of the + * line (in script code also up to a comma, which ends an object property). An expression such + * as a variable or a ternary reads as words that match no token, so it is never reported. + */ +function readValue(t, i, script) { + while (t[i] === ' ' || t[i] === '\t') i++; + const q = t[i]; + if (q === '"' || q === "'" || q === '`') { + const end = t.indexOf(q, i + 1); + if (end === -1) return null; + const v = t.slice(i + 1, end); + return v.includes('${') ? null : v; + } + let depth = 0; + let j = i; + for (; j < t.length; j++) { + const c = t[j]; + if (c === '(') depth++; + else if (c === ')') depth--; + else if (depth <= 0 && (';}\n"\'`{'.includes(c) || (script && c === ','))) break; + } + return t.slice(i, j).trim().replace(/\s*!important$/, ''); +} +/** Split a value into its top-level parts (spaces outside parentheses). */ +function parts(v) { + const out = []; + let depth = 0, cur = ''; + for (const c of v) { + if (c === '(') depth++; + if (c === ')') depth--; + if (/\s/.test(c) && depth === 0) { if (cur) out.push(cur); cur = ''; } else cur += c; + } + if (cur) out.push(cur); + return out; +} + +/** Ranges of +`, + 'dist/_astro/index.css': `:root{--mfb-purple-400:#2B1C58;--sg-angle-base:13deg}.x{color:var(--color-purple-300)}:root{--color-purple-300:#422c70;--color-heading-on-light:#000;--color-teal-500:oklch(70% .1 180)}.y{text-transform:uppercase}.font-mono{font-family:monospace}`, +}; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); +const failures = []; +const run = (args, cwd = tmp) => spawnSync(process.execPath, [CHECK, ...args], { cwd, encoding: 'utf8' }); +const parse = (out) => + out.split('\n').map((l) => l.match(/^brand-check: (?:warning: )?([^:\s][^:]*?)(?::(\d+))?: \[([\w-]+)\]/)).filter(Boolean) + .map((m) => ({ file: m[1], line: m[2] ? Number(m[2]) : null, rule: m[3] })); + +try { + const expected = []; + for (const [file, text] of Object.entries(PLANTED)) { + const lines = text.split('\n'); + const clean = lines.map((l, i) => { + const m = l.match(/\s+@([\w-]+)$/); + if (m) expected.push({ file, line: i + 1, rule: m[1] }); + return m ? l.slice(0, m.index) : l; + }); + fs.mkdirSync(path.join(tmp, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(tmp, file), clean.join('\n')); + } + + // 1. Every planted violation is reported on its line, and nothing in the clean file is. + const r = run([]); + const got = parse(r.stderr + r.stdout); + if (r.status !== 1) failures.push(`planted tree: exit ${r.status}, expected 1`); + for (const e of expected) { + if (!got.some((g) => g.file === e.file && g.line === e.line && g.rule === e.rule)) failures.push(`missed: ${e.file}:${e.line} [${e.rule}]`); + } + for (const g of got.filter((g) => g.file === 'src/components/Clean.astro')) failures.push(`false positive: ${g.file}:${g.line} [${g.rule}]`); + for (const g of got.filter((g) => g.line !== null && g.file !== 'src/components/Clean.astro')) { + if (!expected.some((e) => e.file === g.file && e.line === g.line)) failures.push(`unexpected: ${g.file}:${g.line} [${g.rule}]`); + } + // Built CSS: the off-palette --color-teal-500, uppercase and the mono class; the angle is there. + for (const rule of ['built-color', 'built-uppercase', 'built-font']) { + if (!got.some((g) => g.rule === rule)) failures.push(`missed in the built CSS: [${rule}]`); + } + if (got.some((g) => g.rule === 'built-angle')) failures.push('built CSS has --sg-angle-base: 13deg, yet built-angle was reported'); + if (got.filter((g) => g.rule === 'built-color').length !== 1) failures.push('built-color should report only --color-teal-500'); + + // 2. A missing angle is reported. + fs.writeFileSync(path.join(tmp, 'dist/_astro/index.css'), ':root{--sg-angle-base:10deg}'); + if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-angle')) failures.push('missed: built CSS with a 10deg angle [built-angle]'); + fs.writeFileSync(path.join(tmp, 'dist/_astro/index.css'), ':root{--sg-angle-base:13deg}'); + + // 3. The clean file alone passes. + const clean = run(['--src', 'src/components/Clean.astro']); + if (clean.status !== 0) failures.push(`clean file: exit ${clean.status}, expected 0: ${clean.stderr.trim()}`); + + // 4. An allowlist entry silences its findings; an unused entry is reported; a reason is required. + fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([ + { file: 'src/components/Card.tsx', reason: 'test: a third-party component' }, + { file: 'src/styles/global.css', rule: 'color-literal', match: '422C70', reason: 'test' }, + { file: 'src/none.css', reason: 'test: unused' }, + ])); + const a = run([]); + const ga = parse(a.stderr); + if (ga.some((g) => g.file === 'src/components/Card.tsx')) failures.push('allowlist: a whole-file entry did not silence the file'); + if (ga.some((g) => g.file === 'src/styles/global.css' && g.line === 3)) failures.push('allowlist: a rule and match entry did not silence its finding'); + if (!ga.some((g) => g.file === 'src/styles/global.css' && g.line === 4)) failures.push('allowlist: a rule and match entry silenced another finding'); + if (!/allowlist entry 3 .*matched nothing/.test(a.stderr)) failures.push('allowlist: an unused entry was not reported'); + fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([{ file: 'src/x.css' }])); + if (run([]).status !== 2) failures.push('allowlist: an entry without a reason was accepted'); + fs.rmSync(path.join(tmp, 'brand-check.allow.json')); + + // 5. --warn reports a rule without failing. + const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color']); + if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); + + // 6. No built CSS, and --source-only. + fs.rmSync(path.join(tmp, 'dist'), { recursive: true }); + if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-missing')) failures.push('missed: no built CSS [built-missing]'); + if (run(['--src', 'src/components/Clean.astro', '--source-only']).status !== 0) failures.push('--source-only still read the build'); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} + +if (failures.length) { + console.error('brand-check test: FAIL'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log(`brand-check test: OK (${Object.keys(PLANTED).length} planted files, every rule, the allowlist, --warn, --source-only)`); diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index ef7aa52..dc53bb9 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -628,6 +628,32 @@ const readme = [ `- **Tailwind 4 emits a theme variable only when something uses it.** To read ${code('var(--mfb-radius-md)')} or another new variable in your own CSS, import ${code('brand.css')} as well, or use the utility.`, `- **index.js** exports ${INDEX_EXPORTS.map(([name]) => code(name)).join(', ')} next to the existing ${code('colors')}, ${code('fontFamily')}, ${code('fontSize')} and ${code('fontWeight')}.`, ``, + `## Brand check`, + ``, + `${code('brand-check.mjs')} ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's ${code('tokens.json')}, so it follows the version you pin.`, + ``, + `${FENCE}sh\nnode node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json]\n${FENCE}`, + ``, + `Run it in your build, for example ${code('"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"')}. It exits 1 when the source (${code('--src')}, default ${code('src')}, comments ignored) has:`, + ``, + `- a color literal (hex, ${code('rgb()')}, ${code('rgba()')}, ${code('hsl()')} and the other color functions), or a named color in a color property`, + `- a font family other than ${code('var(--mfb-font-heading)')}, ${code('var(--mfb-font-body)')} or ${code('var(--mfb-font-sans)')}, a family name, or the deprecated mono family`, + `- a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the ${ANGLE} slant comes only from the supergraphics classes`, + `- a hand-rolled ${code('clip-path')} shape, or a typed ${code('--sg-aspect')}`, + `- ${code('text-transform: uppercase')}, the ${code('uppercase')} class or small caps`, + `- a gradient (the deprecated brand gradient and ${code('.sg-photo-zone')} included), or a smooth color filter (Rule 4: halftone, never duotone)`, + `- a Tailwind color utility outside the palette, such as ${code('bg-teal-500')} or Tailwind's own ${code('text-purple-500')}`, + `- a weight above 600, or opacity on a brand shape`, + `- a raw value that has a token: a spacing, radius, duration, easing, container width, font size or font weight literal equal to a token value (${code('padding: 24px')}, ${code('border-radius: 12px')}, ${code('180ms')}), with the token to use instead`, + ``, + `and when the built CSS (${code('--dist')}, default ${code('dist')}) lacks ${code('--sg-angle-base')} with the token value (import ${code('brand.css')} or ${code('supergraphics.css')}), defines a ${code('--color-*')} that is not a palette color or a color role, or sets uppercase or the mono family. ${code('--source-only')} skips the built CSS.`, + ``, + `${code('--allow FILE')} (default ${code('brand-check.allow.json')} when it exists) lists the exceptions, each with its reason, for example a third-party mark:`, + ``, + `${FENCE}json\n[{ "file": "src/assets/partner-logo.svg", "reason": "the partner's own logo colors" },\n { "file": "src/components/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "the network's own badge color" }]\n${FENCE}`, + ``, + `An entry without ${code('rule')} covers every rule in that file, and one without ${code('match')} every finding of the rule. ${code('--warn RULE[,RULE]')} reports a rule without failing, for a project that adopts the check step by step (${code('--warn raw-value')}). ${code('--help')} lists the rule ids.`, + ``, `## Variables`, ``, `Plain CSS variables are namespaced ${code('--mfb-*')} (brand values) and ${code('--sg-*')} (geometry) to avoid collisions. ${code('brand.css')} has all of them; ${code('supergraphics.css')} repeats what its classes need.`, diff --git a/scripts/check.mjs b/scripts/check.mjs index 93e4ef7..d723d53 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -12,7 +12,9 @@ // template or a script without rebuilding, or edited a generated file by hand); // - package.json gains a field that runs code or installs anything in consumers' projects. // Consumers install this package as a git dependency, and npm runs a git dependency's -// `prepare` script (installing its dependencies first) on every consumer install. +// `prepare` script (installing its dependencies first) on every consumer install; +// - brand-check.mjs, the guard the package ships (it is hand-written, not generated), misses a +// planted violation or reports a clean line (scripts/brand-check.test.mjs). // It changes nothing in the repository. import fs from 'fs'; @@ -54,6 +56,14 @@ try { } } + // ---- the shipped brand guard still catches what it must ---- + const guard = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'brand-check.test.mjs')], { cwd: ROOT, encoding: 'utf8' }); + if (guard.status !== 0) { + process.stderr.write(guard.stdout || ''); + process.stderr.write(guard.stderr || ''); + problems.push(`scripts/brand-check.test.mjs failed (exit ${guard.status ?? guard.signal}); see its message above`); + } + // ---- compare every produced file with the committed one ---- const produced = fs.readdirSync(tmp).sort(); if (!problems.length && produced.length === 0) problems.push('the build produced no files'); From 18b4a19d091c431067cd0cb6e4da564cb2c220ab Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 15:32:47 +0000 Subject: [PATCH 17/21] Review fixes: sources and counts, a sharper brand guard, canon differences listed Sources (tokens.json, under the source rule): - muted-on-dark is this package's recommendation (source "declared"): the website's value at the cited line is white at 72%, which is not imported. - link-on-dark names global.css:47, where the link color resolves, and its description is limited to purple-300 and purple-400; links on purple-200 and the dark greys are white and underlined. - mediaRatio.square cites a line that carries 1 / 1; container.measure cites the article width. Every website-declared note gives its use count, all counted the same way (git grep over src at 6368b2a); the spacing scale's notes say the site declares it but never reads it. - The breakpoint tokens are held back: the site has 13 max-width values and no convention, so under rule 4 none is a token until one is picked. - base-h-ratio quotes 918:4219 word for word. Surfaces are named exactly for heading-on-dark, the highlighter roles and the link underline; logo-on-orange says the Brand Book also shows white. - Two older notes no longer name a person or an internal project, and the supergraphics.css header and a canon comment no longer name internal places; published comments and the tokens.json description write My First Bitcoin in full. CONTRIBUTING names roles, not people, in the new source rule. Build: font weights that Tailwind already names must keep Tailwind's value, and the README says so. README: the brand guard's rules, its default warnings and what it does not check; Tailwind 4 notes for code blocks and shadows; text on orange; no "typed --sg-aspect". package.json exports brand-check.mjs. Spec: a table of the supergraphics classes that differ from the Brand Book and what to set instead; links on light always underlined; label size against body text on small screens; breakpoints not declared yet. brand-check.mjs: fewer false positives (@font-face, family names in data, palette words in script data, Inkscape metadata, scale-only matrices, zero skew, var(--font-sans), Tailwind utility definitions and its --default-mono-font-family in built CSS) and more drift caught (SVG rotations, blend modes, arbitrary named colors, translucent sg-* via opacity-[...] or inline style, small-cap features, redefined package variables, built angle overrides, slant geometry computed from the angle, hand-rolled highlighter bands and bars). Spacing literals, orange text, purple headings and typed capitals warn by default; --error makes them fail. The test plants a line for each. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 14 +- README.md | 40 +++-- brand-check.mjs | 252 +++++++++++++++++++++++++------ brand-spec.md | 62 +++++--- brand.css | 3 - index.js | 7 +- package.json | 3 +- scripts/brand-check.test.mjs | 65 ++++++-- scripts/brand-spec.mjs | 1 - scripts/build-design-package.mjs | 66 +++++--- src/brand-spec.template.md | 42 ++++-- src/supergraphics.canon.css | 6 +- supergraphics.css | 8 +- tokens.json | 183 +++++++++------------- 14 files changed, 480 insertions(+), 272 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5961b36..a65bcd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ under the source rule below. `brand-spec.md` lists the ones found so far under "Not imported from the website". 4. **Where the site has no single value** (several near-duplicates with no convention), no token is made until someone picks one. -5. **Quentin or Patrick decides** every brand change, as for any pull request here. +5. **The deciders named under Releases decide** every brand change, as for any pull request here. Every token carries `$extensions.mfb.sourceKind`, defined in `$verification` at the end of `tokens.json`: `rendered-fill` or `rendered-text` (checked by the Figma check), `prose` (stated in @@ -73,8 +73,9 @@ node scripts/check.mjs # the rebuild check that GitHub Actions Commit the source change and the regenerated files together. The build refuses to write anything if the canon's geometry disagrees with `tokens.json`, if a geometry token is neither gated against the canon nor listed as token-only, if a tokens.json group has no entry in its -`GROUP_MAP` (where each group is emitted), or if a new Tailwind key would equal a Tailwind 3 or 4 -default. The spec generator refuses if a token it needs, or its source, is missing. +`GROUP_MAP` (where each group is emitted), if a new Tailwind key would equal a Tailwind 3 or 4 +default, or if a font weight would change Tailwind's value for its name (`font-medium` is 500). +The spec generator refuses if a token it needs, or its source, is missing. A new token group needs an entry in `GROUP_MAP` in `scripts/build-design-package.mjs`: its Tailwind 4 namespace, its Tailwind 3 key and its `brand.css` prefix, or `null` where it is not @@ -135,9 +136,10 @@ check runs it outside GitHub. `--status FILE` writes the full result as JSON for (`node node_modules/@myfirstbitcoin/design/brand-check.mjs`; the README says how). It needs Node.js and nothing else, and reads every value it compares with from the `tokens.json` next to it, so a token change reaches the guard without editing it. It generalises the guards that two -of My First Bitcoin's sites already ran. When you change a rule, add a planted line for it to -`scripts/brand-check.test.mjs` (and a clean line for anything it must not report); -`scripts/check.mjs` runs that test. +of My First Bitcoin's sites already ran. A rule that cannot see enough to be sure (which +background a text sits on, say) is listed in `DEFAULT_WARN` and only warns unless a project passes +`--error`. When you change a rule, add a planted line for it to `scripts/brand-check.test.mjs` +(and a clean line for anything it must not report); `scripts/check.mjs` runs that test. ## The supergraphics canon diff --git a/README.md b/README.md index 388d8b5..59433f7 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ Make these changes in the same commit that raises your pin: ``` - **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in `tokens.json` now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to. +- **Text on orange is black, buttons included.** `.sg-cta`, `.sg-bg-orange` and `.sg-cover--orange` still set white text on orange-300 (2.3:1, below the 4.5:1 that body text and buttons need); set `color: var(--mfb-link-on-orange)` (or `text-link-on-orange`) on them yourself. The other supergraphics classes that differ from the Brand Book are listed in `brand-spec.md` ("Supergraphics classes that differ from the Brand Book"). - **A test that snapshots the `--mfb-*` variables of `brand.css` whose value is a hex** must refresh its snapshot: this release adds `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow`. The other new variables carry no hex; they reference the palette with `var()`. -- **Everything else is added, not changed.** Every new Tailwind utility is `mfb-` prefixed, a type level (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or a color role (`text-heading-on-light`), and none of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below. +- **Everything else is added, not changed.** Every new Tailwind utility is `mfb-` prefixed, a type level (`leading-h1`, `tracking-h1`, `text-h1-fluid`), a color role (`text-heading-on-light`) or `font-regular`. The weights `font-normal`, `font-medium` and `font-semibold` restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below. ## Use - Tailwind 3 @@ -60,6 +61,8 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-para-pattern`, `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, and the `highlighter`. **Never hand-roll these shapes in a page**: the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. +Some classes still carry values the Brand Book does not have, and a release will change them: most visibly, `.sg-cta`, `.sg-bg-orange` and `.sg-cover--orange` set white text on orange, where the book puts black. Set `color: var(--mfb-link-on-orange)` on them until then. `brand-spec.md` lists every such class and what to set. + ### Set --sg-aspect on every frame `sg-spotlight-frame`, `sg-book-frame`, `sg-halftone-cutout` and `sg-para-frame` compute their cuts (and the parallelogram frame its photo scale) from `--sg-aspect`, the frame's width divided by its height. The default, 1.5, is right only for a frame of that shape. Set it to the frame's real ratio, for example `style="--sg-aspect: 0.75"` on a 3:4 frame; otherwise the cuts are not at 13° and 24°, and a parallelogram frame's photo may not cover the frame. @@ -87,16 +90,16 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile | Container widths | `--mfb-container-page`, `--mfb-container-wide`, `--mfb-container-measure` (px) | `max-w-mfb-page`, `max-w-mfb-wide`, `max-w-mfb-measure` (rem) | | Logo size and clear space | `--mfb-logo-min-width`, `--mfb-logo-min-width-print`, `--mfb-logo-aspect`, `--mfb-logo-min-height`, `--mfb-logo-clearspace`, `--mfb-logo-edge-margin` | none | | Shape tones (a shape one step lighter than its base) | `--mfb-shape-on-purple-300`, `--mfb-shape-on-orange-300`, `--mfb-shape-on-gray-700` | none | -| Breakpoints, for JavaScript and `matchMedia` (a variable cannot be read inside `@media`) | `--mfb-breakpoint-phone`, `--mfb-breakpoint-stack`, `--mfb-breakpoint-nav` | none: the package adds no Tailwind breakpoint | | Layers | `--mfb-z-header`, `--mfb-z-dropdown`, `--mfb-z-overlay` | none | | Media ratios | `--mfb-ratio-video`, `--mfb-ratio-photo`, `--mfb-ratio-portrait`, `--mfb-ratio-square` | none (Tailwind has `aspect-video` and `aspect-square`) | | Interface | `--mfb-ui-touch-target`, `--mfb-ui-link-underline-offset` | none | -- **New utilities never replace Tailwind's.** Keys are `mfb-` prefixed (`rounded-mfb-md`, not `rounded-md`; `ease-mfb-out`, not `ease-out`, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default. +- **New utilities never replace Tailwind's.** Keys are `mfb-` prefixed (`rounded-mfb-md`, not `rounded-md`; `ease-mfb-out`, not `ease-out`, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default, and a font weight that would change Tailwind's value for its name. - **In 1.x, `text-h1` sets only the size.** Add `leading-h1` yourself (and `tracking-h1` where something else sets tracking). Folding line height into `text-h1` would change every existing page, so it waits for a major release. - **New `brand.css` variables reference the palette with `var()`** and add no hex: roles are `var(--mfb-black)` and the like, shadows `color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)`. `theme.css` and `tailwind.js` carry the same colors as hex, so Tailwind's opacity modifiers work. - **Tailwind 4 emits a theme variable only when something uses it.** To read `var(--mfb-radius-md)` or another new variable in your own CSS, import `brand.css` as well, or use the utility. -- **index.js** exports `colorRoles`, `shapeTones`, `fontSizeFluid`, `lineHeight`, `letterSpacing`, `logo`, `spacing`, `space`, `radius`, `shadow`, `easing`, `duration`, `container`, `breakpoint`, `zIndex`, `mediaRatio`, `ui` next to the existing `colors`, `fontFamily`, `fontSize` and `fontWeight`. +- **Shadows in Tailwind 4: prefer the `shadow-mfb-*` utilities.** Tailwind compiles them to a hex color with alpha. A `var(--mfb-shadow-*)` from `brand.css` goes through Tailwind 4's CSS compiler, which gives it a fallback without `color-mix()` for browsers that lack it (Chrome before 111, Safari before 16.2), and that fallback draws the shadow fully opaque. +- **index.js** exports `colorRoles`, `shapeTones`, `fontSizeFluid`, `lineHeight`, `letterSpacing`, `logo`, `spacing`, `space`, `radius`, `shadow`, `easing`, `duration`, `container`, `zIndex`, `mediaRatio`, `ui` next to the existing `colors`, `fontFamily`, `fontSize` and `fontWeight`. ## Brand check @@ -106,19 +109,24 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile node node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json] ``` -Run it in your build, for example `"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"`. It exits 1 when the source (`--src`, default `src`, comments ignored) has: +Run it in your build, for example `"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"`. To find its path from a script, resolve `@myfirstbitcoin/design/brand-check.mjs` (it is in the package's `exports`). It exits 1 when the source (`--src`, default `src`, comments ignored) has: -- a color literal (hex, `rgb()`, `rgba()`, `hsl()` and the other color functions), or a named color in a color property -- a font family other than `var(--mfb-font-heading)`, `var(--mfb-font-body)` or `var(--mfb-font-sans)`, a family name, or the deprecated mono family -- a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the 13° slant comes only from the supergraphics classes -- a hand-rolled `clip-path` shape, or a typed `--sg-aspect` -- `text-transform: uppercase`, the `uppercase` class or small caps -- a gradient (the deprecated brand gradient and `.sg-photo-zone` included), or a smooth color filter (Rule 4: halftone, never duotone) +- a color literal (hex, `rgb()`, `rgba()`, `hsl()` and the other color functions), or a named color in a color property, a style, an SVG color attribute or an arbitrary utility (`bg-[teal]`) +- a font family other than `var(--mfb-font-heading)`, `var(--mfb-font-body)` or `var(--mfb-font-sans)` (in Tailwind 4, `var(--font-sans)` too), a family name, or the deprecated mono family. Inside `@font-face`, which describes a face rather than using one, family names and weights are allowed +- a skew, a rotation or an angle literal that is not a quarter turn (in CSS or in an SVG `transform` attribute), a transform matrix that rotates or skews, or slant geometry computed from `--sg-angle-*`: the 13° slant comes only from the supergraphics classes +- a hand-rolled `clip-path` shape, or a hand-rolled highlighter (a `linear-gradient(transparent 80%, ...)` band, or an orange pseudo-element sized in `em`) +- a redefinition of a variable the package defines (`--mfb-*`, or the `--sg-*` geometry such as `--sg-angle-base`); pages set only `--sg-aspect` and `--sg-highlighter-color`, which must be a palette variable +- `text-transform: uppercase`, the `uppercase` class, small caps or small-cap font features +- a gradient (the deprecated brand gradient and `.sg-photo-zone` included), a smooth color filter or a blend mode (Rule 4: halftone, never duotone) - a Tailwind color utility outside the palette, such as `bg-teal-500` or Tailwind's own `text-purple-500` -- a weight above 600, or opacity on a brand shape -- a raw value that has a token: a spacing, radius, duration, easing, container width, font size or font weight literal equal to a token value (`padding: 24px`, `border-radius: 12px`, `180ms`), with the token to use instead +- a weight above 600, or opacity on a brand shape (a class, `opacity-[.85]` or an inline style) +- a raw value that has a token: a radius, duration, easing, container width, font size, font weight, shadow or semantic spacing literal equal to a token value (`border-radius: 12px`, `180ms`, `clamp(64px, 9vw, 128px)`), with the token to use instead + +and when the built CSS (`--dist`, default `dist`) lacks `--sg-angle-base` with the token value (import `brand.css` or `supergraphics.css`) or sets either angle to another value, defines a `--color-*` that is not a palette color or a color role, or sets uppercase or the mono family outside a utility class definition (Tailwind emits a definition such as `.uppercase` when it sees the word in a comment; the source check already reports the class where it is used). `--source-only` skips the built CSS. + +Three rules only warn, because the right answer depends on what the check cannot see: `raw-spacing` (a px or rem spacing literal on the scale, such as `padding: 24px`: Tailwind's own steps are the same values), `text-color` (orange text, which is right only on purple-300 and purple-400, and a purple heading) and `typed-caps` (two or more words typed in capitals). `--error RULE[,RULE]` makes them fail. -and when the built CSS (`--dist`, default `dist`) lacks `--sg-angle-base` with the token value (import `brand.css` or `supergraphics.css`), defines a `--color-*` that is not a palette color or a color role, or sets uppercase or the mono family. `--source-only` skips the built CSS. +**What it does not check.** It reads how values are written, not how the page looks: it cannot measure contrast, tell which background a text sits on, or read text that a script or a content system supplies. It does not check line height, the one-word highlighter rule, which shape fits which content, or photos themselves. Status colors for forms (error, success) are not in the Brand Book or the palette, so there is no token for them yet: a project that needs them lists them in its allowlist, with the reason. `--allow FILE` (default `brand-check.allow.json` when it exists) lists the exceptions, each with its reason, for example a third-party mark: @@ -140,7 +148,7 @@ Plain CSS variables are namespaced `--mfb-*` (brand values) and `--sg-*` (geomet - **Halftone colors:** `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow` (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colors, so they have no Tailwind utilities - **Geometry:** `--sg-angle-base`, `--sg-angle-alt`, `--sg-aspect`, `--sg-logo-icon-h`, `--sg-base-h-ratio`, `--sg-highlighter-coverage`, `--sg-highlighter-aspect`, `--sg-highlighter-offset`, `--sg-highlighter-shape` -Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. `bg-purple-400`, `text-orange-300`, `text-gray-900`, `text-h1`), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched. +Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy). Utilities use the brand palette at the top level (e.g. `bg-purple-400`, `bg-orange-300`, `text-gray-700`, `text-h1`), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched. ## Deprecated (still shipped for compatibility) @@ -148,7 +156,7 @@ These are not in the Brand Book. They stay until a major release so that pages u - **The brand gradient:** `--mfb-gradient-brand` (brand.css), `--mfb-gradient` (supergraphics.css), `bg-brand-gradient` (Tailwind 3 preset) and `.sg-bg-gradient`. Use a solid palette fill instead, for example purple-300 or purple-400 (`.sg-bg-purple`). - **`.sg-photo-zone`:** a gradient placeholder. Put the photo itself (an `img`) in the frame, or a solid palette fill while it is missing. -- **The mono font:** `--mfb-font-mono`, `--font-mono` (Tailwind 4) and `font-mono` (Tailwind 3). IBM Plex Mono is not in the Brand Book. Use the brand sans (`--mfb-font-body`, `font-sans`), whose system fallback is Arial (Brand Book 918:2375). +- **The mono font:** `--mfb-font-mono`, `--font-mono` (Tailwind 4) and `font-mono` (Tailwind 3). IBM Plex Mono is not in the Brand Book. Use the brand sans (`--mfb-font-body`, `font-sans`), whose system fallback is Arial (Brand Book 918:2375). Tailwind 4's base styles set `code`, `kbd`, `samp` and `pre` in `--default-mono-font-family`, which reads `--font-mono`; to keep them in the brand sans, add `@theme { --default-mono-font-family: var(--font-sans); }` after importing `theme.css`. ## Brand rules diff --git a/brand-check.mjs b/brand-check.mjs index e17c236..a764746 100644 --- a/brand-check.mjs +++ b/brand-check.mjs @@ -10,6 +10,7 @@ // --source-only skip the build output // --allow FILE allowlist (default: brand-check.allow.json, when it exists) // --warn RULES report these rules (comma-separated ids) without failing +// --error RULES fail on rules that only warn by default (raw-spacing, text-color, typed-caps) // --root DIR project root that the paths above are relative to (default: the current directory) // --help print the rules and exit // @@ -19,6 +20,10 @@ // the source, comments ignored, carries a brand value with another origin, or when the built CSS // lacks the package's geometry. Exit 2 means it could not run (a bad option or allowlist). // +// It checks how values are written, not how a page looks: it cannot measure contrast, tell which +// background a text sits on, or read text that a script or a CMS supplies. A few rules that need +// that context (orange or purple text, typed capitals, raw spacing) only warn unless --error asks. +// // The allowlist is a JSON array of exceptions, each with its reason: // [{ "file": "src/assets/partner.svg", "reason": "the partner's own logo colors" }, // { "file": "src/components/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "..." }] @@ -32,33 +37,41 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const tokens = JSON.parse(readFileSync(join(HERE, 'tokens.json'), 'utf8')); +// Rules that warn without failing unless --error names them: they cannot see the background a +// text sits on, or they flag a value that is right more often than not. +const DEFAULT_WARN = new Set(['raw-spacing', 'text-color', 'typed-caps']); const RULES = { 'color-literal': 'a hex color or a color function (rgb, rgba, hsl, hwb, lab, lch, oklab, oklch, color)', - 'named-color': 'a named CSS color (white, teal...) in a color property or an SVG color attribute', + 'named-color': 'a named CSS color (white, teal...) in a color property, a style, an SVG color attribute or an arbitrary utility (bg-[teal])', 'off-palette-utility': 'a Tailwind color utility outside the palette and the color roles (bg-teal-500, text-purple-500)', gradient: 'a gradient (the Brand Book has none; an alpha-only mask is allowed)', 'deprecated-gradient': 'the deprecated brand gradient (--mfb-gradient-brand, --mfb-gradient, .sg-bg-gradient, bg-brand-gradient) or .sg-photo-zone', - 'color-filter': 'a smooth color filter (grayscale, sepia, hue-rotate, saturate): photos are halftone, never duotone', - 'font-family': 'a font family other than var(--mfb-font-heading|body|sans), a family name, or the mono or serif family', - uppercase: 'text-transform: uppercase, the uppercase class, or small caps', + 'color-filter': 'a smooth color filter (grayscale, sepia, hue-rotate, saturate) or a blend mode: photos are halftone, never duotone', + 'font-family': 'a font family other than var(--mfb-font-heading|body|sans) (or var(--font-sans|heading|body) from theme.css), a family name, or the mono or serif family', + uppercase: 'text-transform: uppercase, the uppercase class, small caps or small-cap font features', weight: 'a font weight above 600', - angle: 'a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the slant comes from the supergraphics classes', + angle: 'a skew, a rotation or an angle literal that is not a quarter turn, a rotating transform matrix, or slant geometry computed from --sg-angle-*: the slant comes from the supergraphics classes', 'clip-path': 'a hand-rolled clip-path shape (shapes come from the supergraphics classes)', - 'translucent-shape': 'opacity or a translucent fill on a brand shape (sg-* or the highlighter)', - 'raw-value': 'a spacing, radius, duration, easing, container, font size, font weight or shadow literal equal to a token value', - 'built-angle': 'the built CSS lacks --sg-angle-base with the token value (import brand.css or supergraphics.css)', + 'translucent-shape': 'opacity or a translucent fill on a brand shape (sg-* or the highlighter), or a highlighter color that is not a palette variable', + 'hand-rolled-highlighter': 'a local highlighter band or bar (a gradient band, or an orange pseudo-element sized in em): the package\'s .highlighter draws the Brand Book line', + 'token-override': 'a redefinition of a variable the package defines (--mfb-*, the --sg-* geometry); pages set only --sg-aspect and --sg-highlighter-color', + 'raw-value': 'a radius, duration, easing, container, font size, font weight, shadow or semantic spacing literal equal to a token value', + 'raw-spacing': 'a px or rem spacing literal on the spacing scale (warns by default)', + 'text-color': 'orange text, or a purple heading: the background decides (Color Contrast, 918:2588); use a color role (warns by default)', + 'typed-caps': 'two or more words typed in capitals in markup text (warns by default)', + 'built-angle': 'the built CSS lacks --sg-angle-base with the token value, or sets --sg-angle-base or --sg-angle-alt to another value', 'built-color': 'the built CSS defines a --color-* that is not a palette color or a color role, or not its value', - 'built-uppercase': 'the built CSS sets uppercase or small caps', - 'built-font': 'the built CSS uses the mono or serif family', + 'built-uppercase': 'the built CSS sets uppercase or small caps outside a utility class definition', + 'built-font': 'the built CSS uses the mono or serif family outside a utility class definition', 'built-missing': 'no built CSS was found (build first, or pass --source-only)', }; // ---- options ---- const args = process.argv.slice(2); -const opt = { src: [], dist: [], allow: null, warn: new Set(), root: process.cwd(), sourceOnly: false }; +const opt = { src: [], dist: [], allow: null, warn: new Set(), error: new Set(), root: process.cwd(), sourceOnly: false }; const usage = (msg) => { if (msg) console.error(`brand-check: ${msg}`); - console.error('usage: brand-check.mjs [--src DIR] [--dist DIR] [--source-only] [--allow FILE] [--warn RULES] [--root DIR] [--help]'); + console.error('usage: brand-check.mjs [--src DIR] [--dist DIR] [--source-only] [--allow FILE] [--warn RULES] [--error RULES] [--root DIR] [--help]'); process.exit(2); }; for (let i = 0; i < args.length; i++) { @@ -67,18 +80,21 @@ for (let i = 0; i < args.length; i++) { const list = (v) => v.split(',').map((s) => s.trim()).filter(Boolean); if (a === '--help' || a === '-h') { console.log('brand-check: rules (ids for --warn and the allowlist)'); - for (const [id, why] of Object.entries(RULES)) console.log(` ${id.padEnd(20)} ${why}`); + for (const [id, why] of Object.entries(RULES)) console.log(` ${id.padEnd(24)} ${why}${DEFAULT_WARN.has(id) ? ' [warning]' : ''}`); process.exit(0); } else if (a === '--src') opt.src.push(...list(next())); else if (a === '--dist') opt.dist.push(...list(next())); else if (a === '--allow') opt.allow = next(); else if (a === '--warn') for (const r of list(next())) { if (!RULES[r]) usage(`--warn: unknown rule "${r}"`); opt.warn.add(r); } + else if (a === '--error') for (const r of list(next())) { if (!RULES[r]) usage(`--error: unknown rule "${r}"`); opt.error.add(r); } else if (a === '--root') opt.root = resolve(next()); else if (a === '--source-only') opt.sourceOnly = true; else usage(`unknown option ${a}`); } if (!opt.src.length) opt.src.push('src'); if (!opt.dist.length) opt.dist.push('dist'); +for (const r of DEFAULT_WARN) opt.warn.add(r); +for (const r of opt.error) opt.warn.delete(r); const ROOT = opt.root; // ---- allowlist ---- @@ -120,6 +136,22 @@ for (const key of Object.keys(palette)) { familySteps.get(m[1]).add(m[2] ?? ''); } const ANGLE = tokens.geometry['angle-base'].$value; +const ANGLE_ALT = tokens.geometry['angle-alt'].$value; +// Words that name a palette family. In script code they are often data (a CMS surface name such +// as backgroundColor: 'purple'), so there they count as named colors only inside a style. +const PALETTE_WORDS = new Set([...familySteps.keys(), 'grey']); +// The variables the package defines (every :root declaration of brand.css and supergraphics.css, +// next to this file). A page that redefines one gives a brand value a second origin. Pages set +// --sg-aspect on each frame, and --sg-highlighter-color, which no :root block declares. +const PKG_VARS = new Set(); +for (const f of ['brand.css', 'supergraphics.css']) { + const p = join(HERE, f); + if (!existsSync(p)) continue; + for (const block of readFileSync(p, 'utf8').matchAll(/:root\s*\{([^}]*)\}/g)) { + for (const m of block[1].matchAll(/(--(?:mfb|sg)-[\w-]+)\s*:/g)) PKG_VARS.add(m[1]); + } +} +PKG_VARS.delete('--sg-aspect'); const px = (v) => { const m = String(v).trim().match(/^(-?\d*\.?\d+)(px|rem)?$/); @@ -225,6 +257,10 @@ function stripComments(text, ext) { if (!CSS_EXT.has(ext) && ext !== '.html' && ext !== '.svg') { out = out.replace(/(^|[^:"'`\\])(\/\/[^\n]*)/g, (m, pre, c) => pre + blank(c)); } + if (!CSS_EXT.has(ext)) { + // Editor metadata in SVG files (Inkscape's page color, guides, zoom) is not drawn. + out = out.replace(/<\/?(?:sodipodi|inkscape):[^>]*>/g, blank).replace(/\s(?:sodipodi|inkscape):[\w-]+\s*=\s*(?:"[^"]*"|'[^']*')/g, blank); + } return out; } @@ -265,19 +301,25 @@ function parts(v) { return out; } +const rangesOf = (re, t) => [...t.matchAll(re)].map((m) => [m.index, m.index + m[0].length]); +const inRanges = (i, ranges) => ranges.some(([a, b]) => i >= a && i < b); /** Ranges of `, - 'dist/_astro/index.css': `:root{--mfb-purple-400:#2B1C58;--sg-angle-base:13deg}.x{color:var(--color-purple-300)}:root{--color-purple-300:#422c70;--color-heading-on-light:#000;--color-teal-500:oklch(70% .1 180)}.y{text-transform:uppercase}.font-mono{font-family:monospace}`, + // Built CSS: one off-palette color, one uppercase rule, one mono use and one angle override, + // next to what a Tailwind build emits on its own (utility definitions its content scanner saw in + // a comment, and Tailwind 4's --default-mono-font-family), which is not reported. + 'dist/_astro/index.css': ':root{--mfb-purple-400:#2B1C58;--sg-angle-base:13deg;--default-mono-font-family:var(--font-mono)}.x{color:var(--color-purple-300)}' + + ':root{--color-purple-300:#422c70;--color-heading-on-light:#000;--color-teal-500:oklch(70% .1 180)}.y{text-transform:uppercase}.code{font-family:var(--font-mono)}' + + '.uppercase{text-transform:uppercase}.font-mono{font-family:var(--font-mono)}@media (min-width:48rem){.md\\:uppercase{text-transform:uppercase}}.extra{--sg-angle-base:calc(var(--sg-angle-alt) / 2.4)}', }; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); @@ -116,12 +148,12 @@ try { for (const g of got.filter((g) => g.line !== null && g.file !== 'src/components/Clean.astro')) { if (!expected.some((e) => e.file === g.file && e.line === g.line)) failures.push(`unexpected: ${g.file}:${g.line} [${g.rule}]`); } - // Built CSS: the off-palette --color-teal-500, uppercase and the mono class; the angle is there. - for (const rule of ['built-color', 'built-uppercase', 'built-font']) { - if (!got.some((g) => g.rule === rule)) failures.push(`missed in the built CSS: [${rule}]`); + // Built CSS: exactly one finding per planted rule; utility definitions and Tailwind's own + // --default-mono-font-family are not reported. + for (const rule of ['built-color', 'built-uppercase', 'built-font', 'built-angle']) { + const n = got.filter((g) => g.rule === rule).length; + if (n !== 1) failures.push(`built CSS: [${rule}] reported ${n} times, expected once`); } - if (got.some((g) => g.rule === 'built-angle')) failures.push('built CSS has --sg-angle-base: 13deg, yet built-angle was reported'); - if (got.filter((g) => g.rule === 'built-color').length !== 1) failures.push('built-color should report only --color-teal-500'); // 2. A missing angle is reported. fs.writeFileSync(path.join(tmp, 'dist/_astro/index.css'), ':root{--sg-angle-base:10deg}'); @@ -148,9 +180,14 @@ try { if (run([]).status !== 2) failures.push('allowlist: an entry without a reason was accepted'); fs.rmSync(path.join(tmp, 'brand-check.allow.json')); - // 5. --warn reports a rule without failing. - const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color']); + // 5. --warn reports a rule without failing; the default warnings fail only under --error. + const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter']); if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); + fs.mkdirSync(path.join(tmp, 'src2')); + fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n'); + const dw = run(['--src', 'src2', '--source-only']); + if (dw.status !== 0 || !/warning: src2\/a\.css:1: \[raw-spacing\]/.test(dw.stderr)) failures.push(`default warning: exit ${dw.status}, expected 0 with a raw-spacing warning`); + if (run(['--src', 'src2', '--source-only', '--error', 'raw-spacing']).status !== 1) failures.push('--error raw-spacing did not fail'); // 6. No built CSS, and --source-only. fs.rmSync(path.join(tmp, 'dist'), { recursive: true }); @@ -165,4 +202,4 @@ if (failures.length) { for (const f of failures) console.error(` - ${f}`); process.exit(1); } -console.log(`brand-check test: OK (${Object.keys(PLANTED).length} planted files, every rule, the allowlist, --warn, --source-only)`); +console.log(`brand-check test: OK (${Object.keys(PLANTED).length} planted files, every rule, the allowlist, --warn, --error, --source-only)`); diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index b379ab1..c0d5ef7 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -176,7 +176,6 @@ values.SHADOW_ROWS = rows('shadow', true, (name, tok) => values.EASING_ROWS = rows('easing', true, (name, tok) => `| \`--mfb-ease-${name}\` | \`cubic-bezier(${[].concat(tok.$value).join(', ')})\` | ${cell(tok.$description)} | ${sourceOf('easing', name)} |`); values.DURATION_ROWS = rows('duration', true, valueRow('duration', '--mfb-duration-')); -values.BREAKPOINT_ROWS = rows('breakpoint', true, valueRow('breakpoint', '--mfb-breakpoint-')); values.Z_ROWS = rows('zIndex', true, valueRow('zIndex', '--mfb-z-')); values.RATIO_ROWS = rows('mediaRatio', true, valueRow('mediaRatio', '--mfb-ratio-')); values.UI_ROWS = rows('ui', true, valueRow('ui', '--mfb-ui-')); diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index dc53bb9..23b7447 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -17,9 +17,10 @@ // It does NOT write package.json: the version, exports and files list are edited by hand in a // pull request, and a version bump is what releases (see .github/workflows/design.yml). // -// The emitted header strings ("by build-design-package.mjs", "Canon: shared/supergraphics.css") -// are kept exactly as they were before the build moved into this repository, so a release -// changes only what it means to change. +// The emitted header strings ("by build-design-package.mjs") are kept as they were before the +// build moved into this repository, so a release changes only what it means to change. The +// supergraphics.css header names the canon in this repository (src/supergraphics.canon.css) +// since v1.4.0. import fs from 'fs'; import path from 'path'; @@ -124,7 +125,6 @@ const GROUP_MAP = { easing: { tw4: '--ease-', tw3: 'transitionTimingFunction', twKey: 'mfb-', suffix: '', css: '--mfb-ease-' }, duration: { tw4: '--transition-duration-', tw3: 'transitionDuration', twKey: 'mfb-', suffix: '', css: '--mfb-duration-' }, container: { tw4: '--container-', tw3: 'maxWidth', twKey: 'mfb-', suffix: '', css: '--mfb-container-' }, - breakpoint: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-breakpoint-' }, zIndex: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-z-' }, mediaRatio: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ratio-' }, ui: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ui-' }, @@ -284,7 +284,6 @@ emitGroup('shadow'); emitGroup('easing'); emitGroup('duration'); emitGroup('container'); -emitGroup('breakpoint'); emitGroup('zIndex'); emitGroup('mediaRatio'); emitGroup('ui'); @@ -317,6 +316,18 @@ for (const [twKey, entries] of Object.entries(tw3Extend)) { if (twKey === 'colors' && (tokens.color[key] || TW_COLOR_FAMILIES.includes(key))) gateProblems.push(`tailwind.js colors.${key}: an existing color key is frozen in 1.x`); } } +// Font weights. fontWeight is an original group, emitted outside GROUP_MAP (theme.css +// --font-weight-*, the Tailwind 3 preset's fontWeight, both extending Tailwind's scale). A key +// Tailwind already has (normal, medium, semibold...) may only restate Tailwind's own value, so no +// token edit can change what font-medium means in a project. Tailwind 3.4.19 and 4.3.3 agree. +const TW_FONT_WEIGHT = { thin: 100, extralight: 200, light: 300, normal: 400, medium: 500, semibold: 600, bold: 700, extrabold: 800, black: 900 }; +const weightKeys = Object.entries(tokens.fontWeight || {}).map(([k, tok]) => [k, tok.$value]); +if (tokens.fontWeight?.regular && !tokens.fontWeight.normal) weightKeys.push(['normal', tokens.fontWeight.regular.$value]); +for (const [k, v] of weightKeys) { + if (k in TW_FONT_WEIGHT && Number(v) !== TW_FONT_WEIGHT[k]) { + gateProblems.push(`fontWeight.${k}: ${v} would change Tailwind's font-${k} (${TW_FONT_WEIGHT[k]}); a weight key Tailwind already has keeps Tailwind's value`); + } +} // (d) disjointness. const keysOf = (group) => Object.keys(tokens[group] || {}); const overlap = (a, b) => a.filter((k) => b.includes(k)); @@ -456,7 +467,7 @@ if (path.join(OUT_DIR, 'tokens.json') !== TOKENS_PATH) { // primitives instead of hand-rolling wrong angles. let sgPrelude = '/* Generated by build-design-package.mjs - DO NOT EDIT BY HAND.\n' + - ' Canon: shared/supergraphics.css (@mfb/shared) + token prelude from tokens.json.\n' + + ' Canon: src/supergraphics.canon.css + token prelude from tokens.json.\n' + ' Geometry below is asserted equal to the canon at build time; a mismatch fails the build. */\n' + ':root {\n'; for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; @@ -495,14 +506,13 @@ const INDEX_EXPORTS = [ ['easing', 'easing'], ['duration', 'duration'], ['container', 'container'], - ['breakpoint', 'breakpoint'], ['zIndex', 'zIndex'], ['mediaRatio', 'mediaRatio'], ['ui', 'ui'], ]; write( 'index.js', - `// @myfirstbitcoin/design - programmatic access to MFB brand tokens.\n` + + `// @myfirstbitcoin/design - programmatic access to My First Bitcoin brand tokens.\n` + `export { default as tailwindPreset } from './tailwind.js';\n` + `export const colors = ${JSON.stringify(mfb, null, 2)};\n` + `export const fontFamily = ${JSON.stringify(fontFamily, null, 2)};\n` + @@ -555,8 +565,9 @@ const readme = [ `${FENCE}css\n@import "tailwindcss";\n@theme { --font-weight-*: initial; }\n@import "@myfirstbitcoin/design/theme.css";\n${FENCE}`, ``, `- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in ${code('tokens.json')} now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to.`, + `- **Text on orange is black, buttons included.** ${code('.sg-cta')}, ${code('.sg-bg-orange')} and ${code('.sg-cover--orange')} still set white text on orange-300 (2.3:1, below the 4.5:1 that body text and buttons need); set ${code('color: var(--mfb-link-on-orange)')} (or ${code('text-link-on-orange')}) on them yourself. The other supergraphics classes that differ from the Brand Book are listed in ${code('brand-spec.md')} ("Supergraphics classes that differ from the Brand Book").`, `- **A test that snapshots the ${code('--mfb-*')} variables of ${code('brand.css')} whose value is a hex** must refresh its snapshot: this release adds ${halftoneList}. The other new variables carry no hex; they reference the palette with ${code('var()')}.`, - `- **Everything else is added, not changed.** Every new Tailwind utility is ${code('mfb-')} prefixed, a type level (${code('leading-h1')}, ${code('tracking-h1')}, ${code('text-h1-fluid')}) or a color role (${code('text-heading-on-light')}), and none of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below.`, + `- **Everything else is added, not changed.** Every new Tailwind utility is ${code('mfb-')} prefixed, a type level (${code('leading-h1')}, ${code('tracking-h1')}, ${code('text-h1-fluid')}), a color role (${code('text-heading-on-light')}) or ${code('font-regular')}. The weights ${code('font-normal')}, ${code('font-medium')} and ${code('font-semibold')} restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below.`, ``, `## Use - Tailwind 3`, ``, @@ -580,6 +591,8 @@ const readme = [ `**Never hand-roll these shapes in a page**: the brand angle is exactly ${ANGLE} (${code('--sg-angle-base')}) and hand-rolled copies drift. ` + `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.`, ``, + `Some classes still carry values the Brand Book does not have, and a release will change them: most visibly, ${code('.sg-cta')}, ${code('.sg-bg-orange')} and ${code('.sg-cover--orange')} set white text on orange, where the book puts black. Set ${code('color: var(--mfb-link-on-orange)')} on them until then. ${code('brand-spec.md')} lists every such class and what to set.`, + ``, `### Set --sg-aspect on every frame`, ``, `${code('sg-spotlight-frame')}, ${code('sg-book-frame')}, ${code('sg-halftone-cutout')} and ${code('sg-para-frame')} compute their cuts (and the parallelogram frame its photo scale) ` + @@ -617,15 +630,15 @@ const readme = [ `| Container widths | ${keysOf('container').map((k) => code(`--mfb-container-${k}`)).join(', ')} (px) | ${keysOf('container').map((k) => code(`max-w-mfb-${k}`)).join(', ')} (rem) |`, `| Logo size and clear space | ${keysOf('logo').map((k) => code(`--mfb-logo-${k}`)).join(', ')} | none |`, `| Shape tones (a shape one step lighter than its base) | ${keysOf('shapeTone').map((k) => code(`--mfb-shape-on-${k}`)).join(', ')} | none |`, - `| Breakpoints, for JavaScript and ${code('matchMedia')} (a variable cannot be read inside ${code('@media')}) | ${keysOf('breakpoint').map((k) => code(`--mfb-breakpoint-${k}`)).join(', ')} | none: the package adds no Tailwind breakpoint |`, `| Layers | ${keysOf('zIndex').map((k) => code(`--mfb-z-${k}`)).join(', ')} | none |`, `| Media ratios | ${keysOf('mediaRatio').map((k) => code(`--mfb-ratio-${k}`)).join(', ')} | none (Tailwind has ${code('aspect-video')} and ${code('aspect-square')}) |`, `| Interface | ${keysOf('ui').map((k) => code(`--mfb-ui-${k}`)).join(', ')} | none |`, ``, - `- **New utilities never replace Tailwind's.** Keys are ${code('mfb-')} prefixed (${code('rounded-mfb-md')}, not ${code('rounded-md')}; ${code('ease-mfb-out')}, not ${code('ease-out')}, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default.`, + `- **New utilities never replace Tailwind's.** Keys are ${code('mfb-')} prefixed (${code('rounded-mfb-md')}, not ${code('rounded-md')}; ${code('ease-mfb-out')}, not ${code('ease-out')}, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default, and a font weight that would change Tailwind's value for its name.`, `- **In 1.x, ${code('text-h1')} sets only the size.** Add ${code('leading-h1')} yourself (and ${code('tracking-h1')} where something else sets tracking). Folding line height into ${code('text-h1')} would change every existing page, so it waits for a major release.`, `- **New ${code('brand.css')} variables reference the palette with ${code('var()')}** and add no hex: roles are ${code('var(--mfb-black)')} and the like, shadows ${code('color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)')}. ${code('theme.css')} and ${code('tailwind.js')} carry the same colors as hex, so Tailwind's opacity modifiers work.`, `- **Tailwind 4 emits a theme variable only when something uses it.** To read ${code('var(--mfb-radius-md)')} or another new variable in your own CSS, import ${code('brand.css')} as well, or use the utility.`, + `- **Shadows in Tailwind 4: prefer the ${code('shadow-mfb-*')} utilities.** Tailwind compiles them to a hex color with alpha. A ${code('var(--mfb-shadow-*)')} from ${code('brand.css')} goes through Tailwind 4's CSS compiler, which gives it a fallback without ${code('color-mix()')} for browsers that lack it (Chrome before 111, Safari before 16.2), and that fallback draws the shadow fully opaque.`, `- **index.js** exports ${INDEX_EXPORTS.map(([name]) => code(name)).join(', ')} next to the existing ${code('colors')}, ${code('fontFamily')}, ${code('fontSize')} and ${code('fontWeight')}.`, ``, `## Brand check`, @@ -634,19 +647,25 @@ const readme = [ ``, `${FENCE}sh\nnode node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json]\n${FENCE}`, ``, - `Run it in your build, for example ${code('"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"')}. It exits 1 when the source (${code('--src')}, default ${code('src')}, comments ignored) has:`, + `Run it in your build, for example ${code('"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"')}. ` + + `To find its path from a script, resolve ${code('@myfirstbitcoin/design/brand-check.mjs')} (it is in the package's ${code('exports')}). It exits 1 when the source (${code('--src')}, default ${code('src')}, comments ignored) has:`, ``, - `- a color literal (hex, ${code('rgb()')}, ${code('rgba()')}, ${code('hsl()')} and the other color functions), or a named color in a color property`, - `- a font family other than ${code('var(--mfb-font-heading)')}, ${code('var(--mfb-font-body)')} or ${code('var(--mfb-font-sans)')}, a family name, or the deprecated mono family`, - `- a skew, a rotation or an angle literal that is not a quarter turn, or a transform matrix: the ${ANGLE} slant comes only from the supergraphics classes`, - `- a hand-rolled ${code('clip-path')} shape, or a typed ${code('--sg-aspect')}`, - `- ${code('text-transform: uppercase')}, the ${code('uppercase')} class or small caps`, - `- a gradient (the deprecated brand gradient and ${code('.sg-photo-zone')} included), or a smooth color filter (Rule 4: halftone, never duotone)`, + `- a color literal (hex, ${code('rgb()')}, ${code('rgba()')}, ${code('hsl()')} and the other color functions), or a named color in a color property, a style, an SVG color attribute or an arbitrary utility (${code('bg-[teal]')})`, + `- a font family other than ${code('var(--mfb-font-heading)')}, ${code('var(--mfb-font-body)')} or ${code('var(--mfb-font-sans)')} (in Tailwind 4, ${code('var(--font-sans)')} too), a family name, or the deprecated mono family. Inside ${code('@font-face')}, which describes a face rather than using one, family names and weights are allowed`, + `- a skew, a rotation or an angle literal that is not a quarter turn (in CSS or in an SVG ${code('transform')} attribute), a transform matrix that rotates or skews, or slant geometry computed from ${code('--sg-angle-*')}: the ${ANGLE} slant comes only from the supergraphics classes`, + `- a hand-rolled ${code('clip-path')} shape, or a hand-rolled highlighter (a ${code('linear-gradient(transparent 80%, ...)')} band, or an orange pseudo-element sized in ${code('em')})`, + `- a redefinition of a variable the package defines (${code('--mfb-*')}, or the ${code('--sg-*')} geometry such as ${code('--sg-angle-base')}); pages set only ${code('--sg-aspect')} and ${code('--sg-highlighter-color')}, which must be a palette variable`, + `- ${code('text-transform: uppercase')}, the ${code('uppercase')} class, small caps or small-cap font features`, + `- a gradient (the deprecated brand gradient and ${code('.sg-photo-zone')} included), a smooth color filter or a blend mode (Rule 4: halftone, never duotone)`, `- a Tailwind color utility outside the palette, such as ${code('bg-teal-500')} or Tailwind's own ${code('text-purple-500')}`, - `- a weight above 600, or opacity on a brand shape`, - `- a raw value that has a token: a spacing, radius, duration, easing, container width, font size or font weight literal equal to a token value (${code('padding: 24px')}, ${code('border-radius: 12px')}, ${code('180ms')}), with the token to use instead`, + `- a weight above 600, or opacity on a brand shape (a class, ${code('opacity-[.85]')} or an inline style)`, + `- a raw value that has a token: a radius, duration, easing, container width, font size, font weight, shadow or semantic spacing literal equal to a token value (${code('border-radius: 12px')}, ${code('180ms')}, ${code('clamp(64px, 9vw, 128px)')}), with the token to use instead`, + ``, + `and when the built CSS (${code('--dist')}, default ${code('dist')}) lacks ${code('--sg-angle-base')} with the token value (import ${code('brand.css')} or ${code('supergraphics.css')}) or sets either angle to another value, defines a ${code('--color-*')} that is not a palette color or a color role, or sets uppercase or the mono family outside a utility class definition (Tailwind emits a definition such as ${code('.uppercase')} when it sees the word in a comment; the source check already reports the class where it is used). ${code('--source-only')} skips the built CSS.`, + ``, + `Three rules only warn, because the right answer depends on what the check cannot see: ${code('raw-spacing')} (a px or rem spacing literal on the scale, such as ${code('padding: 24px')}: Tailwind's own steps are the same values), ${code('text-color')} (orange text, which is right only on purple-300 and purple-400, and a purple heading) and ${code('typed-caps')} (two or more words typed in capitals). ${code('--error RULE[,RULE]')} makes them fail.`, ``, - `and when the built CSS (${code('--dist')}, default ${code('dist')}) lacks ${code('--sg-angle-base')} with the token value (import ${code('brand.css')} or ${code('supergraphics.css')}), defines a ${code('--color-*')} that is not a palette color or a color role, or sets uppercase or the mono family. ${code('--source-only')} skips the built CSS.`, + `**What it does not check.** It reads how values are written, not how the page looks: it cannot measure contrast, tell which background a text sits on, or read text that a script or a content system supplies. It does not check line height, the one-word highlighter rule, which shape fits which content, or photos themselves. Status colors for forms (error, success) are not in the Brand Book or the palette, so there is no token for them yet: a project that needs them lists them in its allowlist, with the reason.`, ``, `${code('--allow FILE')} (default ${code('brand-check.allow.json')} when it exists) lists the exceptions, each with its reason, for example a third-party mark:`, ``, @@ -666,7 +685,7 @@ const readme = [ `- **Halftone colors:** ${halftoneList} (highlight for cutout portraits, highlight for full portraits, shadow). They are book-specified values, not palette colors, so they have no Tailwind utilities`, `- **Geometry:** ${Object.values(GEOM_VARS).map(code).join(', ')}`, ``, - `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. ${code('bg-purple-400')}, ${code('text-orange-300')}, ${code('text-gray-900')}, ${code('text-h1')}), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched.`, + `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy). Utilities use the brand palette at the top level (e.g. ${code('bg-purple-400')}, ${code('bg-orange-300')}, ${code('text-gray-700')}, ${code('text-h1')}), overriding Tailwind's default purple/orange/gray with the brand values. Other defaults (red, blue, etc.) are untouched.`, ``, `## Deprecated (still shipped for compatibility)`, ``, @@ -676,7 +695,8 @@ const readme = [ `Use a solid palette fill instead, for example purple-300 or purple-400 (${code('.sg-bg-purple')}).`, `- **${code('.sg-photo-zone')}:** a gradient placeholder. Put the photo itself (an ${code('img')}) in the frame, or a solid palette fill while it is missing.`, `- **The mono font:** ${code('--mfb-font-mono')}, ${code('--font-mono')} (Tailwind 4) and ${code('font-mono')} (Tailwind 3). IBM Plex Mono is not in the Brand Book. ` + - `Use the brand sans (${code('--mfb-font-body')}, ${code('font-sans')}), whose system fallback is Arial (Brand Book 918:2375).`, + `Use the brand sans (${code('--mfb-font-body')}, ${code('font-sans')}), whose system fallback is Arial (Brand Book 918:2375). ` + + `Tailwind 4's base styles set ${code('code')}, ${code('kbd')}, ${code('samp')} and ${code('pre')} in ${code('--default-mono-font-family')}, which reads ${code('--font-mono')}; to keep them in the brand sans, add ${code('@theme { --default-mono-font-family: var(--font-sans); }')} after importing ${code('theme.css')}.`, ``, `## Brand rules`, ``, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 66d3287..c67a720 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -77,7 +77,7 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw - **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). - **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). -- **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size. +- **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size, so below about 1125px wide the fluid label is smaller than body text (16px against 18px at a phone's width), the reverse of the Brand Book's order; where a label sits next to body text on small screens, use the fixed label size. A fluid body size is not decided yet. - **Space after a heading:** {{SPACE_HEADING_BODY}} between any heading and the body text under it, at the 18px body size ({{SPACE_HEADING_BODY_SRC}}). **Between a quote and its label:** {{SPACE_QUOTE_LABEL}} ({{SPACE_QUOTE_LABEL_SRC}}). ### Logo @@ -91,7 +91,7 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw This package's `brand.css` exposes every color as `var(--mfb-)`, the font families as `var(--mfb-font-)`, the font sizes as `var(--mfb-size-)`, the font weights as `var(--mfb-weight-)`, the halftone colors as `var(--mfb-halftone-highlight)`, `var(--mfb-halftone-highlight-full)` and `var(--mfb-halftone-shadow)`, and the geometry as `var(--sg-angle-base)` and its siblings, the highlighter's `var(--sg-highlighter-*)` included. `theme.css` (Tailwind 4) carries the same colors, font families, font sizes and font weights (`--font-weight-`, with `normal` as Tailwind's name for regular), and `tailwind.js` (Tailwind 3) carries those plus the deprecated brand gradient; the geometry and halftone variables are only in `brand.css` and `supergraphics.css`, and the halftone colors are deliberately not color utilities. -The values in the next sections are in `brand.css` as `--mfb--` (for example `--mfb-space-section`, `--mfb-radius-lg`, `--mfb-ease-out`, `--mfb-heading-on-light`). In Tailwind (both versions) the new utility names never replace a Tailwind default: they are `mfb-` prefixed (`rounded-mfb-lg`, `shadow-mfb-media`, `ease-mfb-out`, `duration-mfb-fast`, `max-w-mfb-page`, `py-mfb-section`), type levels (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or color roles (`text-heading-on-light`). Breakpoints, layers, media ratios, logo and interface values are in `brand.css` and `index.js` only. +The values in the next sections are in `brand.css` as `--mfb--` (for example `--mfb-space-section`, `--mfb-radius-lg`, `--mfb-ease-out`, `--mfb-heading-on-light`). In Tailwind (both versions) the new utility names never replace a Tailwind default: they are `mfb-` prefixed (`rounded-mfb-lg`, `shadow-mfb-media`, `ease-mfb-out`, `duration-mfb-fast`, `max-w-mfb-page`, `py-mfb-section`), type levels (`leading-h1`, `tracking-h1`, `text-h1-fluid`) or color roles (`text-heading-on-light`). Layers, media ratios, logo and interface values are in `brand.css` and `index.js` only. ### Layout and spacing @@ -106,7 +106,7 @@ Where the Brand Book is silent, these come from the website (Section 0). ### Radius and shadow -Radius is for interface elements (cards, buttons, inputs, images). **Brand shapes keep sharp corners**: never round a parallelogram, a spotlight, a book or the highlighter. +Radius is for interface elements (cards, buttons, inputs, images). **Brand shapes keep sharp corners**: never round a parallelogram, a spotlight, a book or the highlighter. (`.sg-para-frame` and `.sg-para-accent` still round their corners by 6px: see "Supergraphics classes that differ from the Brand Book" in Section 3.) | Variable | Value | Use | Source | |----------|-------|-----|--------| @@ -124,13 +124,12 @@ Shadows are tinted with palette colors only, and none is used on dark background Under `prefers-reduced-motion: reduce`, nothing moves: reveals show at once, marquees and carousels stop, and transitions are instant. Moving text pauses on hover. Never animate the highlighter ("Do not alter its appearance", `918:2874`). -### Breakpoints, layers, media and interface +### Layers, media and interface -Breakpoints are for JavaScript and `matchMedia` (`index.js` exports them as `breakpoint`): a CSS variable cannot be read inside `@media`, and the package adds no Tailwind breakpoint, so Tailwind's own `sm:`, `md:` and `lg:` stay as they are. +**Breakpoints:** the package declares none yet. The website has no single convention: 13 different `max-width` values in its media queries, the most used being 720px, 900px and 600px, next to near-duplicates such as 760px, 768px and 860px. Under the source rule no value becomes a token until one is chosen. Tailwind's own `sm:`, `md:` and `lg:` stay as they are. | Variable | Value | Use | Source | |----------|-------|-----|--------| -{{BREAKPOINT_ROWS}} {{Z_ROWS}} {{RATIO_ROWS}} {{UI_ROWS}} @@ -177,10 +176,10 @@ The website also carries values that contradict the Brand Book. They are not tok - **Thickness:** the line's length divided by {{HL_ASPECT}}, about 3% of the word's width. It is proportional to the word's width, so at the same font size a longer word gets a thicker line - **Position:** the center of its bounding box (half-way between its top and bottom edges) sits {{HL_OFFSET}} below the baseline, so it straddles the baseline and sits mostly below it; the glyphs, descenders included, paint over it - **Shape:** a filled quad that tapers from 6/7 of its height at the start to 4/7 at the end, rises slightly to the right, with slant-cut ends (as a CSS clip-path: `{{HL_SHAPE}}`) -- **Color:** orange-300 on purple and on white; white on orange and on the mid grey of the Brand Book's example (close to gray-500). The word itself keeps the heading's color +- **Color:** orange-300 on purple, on white and on light greys up to gray-300; white on orange, and on gray-400 and gray-500 (the Brand Book's example is a mid grey close to gray-500), where an orange line all but disappears. The word itself keeps the heading's color **Do:** -- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line described above, orange-300 by default and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there +- Use the `highlighter` class from this package's `supergraphics.css` on a span around the one word: `

Open Source Education

`. It draws the line described above, orange-300 by default and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. On another orange surface, or on gray-400 or gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there - Leave the span's `display` and `text-align` alone: the line's length and thickness are computed from the span's own width - Where CSS cannot be used (print, canvas), draw the same construction from the geometry tokens `highlighter-coverage`, `highlighter-aspect`, `highlighter-offset` and `highlighter-shape` - End the highlight at the word boundary, with no trailing punctuation inside it (`Foundations` ✓ · `Foundations.` ✗) @@ -342,8 +341,8 @@ The website also carries values that contradict the Brand Book. They are not tok | Background | Headings and text | Links | Accents | |------------|-------------------|-------|---------| -| purple-200, purple-300, purple-400, gray-600 to gray-900 | white | orange-300 | orange-300 highlighter; purple-200 shapes on purple-300; gray-400 for secondary text | -| white, gray-100 to gray-500 | black | purple-300, underlined in orange-300 | orange-300 highlighter and fills (with black text); gray-700 for secondary text | +| purple-200, purple-300, purple-400, gray-600 to gray-900 | white (on gray-600, headings and text at 24px and up only) | orange-300 on purple-300 and purple-400; white and underlined on purple-200 and the greys | orange-300 highlighter; purple-200 shapes on purple-300; gray-400 for secondary text on purples | +| white, gray-100 to gray-500 | black | purple-300, always underlined: in orange-300 on white to gray-300, in the link's own color on gray-400 and gray-500 | orange-300 highlighter on white to gray-300, white on gray-400 and gray-500; orange-300 fills (with black text); gray-700 for secondary text on white and gray-100 | | orange-200, orange-300, orange-400 | black | black | white highlighter; orange-200 shapes on orange-300; no other accent | The roles behind this table are in Section 1 (Color roles). @@ -353,12 +352,29 @@ The roles behind this table are in Section 1 (Color roles). - **Headings on light backgrounds:** black (`{{BLACK}}`), as in the Brand Book's *Type Relationships* (`918:2990`) and its Brand in Use examples. purple-300 is the brand's surface and shape color, not a heading color on white - **Text on light backgrounds:** black (`{{BLACK}}`), body copy included (`918:2994`). gray-700 is for secondary text only (captions, meta), on white or gray-100 - **Headings and text on dark backgrounds:** white -- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them -- **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule -- **Links on dark backgrounds:** orange-300 (or white) +- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them (see "Supergraphics classes that differ from the Brand Book" below) +- **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule. A link on a light background never loses its underline: purple-300 is only 1.8:1 from black text, so the underline is what marks it. On gray-400 and gray-500 an orange underline cannot be seen (1.3:1 and 1.0:1): underline in the link's own color there +- **Links on dark backgrounds:** orange-300 on purple-300 and purple-400 (5.0:1 and 6.5:1), or white. On purple-200 (orange-300 is 3.8:1 there) and on the dark greys, links are white and underlined (8.7:1 on purple-200) - **Orange is for accents only** (CTA fills with black text, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes, and never text on a light background - **Gradient:** the brand gradient is deprecated (Section 1): do not use it in new designs. Where an existing page still uses it, keep its 135deg direction (top-left to bottom-right), and never use it on covers. +### Supergraphics classes that differ from the Brand Book + +A few classes in this package's `supergraphics.css` still carry values from before the Brand Book was measured. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: + +| Class | What it sets | The Brand Book | Set instead | +|-------|--------------|----------------|-------------| +| `.sg-cta` | white text on orange-300 (2.3:1), 1px letter spacing, a 40px radius | black text on orange, no tracking | `color: var(--mfb-link-on-orange)` and `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)`) | +| `.sg-bg-orange` | white text on orange-300 | black text on orange | `color: var(--mfb-body-on-orange)` | +| `.sg-cover--orange` | white title and text on orange-300 (from `.sg-cover`) | black text on orange | `color: var(--mfb-heading-on-orange)` | +| `.sg-info-bar--on-light` | gray-900 text; the date (`.sg-info-bar__date`) in orange-300 on white (2.3:1), and the line under it at 75% opacity (1.9:1) | black text on light colors, never orange text there | `color: var(--mfb-body-on-light)` on the bar, and the same on its date and the date's `small` (with `opacity: 1`) | +| `.sg-bg-white`, `.sg-bg-gray` | gray-900 text | black text on light colors | `color: var(--mfb-body-on-light)` | +| `.sg-headline`, `.sg-cover__title` | line height 1.05, letter spacing -0.03em | line height 1 for H1 to H4 (1.1 for H5 and H6), no tracking | `line-height: var(--mfb-leading-h1)` (the level you set) and `letter-spacing: 0` | +| `.sg-label` | 18px, Medium (500), 1.5px letter spacing | 22px, Regular (400), no tracking | `font-size: var(--mfb-size-label)`, `font-weight: var(--mfb-weight-regular)`, `letter-spacing: 0` | +| `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram | sharp corners | `border-radius: 0` | +| `.sg-para-pattern` | rows 10px apart, stepped by 1.25 and 1.55 | rows that touch, each 1.2 times the one before (Rule 7) | nothing yet: a release changes the class | +| `.sg-logo`, `.sg-cover__logo` | a logo {{LOGO_ICON_H}} tall | a logo at least {{LOGO_MIN_H}} tall on screens (Logo, Section 1) | nothing yet: the height is not decided | + ### Logo files The logo files are in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand), under `Logo & Logo Animation/logo/`, as PNG and SVG: diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 9ed9dd2..67eb12b 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -1,18 +1,18 @@ -/* MFB Supergraphics — Brand Visual Elements Layer */ +/* My First Bitcoin Supergraphics: Brand Visual Elements Layer */ /* Builds on brand.css variables. Prefix: sg- to avoid collisions. */ /* 2026-08-19: the five skew transforms below used to hardcode 13deg while this same file declared --sg-angle-base: 13deg twenty lines further down and the twelve clip-path calculations used the variable correctly. A file disagreeing with its own variable is how five different slant angles (-13, -10, -19, -1.4, 0) reached production. The angle - now has one origin: geometry.angle-base in shared-context/brand/tokens.json, emitted + now has one origin: geometry.angle-base in tokens.json, emitted into the :root block below by build-design-package.mjs. */ /* ═══════════════════════════════════════════════════════════════════ PARALLELOGRAM FRAMES Brand book Rule 7 (p.30) — Skew always -13deg (left-to-right tilt), - the same 13° angle as the MFB logo icon. Base height = 2x logo-icon + the same 13° angle as the My First Bitcoin logo icon. Base height = 2x logo-icon height. Never alter angle. Vary scale. Never uniform spacing. ═══════════════════════════════════════════════════════════════════ */ diff --git a/supergraphics.css b/supergraphics.css index 9ace6f1..3db03c7 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -1,5 +1,5 @@ /* Generated by build-design-package.mjs - DO NOT EDIT BY HAND. - Canon: shared/supergraphics.css (@mfb/shared) + token prelude from tokens.json. + Canon: src/supergraphics.canon.css + token prelude from tokens.json. Geometry below is asserted equal to the canon at build time; a mismatch fails the build. */ :root { --mfb-purple-300: #422C70; @@ -49,21 +49,21 @@ --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); } -/* MFB Supergraphics — Brand Visual Elements Layer */ +/* My First Bitcoin Supergraphics: Brand Visual Elements Layer */ /* Builds on brand.css variables. Prefix: sg- to avoid collisions. */ /* 2026-08-19: the five skew transforms below used to hardcode 13deg while this same file declared --sg-angle-base: 13deg twenty lines further down and the twelve clip-path calculations used the variable correctly. A file disagreeing with its own variable is how five different slant angles (-13, -10, -19, -1.4, 0) reached production. The angle - now has one origin: geometry.angle-base in shared-context/brand/tokens.json, emitted + now has one origin: geometry.angle-base in tokens.json, emitted into the :root block below by build-design-package.mjs. */ /* ═══════════════════════════════════════════════════════════════════ PARALLELOGRAM FRAMES Brand book Rule 7 (p.30) — Skew always -13deg (left-to-right tilt), - the same 13° angle as the MFB logo icon. Base height = 2x logo-icon + the same 13° angle as the My First Bitcoin logo icon. Base height = 2x logo-icon height. Never alter angle. Vary scale. Never uniform spacing. ═══════════════════════════════════════════════════════════════════ */ diff --git a/tokens.json b/tokens.json index c96eac0..989a3a0 100644 --- a/tokens.json +++ b/tokens.json @@ -1,5 +1,5 @@ { - "$description": "MFB Brand Tokens (W3C Design Token Community Group format). Canonical source of truth: Figma 'My First Bitcoin_Full Brand Assets' (file mFIc75UUSyftaqnNUQgjLX). This file is the code-side mirror of the Figma variables; edit it ONLY to match Figma, never the other way around.", + "$description": "My First Bitcoin brand tokens (W3C Design Token Community Group format). Canonical source of truth: Figma 'My First Bitcoin_Full Brand Assets' (file mFIc75UUSyftaqnNUQgjLX). This file is the code-side mirror of the Figma variables; edit it ONLY to match Figma, never the other way around.", "color": { "purple-300": { "$type": "color", @@ -380,7 +380,7 @@ "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-08-19", - "note": "Not in the brand book: node 918:3637 says only 'two diagonal cuts with different angles' and names no number. The nearest drawn construction line (918:3645) measures -21.9deg. Quentin's decision 2026-08-19: keep 24deg and record it as declared rather than invent provenance. On the list of what Figma owes." + "note": "Not in the brand book: node 918:3637 says only 'two diagonal cuts with different angles' and names no number. The nearest drawn construction line (918:3645) measures -21.9deg. A maintainer decision on 2026-08-19: keep 24deg and record it as declared rather than invent provenance. On the list of what Figma owes." } } }, @@ -406,7 +406,7 @@ "source": "figma:918:4219", "sourceKind": "prose", "verifiedOn": "2026-10-06", - "note": "Brand book: 'base height = 2x the height of the logo icon' (the visible copy, 918:4219 on the Patterns slide 918:4212; the copy at 918:4196 sits in a hidden frame). No drawing in the book shows the 2x relation: 918:4200 is drawn at 1.00x the icon of 918:4199, and the Brand in Use slivers are 4.6x and 5.5x the logo height (918:4177 against 918:4186, 918:3820 against 918:3827). The prose stays normative. Until 2026-10-06 this cited 918:4196." + "note": "Brand Book: 'The base parallelogram height equals 2× the height of the logo icon.' (the visible copy, 918:4219 on the Patterns slide 918:4212; the copy at 918:4196 sits in a hidden frame). No drawing in the book shows the 2x relation: 918:4200 is drawn at 1.00x the icon of 918:4199, and the Brand in Use slivers are 4.6x and 5.5x the logo height (918:4177 against 918:4186, 918:3820 against 918:3827). The prose stays normative. Until 2026-10-06 this cited 918:4196." } } }, @@ -611,7 +611,7 @@ "mfb": { "source": "declared", "sourceKind": "declared", - "note": "Deprecated 2026-07-14: IBM Plex Mono appears nowhere in the canonical Figma file, yet mfb-crm still references it 522 times." + "note": "Deprecated 2026-07-14: IBM Plex Mono appears nowhere in the canonical Figma file, yet existing pages still use it." } } } @@ -1024,7 +1024,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:66", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h1 size and the build refuses any other; the floor and the slope are the website's. 1 uses on the site." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h1 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 2 lines read var(--fs-h1)." } } }, @@ -1037,7 +1037,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:67", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h2 size and the build refuses any other; the floor and the slope are the website's. 7 uses on the site." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h2 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 8 lines read var(--fs-h2)." } } }, @@ -1050,7 +1050,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:68", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h3 size and the build refuses any other; the floor and the slope are the website's. 29 uses on the site." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h3 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 30 lines read var(--fs-h3)." } } }, @@ -1063,7 +1063,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:69", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h4 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h4 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h4): declared on the site, not used there yet." } } }, @@ -1076,7 +1076,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:70", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h5 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h5 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h5): declared on the site, not used there yet." } } }, @@ -1089,7 +1089,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:71", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h6 size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h6 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h6): declared on the site, not used there yet." } } }, @@ -1102,7 +1102,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:72", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's quote size and the build refuses any other; the floor and the slope are the website's. Declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's quote size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-quote): declared on the site, not used there yet." } } }, @@ -1115,7 +1115,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:73", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's label size and the build refuses any other; the floor and the slope are the website's. 3 uses on the site." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's label size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 4 lines read var(--fs-label)." } } } @@ -1156,7 +1156,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:103", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour, defined in 33 blocks. 5.2:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour on light surfaces. Uses counted with git grep over src at 6368b2a: 57 lines read var(--gray-700), all as a text colour. 5.2:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." } } }, @@ -1169,20 +1169,20 @@ "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "Not in the Brand Book: this package's recommendation, which keeps to the contrast rule (918:2588). The Brand Book has no link style; the website's orange link text (src/styles/global.css:166) is 2.3:1 on white and breaks 918:2588, so it is not imported. 11.5:1 on white." + "note": "Not in the Brand Book: this package's recommendation, open for a decision, which keeps to the contrast rule (918:2588). The Brand Book has no link style; the website's orange link text (src/styles/global.css:166) is 2.3:1 on white and breaks 918:2588, so it is not imported. 11.5:1 on white." } } }, "link-underline-on-light": { "$type": "color", "$value": "{color.orange-300}", - "$description": "Underline of a link on white and light greys.", + "$description": "Underline of a link on white and gray-100 to gray-300.", "$extensions": { "mfb": { "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "Not in the Brand Book: this package's recommendation, which keeps to the contrast rule (918:2588). The underline is a mark, not text, so orange is allowed there." + "note": "Not in the Brand Book: this package's recommendation, open for a decision, which keeps to the contrast rule (918:2588). The underline is a mark, not text, so orange is allowed there. On gray-400 and gray-500 an orange underline cannot be seen (1.3:1 and 1.0:1); underline in the link's own colour there. A link on a light background always keeps its underline: purple-300 is only 1.8:1 from black body text." } } }, @@ -1195,20 +1195,20 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:96", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Defined in 19 blocks. A palette colour; its role comes from the website." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. A palette colour; its role comes from the website. Uses counted with git grep over src at 6368b2a: 53 lines read var(--gray-200), most of them as a border or divider colour." } } }, "highlighter-on-light": { "$type": "color", "$value": "{color.orange-300}", - "$description": "Highlighter line on white and light greys.", + "$description": "Highlighter line on white and gray-100 to gray-300.", "$extensions": { "mfb": { "source": "figma:918:2898", "sourceKind": "measured", "verifiedOn": "2026-10-06", - "note": "Read through the Figma MCP (read only). Rendered fill of the highlighter on white, Highlighter slide 918:2874." + "note": "Read through the Figma MCP (read only). Rendered fill of the highlighter on white, Highlighter slide 918:2874. On gray-400 and gray-500 an orange line all but disappears (1.3:1 and 1.0:1), so the white line (highlighter-on-orange) is used there." } } }, @@ -1228,13 +1228,13 @@ "heading-on-dark": { "$type": "color", "$value": "{color.white}", - "$description": "Headings on purples and on gray-600 to gray-900.", + "$description": "Headings on purples and on gray-700 to gray-900 (gray-600: headings at 24px and up only).", "$extensions": { "mfb": { "source": "figma:918:3829", "sourceKind": "measured", "verifiedOn": "2026-10-06", - "note": "Read through the Figma MCP (read only). Poster heading 918:3829 (white on purple-300); 'Aa' 918:2623 to 918:2625 on the purples." + "note": "Read through the Figma MCP (read only). Poster heading 918:3829 (white on purple-300); 'Aa' 918:2623 to 918:2625 on the purples. 3.5:1 on gray-600, which counts only for large text (24px and up at weight 500)." } } }, @@ -1257,23 +1257,23 @@ "$description": "Secondary text on purples.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:94", + "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site uses white at 72% (rgba(255, 255, 255, 0.72), 35 uses), which is not a palette colour; gray-400 is the nearest palette colour on purple-300 and purple-400. 6.4:1 on purple-300, 8.3:1 on purple-400, 4.9:1 on purple-200." + "note": "Not in the Brand Book: this package's recommendation, open for a decision. The website's muted text on dark surfaces is white at 72% (rgba(255,255,255,0.72), 35 lines in src at 6368b2a, for example src/components/blocks/BlogCarousel.tsx:94), which is not a palette colour and is not imported (translucent white text). gray-400 is the palette colour nearest to it on purple-300 and purple-400; the website never uses gray-400 as text on a dark surface. 6.4:1 on purple-300, 8.3:1 on purple-400, 4.9:1 on purple-200." } } }, "link-on-dark": { "$type": "color", "$value": "{color.orange-300}", - "$description": "Link text on purples.", + "$description": "Link text on purple-300 and purple-400. On purple-200 and on dark greys, links are white and underlined.", "$extensions": { "mfb": { "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:166", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's link colour, which also applies on its purple surfaces. Orange-300 is a light colour in the contrast rule (918:2588), so it is light text on a dark colour, and the book puts orange-300 marks on purple (918:2883). 5.0:1 on purple-300, 6.5:1 on purple-400, 3.8:1 on purple-200 (large text only there). The same rule on light backgrounds is not imported." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's link colour: global.css:166 sets every link to var(--accent), which global.css:47 resolves to orange-300, on its purple surfaces too. Orange-300 is a light colour in the contrast rule (918:2588), so it is light text on a dark colour, and the book puts orange-300 marks on purple (918:2883). 5.0:1 on purple-300, 6.5:1 on purple-400; 3.8:1 on purple-200 and 1.5:1 to 3.5:1 on gray-600 to gray-800, too low for link text, so links there are white and underlined (8.7:1 on purple-200). The same rule on light backgrounds is not imported." } } }, @@ -1345,26 +1345,26 @@ "highlighter-on-orange": { "$type": "color", "$value": "{color.white}", - "$description": "Highlighter line on oranges (and on mid greys such as gray-500).", + "$description": "Highlighter line on oranges, and on gray-400 and gray-500.", "$extensions": { "mfb": { "source": "figma:918:2887", "sourceKind": "measured", "verifiedOn": "2026-10-06", - "note": "Read through the Figma MCP (read only). Rendered fills: 918:2887 (white on orange-300), 918:2893 (white on a mid grey); program card 918:4180." + "note": "Read through the Figma MCP (read only). Rendered fills: 918:2887 (white on orange-300), 918:2893 (white on a mid grey); program card 918:4180. On gray-400 and gray-500 white stands out more than orange (1.8:1 and 2.2:1, against 1.3:1 and 1.0:1)." } } }, "logo-on-orange": { "$type": "color", "$value": "{color.black}", - "$description": "Logo on oranges.", + "$description": "Logo on oranges (the Brand Book also shows white there).", "$extensions": { "mfb": { "source": "figma:918:2978", "sourceKind": "measured", "verifiedOn": "2026-10-06", - "note": "Read through the Figma MCP (read only). 918:2978 (black on orange) and the program card 918:4186. The book also shows a white logo on orange (918:2980); black is the one that keeps to the contrast rule (918:2588)." + "note": "Read through the Figma MCP (read only). 918:2978 (black on orange) and the program card 918:4186. The book also shows a white logo on orange (918:2980), so it allows both; black is this package's selection, because it keeps to the contrast rule (918:2588), and it is open for a decision." } } } @@ -1500,7 +1500,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:78", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-1, keyed here by Tailwind's step number so that --mfb-space-1 always equals p-1." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-1, keyed here by Tailwind's step number so that --mfb-space-1 always equals p-1. Uses counted with git grep over src at 6368b2a: no line reads var(--s-1), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1513,7 +1513,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:79", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-2, keyed here by Tailwind's step number so that --mfb-space-2 always equals p-2." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-2, keyed here by Tailwind's step number so that --mfb-space-2 always equals p-2. Uses counted with git grep over src at 6368b2a: no line reads var(--s-2), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1526,7 +1526,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:80", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-3, keyed here by Tailwind's step number so that --mfb-space-3 always equals p-3." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-3, keyed here by Tailwind's step number so that --mfb-space-3 always equals p-3. Uses counted with git grep over src at 6368b2a: no line reads var(--s-3), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1539,7 +1539,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:81", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-4, keyed here by Tailwind's step number so that --mfb-space-4 always equals p-4." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-4, keyed here by Tailwind's step number so that --mfb-space-4 always equals p-4. Uses counted with git grep over src at 6368b2a: no line reads var(--s-4), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1552,7 +1552,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:82", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-5, keyed here by Tailwind's step number so that --mfb-space-6 always equals p-6." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-5, keyed here by Tailwind's step number so that --mfb-space-6 always equals p-6. Uses counted with git grep over src at 6368b2a: no line reads var(--s-5), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1565,7 +1565,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:83", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-6, keyed here by Tailwind's step number so that --mfb-space-8 always equals p-8." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-6, keyed here by Tailwind's step number so that --mfb-space-8 always equals p-8. Uses counted with git grep over src at 6368b2a: no line reads var(--s-6), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1578,7 +1578,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:84", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-7, keyed here by Tailwind's step number so that --mfb-space-12 always equals p-12." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-7, keyed here by Tailwind's step number so that --mfb-space-12 always equals p-12. Uses counted with git grep over src at 6368b2a: no line reads var(--s-7), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1591,7 +1591,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:85", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-8, keyed here by Tailwind's step number so that --mfb-space-16 always equals p-16." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-8, keyed here by Tailwind's step number so that --mfb-space-16 always equals p-16. Uses counted with git grep over src at 6368b2a: no line reads var(--s-8), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1604,7 +1604,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:86", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-9, keyed here by Tailwind's step number so that --mfb-space-24 always equals p-24." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-9, keyed here by Tailwind's step number so that --mfb-space-24 always equals p-24. Uses counted with git grep over src at 6368b2a: no line reads var(--s-9), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1617,7 +1617,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:87", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-10, keyed here by Tailwind's step number so that --mfb-space-32 always equals p-32." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-10, keyed here by Tailwind's step number so that --mfb-space-32 always equals p-32. Uses counted with git grep over src at 6368b2a: no line reads var(--s-10), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." } } } @@ -1658,7 +1658,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:97", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container-px, the padding of every .container (50 uses)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container-px, the side padding of its .container rule (global.css:277-278). Uses counted with git grep over src at 6368b2a: 50 lines set the class container." } } }, @@ -1671,7 +1671,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:281", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section, used 39 times." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section padding. Uses counted with git grep over src at 6368b2a: 40 lines set the class section. One page overrides it (global.css:286, clamp(40px, 5vw, 72px))." } } }, @@ -1684,7 +1684,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:282", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section--tight." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section--tight padding. Uses counted with git grep over src at 6368b2a: 3 lines set the class section--tight (ActionStrip, BreakingNews, StatsBanner)." } } }, @@ -1697,7 +1697,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EventsCalendar.tsx:91", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 13 uses across the site's blocks (FaqAccordion, ImageGallery, ImpactChart, StatsBanner, TeamGrid and others)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 13 lines carry clamp(32px, 4vw, 56px), in 11 files (EventsCalendar, FaqAccordion, ImageGallery, ImpactChart, StatsBanner, TeamGrid and others)." } } }, @@ -1710,7 +1710,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:297", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 11 uses across the site's blocks (Countdown, EventsGrid, StatsBanner, TeamGrid and others)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 11 lines carry clamp(32px, 4vw, 48px), in 10 files (BlogCarousel, Countdown, EventsGrid, StatsBanner, TeamGrid and others)." } } } @@ -1725,7 +1725,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:90", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 2 lines read var(--r-sm)." } } }, @@ -1738,7 +1738,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:91", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The Brand Book's own specimen label chips are drawn with 8px (918:2998): slide chrome, not a rule." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 20 lines read var(--r-md). The Brand Book's own specimen label chips are drawn with 8px (918:2998): slide chrome, not a rule." } } }, @@ -1751,7 +1751,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:92", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The card radius, 42 uses on the site. The Brand Book's specimen surfaces on Type Relationships (918:2993, 918:3005, 918:3020) use 12px: slide chrome, not a rule." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The card radius. Uses counted with git grep over src at 6368b2a: 48 lines read var(--r-lg). The Brand Book's specimen surfaces on Type Relationships (918:2993, 918:3005, 918:3020) use 12px: slide chrome, not a rule." } } }, @@ -1764,7 +1764,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:93", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 9 lines read var(--r-pill)." } } } @@ -1786,7 +1786,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:114", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(43, 28, 88, 0.25), purple-400 at 25%. 6 exact uses (Features, VideoSplit, SupporterStories, VideoEmbed, ContactCard, Hero), always none on dark surfaces." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(43, 28, 88, 0.25), purple-400 at 25%. Uses counted with git grep over src at 6368b2a: 6 lines carry it exactly (Features, VideoSplit, SupporterStories, VideoEmbed, ContactCard, Hero), always none on dark surfaces; one more uses 18% (EducatorSignupForm.tsx:180)." } } }, @@ -1806,7 +1806,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Hero.tsx:551", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.4), black at 40%. 6 uses (Hero, VideoSplit, VideoCarousel, VideoEmbed, ReportEmbed, VideoTile)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.4), black at 40%. Uses counted with git grep over src at 6368b2a: 7 lines (Hero, VideoSplit, VideoCarousel, VideoEmbed, ReportEmbed, VideoTile, the news article page)." } } }, @@ -1826,7 +1826,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1134", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.5), black at 50%. Also JourneyScoreboard and TransparencyFlow." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.5), black at 50%. Uses counted with git grep over src at 6368b2a: 3 lines (global.css, JourneyScoreboard, TransparencyFlow); one more uses 60% (global.css:1194)." } } } @@ -1846,7 +1846,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:100", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-out, 83 uses. Not Tailwind's ease-out, which is a different curve." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-out. Uses counted with git grep over src at 6368b2a: 82 lines read var(--ease-out). Not Tailwind's ease-out, which is a different curve." } } }, @@ -1864,7 +1864,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:101", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-in-out, 7 uses, all carousel tracks. Not Tailwind's ease-in-out, which is a different curve." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-in-out. Uses counted with git grep over src at 6368b2a: 7 lines read var(--ease-in-out), all carousel tracks. Not Tailwind's ease-in-out, which is a different curve." } } } @@ -1879,7 +1879,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:102", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-fast, 78 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-fast. Uses counted with git grep over src at 6368b2a: 77 lines read var(--dur-fast)." } } }, @@ -1892,7 +1892,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:103", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-med, 5 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-med. Uses counted with git grep over src at 6368b2a: 5 lines read var(--dur-med)." } } }, @@ -1905,7 +1905,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:197", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses (BlogCarousel, VideoCarousel, VerticalCarousel, Testimonials, EducatorCarousel, TestimonialCards)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 6 lines carry 520ms (BlogCarousel, VideoCarousel, VerticalCarousel, Testimonials, EducatorCarousel, TestimonialCards)." } } }, @@ -1918,7 +1918,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:533", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .reveal (global.css:530-535) on 105 elements, with a reduced-motion fallback (global.css:553-561). The site's --dur-slow (600ms, global.css:104) is never used and is not imported." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .reveal (global.css:530-535), with a reduced-motion fallback (global.css:553-561). Uses counted with git grep over src at 6368b2a: 105 lines set the class reveal. The site's --dur-slow (600ms, global.css:104) is never read and is not imported." } } } @@ -1933,7 +1933,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:96", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container, 50 uses of .container." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container, the max width of its .container rule (global.css:275). Uses counted with git grep over src at 6368b2a: 50 lines set the class container." } } }, @@ -1946,7 +1946,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlockWrapper.tsx:18", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses (BlockWrapper, Content, Roadmap, Header, Footer, the page hero grid)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 6 lines carry 1440px (BlockWrapper, Content, Roadmap, Header, Footer, the page hero grid)." } } }, @@ -1956,51 +1956,10 @@ "$description": "Maximum width of running text and of a centered section header.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:133", - "sourceKind": "declared", - "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 19 uses in the site's blocks, plus the article width (global.css:131)." - } - } - } - }, - "breakpoint": { - "phone": { - "$type": "dimension", - "$value": "600px", - "$description": "Phone layouts: max-width 600px.", - "$extensions": { - "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:749", - "sourceKind": "declared", - "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses. For JavaScript and matchMedia: a CSS variable cannot be read inside @media, and the package adds no Tailwind breakpoint." - } - } - }, - "stack": { - "$type": "dimension", - "$value": "720px", - "$description": "Multi-column layouts stack into one column: max-width 720px.", - "$extensions": { - "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:590", - "sourceKind": "declared", - "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's most used breakpoint: 7 media queries and 3 JavaScript thresholds. For JavaScript and matchMedia, as breakpoint.phone." - } - } - }, - "nav": { - "$type": "dimension", - "$value": "900px", - "$description": "The navigation collapses into a menu: max-width 900px.", - "$extensions": { - "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:777", + "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:131", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 7 uses (Header, Footer, JourneyScoreboard, the news pages, EducatorCarousel). For JavaScript and matchMedia, as breakpoint.phone." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The width of the article element (global.css:131), which holds the running text of news articles; the news page sets it inside a 760px column (src/pages/news/[...slug].astro:304). Uses counted with git grep over src at 6368b2a: the same 720px is also the max width of 19 centered section headers in the blocks (Features.tsx:133 and 18 others)." } } } @@ -2015,7 +1974,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:381", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 1 line (the sticky header); the site's other layers are -1 to 5, 999 and 1000." } } }, @@ -2028,7 +1987,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:486", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 1 line (the navigation dropdowns)." } } }, @@ -2041,7 +2000,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1115", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 8 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 8 lines carry z-index 1000, in 6 files (global.css, ImageGallery, JourneyScoreboard, TestimonialCards, TransparencyFlow, VideoCarousel)." } } } @@ -2056,7 +2015,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/VideoTile.astro:50", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 6 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 7 lines set aspect-ratio 16 / 9 (14 more content entries choose '16:9' for a video block)." } } }, @@ -2069,7 +2028,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/ImageGallery.tsx:168", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 4 lines set aspect-ratio 4 / 3." } } }, @@ -2082,7 +2041,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EducatorCarousel.tsx:107", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 3 lines set aspect-ratio 4 / 5 (EducatorCarousel, EventsGrid, SplitContent)." } } }, @@ -2092,10 +2051,10 @@ "$description": "Square images and avatars.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/TeamGrid.tsx:201", + "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/SupporterStories.tsx:84", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 4 uses." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 3 lines set a square aspect ratio: 1 / 1 at SupporterStories.tsx:84 and ArticleImage.astro:79, and '1' at TeamGrid.tsx:201." } } } @@ -2110,7 +2069,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:409", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. 9 uses (ImageGallery, JourneyScoreboard, ProgramGrid, TestimonialCards, TransparencyFlow, VerticalCarousel, VideoCarousel, global.css)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 9 elements set both width and height to 44 (ImageGallery, JourneyScoreboard, ProgramGrid, TestimonialCards, TransparencyFlow, VerticalCarousel, VideoCarousel, and two in global.css)." } } }, @@ -2123,7 +2082,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/pages/news/[...slug].astro:342", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Only the offset is imported: the orange-400 link text in the same rule (line 340) breaks the contrast rule (918:2588)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Only the offset is imported: the orange-400 link text in the same rule (line 340) breaks the contrast rule (918:2588). Uses counted with git grep over src at 6368b2a: 2 lines, both on the news article page." } } } From 1e1e61f43c8f96964d5aff9a3655f2075f1659ff Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 15:44:02 +0000 Subject: [PATCH 18/21] Canon follows the Brand Book: dark text on orange, black on light, book type relationships The supergraphics canon now reads the color roles and the type relationships: - .sg-cta, .sg-bg-orange and .sg-cover--orange set black text on orange-300 (9.2:1; white was 2.3:1) - .sg-bg-white, .sg-bg-gray and .sg-info-bar--on-light set black instead of gray-900 - the light info bar's date is black instead of orange-300; the date's small line loses its 75% opacity and takes gray-700 on light and gray-400 on dark - .sg-headline and .sg-cover__title: line height 1 and no tracking (were 1.05 and -0.03em); the comment that called -0.03em book tracking is corrected - .sg-label: Regular, line height 1.2, no tracking (was Medium, 1.5px); .sg-body reads the token The 6px radius on .sg-para-frame and .sg-para-accent and the 40px radius on .sg-cta have no radius token of that value, so they stay and the spec lists them. New declared token lineHeight.body-long (1.7), from the website's article prose, for long reading only and flagged for review; the Brand Book's body line height is for body copy next to a heading. The build allows it as a level variant and refuses a canon that reads a variable the supergraphics prelude does not set; the prelude now carries the roles, line heights and letter spacing. README upgrade notes and the spec list every visible change. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++-- brand-spec.md | 18 +++---- brand.css | 1 + index.js | 1 + scripts/brand-spec.mjs | 4 ++ scripts/build-design-package.mjs | 92 ++++++++++++++++++++++---------- src/brand-spec.template.md | 18 +++---- src/supergraphics.canon.css | 37 ++++++++----- supergraphics.css | 75 +++++++++++++++++++++----- tailwind.js | 1 + theme.css | 1 + tokens.json | 15 +++++- 12 files changed, 194 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 59433f7..783cad8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Make these changes in the same commit that raises your pin: - **Delete any local `.highlighter` band** (for example a `linear-gradient(transparent 80%, ...)` background on `.highlighter`), and any local rule that changes its `display` or `text-align`. The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color. - **Remove any padding your project adds around `.sg-para-pattern` to make room for the slant.** Each row now insets itself by half its height times tan(13°), so its first shape is no longer clipped; extra padding moves the rows further right. - **Set `--sg-aspect` on parallelogram frames too.** The photo scale in `.sg-para-frame` was a fixed 1.12; it is now `1 + tan(--sg-angle-base) / --sg-aspect`, the smallest scale that covers the frame, so it depends on the frame's real shape. -- **`supergraphics.css` sets more variables in `:root`**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing `supergraphics.css`, or import the file into a cascade layer. +- **`supergraphics.css` sets more variables in `:root`**: the font families, sizes and weights, the halftone colors, the color roles (`--mfb--on-`) and the line height and letter spacing per level (`--mfb-leading-`, `--mfb-tracking-`), besides the palette and the geometry. If you override any of them, do it after importing `supergraphics.css`, or import the file into a cascade layer. - `.sg-cover__title` reads `--mfb-size-h2`. The `--mfb-font-h2` variable it used to read was never defined by this package; set `font-size` on the title instead. - **Tailwind 4 projects that restrict a theme namespace**, for example with `--font-weight-*: initial` or `--radius-*: initial`, must put that reset in its own `@theme` block before importing `theme.css`: placed after the import, it removes the package's values too, and no utility is generated for them. @@ -28,7 +28,13 @@ Make these changes in the same commit that raises your pin: ``` - **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in `tokens.json` now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to. -- **Text on orange is black, buttons included.** `.sg-cta`, `.sg-bg-orange` and `.sg-cover--orange` still set white text on orange-300 (2.3:1, below the 4.5:1 that body text and buttons need); set `color: var(--mfb-link-on-orange)` (or `text-link-on-orange`) on them yourself. The other supergraphics classes that differ from the Brand Book are listed in `brand-spec.md` ("Supergraphics classes that differ from the Brand Book"). +- **Text on orange is black, buttons included, and the supergraphics classes now set it.** `.sg-cta` sets `var(--mfb-link-on-orange)`, `.sg-bg-orange` `var(--mfb-body-on-orange)` and `.sg-cover--orange` (with its `.sg-cover__title`) `var(--mfb-heading-on-orange)`: black, 9.2:1 on orange-300, where they set white (2.3:1, below the 4.5:1 that body text and buttons need). Delete any local white `color` on them, which would bring the old contrast back; a local black one can go too. +- **Text on the light supergraphics surfaces is black.** `.sg-bg-white`, `.sg-bg-gray` and `.sg-info-bar--on-light` set `var(--mfb-body-on-light)` (black) instead of gray-900. +- **The light info bar's date is black, and the line under it is a solid color.** On `.sg-info-bar--on-light`, `.sg-info-bar__date` is `var(--mfb-heading-on-light)` (black) instead of orange-300 (2.3:1 on white). The date's `small` loses its 75% opacity: it is `var(--mfb-muted-on-light)` (gray-700, 5.2:1) on the light bar and `var(--mfb-muted-on-dark)` (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without either variant it keeps the date's color, fully opaque. +- **`.sg-headline` and `.sg-cover__title` set line height 1 and no letter spacing** (`--mfb-leading-h1` and `--mfb-tracking-h1`; `h2` for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box. +- **`.sg-label` is Regular (400), not Medium, with line height 1.2 and no letter spacing** (`--mfb-leading-label`, `--mfb-tracking-label`), instead of 1.5px. Its size stays 18px. `.sg-body` reads `--mfb-leading-body`, the same 1.2 as before. +- **A line height for long reading, `--mfb-leading-body-long` (`leading-body-long`), 1.7.** It is declared from the website's article text, because the Brand Book only sets body copy next to a heading (`--mfb-leading-body`, 1.2). It is under review, and nothing uses it until a page adopts it. +- The supergraphics classes that still differ from the Brand Book are listed in `brand-spec.md` ("Supergraphics classes that differ from the Brand Book"). - **A test that snapshots the `--mfb-*` variables of `brand.css` whose value is a hex** must refresh its snapshot: this release adds `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow`. The other new variables carry no hex; they reference the palette with `var()`. - **Everything else is added, not changed.** Every new Tailwind utility is `mfb-` prefixed, a type level (`leading-h1`, `tracking-h1`, `text-h1-fluid`), a color role (`text-heading-on-light`) or `font-regular`. The weights `font-normal`, `font-medium` and `font-semibold` restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below. @@ -61,7 +67,7 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-para-pattern`, `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, and the `highlighter`. **Never hand-roll these shapes in a page**: the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. -Some classes still carry values the Brand Book does not have, and a release will change them: most visibly, `.sg-cta`, `.sg-bg-orange` and `.sg-cover--orange` set white text on orange, where the book puts black. Set `color: var(--mfb-link-on-orange)` on them until then. `brand-spec.md` lists every such class and what to set. +The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: `.sg-cta`'s 1px letter spacing and 40px radius, `.sg-label`'s 18px size, the 6px corners of `.sg-para-frame` and `.sg-para-accent`, the pattern's spacing and the logo's height. `brand-spec.md` lists every such class and what to set. ### Set --sg-aspect on every frame @@ -78,7 +84,7 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile | What | brand.css | Tailwind 3 and 4 | |------|-----------|------------------| | Color roles | `--mfb--on-`: `heading-on-light`, `body-on-light`, `muted-on-light`, `link-on-light`, `link-underline-on-light`, `border-on-light`, `highlighter-on-light`, `logo-on-light`, `heading-on-dark`, `body-on-dark`, `muted-on-dark`, `link-on-dark`, `highlighter-on-dark`, `logo-on-dark`, `heading-on-orange`, `body-on-orange`, `link-on-orange`, `highlighter-on-orange`, `logo-on-orange` | `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light` ... | -| Line height per level | `--mfb-leading-` | `leading-`, for example `leading-h1` | +| Line height per level | `--mfb-leading-`, and `--mfb-leading-body-long` for long reading (declared from the website, under review) | `leading-`, for example `leading-h1`; `leading-body-long` | | Letter spacing per level | `--mfb-tracking-` | `tracking-` | | Fluid sizes for web pages | `--mfb-size--fluid` (h1, h2, h3, h4, h5, h6, quote, label) | `text--fluid` | | Spacing scale | `--mfb-space-1`, `--mfb-space-2`, `--mfb-space-3`, `--mfb-space-4`, `--mfb-space-6`, `--mfb-space-8`, `--mfb-space-12`, `--mfb-space-16`, `--mfb-space-24`, `--mfb-space-32` | Tailwind's own `p-1` to `p-32`: the same values, so the package adds none | diff --git a/brand-spec.md b/brand-spec.md index 30e1f77..7f9b8f6 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -132,6 +132,7 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw | quote | 40px | 1 | 0em | `clamp(22px, 2.8vw, 40px)` (website `src/styles/global.css:72`) | - **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). +- **Body line height, short and long.** The Brand Book's body line height is for body copy paired with a heading: its type specimen states 1.2, and Type Relationships (`918:2990`) draws body copy under a heading at 1.22; the token keeps the stated number. The book shows no long reading text. For articles and other long running prose, `--mfb-leading-body-long` (`leading-body-long`) is 1.7, a value declared from the website's article text (website `src/pages/news/[...slug].astro:312`), not a Brand Book value. It is flagged for review: use it only for long reading text, never for headings or for body copy next to a heading. - **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). - **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size, so below about 1125px wide the fluid label is smaller than body text (16px against 18px at a phone's width), the reverse of the Brand Book's order; where a label sits next to body text on small screens, use the fixed label size. A fluid body size is not decided yet. - **Space after a heading:** 40px between any heading and the body text under it, at the 18px body size (Brand Book `918:2990`, measured). **Between a quote and its label:** 48px (Brand Book `918:3005`, measured). @@ -225,7 +226,7 @@ The website also carries values that contradict the Brand Book. They are not tok - teal, indigo and other off-palette colors, a blue-grey page text color, and translucent white text: palette colors only - orange text and links on light backgrounds, and white text on orange buttons: dark text on light colors and on every orange (`918:2588`) - gray-900 headings and body text: black (`918:2990`) -- heading line height 1.05 and body line height 1.55 or 1.7: the Brand Book's line heights (Type relationships above) +- heading line height 1.05, and body line height 1.55 in page sections: the Brand Book's line heights (Type relationships above). The website's 1.7 is declared for long reading text only (`--mfb-leading-body-long`), not for body copy next to a heading ## 2. Binding Rules (The 12) @@ -431,7 +432,7 @@ The roles behind this table are in Section 1 (Color roles). - **Headings on light backgrounds:** black (`#000000`), as in the Brand Book's *Type Relationships* (`918:2990`) and its Brand in Use examples. purple-300 is the brand's surface and shape color, not a heading color on white - **Text on light backgrounds:** black (`#000000`), body copy included (`918:2994`). gray-700 is for secondary text only (captions, meta), on white or gray-100 - **Headings and text on dark backgrounds:** white -- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them (see "Supergraphics classes that differ from the Brand Book" below) +- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` set it (black, 9.2:1 on orange-300) - **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule. A link on a light background never loses its underline: purple-300 is only 1.8:1 from black text, so the underline is what marks it. On gray-400 and gray-500 an orange underline cannot be seen (1.3:1 and 1.0:1): underline in the link's own color there - **Links on dark backgrounds:** orange-300 on purple-300 and purple-400 (5.0:1 and 6.5:1), or white. On purple-200 (orange-300 is 3.8:1 there) and on the dark greys, links are white and underlined (8.7:1 on purple-200) - **Orange is for accents only** (CTA fills with black text, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes, and never text on a light background @@ -439,18 +440,13 @@ The roles behind this table are in Section 1 (Color roles). ### Supergraphics classes that differ from the Brand Book -A few classes in this package's `supergraphics.css` still carry values from before the Brand Book was measured. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: +The classes in this package's `supergraphics.css` read the color roles and the type relationships above: text on orange is black (`.sg-cta`, `.sg-bg-orange`, `.sg-cover--orange`), text on light surfaces is black (`.sg-bg-white`, `.sg-bg-gray`, `.sg-info-bar--on-light`, whose date is black and whose date line is gray-700, with no opacity), `.sg-headline` and `.sg-cover__title` have line height 1 and no tracking, and `.sg-label` is Regular at line height 1.2 with no tracking. A few values from before the Brand Book was measured remain. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: | Class | What it sets | The Brand Book | Set instead | |-------|--------------|----------------|-------------| -| `.sg-cta` | white text on orange-300 (2.3:1), 1px letter spacing, a 40px radius | black text on orange, no tracking | `color: var(--mfb-link-on-orange)` and `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)`) | -| `.sg-bg-orange` | white text on orange-300 | black text on orange | `color: var(--mfb-body-on-orange)` | -| `.sg-cover--orange` | white title and text on orange-300 (from `.sg-cover`) | black text on orange | `color: var(--mfb-heading-on-orange)` | -| `.sg-info-bar--on-light` | gray-900 text; the date (`.sg-info-bar__date`) in orange-300 on white (2.3:1), and the line under it at 75% opacity (1.9:1) | black text on light colors, never orange text there | `color: var(--mfb-body-on-light)` on the bar, and the same on its date and the date's `small` (with `opacity: 1`) | -| `.sg-bg-white`, `.sg-bg-gray` | gray-900 text | black text on light colors | `color: var(--mfb-body-on-light)` | -| `.sg-headline`, `.sg-cover__title` | line height 1.05, letter spacing -0.03em | line height 1 for H1 to H4 (1.1 for H5 and H6), no tracking | `line-height: var(--mfb-leading-h1)` (the level you set) and `letter-spacing: 0` | -| `.sg-label` | 18px, Medium (500), 1.5px letter spacing | 22px, Regular (400), no tracking | `font-size: var(--mfb-size-label)`, `font-weight: var(--mfb-weight-regular)`, `letter-spacing: 0` | -| `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram | sharp corners | `border-radius: 0` | +| `.sg-cta` | 1px letter spacing, a 40px radius | no tracking | `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)` on a button up to 80px tall; no radius token is 40px, so the class keeps it) | +| `.sg-label` | 18px | 22px | `font-size: var(--mfb-size-label)` | +| `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram (no radius token is 6px) | sharp corners | `border-radius: 0` | | `.sg-para-pattern` | rows 10px apart, stepped by 1.25 and 1.55 | rows that touch, each 1.2 times the one before (Rule 7) | nothing yet: a release changes the class | | `.sg-logo`, `.sg-cover__logo` | a logo 40px tall | a logo at least 63px tall on screens (Logo, Section 1) | nothing yet: the height is not decided | diff --git a/brand.css b/brand.css index 33c02ef..d06eca1 100644 --- a/brand.css +++ b/brand.css @@ -83,6 +83,7 @@ --mfb-leading-h5: 1.1; --mfb-leading-h6: 1.1; --mfb-leading-body: 1.2; + --mfb-leading-body-long: 1.7; --mfb-leading-label: 1.2; --mfb-leading-quote: 1; --mfb-tracking-h1: 0em; diff --git a/index.js b/index.js index 150f3e2..3d427b1 100644 --- a/index.js +++ b/index.js @@ -107,6 +107,7 @@ export const lineHeight = { "h5": 1.1, "h6": 1.1, "body": 1.2, + "body-long": 1.7, "label": 1.2, "quote": 1 }; diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index c0d5ef7..a292145 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -158,6 +158,10 @@ values.TYPE_ROWS = groupEntries('fontSize', true) }) .join('\n'); +values.BODY_LH = need('lineHeight', 'body'); +values.BODY_LONG_LH = need('lineHeight', 'body-long'); +values.BODY_LONG_SRC = sourceOf('lineHeight', 'body-long'); + values.COLOR_ROLE_ROWS = rows('colorRole', true, (name, tok) => `| \`${name}\` | ${colorOf(`colorRole.${name}`, tok.$value)} | ${cell(tok.$description)} | ${sourceOf('colorRole', name)} |`); values.SHAPE_TONE_ITEMS = rows('shapeTone', true, (name, tok) => diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 23b7447..bd80c10 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -240,11 +240,23 @@ for (const k of Object.keys(GEOM_TOKEN_ONLY)) { // Type levels: line height, letter spacing and fluid sizes exist only for fontSize levels, and a // fluid size grows up to the Brand Book size and no further (the cap is the book's, the floor -// and slope are the website's). +// and slope are the website's). A level variant (LEVEL_VARIANTS) is a second value for one +// level, named -, with the reason; its Tailwind key is a type level's, so it needs no +// mfb- prefix. const LEVELS = Object.keys(tokens.fontSize || {}); +const LEVEL_VARIANTS = { + lineHeight: { 'body-long': 'body text in long reading (articles); the Brand Book sets only short body copy' }, +}; for (const group of ['lineHeight', 'letterSpacing', 'fontSizeFluid']) { for (const k of Object.keys(tokens[group] || {})) { - if (!LEVELS.includes(k)) gateProblems.push(`${group}.${k}: not a fontSize level (${LEVELS.join(', ')})`); + const variant = LEVEL_VARIANTS[group]?.[k]; + if (variant && LEVELS.includes(k.split('-')[0])) continue; + if (!LEVELS.includes(k)) gateProblems.push(`${group}.${k}: not a fontSize level (${LEVELS.join(', ')}) nor a level variant listed in LEVEL_VARIANTS`); + } +} +for (const [group, variants] of Object.entries(LEVEL_VARIANTS)) { + for (const k of Object.keys(variants)) { + if (!tokens[group]?.[k]) gateProblems.push(`LEVEL_VARIANTS names ${group}.${k}, which tokens.json lacks`); } } for (const [k, tok] of Object.entries(tokens.fontSizeFluid || {})) { @@ -407,6 +419,46 @@ const halftone = Object.entries(tokens.geometry || {}) .map(([k, tok]) => [`--mfb-${k}`, tok.$value]); const ff = (arr) => arr.map((f) => (/\s/.test(f) ? `"${f}"` : f)).join(', '); +// The supergraphics prelude (written in section 4b) is assembled here, before any file is +// written, so a canon that reads a variable the prelude lacks stops the build with nothing written. +const SG_PRELUDE_GROUPS = ['colorRole', 'lineHeight', 'letterSpacing']; +let sgPrelude = + '/* Generated by build-design-package.mjs - DO NOT EDIT BY HAND.\n' + + ' Canon: src/supergraphics.canon.css + token prelude from tokens.json.\n' + + ' Geometry below is asserted equal to the canon at build time; a mismatch fails the build. */\n' + + ':root {\n'; +for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; +if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; +// The canon's type classes (.sg-headline, .sg-cta ...) read the font, size and weight +// variables, so the prelude carries them. Deprecated families (mono) are left out: nothing in +// the canon uses them, and brand.css still serves them for compatibility. +const isDeprecated = (tok) => /^DEPRECATED/i.test(String(tok?.$status || '')); +for (const [k, v] of Object.entries(fontFamily)) { + const tok = tokens.fontFamily[k] || tokens.fontFamily.body; // `sans` mirrors body + if (!isDeprecated(tok)) sgPrelude += ` --mfb-font-${k}: ${ff(v)};\n`; +} +for (const [k, tok] of Object.entries(tokens.fontSize || {})) sgPrelude += ` --mfb-size-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; +for (const [cssVar, v] of halftone) sgPrelude += ` ${cssVar}: ${v};\n`; +for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { + sgPrelude += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; +} +// The canon's text colors, line heights and letter spacing read the color roles and the type +// relationships (the Brand Book's), so the prelude carries those groups as brand.css writes them. +for (const group of SG_PRELUDE_GROUPS) { + const map = GROUP_MAP[group]; + for (const [k, tok] of Object.entries(tokens[group] || {})) sgPrelude += ` ${map.css}${k}${map.suffix}: ${valueFor(group, k, tok, 'css')};\n`; +} +sgPrelude += '}\n\n'; +// Every --mfb-* variable the canon reads must be in the prelude, so the file stays standalone. +const preludeVars = new Set([...sgPrelude.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1])); +const unset = [...new Set([...sgCanon.matchAll(/var\((--mfb-[\w-]+)/g)].map((m) => m[1]))].filter((v) => !preludeVars.has(v)); +if (unset.length) { + console.error('REFUSING TO BUILD: the canon reads variables the supergraphics prelude does not set:'); + for (const v of unset) console.error(` - ${v}`); + process.exit(1); +} + // ---- 1. tailwind.js - Tailwind 3 preset (also usable in TW4 via @config) ---- // The new groups (GROUP_MAP) extend Tailwind's scales under their own keys; colors and fontSize // keep every existing key and value, and gain only the color roles and the fluid sizes. @@ -465,28 +517,6 @@ if (path.join(OUT_DIR, 'tokens.json') !== TOKENS_PATH) { // defines --color-*), so the --mfb-* variables the supergraphics reference must be // self-provided. Public pages cannot reach @mfb/shared, so this package is how they get the // primitives instead of hand-rolling wrong angles. -let sgPrelude = - '/* Generated by build-design-package.mjs - DO NOT EDIT BY HAND.\n' + - ' Canon: src/supergraphics.canon.css + token prelude from tokens.json.\n' + - ' Geometry below is asserted equal to the canon at build time; a mismatch fails the build. */\n' + - ':root {\n'; -for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; -if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; -// The canon's type classes (.sg-headline, .sg-cta ...) read the font, size and weight -// variables, so the prelude carries them. Deprecated families (mono) are left out: nothing in -// the canon uses them, and brand.css still serves them for compatibility. -const isDeprecated = (tok) => /^DEPRECATED/i.test(String(tok?.$status || '')); -for (const [k, v] of Object.entries(fontFamily)) { - const tok = tokens.fontFamily[k] || tokens.fontFamily.body; // `sans` mirrors body - if (!isDeprecated(tok)) sgPrelude += ` --mfb-font-${k}: ${ff(v)};\n`; -} -for (const [k, tok] of Object.entries(tokens.fontSize || {})) sgPrelude += ` --mfb-size-${k}: ${tok.$value};\n`; -for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; -for (const [cssVar, v] of halftone) sgPrelude += ` ${cssVar}: ${v};\n`; -for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { - sgPrelude += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; -} -sgPrelude += '}\n\n'; write('supergraphics.css', sgPrelude + sgCanon); // ---- 5. index.js - programmatic access ---- @@ -558,14 +588,20 @@ const readme = [ `The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color.`, `- **Remove any padding your project adds around ${code('.sg-para-pattern')} to make room for the slant.** Each row now insets itself by half its height times tan(${ANGLE}), so its first shape is no longer clipped; extra padding moves the rows further right.`, `- **Set ${code('--sg-aspect')} on parallelogram frames too.** The photo scale in ${code('.sg-para-frame')} was a fixed 1.12; it is now ${code('1 + tan(--sg-angle-base) / --sg-aspect')}, the smallest scale that covers the frame, so it depends on the frame's real shape.`, - `- **${code('supergraphics.css')} sets more variables in ${code(':root')}**: the font families, sizes and weights and the halftone colors, besides the palette and the geometry. If you override any of them, do it after importing ${code('supergraphics.css')}, or import the file into a cascade layer.`, + `- **${code('supergraphics.css')} sets more variables in ${code(':root')}**: the font families, sizes and weights, the halftone colors, the color roles (${code('--mfb--on-')}) and the line height and letter spacing per level (${code('--mfb-leading-')}, ${code('--mfb-tracking-')}), besides the palette and the geometry. If you override any of them, do it after importing ${code('supergraphics.css')}, or import the file into a cascade layer.`, `- ${code('.sg-cover__title')} reads ${code('--mfb-size-h2')}. The ${code('--mfb-font-h2')} variable it used to read was never defined by this package; set ${code('font-size')} on the title instead.`, `- **Tailwind 4 projects that restrict a theme namespace**, for example with ${code('--font-weight-*: initial')} or ${code('--radius-*: initial')}, must put that reset in its own ${code('@theme')} block before importing ${code('theme.css')}: placed after the import, it removes the package's values too, and no utility is generated for them.`, ``, `${FENCE}css\n@import "tailwindcss";\n@theme { --font-weight-*: initial; }\n@import "@myfirstbitcoin/design/theme.css";\n${FENCE}`, ``, `- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in ${code('tokens.json')} now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to.`, - `- **Text on orange is black, buttons included.** ${code('.sg-cta')}, ${code('.sg-bg-orange')} and ${code('.sg-cover--orange')} still set white text on orange-300 (2.3:1, below the 4.5:1 that body text and buttons need); set ${code('color: var(--mfb-link-on-orange)')} (or ${code('text-link-on-orange')}) on them yourself. The other supergraphics classes that differ from the Brand Book are listed in ${code('brand-spec.md')} ("Supergraphics classes that differ from the Brand Book").`, + `- **Text on orange is black, buttons included, and the supergraphics classes now set it.** ${code('.sg-cta')} sets ${code('var(--mfb-link-on-orange)')}, ${code('.sg-bg-orange')} ${code('var(--mfb-body-on-orange)')} and ${code('.sg-cover--orange')} (with its ${code('.sg-cover__title')}) ${code('var(--mfb-heading-on-orange)')}: black, 9.2:1 on orange-300, where they set white (2.3:1, below the 4.5:1 that body text and buttons need). Delete any local white ${code('color')} on them, which would bring the old contrast back; a local black one can go too.`, + `- **Text on the light supergraphics surfaces is black.** ${code('.sg-bg-white')}, ${code('.sg-bg-gray')} and ${code('.sg-info-bar--on-light')} set ${code('var(--mfb-body-on-light)')} (black) instead of gray-900.`, + `- **The light info bar's date is black, and the line under it is a solid color.** On ${code('.sg-info-bar--on-light')}, ${code('.sg-info-bar__date')} is ${code('var(--mfb-heading-on-light)')} (black) instead of orange-300 (2.3:1 on white). The date's ${code('small')} loses its 75% opacity: it is ${code('var(--mfb-muted-on-light)')} (gray-700, 5.2:1) on the light bar and ${code('var(--mfb-muted-on-dark)')} (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without either variant it keeps the date's color, fully opaque.`, + `- **${code('.sg-headline')} and ${code('.sg-cover__title')} set line height 1 and no letter spacing** (${code('--mfb-leading-h1')} and ${code('--mfb-tracking-h1')}; ${code('h2')} for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box.`, + `- **${code('.sg-label')} is Regular (400), not Medium, with line height 1.2 and no letter spacing** (${code('--mfb-leading-label')}, ${code('--mfb-tracking-label')}), instead of 1.5px. Its size stays 18px. ${code('.sg-body')} reads ${code('--mfb-leading-body')}, the same 1.2 as before.`, + `- **A line height for long reading, ${code('--mfb-leading-body-long')} (${code('leading-body-long')}), ${tokens.lineHeight['body-long'].$value}.** It is declared from the website's article text, because the Brand Book only sets body copy next to a heading (${code('--mfb-leading-body')}, ${tokens.lineHeight.body.$value}). It is under review, and nothing uses it until a page adopts it.`, + `- The supergraphics classes that still differ from the Brand Book are listed in ${code('brand-spec.md')} ("Supergraphics classes that differ from the Brand Book").`, `- **A test that snapshots the ${code('--mfb-*')} variables of ${code('brand.css')} whose value is a hex** must refresh its snapshot: this release adds ${halftoneList}. The other new variables carry no hex; they reference the palette with ${code('var()')}.`, `- **Everything else is added, not changed.** Every new Tailwind utility is ${code('mfb-')} prefixed, a type level (${code('leading-h1')}, ${code('tracking-h1')}, ${code('text-h1-fluid')}), a color role (${code('text-heading-on-light')}) or ${code('font-regular')}. The weights ${code('font-normal')}, ${code('font-medium')} and ${code('font-semibold')} restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below.`, ``, @@ -591,7 +627,7 @@ const readme = [ `**Never hand-roll these shapes in a page**: the brand angle is exactly ${ANGLE} (${code('--sg-angle-base')}) and hand-rolled copies drift. ` + `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.`, ``, - `Some classes still carry values the Brand Book does not have, and a release will change them: most visibly, ${code('.sg-cta')}, ${code('.sg-bg-orange')} and ${code('.sg-cover--orange')} set white text on orange, where the book puts black. Set ${code('color: var(--mfb-link-on-orange)')} on them until then. ${code('brand-spec.md')} lists every such class and what to set.`, + `The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: ${code('.sg-cta')}'s 1px letter spacing and 40px radius, ${code('.sg-label')}'s 18px size, the 6px corners of ${code('.sg-para-frame')} and ${code('.sg-para-accent')}, the pattern's spacing and the logo's height. ${code('brand-spec.md')} lists every such class and what to set.`, ``, `### Set --sg-aspect on every frame`, ``, @@ -618,7 +654,7 @@ const readme = [ `| What | brand.css | Tailwind 3 and 4 |`, `|------|-----------|------------------|`, `| Color roles | ${code('--mfb--on-')}: ${keysOf('colorRole').map((k) => code(k)).join(', ')} | ${code('text-heading-on-light')}, ${code('decoration-link-underline-on-light')}, ${code('border-border-on-light')} ... |`, - `| Line height per level | ${code('--mfb-leading-')} | ${code('leading-')}, for example ${code('leading-h1')} |`, + `| Line height per level | ${code('--mfb-leading-')}, and ${code('--mfb-leading-body-long')} for long reading (declared from the website, under review) | ${code('leading-')}, for example ${code('leading-h1')}; ${code('leading-body-long')} |`, `| Letter spacing per level | ${code('--mfb-tracking-')} | ${code('tracking-')} |`, `| Fluid sizes for web pages | ${code('--mfb-size--fluid')} (${keysOf('fontSizeFluid').join(', ')}) | ${code('text--fluid')} |`, `| Spacing scale | ${keysOf('spaceScale').map((k) => code(`--mfb-space-${k}`)).join(', ')} | Tailwind's own ${code('p-1')} to ${code('p-32')}: the same values, so the package adds none |`, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index c67a720..6030d31 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -76,6 +76,7 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw {{TYPE_ROWS}} - **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). +- **Body line height, short and long.** The Brand Book's body line height is for body copy paired with a heading: its type specimen states {{BODY_LH}}, and Type Relationships (`918:2990`) draws body copy under a heading at 1.22; the token keeps the stated number. The book shows no long reading text. For articles and other long running prose, `--mfb-leading-body-long` (`leading-body-long`) is {{BODY_LONG_LH}}, a value declared from the website's article text ({{BODY_LONG_SRC}}), not a Brand Book value. It is flagged for review: use it only for long reading text, never for headings or for body copy next to a heading. - **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). - **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size, so below about 1125px wide the fluid label is smaller than body text (16px against 18px at a phone's width), the reverse of the Brand Book's order; where a label sits next to body text on small screens, use the fixed label size. A fluid body size is not decided yet. - **Space after a heading:** {{SPACE_HEADING_BODY}} between any heading and the body text under it, at the 18px body size ({{SPACE_HEADING_BODY_SRC}}). **Between a quote and its label:** {{SPACE_QUOTE_LABEL}} ({{SPACE_QUOTE_LABEL_SRC}}). @@ -146,7 +147,7 @@ The website also carries values that contradict the Brand Book. They are not tok - teal, indigo and other off-palette colors, a blue-grey page text color, and translucent white text: palette colors only - orange text and links on light backgrounds, and white text on orange buttons: dark text on light colors and on every orange (`918:2588`) - gray-900 headings and body text: black (`918:2990`) -- heading line height 1.05 and body line height 1.55 or 1.7: the Brand Book's line heights (Type relationships above) +- heading line height 1.05, and body line height 1.55 in page sections: the Brand Book's line heights (Type relationships above). The website's 1.7 is declared for long reading text only (`--mfb-leading-body-long`), not for body copy next to a heading ## 2. Binding Rules (The 12) @@ -352,7 +353,7 @@ The roles behind this table are in Section 1 (Color roles). - **Headings on light backgrounds:** black (`{{BLACK}}`), as in the Brand Book's *Type Relationships* (`918:2990`) and its Brand in Use examples. purple-300 is the brand's surface and shape color, not a heading color on white - **Text on light backgrounds:** black (`{{BLACK}}`), body copy included (`918:2994`). gray-700 is for secondary text only (captions, meta), on white or gray-100 - **Headings and text on dark backgrounds:** white -- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` still set white text on orange; set the text color yourself until a release changes them (see "Supergraphics classes that differ from the Brand Book" below) +- **Text on orange:** black, never white (white on orange-300 is 2.3:1). This applies to buttons too: an orange-300 button has black text. The supergraphics classes `.sg-bg-orange`, `.sg-cover--orange` and `.sg-cta` set it (black, 9.2:1 on orange-300) - **Links on light backgrounds:** purple-300 text with an orange-300 underline. The Brand Book has no link style; its *Color Contrast* slide (`918:2588`) says "Light text should be placed on dark colors, and dark text on light colors", and it pairs every orange with dark text, so orange link text on white breaks it. The purple-300 text and the orange-300 underline are this package's recommendation, not a Brand Book rule. A link on a light background never loses its underline: purple-300 is only 1.8:1 from black text, so the underline is what marks it. On gray-400 and gray-500 an orange underline cannot be seen (1.3:1 and 1.0:1): underline in the link's own color there - **Links on dark backgrounds:** orange-300 on purple-300 and purple-400 (5.0:1 and 6.5:1), or white. On purple-200 (orange-300 is 3.8:1 there) and on the dark greys, links are white and underlined (8.7:1 on purple-200) - **Orange is for accents only** (CTA fills with black text, link underlines, links on dark backgrounds, highlights, ₿ symbol), never a full background fill beyond explicit orange-300 brand shapes, and never text on a light background @@ -360,18 +361,13 @@ The roles behind this table are in Section 1 (Color roles). ### Supergraphics classes that differ from the Brand Book -A few classes in this package's `supergraphics.css` still carry values from before the Brand Book was measured. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: +The classes in this package's `supergraphics.css` read the color roles and the type relationships above: text on orange is black (`.sg-cta`, `.sg-bg-orange`, `.sg-cover--orange`), text on light surfaces is black (`.sg-bg-white`, `.sg-bg-gray`, `.sg-info-bar--on-light`, whose date is black and whose date line is gray-700, with no opacity), `.sg-headline` and `.sg-cover__title` have line height 1 and no tracking, and `.sg-label` is Regular at line height 1.2 with no tracking. A few values from before the Brand Book was measured remain. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: | Class | What it sets | The Brand Book | Set instead | |-------|--------------|----------------|-------------| -| `.sg-cta` | white text on orange-300 (2.3:1), 1px letter spacing, a 40px radius | black text on orange, no tracking | `color: var(--mfb-link-on-orange)` and `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)`) | -| `.sg-bg-orange` | white text on orange-300 | black text on orange | `color: var(--mfb-body-on-orange)` | -| `.sg-cover--orange` | white title and text on orange-300 (from `.sg-cover`) | black text on orange | `color: var(--mfb-heading-on-orange)` | -| `.sg-info-bar--on-light` | gray-900 text; the date (`.sg-info-bar__date`) in orange-300 on white (2.3:1), and the line under it at 75% opacity (1.9:1) | black text on light colors, never orange text there | `color: var(--mfb-body-on-light)` on the bar, and the same on its date and the date's `small` (with `opacity: 1`) | -| `.sg-bg-white`, `.sg-bg-gray` | gray-900 text | black text on light colors | `color: var(--mfb-body-on-light)` | -| `.sg-headline`, `.sg-cover__title` | line height 1.05, letter spacing -0.03em | line height 1 for H1 to H4 (1.1 for H5 and H6), no tracking | `line-height: var(--mfb-leading-h1)` (the level you set) and `letter-spacing: 0` | -| `.sg-label` | 18px, Medium (500), 1.5px letter spacing | 22px, Regular (400), no tracking | `font-size: var(--mfb-size-label)`, `font-weight: var(--mfb-weight-regular)`, `letter-spacing: 0` | -| `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram | sharp corners | `border-radius: 0` | +| `.sg-cta` | 1px letter spacing, a 40px radius | no tracking | `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)` on a button up to 80px tall; no radius token is 40px, so the class keeps it) | +| `.sg-label` | 18px | 22px | `font-size: var(--mfb-size-label)` | +| `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram (no radius token is 6px) | sharp corners | `border-radius: 0` | | `.sg-para-pattern` | rows 10px apart, stepped by 1.25 and 1.55 | rows that touch, each 1.2 times the one before (Rule 7) | nothing yet: a release changes the class | | `.sg-logo`, `.sg-cover__logo` | a logo {{LOGO_ICON_H}} tall | a logo at least {{LOGO_MIN_H}} tall on screens (Logo, Section 1) | nothing yet: the height is not decided | diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 67eb12b..0bd4f22 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -317,9 +317,9 @@ ═══════════════════════════════════════════════════════════════════ */ .sg-bg-purple { background: var(--mfb-purple-400); color: var(--mfb-white); } -.sg-bg-orange { background: var(--mfb-orange-300); color: var(--mfb-white); } -.sg-bg-gray { background: var(--mfb-gray-200); color: var(--mfb-gray-900); } -.sg-bg-white { background: var(--mfb-white); color: var(--mfb-gray-900); } +.sg-bg-orange { background: var(--mfb-orange-300); color: var(--mfb-body-on-orange); } +.sg-bg-gray { background: var(--mfb-gray-200); color: var(--mfb-body-on-light); } +.sg-bg-white { background: var(--mfb-white); color: var(--mfb-body-on-light); } .sg-bg-gradient { background: var(--mfb-gradient); color: var(--mfb-white); } /* ═══════════════════════════════════════════════════════════════════ @@ -345,7 +345,7 @@ background: var(--mfb-purple-400); } .sg-info-bar--on-light { - color: var(--mfb-gray-900); + color: var(--mfb-body-on-light); background: var(--mfb-white); } .sg-info-bar__date { @@ -359,9 +359,17 @@ font-size: 15px; font-weight: var(--mfb-weight-regular); color: inherit; - opacity: 0.75; margin-top: 2px; } +.sg-info-bar--on-dark .sg-info-bar__date small { + color: var(--mfb-muted-on-dark); +} +.sg-info-bar--on-light .sg-info-bar__date { + color: var(--mfb-heading-on-light); +} +.sg-info-bar--on-light .sg-info-bar__date small { + color: var(--mfb-muted-on-light); +} .sg-info-bar__location { text-align: right; font-size: 17px; @@ -388,20 +396,21 @@ .sg-headline { font-family: var(--mfb-font-heading); font-weight: var(--mfb-weight-medium); - line-height: 1.05; - letter-spacing: -0.03em; /* book heading tracking */ + line-height: var(--mfb-leading-h1); + letter-spacing: var(--mfb-tracking-h1); /* book headings: no tracking */ } .sg-body { font-family: var(--mfb-font-body); font-weight: var(--mfb-weight-regular); - line-height: 1.2; /* book specimen body LH */ + line-height: var(--mfb-leading-body); /* book specimen body LH */ } .sg-label { font-family: var(--mfb-font-body); - font-weight: var(--mfb-weight-medium); + font-weight: var(--mfb-weight-regular); font-size: 18px; + line-height: var(--mfb-leading-label); /* no text-transform — brand book p.26 forbids uppercase */ - letter-spacing: 1.5px; + letter-spacing: var(--mfb-tracking-label); } /* ═══════════════════════════════════════════════════════════════════ @@ -424,7 +433,7 @@ .sg-cta { display: inline-block; background: var(--mfb-orange-300); - color: var(--mfb-white); + color: var(--mfb-link-on-orange); padding: 14px 40px; border-radius: 40px; font-family: var(--mfb-font-body); @@ -498,7 +507,7 @@ background: var(--mfb-purple-300); color: var(--mfb-white); } -.sg-cover--orange { background: var(--mfb-orange-300); } +.sg-cover--orange { background: var(--mfb-orange-300); color: var(--mfb-heading-on-orange); } .sg-cover--deep { background: var(--mfb-purple-400); } .sg-cover--rule { border-top: 6px solid var(--mfb-orange-300); } @@ -519,8 +528,8 @@ font-family: var(--mfb-font-heading); font-weight: var(--mfb-weight-medium); font-size: var(--mfb-size-h2); - line-height: 1.05; - letter-spacing: -0.03em; + line-height: var(--mfb-leading-h2); + letter-spacing: var(--mfb-tracking-h2); /* Title Case, short — no text-transform (p.26 forbids uppercase) */ } diff --git a/supergraphics.css b/supergraphics.css index 3db03c7..5d7d487 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -47,6 +47,44 @@ --sg-highlighter-aspect: 26.6; --sg-highlighter-offset: 0.054em; --sg-highlighter-shape: polygon(1.24% calc(100% / 7), 100% 0, 99.47% calc(400% / 7), 0 100%); + --mfb-heading-on-light: var(--mfb-black); + --mfb-body-on-light: var(--mfb-black); + --mfb-muted-on-light: var(--mfb-gray-700); + --mfb-link-on-light: var(--mfb-purple-300); + --mfb-link-underline-on-light: var(--mfb-orange-300); + --mfb-border-on-light: var(--mfb-gray-200); + --mfb-highlighter-on-light: var(--mfb-orange-300); + --mfb-logo-on-light: var(--mfb-purple-300); + --mfb-heading-on-dark: var(--mfb-white); + --mfb-body-on-dark: var(--mfb-white); + --mfb-muted-on-dark: var(--mfb-gray-400); + --mfb-link-on-dark: var(--mfb-orange-300); + --mfb-highlighter-on-dark: var(--mfb-orange-300); + --mfb-logo-on-dark: var(--mfb-white); + --mfb-heading-on-orange: var(--mfb-black); + --mfb-body-on-orange: var(--mfb-black); + --mfb-link-on-orange: var(--mfb-black); + --mfb-highlighter-on-orange: var(--mfb-white); + --mfb-logo-on-orange: var(--mfb-black); + --mfb-leading-h1: 1; + --mfb-leading-h2: 1; + --mfb-leading-h3: 1; + --mfb-leading-h4: 1; + --mfb-leading-h5: 1.1; + --mfb-leading-h6: 1.1; + --mfb-leading-body: 1.2; + --mfb-leading-body-long: 1.7; + --mfb-leading-label: 1.2; + --mfb-leading-quote: 1; + --mfb-tracking-h1: 0em; + --mfb-tracking-h2: 0em; + --mfb-tracking-h3: 0em; + --mfb-tracking-h4: 0em; + --mfb-tracking-h5: 0em; + --mfb-tracking-h6: 0em; + --mfb-tracking-body: 0em; + --mfb-tracking-label: 0em; + --mfb-tracking-quote: 0em; } /* My First Bitcoin Supergraphics: Brand Visual Elements Layer */ @@ -368,9 +406,9 @@ ═══════════════════════════════════════════════════════════════════ */ .sg-bg-purple { background: var(--mfb-purple-400); color: var(--mfb-white); } -.sg-bg-orange { background: var(--mfb-orange-300); color: var(--mfb-white); } -.sg-bg-gray { background: var(--mfb-gray-200); color: var(--mfb-gray-900); } -.sg-bg-white { background: var(--mfb-white); color: var(--mfb-gray-900); } +.sg-bg-orange { background: var(--mfb-orange-300); color: var(--mfb-body-on-orange); } +.sg-bg-gray { background: var(--mfb-gray-200); color: var(--mfb-body-on-light); } +.sg-bg-white { background: var(--mfb-white); color: var(--mfb-body-on-light); } .sg-bg-gradient { background: var(--mfb-gradient); color: var(--mfb-white); } /* ═══════════════════════════════════════════════════════════════════ @@ -396,7 +434,7 @@ background: var(--mfb-purple-400); } .sg-info-bar--on-light { - color: var(--mfb-gray-900); + color: var(--mfb-body-on-light); background: var(--mfb-white); } .sg-info-bar__date { @@ -410,9 +448,17 @@ font-size: 15px; font-weight: var(--mfb-weight-regular); color: inherit; - opacity: 0.75; margin-top: 2px; } +.sg-info-bar--on-dark .sg-info-bar__date small { + color: var(--mfb-muted-on-dark); +} +.sg-info-bar--on-light .sg-info-bar__date { + color: var(--mfb-heading-on-light); +} +.sg-info-bar--on-light .sg-info-bar__date small { + color: var(--mfb-muted-on-light); +} .sg-info-bar__location { text-align: right; font-size: 17px; @@ -439,20 +485,21 @@ .sg-headline { font-family: var(--mfb-font-heading); font-weight: var(--mfb-weight-medium); - line-height: 1.05; - letter-spacing: -0.03em; /* book heading tracking */ + line-height: var(--mfb-leading-h1); + letter-spacing: var(--mfb-tracking-h1); /* book headings: no tracking */ } .sg-body { font-family: var(--mfb-font-body); font-weight: var(--mfb-weight-regular); - line-height: 1.2; /* book specimen body LH */ + line-height: var(--mfb-leading-body); /* book specimen body LH */ } .sg-label { font-family: var(--mfb-font-body); - font-weight: var(--mfb-weight-medium); + font-weight: var(--mfb-weight-regular); font-size: 18px; + line-height: var(--mfb-leading-label); /* no text-transform — brand book p.26 forbids uppercase */ - letter-spacing: 1.5px; + letter-spacing: var(--mfb-tracking-label); } /* ═══════════════════════════════════════════════════════════════════ @@ -475,7 +522,7 @@ .sg-cta { display: inline-block; background: var(--mfb-orange-300); - color: var(--mfb-white); + color: var(--mfb-link-on-orange); padding: 14px 40px; border-radius: 40px; font-family: var(--mfb-font-body); @@ -549,7 +596,7 @@ background: var(--mfb-purple-300); color: var(--mfb-white); } -.sg-cover--orange { background: var(--mfb-orange-300); } +.sg-cover--orange { background: var(--mfb-orange-300); color: var(--mfb-heading-on-orange); } .sg-cover--deep { background: var(--mfb-purple-400); } .sg-cover--rule { border-top: 6px solid var(--mfb-orange-300); } @@ -570,8 +617,8 @@ font-family: var(--mfb-font-heading); font-weight: var(--mfb-weight-medium); font-size: var(--mfb-size-h2); - line-height: 1.05; - letter-spacing: -0.03em; + line-height: var(--mfb-leading-h2); + letter-spacing: var(--mfb-tracking-h2); /* Title Case, short — no text-transform (p.26 forbids uppercase) */ } diff --git a/tailwind.js b/tailwind.js index 2221d85..4d36997 100644 --- a/tailwind.js +++ b/tailwind.js @@ -129,6 +129,7 @@ export default { "h5": "1.1", "h6": "1.1", "body": "1.2", + "body-long": "1.7", "label": "1.2", "quote": "1" }, diff --git a/theme.css b/theme.css index d465100..a831678 100644 --- a/theme.css +++ b/theme.css @@ -85,6 +85,7 @@ --leading-h5: 1.1; --leading-h6: 1.1; --leading-body: 1.2; + --leading-body-long: 1.7; --leading-label: 1.2; --leading-quote: 1; --tracking-h1: 0em; diff --git a/tokens.json b/tokens.json index 989a3a0..34e1785 100644 --- a/tokens.json +++ b/tokens.json @@ -864,7 +864,20 @@ "source": "figma:918:2368", "sourceKind": "prose", "verifiedOn": "2026-10-06", - "note": "Type specimen 918:2323: stated in the label 918:2333 and set as the leading of the text style 918:2368, read through the Figma MCP. Type Relationships (918:2994) draws body at 1.22; the specimen states the number, so it wins. The website sets paragraphs at 1.55, which is not imported." + "note": "Type specimen 918:2323: stated in the label 918:2333 and set as the leading of the text style 918:2368, read through the Figma MCP. Type Relationships (918:2994) draws body copy paired with a heading at 1.22; the specimen states the number, so it wins. The website sets its section paragraphs at 1.55, which is not imported. Long reading text (articles) has its own declared token, body-long." + } + } + }, + "body-long": { + "$type": "number", + "$value": 1.7, + "$description": "Line height of long reading text (articles and other running prose), as a multiple of the body font size. Not the Brand Book's body line height: use body for body copy paired with a heading.", + "$extensions": { + "mfb": { + "source": "myfirstbitcoin.org@6368b2a:src/pages/news/[...slug].astro:312", + "sourceKind": "declared", + "verifiedOn": "2026-10-06", + "note": "The Brand Book is silent on long reading text: its type specimen (918:2323) states 1.2 for body, and Type Relationships (918:2990) draws body copy paired with a heading at 1.22, both on short text. So the value is declared from the live website (main branch at 6368b2a), which sets its article prose (.article-prose) at 1.7, the same as its page default (src/styles/global.css:122). Uses counted with git grep over src at 6368b2a: 3 lines set line-height: 1.7 (the article prose, the page body and the footer). The website's section paragraphs use 1.55 (38 lines), a different setting that is not imported. Flagged for review: for articles only, never for headings or short body copy." } } }, From 0b09f36d3a99f50d102c468233b49678df27e28c Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 16:01:10 +0000 Subject: [PATCH 19/21] Brand guard: catch the website drifts it missed, stop failing correct Tailwind builds Source rules now also report: - angles in SVG patternTransform and gradientTransform attributes, unitless rotate and skew props in script code (rotate: -15), and rotate-x/y/z classes - named colors in custom properties, canvas fillStyle and style objects (a CSSProperties object, or a second property on the same line) - hex colors inside arbitrary utilities (after an underscore) and in values continued on the next line, which were taken for id selectors - translucent orange or purple fills: opacity or fill-opacity beside the fill, alpha modifiers such as bg-orange-200/10, color-mix with transparent - hand-rolled highlighters drawn as orange pseudo-elements sized in em or %, put behind the text or named like a highlight, inset orange shadows, and after:/before: orange utilities The built CSS is now read declaration by declaration, nesting included: every declaration of --sg-angle-base and --sg-angle-alt must have the token value (rules, @property initial-value, style attributes in built HTML); uppercase is reported outside utility class definitions, whatever variant or important marker Tailwind 3 or 4 gives them, since the source check reports where a class is used; the mono rule reads only font-family declarations, so Tailwind 4's --default-mono-font-family and code element base styles pass. Built findings carry line numbers. The test plants each case on its own line, in the source and the built CSS, and keeps Tailwind 3 and Tailwind 4 build output as clean controls. The README documents every option and exit code. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++- brand-check.mjs | 305 +++++++++++++++++++++++-------- scripts/brand-check.test.mjs | 151 ++++++++++++--- scripts/build-design-package.mjs | 34 +++- 4 files changed, 396 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 783cad8..da5ec28 100644 --- a/README.md +++ b/README.md @@ -112,23 +112,34 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile `brand-check.mjs` ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's `tokens.json`, so it follows the version you pin. ```sh -node node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json] +node node_modules/@myfirstbitcoin/design/brand-check.mjs [options] ``` -Run it in your build, for example `"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"`. To find its path from a script, resolve `@myfirstbitcoin/design/brand-check.mjs` (it is in the package's `exports`). It exits 1 when the source (`--src`, default `src`, comments ignored) has: +| Option | What it does | +|--------|--------------| +| `--src DIR` | Source to read, repeatable or comma-separated (default `src`). Comments are ignored. | +| `--dist DIR` | Build output whose CSS (style sheets, ` `, - // Built CSS: one off-palette color, one uppercase rule, one mono use and one angle override, - // next to what a Tailwind build emits on its own (utility definitions its content scanner saw in - // a comment, and Tailwind 4's --default-mono-font-family), which is not reported. - 'dist/_astro/index.css': ':root{--mfb-purple-400:#2B1C58;--sg-angle-base:13deg;--default-mono-font-family:var(--font-mono)}.x{color:var(--color-purple-300)}' + - ':root{--color-purple-300:#422c70;--color-heading-on-light:#000;--color-teal-500:oklch(70% .1 180)}.y{text-transform:uppercase}.code{font-family:var(--font-mono)}' + - '.uppercase{text-transform:uppercase}.font-mono{font-family:var(--font-mono)}@media (min-width:48rem){.md\\:uppercase{text-transform:uppercase}}.extra{--sg-angle-base:calc(var(--sg-angle-alt) / 2.4)}', + // Built CSS that drifts: every declaration of either angle is checked, wherever it sits. + 'dist/_astro/drift.css': ` +.page { --sg-angle-base: 10deg; } @built-angle +@property --sg-angle-alt { syntax: ""; inherits: true; initial-value: 20deg; } @built-angle +.extra{--sg-angle-base:calc(var(--sg-angle-alt) / 2.4)} @built-angle +:root { --color-purple-300: #422c70; --color-teal-500: oklch(70% .1 180); } @built-color +.y { text-transform: uppercase; } @built-uppercase +.btn { @media (width >= 48rem) { font-variant-caps: all-small-caps; } } @built-uppercase +.code { font-family: var(--font-mono); } @built-font +.quote { font: italic 1rem/1.2 Georgia, serif; } @built-font +`, + 'dist/index.html': ` + @built-angle +
@built-angle +`, + // Clean controls: what a correct Tailwind 4 build emits (theme.css's --font-mono, the base + // styles' --default-mono-font-family, utility definitions, nested variants) ... + 'dist/_astro/tw4.css': `@layer theme { + :root, :host { + --font-sans: "IBM Plex Sans", Arial, sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --color-purple-300: #422C70; + --color-heading-on-light: #000; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + html, :host { font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif); } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + } +} +@layer utilities { + .font-mono { font-family: var(--font-mono); } + .uppercase { text-transform: uppercase; } + .uppercase\\! { text-transform: uppercase !important; } + .md\\:uppercase { @media (width >= 48rem) { text-transform: uppercase; } } + .group-hover\\:uppercase { &:is(:where(.group):hover *) { @media (hover: hover) { text-transform: uppercase; } } } +} +:root { --mfb-font-mono: "IBM Plex Mono", monospace; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; } +@property --sg-angle-base { syntax: ""; inherits: true; initial-value: 13deg; } +`, + // ... and a minified Tailwind 3 build, where the word "uppercase" in a comment is enough for + // the scanner to emit .uppercase and its variants, and the preset's mono reaches code elements. + 'dist/_astro/tw3.css': '@font-face{font-family:"IBM Plex Mono";font-weight:400;src:url(data:font/woff2;base64,d09GMgAB) format("woff2")}' + + 'code,kbd,samp,pre{font-family:"IBM Plex Mono",monospace;font-feature-settings:normal;font-size:1em}' + + '.\\!uppercase{text-transform:uppercase!important}.uppercase{text-transform:uppercase}.font-mono{font-family:"IBM Plex Mono",monospace}' + + '@media (min-width:768px){.md\\:uppercase{text-transform:uppercase}}.\\[\\&\\>\\*\\]\\:uppercase>*{text-transform:uppercase}' + + '.group:hover .group-hover\\:uppercase{text-transform:uppercase}\n', }; +// Files that must not be reported at all. +const CLEAN = new Set(['src/components/Clean.astro', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); const failures = []; const run = (args, cwd = tmp) => spawnSync(process.execPath, [CHECK, ...args], { cwd, encoding: 'utf8' }); @@ -137,32 +230,30 @@ try { fs.writeFileSync(path.join(tmp, file), clean.join('\n')); } - // 1. Every planted violation is reported on its line, and nothing in the clean file is. + // 1. Every planted violation is reported on its line, and nothing in a clean file is. const r = run([]); const got = parse(r.stderr + r.stdout); if (r.status !== 1) failures.push(`planted tree: exit ${r.status}, expected 1`); for (const e of expected) { if (!got.some((g) => g.file === e.file && g.line === e.line && g.rule === e.rule)) failures.push(`missed: ${e.file}:${e.line} [${e.rule}]`); } - for (const g of got.filter((g) => g.file === 'src/components/Clean.astro')) failures.push(`false positive: ${g.file}:${g.line} [${g.rule}]`); - for (const g of got.filter((g) => g.line !== null && g.file !== 'src/components/Clean.astro')) { - if (!expected.some((e) => e.file === g.file && e.line === g.line)) failures.push(`unexpected: ${g.file}:${g.line} [${g.rule}]`); + for (const g of got) { + if (CLEAN.has(g.file)) failures.push(`false positive: ${g.file}:${g.line} [${g.rule}]`); + else if (g.line === null || !expected.some((e) => e.file === g.file && e.line === g.line)) failures.push(`unexpected: ${g.file}:${g.line} [${g.rule}]`); } - // Built CSS: exactly one finding per planted rule; utility definitions and Tailwind's own - // --default-mono-font-family are not reported. - for (const rule of ['built-color', 'built-uppercase', 'built-font', 'built-angle']) { - const n = got.filter((g) => g.rule === rule).length; - if (n !== 1) failures.push(`built CSS: [${rule}] reported ${n} times, expected once`); - } - - // 2. A missing angle is reported. - fs.writeFileSync(path.join(tmp, 'dist/_astro/index.css'), ':root{--sg-angle-base:10deg}'); - if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-angle')) failures.push('missed: built CSS with a 10deg angle [built-angle]'); - fs.writeFileSync(path.join(tmp, 'dist/_astro/index.css'), ':root{--sg-angle-base:13deg}'); - // 3. The clean file alone passes. + // 2. The clean files alone pass: the clean source, and the Tailwind 3 and 4 builds. + fs.rmSync(path.join(tmp, 'dist/_astro/drift.css')); + fs.rmSync(path.join(tmp, 'dist/index.html')); const clean = run(['--src', 'src/components/Clean.astro']); - if (clean.status !== 0) failures.push(`clean file: exit ${clean.status}, expected 0: ${clean.stderr.trim()}`); + if (clean.status !== 0) failures.push(`clean files: exit ${clean.status}, expected 0: ${clean.stderr.trim()}`); + + // 3. A build without the angle is reported. + const tw4 = path.join(tmp, 'dist/_astro/tw4.css'); + const tw4Text = fs.readFileSync(tw4, 'utf8'); + fs.writeFileSync(tw4, tw4Text.replace(/--sg-angle-base: 13deg;|@property --sg-angle-base[^}]*\}/g, '')); + if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-angle' && g.line === null)) failures.push('missed: built CSS without --sg-angle-base [built-angle]'); + fs.writeFileSync(tw4, tw4Text); // 4. An allowlist entry silences its findings; an unused entry is reported; a reason is required. fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([ @@ -181,7 +272,7 @@ try { fs.rmSync(path.join(tmp, 'brand-check.allow.json')); // 5. --warn reports a rule without failing; the default warnings fail only under --error. - const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter']); + const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter']); if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); fs.mkdirSync(path.join(tmp, 'src2')); fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n'); @@ -202,4 +293,4 @@ if (failures.length) { for (const f of failures) console.error(` - ${f}`); process.exit(1); } -console.log(`brand-check test: OK (${Object.keys(PLANTED).length} planted files, every rule, the allowlist, --warn, --error, --source-only)`); +console.log(`brand-check test: OK (${Object.keys(PLANTED).length} files, ${CLEAN.size} of them clean controls, every rule, the allowlist, --warn, --error, --source-only)`); diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index bd80c10..b5010a8 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -681,23 +681,37 @@ const readme = [ ``, `${code('brand-check.mjs')} ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's ${code('tokens.json')}, so it follows the version you pin.`, ``, - `${FENCE}sh\nnode node_modules/@myfirstbitcoin/design/brand-check.mjs [--src src] [--dist dist] [--allow brand-check.allow.json]\n${FENCE}`, - ``, - `Run it in your build, for example ${code('"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"')}. ` + - `To find its path from a script, resolve ${code('@myfirstbitcoin/design/brand-check.mjs')} (it is in the package's ${code('exports')}). It exits 1 when the source (${code('--src')}, default ${code('src')}, comments ignored) has:`, - ``, - `- a color literal (hex, ${code('rgb()')}, ${code('rgba()')}, ${code('hsl()')} and the other color functions), or a named color in a color property, a style, an SVG color attribute or an arbitrary utility (${code('bg-[teal]')})`, + `${FENCE}sh\nnode node_modules/@myfirstbitcoin/design/brand-check.mjs [options]\n${FENCE}`, + ``, + `| Option | What it does |`, + `|--------|--------------|`, + `| ${code('--src DIR')} | Source to read, repeatable or comma-separated (default ${code('src')}). Comments are ignored. |`, + `| ${code('--dist DIR')} | Build output whose CSS (style sheets, ${code(' +`, + 'src/styles/legacy.scss': `.x { + // legacy value was #fff + color: var(--mfb-black); +} `, // Built CSS that drifts: every declaration of either angle is checked, wherever it sits. 'dist/_astro/drift.css': ` @@ -164,6 +209,7 @@ const spin = { rotate: 180, skewX: 0 }; .btn { @media (width >= 48rem) { font-variant-caps: all-small-caps; } } @built-uppercase .code { font-family: var(--font-mono); } @built-font .quote { font: italic 1rem/1.2 Georgia, serif; } @built-font +.eyebrow.uppercase { text-transform: uppercase; } @built-uppercase `, 'dist/index.html': ` @built-angle @@ -197,6 +243,7 @@ const spin = { rotate: 180, skewX: 0 }; } :root { --mfb-font-mono: "IBM Plex Mono", monospace; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; } @property --sg-angle-base { syntax: ""; inherits: true; initial-value: 13deg; } +@property --sg-angle-alt { syntax: ""; inherits: true; initial-value: 24.0deg; } `, // ... and a minified Tailwind 3 build, where the word "uppercase" in a comment is enough for // the scanner to emit .uppercase and its variants, and the preset's mono reaches code elements. @@ -208,7 +255,7 @@ const spin = { rotate: 180, skewX: 0 }; }; // Files that must not be reported at all. -const CLEAN = new Set(['src/components/Clean.astro', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); +const CLEAN = new Set(['src/components/Clean.astro', 'src/styles/legacy.scss', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); const failures = []; @@ -272,13 +319,15 @@ try { fs.rmSync(path.join(tmp, 'brand-check.allow.json')); // 5. --warn reports a rule without failing; the default warnings fail only under --error. - const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter']); + const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter,clip-path']); if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); fs.mkdirSync(path.join(tmp, 'src2')); - fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n'); + fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n.y { letter-spacing: 0.1em; }\n'); const dw = run(['--src', 'src2', '--source-only']); - if (dw.status !== 0 || !/warning: src2\/a\.css:1: \[raw-spacing\]/.test(dw.stderr)) failures.push(`default warning: exit ${dw.status}, expected 0 with a raw-spacing warning`); + if (dw.status !== 0 || !/warning: src2\/a\.css:1: \[raw-spacing\]/.test(dw.stderr) || !/warning: src2\/a\.css:2: \[letter-spacing\]/.test(dw.stderr)) + failures.push(`default warning: exit ${dw.status}, expected 0 with a raw-spacing and a letter-spacing warning`); if (run(['--src', 'src2', '--source-only', '--error', 'raw-spacing']).status !== 1) failures.push('--error raw-spacing did not fail'); + if (run(['--src', 'src2', '--source-only', '--error', 'letter-spacing']).status !== 1) failures.push('--error letter-spacing did not fail'); // 6. No built CSS, and --source-only. fs.rmSync(path.join(tmp, 'dist'), { recursive: true }); diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index b5010a8..2bb2a4f 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -597,9 +597,9 @@ const readme = [ `- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in ${code('tokens.json')} now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to.`, `- **Text on orange is black, buttons included, and the supergraphics classes now set it.** ${code('.sg-cta')} sets ${code('var(--mfb-link-on-orange)')}, ${code('.sg-bg-orange')} ${code('var(--mfb-body-on-orange)')} and ${code('.sg-cover--orange')} (with its ${code('.sg-cover__title')}) ${code('var(--mfb-heading-on-orange)')}: black, 9.2:1 on orange-300, where they set white (2.3:1, below the 4.5:1 that body text and buttons need). Delete any local white ${code('color')} on them, which would bring the old contrast back; a local black one can go too.`, `- **Text on the light supergraphics surfaces is black.** ${code('.sg-bg-white')}, ${code('.sg-bg-gray')} and ${code('.sg-info-bar--on-light')} set ${code('var(--mfb-body-on-light)')} (black) instead of gray-900.`, - `- **The light info bar's date is black, and the line under it is a solid color.** On ${code('.sg-info-bar--on-light')}, ${code('.sg-info-bar__date')} is ${code('var(--mfb-heading-on-light)')} (black) instead of orange-300 (2.3:1 on white). The date's ${code('small')} loses its 75% opacity: it is ${code('var(--mfb-muted-on-light)')} (gray-700, 5.2:1) on the light bar and ${code('var(--mfb-muted-on-dark)')} (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without either variant it keeps the date's color, fully opaque.`, - `- **${code('.sg-headline')} and ${code('.sg-cover__title')} set line height 1 and no letter spacing** (${code('--mfb-leading-h1')} and ${code('--mfb-tracking-h1')}; ${code('h2')} for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box.`, - `- **${code('.sg-label')} is Regular (400), not Medium, with line height 1.2 and no letter spacing** (${code('--mfb-leading-label')}, ${code('--mfb-tracking-label')}), instead of 1.5px. Its size stays 18px. ${code('.sg-body')} reads ${code('--mfb-leading-body')}, the same 1.2 as before.`, + `- **The info bar's date follows its surface, and the line under it is a solid color.** On ${code('.sg-info-bar--on-light')}, ${code('.sg-info-bar__date')} is ${code('var(--mfb-heading-on-light)')} (black) instead of orange-300 (2.3:1 on white). A bar without a variant has no background of its own: its date stays orange-300 on purple surfaces and photos, and is black when the bar sits directly in ${code('.sg-bg-white')} or ${code('.sg-bg-gray')} (where orange-300 fails the contrast rule) or in one of the package's orange surfaces, ${code('.sg-bg-orange')}, ${code('.sg-cover--orange')} and the orange spotlight, book and halftone frames (where an orange date could not be seen). On any other light or orange surface, give the bar a variant. The date's ${code('small')} loses its 75% opacity: it is ${code('var(--mfb-muted-on-light)')} (gray-700, 5.1:1) on the light bar and ${code('var(--mfb-muted-on-dark)')} (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without a variant it keeps the date's color, fully opaque.`, + `- **${code('.sg-headline')} and ${code('.sg-cover__title')} set line height 1 and no letter spacing** (${code('--mfb-leading-h1')} and ${code('--mfb-tracking-h1')}; ${code('h2')} for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Line height 1 is the book's for H1 to H4 and quotes. ${code('.sg-headline')} sets no size, so a headline set at the H5 or H6 size also takes ${code('.sg-headline--h5')} or ${code('.sg-headline--h6')}, new in this release, which set the book's ${tokens.lineHeight.h5.$value} (${code('--mfb-leading-h5')}, ${code('--mfb-leading-h6')}). Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box.`, + `- **${code('.sg-label')} is Regular (400), not Medium, with line height 1.2 and no letter spacing** (${code('--mfb-leading-label')}, ${code('--mfb-tracking-label')}), instead of 1.5px. Its size stays 18px. ${code('.sg-body')} reads ${code('--mfb-leading-body')}, the same 1.2 as before, and now sets ${code('--mfb-tracking-body')} (none), so it no longer takes a letter spacing from its parent.`, `- **A line height for long reading, ${code('--mfb-leading-body-long')} (${code('leading-body-long')}), ${tokens.lineHeight['body-long'].$value}.** It is declared from the website's article text, because the Brand Book only sets body copy next to a heading (${code('--mfb-leading-body')}, ${tokens.lineHeight.body.$value}). It is under review, and nothing uses it until a page adopts it.`, `- The supergraphics classes that still differ from the Brand Book are listed in ${code('brand-spec.md')} ("Supergraphics classes that differ from the Brand Book").`, `- **A test that snapshots the ${code('--mfb-*')} variables of ${code('brand.css')} whose value is a hex** must refresh its snapshot: this release adds ${halftoneList}. The other new variables carry no hex; they reference the palette with ${code('var()')}.`, @@ -627,7 +627,7 @@ const readme = [ `**Never hand-roll these shapes in a page**: the brand angle is exactly ${ANGLE} (${code('--sg-angle-base')}) and hand-rolled copies drift. ` + `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.`, ``, - `The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: ${code('.sg-cta')}'s 1px letter spacing and 40px radius, ${code('.sg-label')}'s 18px size, the 6px corners of ${code('.sg-para-frame')} and ${code('.sg-para-accent')}, the pattern's spacing and the logo's height. ${code('brand-spec.md')} lists every such class and what to set.`, + `The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: ${code('.sg-cta')}'s 1px letter spacing and 40px radius, ${code('.sg-label')}'s 18px size, the type sizes and SemiBold date of ${code('.sg-info-bar')}, the 6px corners of ${code('.sg-para-frame')} and ${code('.sg-para-accent')}, the pattern's spacing and the logo's height. ${code('brand-spec.md')} lists every such class and what to set.`, ``, `### Set --sg-aspect on every frame`, ``, @@ -690,7 +690,7 @@ const readme = [ `| ${code('--source-only')} | Skip the build output. |`, `| ${code('--allow FILE')} | The allowlist (default ${code('brand-check.allow.json')} when it exists), below. |`, `| ${code('--warn RULE[,RULE]')} | Report these rules without failing. |`, - `| ${code('--error RULE[,RULE]')} | Fail on rules that only warn by default (${code('raw-spacing')}, ${code('text-color')}, ${code('typed-caps')}). |`, + `| ${code('--error RULE[,RULE]')} | Fail on rules that only warn by default (${code('raw-spacing')}, ${code('text-color')}, ${code('typed-caps')}, ${code('translucent-text')}, ${code('letter-spacing')}). |`, `| ${code('--root DIR')} | The project root the paths above are relative to (default: the current directory). |`, `| ${code('--help')} | List the rule ids, with what each reports. |`, ``, @@ -698,24 +698,25 @@ const readme = [ `Run it in your build, for example ${code('"build": "astro build && node node_modules/@myfirstbitcoin/design/brand-check.mjs"')}. ` + `To find its path from a script, resolve ${code('@myfirstbitcoin/design/brand-check.mjs')} (it is in the package's ${code('exports')}). It fails when the source has:`, ``, - `- a color literal (hex from ${code('#RGB')} to ${code('#RRGGBBAA')}, also inside an arbitrary utility such as ${code('shadow-[0_0_0_3px_#f7931a80]')}, ${code('rgb()')}, ${code('hsl()')} and the other color functions), or a named color in a color property, a custom property, a style object (${code('style={{ ... }}')}, or an object named like a style or typed ${code('CSSProperties')}), a canvas ${code('fillStyle')}, an SVG color attribute or an arbitrary utility (${code('bg-[teal]')})`, - `- a font family other than ${code('var(--mfb-font-heading)')}, ${code('var(--mfb-font-body)')} or ${code('var(--mfb-font-sans)')} (in Tailwind 4, ${code('var(--font-sans)')} too), a family name, or the deprecated mono family. Inside ${code('@font-face')}, which describes a face rather than using one, family names and weights are allowed`, - `- a skew, a rotation or an angle literal that is not a quarter turn (in CSS, a style object, an animation prop such as ${code('rotate: -15')}, a Tailwind class such as ${code('rotate-3')} or ${code('rotate-x-12')}, or an SVG ${code('transform')}, ${code('patternTransform')} or ${code('gradientTransform')} attribute), a transform matrix that rotates or skews, or slant geometry computed from ${code('--sg-angle-*')}: the ${ANGLE} slant comes only from the supergraphics classes`, - `- a hand-rolled ${code('clip-path')} shape, or a hand-rolled highlighter: a ${code('linear-gradient(transparent 80%, ...)')} band, an orange pseudo-element drawn as a marker (sized in ${code('em')} or ${code('%')}, put behind the text with a negative ${code('z-index')}, or named like a highlight or a mark), an orange inset ${code('box-shadow')} under the text, or orange ${code('after:')} or ${code('before:')} utilities`, + `- a color literal (hex from ${code('#RGB')} to ${code('#RRGGBBAA')}, also inside an arbitrary utility such as ${code('shadow-[0_0_0_3px_#f7931a80]')}, ${code('rgb()')}, ${code('hsl()')} and the other color functions; text between markup tags, such as a block number written ${code('#840000')}, is prose and is not read), or a named color in a color property, a custom property, a style object (${code('style={{ ... }}')}, or an object named like a style or typed ${code('CSSProperties')}), a canvas ${code('fillStyle')}, an SVG color attribute or an arbitrary utility (${code('bg-[teal]')})`, + `- a font family other than ${code('var(--mfb-font-heading)')}, ${code('var(--mfb-font-body)')} or ${code('var(--mfb-font-sans)')} (in Tailwind 4, ${code('var(--font-sans)')} too), in ${code('font-family')}, the ${code('font')} shorthand or a ${code('--font-*')} theme variable, a family name, or the deprecated mono family. Inside ${code('@font-face')}, which describes a face rather than using one, family names and weights are allowed`, + `- a skew, a rotation or an angle literal that is not a quarter turn (in CSS, a style object, an animation prop such as ${code('rotate: -15')}, a Tailwind class such as ${code('rotate-3')} or ${code('rotate-x-12')}, an SVG ${code('transform')}, ${code('patternTransform')} or ${code('gradientTransform')} attribute, or the ${code('rotate')} attribute of SVG text), a transform matrix that rotates or skews, or slant geometry computed from ${code('--sg-angle-*')}: the ${ANGLE} slant comes only from the supergraphics classes`, + `- a hand-rolled ${code('clip-path')} shape or an SVG ${code('clipPath')} drawn with a polygon, or a hand-rolled highlighter: a ${code('linear-gradient(transparent 80%, ...)')} band, an orange pseudo-element drawn as a marker (sized in ${code('em')} or ${code('%')}, put behind the text with a negative ${code('z-index')}, or named like a highlight or a mark), an orange ${code('mark')} element, an orange inset ${code('box-shadow')} under the text, an orange underline 0.2em or 4px thick or more, or orange ${code('after:')} or ${code('before:')} utilities`, `- a redefinition of a variable the package defines (${code('--mfb-*')}, or the ${code('--sg-*')} geometry such as ${code('--sg-angle-base')}); pages set only ${code('--sg-aspect')} and ${code('--sg-highlighter-color')}, which must be a palette variable`, - `- ${code('text-transform: uppercase')}, the ${code('uppercase')} class, small caps or small-cap font features`, - `- a gradient (the deprecated brand gradient and ${code('.sg-photo-zone')} included), a smooth color filter or a blend mode (Rule 4: halftone, never duotone)`, + `- ${code('text-transform: uppercase')} (in any case, and as ${code('textTransform')} in a style object), the ${code('uppercase')} class (in a class attribute, or as a string such as ${code("clsx('uppercase')")} or ${code("class:list={['uppercase']}")}), small caps or small-cap font features`, + `- a gradient, in CSS or as an SVG ${code('linearGradient')} or ${code('radialGradient')} (the deprecated brand gradient and ${code('.sg-photo-zone')} included), a smooth color filter or a blend mode (Rule 4: halftone, never duotone). An alpha-only mask is allowed, and so is a gradient whose colors meet at hard stops without blending (a two-color split, a dashed line), unless it draws in orange`, `- a Tailwind color utility outside the palette, such as ${code('bg-teal-500')} or Tailwind's own ${code('text-purple-500')}`, - `- a weight above 600, or a translucent brand shape: opacity on a supergraphics shape or the highlighter (a class, ${code('opacity-[.85]')} or an inline style), or an orange or purple fill that is not solid (${code('opacity')} or ${code('fill-opacity')} beside it, an alpha modifier such as ${code('bg-orange-200/10')}, or ${code('color-mix()')} with ${code('transparent')}). Opacity 0 (hidden) and states such as ${code('hover:opacity-90')} are left alone`, + `- a weight above 600, or a translucent brand shape: opacity on a supergraphics shape or the highlighter (a class, ${code('opacity-[.85]')} or an inline style), or an orange or purple fill that is not solid (${code('opacity')}, ${code('fill-opacity')} or ${code('filter: opacity()')} beside it, an alpha modifier such as ${code('bg-orange-200/10')}, or a fill or border that mixes orange or purple with ${code('transparent')} in ${code('color-mix()')}). Opacity 0 (hidden), states such as ${code('hover:opacity-90')}, and rules for a disabled element (${code(':disabled')}, ${code('[aria-disabled]')}) are left alone`, `- a raw value that has a token: a radius, duration, easing, container width, font size, font weight, shadow or semantic spacing literal equal to a token value (${code('border-radius: 12px')}, ${code('180ms')}, ${code('clamp(64px, 9vw, 128px)')}), with the token to use instead`, ``, `and when the built CSS lacks ${code('--sg-angle-base')} with the token value (import ${code('brand.css')} or ${code('supergraphics.css')}), or any declaration of ${code('--sg-angle-base')} or ${code('--sg-angle-alt')} has another value (a rule, a nested rule, an ${code('@property')} ${code('initial-value')}, or a style attribute in built HTML); defines a ${code('--color-*')} that is not a palette color or a color role; sets uppercase or small caps; or gives a ${code('font-family')} the mono or serif family. ` + `A utility class definition is not a use: Tailwind 3 emits ${code('.uppercase')} or ${code('.font-mono')} when its content scanner sees the word anywhere, a comment included, and the source check reports the class where a page uses it. ` + - `Nor is a variable definition or the base style of ${code('code')}, ${code('kbd')}, ${code('samp')} and ${code('pre')}: Tailwind 4's base styles always set ${code('--default-mono-font-family: var(--font-mono)')}, and ${code('theme.css')} ships ${code('--font-mono')} for the deprecated mono family.`, + `Nor is a variable definition or the base style of ${code('code')}, ${code('kbd')}, ${code('samp')} and ${code('pre')}: Tailwind 4's base styles always set ${code('--default-mono-font-family: var(--font-mono)')}, and ${code('theme.css')} ships ${code('--font-mono')} for the deprecated mono family. ` + + `A rule where the utility class shares its compound selector with another class, such as ${code('.eyebrow.uppercase')}, is the page's own rule, and it is reported.`, ``, - `Three rules only warn, because the right answer depends on what the check cannot see: ${code('raw-spacing')} (a px or rem spacing literal on the scale, such as ${code('padding: 24px')}: Tailwind's own steps are the same values), ${code('text-color')} (orange text, which is right only on purple-300 and purple-400, and a purple heading) and ${code('typed-caps')} (two or more words typed in capitals). ${code('--error RULE[,RULE]')} makes them fail.`, + `Five rules only warn, because the right answer depends on what the check cannot see or because the drift is widespread: ${code('raw-spacing')} (a px or rem spacing literal on the scale, such as ${code('padding: 24px')}: Tailwind's own steps are the same values), ${code('text-color')} (orange text, which is right only on purple-300 and purple-400; a purple heading; gray-900 text, where the Brand Book draws text on light backgrounds black: ${code('color: var(--mfb-gray-900)')}, ${code('text-gray-900')}, or a value given to a name such as ${code('textColor')}), ${code('translucent-text')} (a text color mixed with ${code('transparent')}, or an alpha modifier such as ${code('text-white/85')}: use a solid palette color, for example ${code('var(--mfb-muted-on-dark)')}), ${code('letter-spacing')} (a non-zero ${code('letter-spacing')} or ${code('letterSpacing')}, or a ${code('tracking-*')} utility other than ${code('tracking-normal')} and the type levels: no brand text style has tracking) and ${code('typed-caps')} (two or more words typed in capitals). ${code('--error RULE[,RULE]')} makes them fail.`, ``, - `**What it does not check.** It reads how values are written, not how the page looks: it cannot measure contrast, tell which background a text sits on, or read text that a script or a content system supplies. It does not check line height, the one-word highlighter rule, which shape fits which content, or photos themselves. Status colors for forms (error, success) are not in the Brand Book or the palette, so there is no token for them yet: a project that needs them lists them in its allowlist, with the reason.`, + `**What it does not check.** It reads how values are written, not how the page looks: it cannot measure contrast, tell which background a text sits on, or read text that a script or a content system supplies. It does not check line height, the one-word highlighter rule, which shape fits which content, or photos themselves, and it does not read Markdown or MDX files. Status colors for forms (error, success) are not in the Brand Book or the palette, so there is no token for them yet: a project that needs them lists them in its allowlist, with the reason.`, ``, `${code('--allow FILE')} (default ${code('brand-check.allow.json')} when it exists) lists the exceptions, each with its reason, for example a third-party mark:`, ``, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 6030d31..8e3c6b1 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -361,12 +361,13 @@ The roles behind this table are in Section 1 (Color roles). ### Supergraphics classes that differ from the Brand Book -The classes in this package's `supergraphics.css` read the color roles and the type relationships above: text on orange is black (`.sg-cta`, `.sg-bg-orange`, `.sg-cover--orange`), text on light surfaces is black (`.sg-bg-white`, `.sg-bg-gray`, `.sg-info-bar--on-light`, whose date is black and whose date line is gray-700, with no opacity), `.sg-headline` and `.sg-cover__title` have line height 1 and no tracking, and `.sg-label` is Regular at line height 1.2 with no tracking. A few values from before the Brand Book was measured remain. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: +The classes in this package's `supergraphics.css` read the color roles and the type relationships above: text on orange is black (`.sg-cta`, `.sg-bg-orange`, `.sg-cover--orange`), text on light surfaces is black (`.sg-bg-white`, `.sg-bg-gray`, `.sg-info-bar--on-light`, whose date is black and whose date line is gray-700, with no opacity), an `.sg-info-bar` without a variant has a black date when it sits directly in `.sg-bg-white`, `.sg-bg-gray` or one of the package's orange surfaces (on any other light or orange surface, give the bar a variant), `.sg-headline` and `.sg-cover__title` have line height 1 and no tracking (a headline set at the H5 or H6 size adds `.sg-headline--h5` or `.sg-headline--h6`, for the book's 1.1), `.sg-body` has no tracking, and `.sg-label` is Regular at line height 1.2 with no tracking. A few values from before the Brand Book was measured remain. Changing them changes how existing pages look, so it waits for a release that decides it. Until then, set the value yourself where you use the class: | Class | What it sets | The Brand Book | Set instead | |-------|--------------|----------------|-------------| | `.sg-cta` | 1px letter spacing, a 40px radius | no tracking | `letter-spacing: 0` (its 40px radius draws the same pill as `var(--mfb-radius-pill)` on a button up to 80px tall; no radius token is 40px, so the class keeps it) | | `.sg-label` | 18px | 22px | `font-size: var(--mfb-size-label)` | +| `.sg-info-bar` | a 32px date in SemiBold at line height 1.1, a 15px date line, and a 17px location at line height 1.3 | 32px is the H6 size, in Medium at 1.1; body text is 18px and labels 22px, both at 1.2 | `font-weight: var(--mfb-weight-medium)` on `.sg-info-bar__date`; the body or label size and line height on its `small` and on `.sg-info-bar__location` | | `.sg-para-frame`, `.sg-para-accent` | a 6px corner radius on a parallelogram (no radius token is 6px) | sharp corners | `border-radius: 0` | | `.sg-para-pattern` | rows 10px apart, stepped by 1.25 and 1.55 | rows that touch, each 1.2 times the one before (Rule 7) | nothing yet: a release changes the class | | `.sg-logo`, `.sg-cover__logo` | a logo {{LOGO_ICON_H}} tall | a logo at least {{LOGO_MIN_H}} tall on screens (Logo, Section 1) | nothing yet: the height is not decided | diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css index 0bd4f22..22d8e3d 100644 --- a/src/supergraphics.canon.css +++ b/src/supergraphics.canon.css @@ -361,6 +361,15 @@ color: inherit; margin-top: 2px; } +:where(.sg-bg-white, .sg-bg-gray) > .sg-info-bar .sg-info-bar__date { + color: var(--mfb-heading-on-light); +} +:where(.sg-bg-orange, .sg-cover--orange, .sg-spotlight--orange, .sg-spotlight-frame--orange, .sg-book-frame--orange, .sg-halftone-cutout--orange-bg) > .sg-info-bar .sg-info-bar__date { + color: var(--mfb-heading-on-orange); +} +.sg-info-bar--on-dark .sg-info-bar__date { + color: var(--mfb-orange-300); +} .sg-info-bar--on-dark .sg-info-bar__date small { color: var(--mfb-muted-on-dark); } @@ -399,10 +408,13 @@ line-height: var(--mfb-leading-h1); letter-spacing: var(--mfb-tracking-h1); /* book headings: no tracking */ } +.sg-headline--h5 { line-height: var(--mfb-leading-h5); } +.sg-headline--h6 { line-height: var(--mfb-leading-h6); } .sg-body { font-family: var(--mfb-font-body); font-weight: var(--mfb-weight-regular); line-height: var(--mfb-leading-body); /* book specimen body LH */ + letter-spacing: var(--mfb-tracking-body); } .sg-label { font-family: var(--mfb-font-body); diff --git a/supergraphics.css b/supergraphics.css index 5d7d487..7a17483 100644 --- a/supergraphics.css +++ b/supergraphics.css @@ -450,6 +450,15 @@ color: inherit; margin-top: 2px; } +:where(.sg-bg-white, .sg-bg-gray) > .sg-info-bar .sg-info-bar__date { + color: var(--mfb-heading-on-light); +} +:where(.sg-bg-orange, .sg-cover--orange, .sg-spotlight--orange, .sg-spotlight-frame--orange, .sg-book-frame--orange, .sg-halftone-cutout--orange-bg) > .sg-info-bar .sg-info-bar__date { + color: var(--mfb-heading-on-orange); +} +.sg-info-bar--on-dark .sg-info-bar__date { + color: var(--mfb-orange-300); +} .sg-info-bar--on-dark .sg-info-bar__date small { color: var(--mfb-muted-on-dark); } @@ -488,10 +497,13 @@ line-height: var(--mfb-leading-h1); letter-spacing: var(--mfb-tracking-h1); /* book headings: no tracking */ } +.sg-headline--h5 { line-height: var(--mfb-leading-h5); } +.sg-headline--h6 { line-height: var(--mfb-leading-h6); } .sg-body { font-family: var(--mfb-font-body); font-weight: var(--mfb-weight-regular); line-height: var(--mfb-leading-body); /* book specimen body LH */ + letter-spacing: var(--mfb-tracking-body); } .sg-label { font-family: var(--mfb-font-body); diff --git a/tokens.json b/tokens.json index 34e1785..69dc4c5 100644 --- a/tokens.json +++ b/tokens.json @@ -1169,7 +1169,7 @@ "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:103", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour on light surfaces. Uses counted with git grep over src at 6368b2a: 57 lines read var(--gray-700), all as a text colour. 5.2:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour on light surfaces. Uses counted with git grep over src at 6368b2a: 57 lines read var(--gray-700), all as a text colour. 5.1:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." } } }, From 25c88f3c204ffcdf1aaa0476264aadeee1ee0c5b Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Tue, 6 Oct 2026 16:50:52 +0000 Subject: [PATCH 21/21] Cite the website by commit and CSS name, not by private file and line The website's source repository is private and this one is public, so a website-declared token no longer names a file, directory or line of it. Its source is now "myfirstbitcoin.org@, ": the custom property, selector and property, or inline property (with where it is used) that carries the value in the CSS and pages the site serves, for example "myfirstbitcoin.org@6368b2a, --r-lg". The deciders named in CONTRIBUTING keep the exact lines outside the repository. - tokens.json: the 52 website sources move to the new form; notes drop file and line references and component names (use counts stay, counted with git grep in the site's source at 6368b2a). No value changes. - CONTRIBUTING source rule 2 and $verification describe the new form. - The spec generator reads the new form, shows the CSS name, and refuses a website source that names a file, a directory or a line. - README and brand-spec.md say what a website source names; regenerated. - The brand guard's test fixtures and allowlist example use src/ui and src/styles/site.css, generic consumer paths. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 14 +- README.md | 4 +- brand-check.mjs | 2 +- brand-spec.md | 88 ++++++------- scripts/brand-check.test.mjs | 30 ++--- scripts/brand-spec.mjs | 22 +++- scripts/build-design-package.mjs | 4 +- src/brand-spec.template.md | 2 +- tokens.json | 214 +++++++++++++++---------------- 9 files changed, 197 insertions(+), 183 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b590ce4..612ac1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,11 +12,15 @@ under the source rule below. `262:2`) defines a value, its value is the token, cited by node (`"source": "figma:"`). 2. **Where the Brand Book is silent**, the value used on the live website, myfirstbitcoin.org, may become the token. The website was built following the Brand Book, so what it does where - the book says nothing is the best evidence there is. Such a token is declared with its exact - origin on the site's main branch: `"sourceKind": "declared"` and - `"source": "myfirstbitcoin.org@::"`, with the number of uses in its note. - The site's source repository is private, so only people with access to it can open the cited - file and line; what a visitor's browser receives from the site (its CSS and pages) is public. + the book says nothing is the best evidence there is. Such a token is declared with its origin + on the site's main branch: `"sourceKind": "declared"` and + `"source": "myfirstbitcoin.org@, "`, with the number of uses in its note. + The CSS name is what carries the value in the CSS and pages the site serves, which are public: + a custom property (`myfirstbitcoin.org@6368b2a, --r-lg`), a selector and property + (`myfirstbitcoin.org@6368b2a, .article-prose line-height`), or a property set inline, with + where it is used (`box-shadow (inline, media frames)`). The site's source repository is + private, so no file, directory or line of it is named in this repository; the deciders named + under Releases keep the exact lines outside it. 3. **Never a website value that contradicts the Brand Book**, however often the site uses it: a slant other than the book's angle, a highlighter other than the book's thin line, duotone photos, a font or a color outside the book, all-caps text, orange text on a light background. diff --git a/README.md b/README.md index 93596ee..d7040c9 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Wrap the one word you emphasize in a heading: `

Open Source )`, for example `var(--mfb-heading-on-light)`. In Tailw | Level | Size | Line height | Letter spacing | On web pages (fluid) | |-------|------|-------------|----------------|----------------------| -| h1 | 70px | 1 | 0em | `clamp(44px, 6vw, 70px)` (website `src/styles/global.css:66`) | -| h2 | 58px | 1 | 0em | `clamp(36px, 5vw, 58px)` (website `src/styles/global.css:67`) | -| h3 | 48px | 1 | 0em | `clamp(30px, 4vw, 48px)` (website `src/styles/global.css:68`) | -| h4 | 40px | 1 | 0em | `clamp(26px, 3.2vw, 40px)` (website `src/styles/global.css:69`) | -| h5 | 36px | 1.1 | 0em | `clamp(22px, 2.6vw, 36px)` (website `src/styles/global.css:70`) | -| h6 | 32px | 1.1 | 0em | `clamp(20px, 2.2vw, 32px)` (website `src/styles/global.css:71`) | +| h1 | 70px | 1 | 0em | `clamp(44px, 6vw, 70px)` (website `--fs-h1`) | +| h2 | 58px | 1 | 0em | `clamp(36px, 5vw, 58px)` (website `--fs-h2`) | +| h3 | 48px | 1 | 0em | `clamp(30px, 4vw, 48px)` (website `--fs-h3`) | +| h4 | 40px | 1 | 0em | `clamp(26px, 3.2vw, 40px)` (website `--fs-h4`) | +| h5 | 36px | 1.1 | 0em | `clamp(22px, 2.6vw, 36px)` (website `--fs-h5`) | +| h6 | 32px | 1.1 | 0em | `clamp(20px, 2.2vw, 32px)` (website `--fs-h6`) | | body | 18px | 1.2 | 0em | fixed, 18px | -| label | 22px | 1.2 | 0em | `clamp(16px, 1.6vw, 22px)` (website `src/styles/global.css:73`) | -| quote | 40px | 1 | 0em | `clamp(22px, 2.8vw, 40px)` (website `src/styles/global.css:72`) | +| label | 22px | 1.2 | 0em | `clamp(16px, 1.6vw, 22px)` (website `--fs-label`) | +| quote | 40px | 1 | 0em | `clamp(22px, 2.8vw, 40px)` (website `--fs-quote`) | - **Line height and letter spacing** are the Brand Book's (type specimen `918:2323`; no brand text style has tracking). Line height is a multiple of the size: `line-height: var(--mfb-leading-h1)`, or `text-h1 leading-h1` in Tailwind (in 1.x, `text-h1` sets only the size). Body text never goes to line height 1 (Type Misuse `918:2841`). -- **Body line height, short and long.** The Brand Book's body line height is for body copy paired with a heading: its type specimen states 1.2, and Type Relationships (`918:2990`) draws body copy under a heading at 1.22; the token keeps the stated number. The book shows no long reading text. For articles and other long running prose, `--mfb-leading-body-long` (`leading-body-long`) is 1.7, a value declared from the website's article text (website `src/pages/news/[...slug].astro:312`), not a Brand Book value. It is flagged for review: use it only for long reading text, never for headings or for body copy next to a heading. +- **Body line height, short and long.** The Brand Book's body line height is for body copy paired with a heading: its type specimen states 1.2, and Type Relationships (`918:2990`) draws body copy under a heading at 1.22; the token keeps the stated number. The book shows no long reading text. For articles and other long running prose, `--mfb-leading-body-long` (`leading-body-long`) is 1.7, a value declared from the website's article text (website `.article-prose line-height`), not a Brand Book value. It is flagged for review: use it only for long reading text, never for headings or for body copy next to a heading. - **Weights:** Medium (500) for H1 to H6 and quotes; Regular (400) for body text and labels; SemiBold (600) only for emphasis inside a line. Body text is never Medium or bold (`918:2841`). - **Fluid sizes** are for web pages: each grows with the viewport up to the Brand Book size and never past it. The cap is the Brand Book's; the floor and the slope are the website's. Body text stays at its fixed size, so below about 1125px wide the fluid label is smaller than body text (16px against 18px at a phone's width), the reverse of the Brand Book's order; where a label sits next to body text on small screens, use the fixed label size. A fluid body size is not decided yet. - **Space after a heading:** 40px between any heading and the body text under it, at the 18px body size (Brand Book `918:2990`, measured). **Between a quote and its label:** 48px (Brand Book `918:3005`, measured). @@ -154,20 +154,20 @@ The values in the next sections are in `brand.css` as `--mfb--` (f Where the Brand Book is silent, these come from the website (Section 0). -**Spacing scale:** 1: 4px, 2: 8px, 3: 12px, 4: 16px, 6: 24px, 8: 32px, 12: 48px, 16: 64px, 24: 96px, 32: 128px (website `src/styles/global.css:78` to `src/styles/global.css:87`). Each step N is `--mfb-space-N`, the same value as Tailwind's own `p-N`, `m-N` and `gap-N`, so use Tailwind's utilities and the package adds none. Stay on the scale: no 6px, 10px, 14px, 18px, 20px or 28px. +**Spacing scale:** 1: 4px, 2: 8px, 3: 12px, 4: 16px, 6: 24px, 8: 32px, 12: 48px, 16: 64px, 24: 96px, 32: 128px (website `--s-1` to `--s-10`). Each step N is `--mfb-space-N`, the same value as Tailwind's own `p-N`, `m-N` and `gap-N`, so use Tailwind's utilities and the package adds none. Stay on the scale: no 6px, 10px, 14px, 18px, 20px or 28px. | Variable | Value | Use | Source | |----------|-------|-----|--------| | `--mfb-space-heading-to-body` | `40px` | Space between a heading and the body text that follows it, for any heading level (at the 18px body size). | Brand Book `918:2990`, measured | | `--mfb-space-quote-to-label` | `48px` | Space between a quote and its label (the attribution). | Brand Book `918:3005`, measured | -| `--mfb-space-gutter` | `clamp(20px, 4vw, 64px)` | Horizontal page gutter inside the page container. | website `src/styles/global.css:97` | -| `--mfb-space-section` | `clamp(64px, 9vw, 128px)` | Vertical padding of a page section (top and bottom). | website `src/styles/global.css:281` | -| `--mfb-space-section-tight` | `clamp(48px, 6vw, 96px)` | Vertical padding of a compact page section. | website `src/styles/global.css:282` | -| `--mfb-space-header-gap` | `clamp(32px, 4vw, 56px)` | Space between a section's header (title and intro) and its content. | website `src/components/blocks/EventsCalendar.tsx:91` | -| `--mfb-space-block-end` | `clamp(32px, 4vw, 48px)` | Space before a section's closing call to action. | website `src/components/blocks/BlogCarousel.tsx:297` | -| `--mfb-container-page` | `1280px` | Maximum width of the page container, gutter included (content is 1152px wide at the largest gutter). | website `src/styles/global.css:96` | -| `--mfb-container-wide` | `1440px` | Maximum width of full-width frames such as the header, the footer and wide blocks. | website `src/components/blocks/BlockWrapper.tsx:18` | -| `--mfb-container-measure` | `720px` | Maximum width of running text and of a centered section header. | website `src/styles/global.css:131` | +| `--mfb-space-gutter` | `clamp(20px, 4vw, 64px)` | Horizontal page gutter inside the page container. | website `--container-px` | +| `--mfb-space-section` | `clamp(64px, 9vw, 128px)` | Vertical padding of a page section (top and bottom). | website `.section padding` | +| `--mfb-space-section-tight` | `clamp(48px, 6vw, 96px)` | Vertical padding of a compact page section. | website `.section--tight padding` | +| `--mfb-space-header-gap` | `clamp(32px, 4vw, 56px)` | Space between a section's header (title and intro) and its content. | website `margin-bottom` (inline, section headers) | +| `--mfb-space-block-end` | `clamp(32px, 4vw, 48px)` | Space before a section's closing call to action. | website `margin-top` (inline, section calls to action) | +| `--mfb-container-page` | `1280px` | Maximum width of the page container, gutter included (content is 1152px wide at the largest gutter). | website `--container` | +| `--mfb-container-wide` | `1440px` | Maximum width of full-width frames such as the header, the footer and wide blocks. | website `max-width` (inline, wide frames) | +| `--mfb-container-measure` | `720px` | Maximum width of running text and of a centered section header. | website `article width` | ### Radius and shadow @@ -175,13 +175,13 @@ Radius is for interface elements (cards, buttons, inputs, images). **Brand shape | Variable | Value | Use | Source | |----------|-------|-----|--------| -| `--mfb-radius-sm` | `4px` | Small radius: focus rings, small tags. | website `src/styles/global.css:90` | -| `--mfb-radius-md` | `8px` | Medium radius: inputs, small media, menus. | website `src/styles/global.css:91` | -| `--mfb-radius-lg` | `12px` | Large radius: cards and media frames. | website `src/styles/global.css:92` | -| `--mfb-radius-pill` | `999px` | Pill radius: buttons and chips. In Tailwind, use rounded-full. | website `src/styles/global.css:93` | -| `--mfb-shadow-media` | `0px 20px 60px -20px`, purple-400 at 25% | Shadow under images and video on light backgrounds. None on dark backgrounds. | website `src/components/blocks/Features.tsx:114` | -| `--mfb-shadow-media-control` | `0px 12px 32px -8px`, black at 40% | Shadow of a control that sits on an image or video, such as a play button. | website `src/components/blocks/Hero.tsx:551` | -| `--mfb-shadow-overlay` | `0px 40px 80px -20px`, black at 50% | Shadow of a modal or lightbox above the page. | website `src/styles/global.css:1134` | +| `--mfb-radius-sm` | `4px` | Small radius: focus rings, small tags. | website `--r-sm` | +| `--mfb-radius-md` | `8px` | Medium radius: inputs, small media, menus. | website `--r-md` | +| `--mfb-radius-lg` | `12px` | Large radius: cards and media frames. | website `--r-lg` | +| `--mfb-radius-pill` | `999px` | Pill radius: buttons and chips. In Tailwind, use rounded-full. | website `--r-pill` | +| `--mfb-shadow-media` | `0px 20px 60px -20px`, purple-400 at 25% | Shadow under images and video on light backgrounds. None on dark backgrounds. | website `box-shadow` (inline, media frames) | +| `--mfb-shadow-media-control` | `0px 12px 32px -8px`, black at 40% | Shadow of a control that sits on an image or video, such as a play button. | website `box-shadow` (inline, video play buttons) | +| `--mfb-shadow-overlay` | `0px 40px 80px -20px`, black at 50% | Shadow of a modal or lightbox above the page. | website `.mfb-overlay__dialog box-shadow` | Shadows are tinted with palette colors only, and none is used on dark backgrounds. @@ -189,12 +189,12 @@ Shadows are tinted with palette colors only, and none is used on dark background | Variable | Value | Use | Source | |----------|-------|-----|--------| -| `--mfb-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Easing for hovers, presses, color changes and reveals. | website `src/styles/global.css:100` | -| `--mfb-ease-in-out` | `cubic-bezier(0.65, 0, 0.35, 1)` | Easing for carousel slides. | website `src/styles/global.css:101` | -| `--mfb-duration-fast` | `180ms` | Hover, press and color transitions. | website `src/styles/global.css:102` | -| `--mfb-duration-med` | `320ms` | Small state changes, such as a carousel pager dot growing. | website `src/styles/global.css:103` | -| `--mfb-duration-slide` | `520ms` | A carousel track sliding to the next item (with easing.in-out). | website `src/components/blocks/VideoCarousel.tsx:197` | -| `--mfb-duration-reveal` | `700ms` | Scroll reveal: an element fades in and rises 24px into place (with easing.out). None under reduced motion. | website `src/styles/global.css:533` | +| `--mfb-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Easing for hovers, presses, color changes and reveals. | website `--ease-out` | +| `--mfb-ease-in-out` | `cubic-bezier(0.65, 0, 0.35, 1)` | Easing for carousel slides. | website `--ease-in-out` | +| `--mfb-duration-fast` | `180ms` | Hover, press and color transitions. | website `--dur-fast` | +| `--mfb-duration-med` | `320ms` | Small state changes, such as a carousel pager dot growing. | website `--dur-med` | +| `--mfb-duration-slide` | `520ms` | A carousel track sliding to the next item (with easing.in-out). | website `transition` (inline, carousel tracks) | +| `--mfb-duration-reveal` | `700ms` | Scroll reveal: an element fades in and rises 24px into place (with easing.out). None under reduced motion. | website `.reveal transition` | Under `prefers-reduced-motion: reduce`, nothing moves: reveals show at once, marquees and carousels stop, and transitions are instant. Moving text pauses on hover. Never animate the highlighter ("Do not alter its appearance", `918:2874`). @@ -204,15 +204,15 @@ Under `prefers-reduced-motion: reduce`, nothing moves: reveals show at once, mar | Variable | Value | Use | Source | |----------|-------|-----|--------| -| `--mfb-z-header` | `100` | Sticky page header. | website `src/components/Header.astro:381` | -| `--mfb-z-dropdown` | `200` | Navigation dropdowns, above the header. | website `src/components/Header.astro:486` | -| `--mfb-z-overlay` | `1000` | Modals, lightboxes and the mobile menu, above everything. | website `src/styles/global.css:1115` | -| `--mfb-ratio-video` | `16 / 9` | Video and wide media. | website `src/components/VideoTile.astro:50` | -| `--mfb-ratio-photo` | `4 / 3` | Landscape photos. | website `src/components/blocks/ImageGallery.tsx:168` | -| `--mfb-ratio-portrait` | `4 / 5` | Portrait photos of people. | website `src/components/blocks/EducatorCarousel.tsx:107` | -| `--mfb-ratio-square` | `1 / 1` | Square images and avatars. | website `src/components/blocks/SupporterStories.tsx:84` | -| `--mfb-ui-touch-target` | `44px` | Smallest width and height of an icon button or any other tap target. | website `src/components/blocks/VideoCarousel.tsx:409` | -| `--mfb-ui-link-underline-offset` | `3px` | Distance between a link and its underline. | website `src/pages/news/[...slug].astro:342` | +| `--mfb-z-header` | `100` | Sticky page header. | website `.mfb-header z-index` | +| `--mfb-z-dropdown` | `200` | Navigation dropdowns, above the header. | website `.mfb-dropdown z-index` | +| `--mfb-z-overlay` | `1000` | Modals, lightboxes and the mobile menu, above everything. | website `.mfb-overlay z-index` | +| `--mfb-ratio-video` | `16 / 9` | Video and wide media. | website `.video-tile aspect-ratio` | +| `--mfb-ratio-photo` | `4 / 3` | Landscape photos. | website `aspect-ratio` (inline, gallery images) | +| `--mfb-ratio-portrait` | `4 / 5` | Portrait photos of people. | website `aspect-ratio` (inline, educator photos) | +| `--mfb-ratio-square` | `1 / 1` | Square images and avatars. | website `aspect-ratio` (inline, supporter photos) | +| `--mfb-ui-touch-target` | `44px` | Smallest width and height of an icon button or any other tap target. | website `width and height` (inline, icon buttons) | +| `--mfb-ui-link-underline-offset` | `3px` | Distance between a link and its underline. | website `.article-prose a text-underline-offset` | ### Not imported from the website diff --git a/scripts/brand-check.test.mjs b/scripts/brand-check.test.mjs index 8f84ba9..1a3dd6f 100644 --- a/scripts/brand-check.test.mjs +++ b/scripts/brand-check.test.mjs @@ -23,7 +23,7 @@ const CHECK = path.join(ROOT, 'brand-check.mjs'); // [file, content]. A line ending in "@rule" must be reported with that rule on that line. const PLANTED = { - 'src/styles/global.css': ` + 'src/styles/site.css': ` :root { --mfb-local: 1; } .a { color: #422C70; } @color-literal .b { background: rgba(0, 0, 0, 0.5); } @color-literal @@ -79,7 +79,7 @@ mark { background: var(--mfb-orange-300); } @hand-rolled-highlig .eyebrow { letter-spacing: 0.12em; } @letter-spacing .faint { color: color-mix(in srgb, var(--mfb-white) 85%, transparent); } @translucent-text `, - 'src/components/Card.tsx': ` + 'src/ui/Card.tsx': ` const panel: React.CSSProperties = { background: 'orange', padding: 0 }; @named-color const tile = { backgroundColor: 'purple', color: 'rebeccapurple' }; @named-color const tone = { textColor: open ? 'var(--mfb-white)' : 'var(--mfb-gray-900)' }; @text-color @@ -144,12 +144,12 @@ export const Card = ({ open }) => ( @translucent-shape `, - 'src/components/Caps.astro': `--- + 'src/ui/Caps.astro': `--- const on = true; ---

x

@uppercase `, - 'src/components/Clean.astro': `--- + 'src/ui/Clean.astro': `--- const href = '#feed'; const EDGE_MASK = 'linear-gradient(to right, transparent 0, var(--mfb-black) 56px, var(--mfb-black) calc(100% - 56px), transparent 100%)'; @@ -255,7 +255,7 @@ function color(name) { return name; } }; // Files that must not be reported at all. -const CLEAN = new Set(['src/components/Clean.astro', 'src/styles/legacy.scss', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); +const CLEAN = new Set(['src/ui/Clean.astro', 'src/styles/legacy.scss', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); const failures = []; @@ -292,34 +292,34 @@ try { // 2. The clean files alone pass: the clean source, and the Tailwind 3 and 4 builds. fs.rmSync(path.join(tmp, 'dist/_astro/drift.css')); fs.rmSync(path.join(tmp, 'dist/index.html')); - const clean = run(['--src', 'src/components/Clean.astro']); + const clean = run(['--src', 'src/ui/Clean.astro']); if (clean.status !== 0) failures.push(`clean files: exit ${clean.status}, expected 0: ${clean.stderr.trim()}`); // 3. A build without the angle is reported. const tw4 = path.join(tmp, 'dist/_astro/tw4.css'); const tw4Text = fs.readFileSync(tw4, 'utf8'); fs.writeFileSync(tw4, tw4Text.replace(/--sg-angle-base: 13deg;|@property --sg-angle-base[^}]*\}/g, '')); - if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-angle' && g.line === null)) failures.push('missed: built CSS without --sg-angle-base [built-angle]'); + if (!parse(run(['--src', 'src/ui/Clean.astro']).stderr).some((g) => g.rule === 'built-angle' && g.line === null)) failures.push('missed: built CSS without --sg-angle-base [built-angle]'); fs.writeFileSync(tw4, tw4Text); // 4. An allowlist entry silences its findings; an unused entry is reported; a reason is required. fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([ - { file: 'src/components/Card.tsx', reason: 'test: a third-party component' }, - { file: 'src/styles/global.css', rule: 'color-literal', match: '422C70', reason: 'test' }, + { file: 'src/ui/Card.tsx', reason: 'test: a third-party component' }, + { file: 'src/styles/site.css', rule: 'color-literal', match: '422C70', reason: 'test' }, { file: 'src/none.css', reason: 'test: unused' }, ])); const a = run([]); const ga = parse(a.stderr); - if (ga.some((g) => g.file === 'src/components/Card.tsx')) failures.push('allowlist: a whole-file entry did not silence the file'); - if (ga.some((g) => g.file === 'src/styles/global.css' && g.line === 3)) failures.push('allowlist: a rule and match entry did not silence its finding'); - if (!ga.some((g) => g.file === 'src/styles/global.css' && g.line === 4)) failures.push('allowlist: a rule and match entry silenced another finding'); + if (ga.some((g) => g.file === 'src/ui/Card.tsx')) failures.push('allowlist: a whole-file entry did not silence the file'); + if (ga.some((g) => g.file === 'src/styles/site.css' && g.line === 3)) failures.push('allowlist: a rule and match entry did not silence its finding'); + if (!ga.some((g) => g.file === 'src/styles/site.css' && g.line === 4)) failures.push('allowlist: a rule and match entry silenced another finding'); if (!/allowlist entry 3 .*matched nothing/.test(a.stderr)) failures.push('allowlist: an unused entry was not reported'); fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([{ file: 'src/x.css' }])); if (run([]).status !== 2) failures.push('allowlist: an entry without a reason was accepted'); fs.rmSync(path.join(tmp, 'brand-check.allow.json')); // 5. --warn reports a rule without failing; the default warnings fail only under --error. - const w = run(['--src', 'src/components', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter,clip-path']); + const w = run(['--src', 'src/ui', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter,clip-path']); if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); fs.mkdirSync(path.join(tmp, 'src2')); fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n.y { letter-spacing: 0.1em; }\n'); @@ -331,8 +331,8 @@ try { // 6. No built CSS, and --source-only. fs.rmSync(path.join(tmp, 'dist'), { recursive: true }); - if (!parse(run(['--src', 'src/components/Clean.astro']).stderr).some((g) => g.rule === 'built-missing')) failures.push('missed: no built CSS [built-missing]'); - if (run(['--src', 'src/components/Clean.astro', '--source-only']).status !== 0) failures.push('--source-only still read the build'); + if (!parse(run(['--src', 'src/ui/Clean.astro']).stderr).some((g) => g.rule === 'built-missing')) failures.push('missed: no built CSS [built-missing]'); + if (run(['--src', 'src/ui/Clean.astro', '--source-only']).status !== 0) failures.push('--source-only still read the build'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index a292145..5441892 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -15,6 +15,8 @@ // // It refuses to write when: // - a token the prose depends on is absent (a blank where a brand value belongs); +// - a website source is not "myfirstbitcoin.org@, ", or names a file, a +// directory or a line of the site's private source repository; // - the template names a placeholder this script does not fill, or leaves one unfilled; // - the result contains an em-dash, a machine path or a date stamp (the public file carries none). @@ -86,10 +88,14 @@ const values = { }; // ---- sources ---- -// Every value in the new sections is shown with where it comes from: a Brand Book node, the line -// of the live website it was declared from (where the book is silent), or this package's +// Every value in the new sections is shown with where it comes from: a Brand Book node, the +// website CSS name it was declared from (where the book is silent), or this package's // recommendation. tokens.json carries the full note; the spec shows the short form. -const SITE_RE = /^myfirstbitcoin\.org@([0-9a-f]{7,40}):(.+)$/; +// A website source is "myfirstbitcoin.org@, ", optionally followed by where an +// inline property is used in parentheses. The site's source repository is private, so a source +// that names a file, a directory or a line of it is refused. +const SITE_RE = /^myfirstbitcoin\.org@([0-9a-f]{7,40}), (.+?)(?: \((.+)\))?$/; +const PRIVATE_PATH_RE = /\/|\w\.(?:astro|tsx|jsx|ts|js|mjs|css|scss)(?![\w-])|:\d/; const siteCommits = new Set(); const sourceOf = (group, name) => { const tok = (tokens[group] || {})[name]; @@ -99,10 +105,14 @@ const sourceOf = (group, name) => { return ''; } if (m.source.startsWith('figma:')) return `Brand Book \`${m.source.slice(6)}\`${m.sourceKind === 'measured' ? ', measured' : ''}`; - const site = m.source.match(SITE_RE); - if (site) { + if (m.source.startsWith('myfirstbitcoin.org')) { + const site = m.source.match(SITE_RE); + if (!site || PRIVATE_PATH_RE.test(m.source.replace(/^myfirstbitcoin\.org@/, ''))) { + missing.push(`${group}.${name} (a website source as myfirstbitcoin.org@, , with no file or line)`); + return ''; + } siteCommits.add(site[1]); - return `website \`${site[2]}\``; + return `website \`${site[2]}\`${site[3] ? ` (${site[3]})` : ''}`; } if (m.source === 'declared') return "this package's recommendation"; return `\`${m.source}\``; diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 2bb2a4f..272adec 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -649,7 +649,7 @@ const readme = [ `## Layout, motion and color roles`, ``, `Where the Brand Book defines a value, the token takes it. Where the book is silent (spacing, radius, shadow, motion, widths), the value is declared from the live website, myfirstbitcoin.org, which was built following the Brand Book; ` + - `each such token's ${code('$extensions.mfb.source')} names the commit, file and line. A website value that contradicts the Brand Book is never imported. ${code('brand-spec.md')} lists every value with its source.`, + `each such token's ${code('$extensions.mfb.source')} names the website commit and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline), for example ${code(tokens.radius.lg.$extensions.mfb.source)}. A website value that contradicts the Brand Book is never imported. ${code('brand-spec.md')} lists every value with its source.`, ``, `| What | brand.css | Tailwind 3 and 4 |`, `|------|-----------|------------------|`, @@ -720,7 +720,7 @@ const readme = [ ``, `${code('--allow FILE')} (default ${code('brand-check.allow.json')} when it exists) lists the exceptions, each with its reason, for example a third-party mark:`, ``, - `${FENCE}json\n[{ "file": "src/assets/partner-logo.svg", "reason": "the partner's own logo colors" },\n { "file": "src/components/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "the network's own badge color" }]\n${FENCE}`, + `${FENCE}json\n[{ "file": "src/assets/partner-logo.svg", "reason": "the partner's own logo colors" },\n { "file": "src/ui/Share.astro", "rule": "color-literal", "match": "1877f2", "reason": "the network's own badge color" }]\n${FENCE}`, ``, `An entry without ${code('rule')} covers every rule in that file, and one without ${code('match')} every finding of the rule. ${code('--warn RULE[,RULE]')} reports a rule without failing, for a project that adopts the check step by step (${code('--warn raw-value')}). ${code('--help')} lists the rule ids.`, ``, diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 8e3c6b1..31f95e5 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -12,7 +12,7 @@ the Brand Book Figma file {{FIGMA_FILE}}. To regenerate: node scripts/brand-spec **The Brand Book Figma (`{{FIGMA_FILE}}`) is the single source of truth for My First Bitcoin's visual rules.** This markdown is a mirror. If anything here conflicts with the Brand Book, the Brand Book wins. Before generating any brand artifact, screenshot the relevant Brand Book rule page (for example with the Figma MCP server's `get_screenshot` tool) so you are working from the visual, not from a summary. -**Where the Brand Book is silent** (spacing, radius, shadow, motion, page widths), the values below are declared from the live website, myfirstbitcoin.org, which was built following the Brand Book. Each one names the file and line it comes from, on the site's main branch at commit `{{SITE_COMMIT}}`. A website value that contradicts the Brand Book is never imported (see "Not imported from the website" in Section 1), so do not copy values from the website's code: take them from this package. +**Where the Brand Book is silent** (spacing, radius, shadow, motion, page widths), the values below are declared from the live website, myfirstbitcoin.org, which was built following the Brand Book. Each one names the CSS that carries it in what the site serves (a custom property, a selector and property, or a property set inline), on the site's main branch at commit `{{SITE_COMMIT}}`. A website value that contradicts the Brand Book is never imported (see "Not imported from the website" in Section 1), so do not copy values from the website's code: take them from this package. **Canonical rule pages (Brand Book node IDs):** diff --git a/tokens.json b/tokens.json index 69dc4c5..4e0d16e 100644 --- a/tokens.json +++ b/tokens.json @@ -874,10 +874,10 @@ "$description": "Line height of long reading text (articles and other running prose), as a multiple of the body font size. Not the Brand Book's body line height: use body for body copy paired with a heading.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/pages/news/[...slug].astro:312", + "source": "myfirstbitcoin.org@6368b2a, .article-prose line-height", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent on long reading text: its type specimen (918:2323) states 1.2 for body, and Type Relationships (918:2990) draws body copy paired with a heading at 1.22, both on short text. So the value is declared from the live website (main branch at 6368b2a), which sets its article prose (.article-prose) at 1.7, the same as its page default (src/styles/global.css:122). Uses counted with git grep over src at 6368b2a: 3 lines set line-height: 1.7 (the article prose, the page body and the footer). The website's section paragraphs use 1.55 (38 lines), a different setting that is not imported. Flagged for review: for articles only, never for headings or short body copy." + "note": "The Brand Book is silent on long reading text: its type specimen (918:2323) states 1.2 for body, and Type Relationships (918:2990) draws body copy paired with a heading at 1.22, both on short text. So the value is declared from the live website (main branch at 6368b2a), which sets its article prose (.article-prose) at 1.7, the same as its page default (the body line-height). Uses counted with git grep in the site's source at 6368b2a: 3 lines set line-height: 1.7 (the article prose, the page body and the footer). The website's section paragraphs use 1.55 (38 lines), a different setting that is not imported. Flagged for review: for articles only, never for headings or short body copy." } } }, @@ -1034,10 +1034,10 @@ "$description": "Fluid h1 for web pages: grows with the viewport up to the Brand Book size (fontSize.h1).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:66", + "source": "myfirstbitcoin.org@6368b2a, --fs-h1", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h1 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 2 lines read var(--fs-h1)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h1 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: 2 lines read var(--fs-h1)." } } }, @@ -1047,10 +1047,10 @@ "$description": "Fluid h2 for web pages: grows with the viewport up to the Brand Book size (fontSize.h2).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:67", + "source": "myfirstbitcoin.org@6368b2a, --fs-h2", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h2 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 8 lines read var(--fs-h2)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h2 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: 8 lines read var(--fs-h2)." } } }, @@ -1060,10 +1060,10 @@ "$description": "Fluid h3 for web pages: grows with the viewport up to the Brand Book size (fontSize.h3).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:68", + "source": "myfirstbitcoin.org@6368b2a, --fs-h3", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h3 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 30 lines read var(--fs-h3)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h3 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: 30 lines read var(--fs-h3)." } } }, @@ -1073,10 +1073,10 @@ "$description": "Fluid h4 for web pages: grows with the viewport up to the Brand Book size (fontSize.h4).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:69", + "source": "myfirstbitcoin.org@6368b2a, --fs-h4", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h4 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h4): declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h4 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--fs-h4): declared on the site, not used there yet." } } }, @@ -1086,10 +1086,10 @@ "$description": "Fluid h5 for web pages: grows with the viewport up to the Brand Book size (fontSize.h5).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:70", + "source": "myfirstbitcoin.org@6368b2a, --fs-h5", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h5 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h5): declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h5 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--fs-h5): declared on the site, not used there yet." } } }, @@ -1099,10 +1099,10 @@ "$description": "Fluid h6 for web pages: grows with the viewport up to the Brand Book size (fontSize.h6).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:71", + "source": "myfirstbitcoin.org@6368b2a, --fs-h6", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h6 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-h6): declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's h6 size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--fs-h6): declared on the site, not used there yet." } } }, @@ -1112,10 +1112,10 @@ "$description": "Fluid quote for web pages: grows with the viewport up to the Brand Book size (fontSize.quote).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:72", + "source": "myfirstbitcoin.org@6368b2a, --fs-quote", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's quote size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: no line reads var(--fs-quote): declared on the site, not used there yet." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's quote size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--fs-quote): declared on the site, not used there yet." } } }, @@ -1125,10 +1125,10 @@ "$description": "Fluid label for web pages: grows with the viewport up to the Brand Book size (fontSize.label).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:73", + "source": "myfirstbitcoin.org@6368b2a, --fs-label", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's label size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep over src at 6368b2a: 4 lines read var(--fs-label)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The cap is the Brand Book's label size and the build refuses any other; the floor and the slope are the website's. Uses counted with git grep in the site's source at 6368b2a: 4 lines read var(--fs-label)." } } } @@ -1166,10 +1166,10 @@ "$description": "Secondary text on white and gray-100 (captions, meta, descriptions), never body copy.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:103", + "source": "myfirstbitcoin.org@6368b2a, --gray-700 (inline text color)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour on light surfaces. Uses counted with git grep over src at 6368b2a: 57 lines read var(--gray-700), all as a text colour. 5.1:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's muted and description colour on light surfaces. Uses counted with git grep in the site's source at 6368b2a: 57 lines read var(--gray-700), all as a text colour. 5.1:1 on white, 4.7:1 on gray-100 (below 4.5:1 on gray-200 and darker). A dark colour in the contrast rule (918:2588)." } } }, @@ -1182,7 +1182,7 @@ "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "Not in the Brand Book: this package's recommendation, open for a decision, which keeps to the contrast rule (918:2588). The Brand Book has no link style; the website's orange link text (src/styles/global.css:166) is 2.3:1 on white and breaks 918:2588, so it is not imported. 11.5:1 on white." + "note": "Not in the Brand Book: this package's recommendation, open for a decision, which keeps to the contrast rule (918:2588). The Brand Book has no link style; the website's orange link text (--accent, the color of every link) is 2.3:1 on white and breaks 918:2588, so it is not imported. 11.5:1 on white." } } }, @@ -1205,10 +1205,10 @@ "$description": "Card and divider borders on white.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:96", + "source": "myfirstbitcoin.org@6368b2a, --gray-200 (inline border color)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. A palette colour; its role comes from the website. Uses counted with git grep over src at 6368b2a: 53 lines read var(--gray-200), most of them as a border or divider colour." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. A palette colour; its role comes from the website. Uses counted with git grep in the site's source at 6368b2a: 53 lines read var(--gray-200), most of them as a border or divider colour." } } }, @@ -1273,7 +1273,7 @@ "source": "declared", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "Not in the Brand Book: this package's recommendation, open for a decision. The website's muted text on dark surfaces is white at 72% (rgba(255,255,255,0.72), 35 lines in src at 6368b2a, for example src/components/blocks/BlogCarousel.tsx:94), which is not a palette colour and is not imported (translucent white text). gray-400 is the palette colour nearest to it on purple-300 and purple-400; the website never uses gray-400 as text on a dark surface. 6.4:1 on purple-300, 8.3:1 on purple-400, 4.9:1 on purple-200." + "note": "Not in the Brand Book: this package's recommendation, open for a decision. The website's muted text on dark surfaces is white at 72% (rgba(255,255,255,0.72), 35 lines in the site's source at 6368b2a), which is not a palette colour and is not imported (translucent white text). gray-400 is the palette colour nearest to it on purple-300 and purple-400; the website never uses gray-400 as text on a dark surface. 6.4:1 on purple-300, 8.3:1 on purple-400, 4.9:1 on purple-200." } } }, @@ -1283,10 +1283,10 @@ "$description": "Link text on purple-300 and purple-400. On purple-200 and on dark greys, links are white and underlined.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:166", + "source": "myfirstbitcoin.org@6368b2a, --accent (the color of every link)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's link colour: global.css:166 sets every link to var(--accent), which global.css:47 resolves to orange-300, on its purple surfaces too. Orange-300 is a light colour in the contrast rule (918:2588), so it is light text on a dark colour, and the book puts orange-300 marks on purple (918:2883). 5.0:1 on purple-300, 6.5:1 on purple-400; 3.8:1 on purple-200 and 1.5:1 to 3.5:1 on gray-600 to gray-800, too low for link text, so links there are white and underlined (8.7:1 on purple-200). The same rule on light backgrounds is not imported." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's link colour: every link is var(--accent), which the site sets to var(--orange-300), on its purple surfaces too. Orange-300 is a light colour in the contrast rule (918:2588), so it is light text on a dark colour, and the book puts orange-300 marks on purple (918:2883). 5.0:1 on purple-300, 6.5:1 on purple-400; 3.8:1 on purple-200 and 1.5:1 to 3.5:1 on gray-600 to gray-800, too low for link text, so links there are white and underlined (8.7:1 on purple-200). The same rule on light backgrounds is not imported." } } }, @@ -1510,10 +1510,10 @@ "$description": "Spacing step 1: the same value as Tailwind's p-1, m-1 and gap-1.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:78", + "source": "myfirstbitcoin.org@6368b2a, --s-1", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-1, keyed here by Tailwind's step number so that --mfb-space-1 always equals p-1. Uses counted with git grep over src at 6368b2a: no line reads var(--s-1), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-1, keyed here by Tailwind's step number so that --mfb-space-1 always equals p-1. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-1), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1523,10 +1523,10 @@ "$description": "Spacing step 2: the same value as Tailwind's p-2, m-2 and gap-2.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:79", + "source": "myfirstbitcoin.org@6368b2a, --s-2", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-2, keyed here by Tailwind's step number so that --mfb-space-2 always equals p-2. Uses counted with git grep over src at 6368b2a: no line reads var(--s-2), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-2, keyed here by Tailwind's step number so that --mfb-space-2 always equals p-2. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-2), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1536,10 +1536,10 @@ "$description": "Spacing step 3: the same value as Tailwind's p-3, m-3 and gap-3.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:80", + "source": "myfirstbitcoin.org@6368b2a, --s-3", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-3, keyed here by Tailwind's step number so that --mfb-space-3 always equals p-3. Uses counted with git grep over src at 6368b2a: no line reads var(--s-3), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-3, keyed here by Tailwind's step number so that --mfb-space-3 always equals p-3. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-3), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1549,10 +1549,10 @@ "$description": "Spacing step 4: the same value as Tailwind's p-4, m-4 and gap-4.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:81", + "source": "myfirstbitcoin.org@6368b2a, --s-4", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-4, keyed here by Tailwind's step number so that --mfb-space-4 always equals p-4. Uses counted with git grep over src at 6368b2a: no line reads var(--s-4), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-4, keyed here by Tailwind's step number so that --mfb-space-4 always equals p-4. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-4), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1562,10 +1562,10 @@ "$description": "Spacing step 6: the same value as Tailwind's p-6, m-6 and gap-6.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:82", + "source": "myfirstbitcoin.org@6368b2a, --s-5", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-5, keyed here by Tailwind's step number so that --mfb-space-6 always equals p-6. Uses counted with git grep over src at 6368b2a: no line reads var(--s-5), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-5, keyed here by Tailwind's step number so that --mfb-space-6 always equals p-6. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-5), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1575,10 +1575,10 @@ "$description": "Spacing step 8: the same value as Tailwind's p-8, m-8 and gap-8.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:83", + "source": "myfirstbitcoin.org@6368b2a, --s-6", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-6, keyed here by Tailwind's step number so that --mfb-space-8 always equals p-8. Uses counted with git grep over src at 6368b2a: no line reads var(--s-6), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-6, keyed here by Tailwind's step number so that --mfb-space-8 always equals p-8. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-6), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1588,10 +1588,10 @@ "$description": "Spacing step 12: the same value as Tailwind's p-12, m-12 and gap-12.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:84", + "source": "myfirstbitcoin.org@6368b2a, --s-7", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-7, keyed here by Tailwind's step number so that --mfb-space-12 always equals p-12. Uses counted with git grep over src at 6368b2a: no line reads var(--s-7), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-7, keyed here by Tailwind's step number so that --mfb-space-12 always equals p-12. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-7), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1601,10 +1601,10 @@ "$description": "Spacing step 16: the same value as Tailwind's p-16, m-16 and gap-16.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:85", + "source": "myfirstbitcoin.org@6368b2a, --s-8", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-8, keyed here by Tailwind's step number so that --mfb-space-16 always equals p-16. Uses counted with git grep over src at 6368b2a: no line reads var(--s-8), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-8, keyed here by Tailwind's step number so that --mfb-space-16 always equals p-16. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-8), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1614,10 +1614,10 @@ "$description": "Spacing step 24: the same value as Tailwind's p-24, m-24 and gap-24.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:86", + "source": "myfirstbitcoin.org@6368b2a, --s-9", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-9, keyed here by Tailwind's step number so that --mfb-space-24 always equals p-24. Uses counted with git grep over src at 6368b2a: no line reads var(--s-9), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-9, keyed here by Tailwind's step number so that --mfb-space-24 always equals p-24. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-9), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } }, @@ -1627,10 +1627,10 @@ "$description": "Spacing step 32: the same value as Tailwind's p-32, m-32 and gap-32.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:87", + "source": "myfirstbitcoin.org@6368b2a, --s-10", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-10, keyed here by Tailwind's step number so that --mfb-space-32 always equals p-32. Uses counted with git grep over src at 6368b2a: no line reads var(--s-10), so this is the scale the site declares (global.css:78-87), not one its code reads. Its values are Tailwind's own steps." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The website's --s-10, keyed here by Tailwind's step number so that --mfb-space-32 always equals p-32. Uses counted with git grep in the site's source at 6368b2a: no line reads var(--s-10), so this is the scale the site declares (--s-1 to --s-10), not one its code reads. Its values are Tailwind's own steps." } } } @@ -1668,10 +1668,10 @@ "$description": "Horizontal page gutter inside the page container.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:97", + "source": "myfirstbitcoin.org@6368b2a, --container-px", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container-px, the side padding of its .container rule (global.css:277-278). Uses counted with git grep over src at 6368b2a: 50 lines set the class container." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container-px, the side padding of its .container rule. Uses counted with git grep in the site's source at 6368b2a: 50 lines set the class container." } } }, @@ -1681,10 +1681,10 @@ "$description": "Vertical padding of a page section (top and bottom).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:281", + "source": "myfirstbitcoin.org@6368b2a, .section padding", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section padding. Uses counted with git grep over src at 6368b2a: 40 lines set the class section. One page overrides it (global.css:286, clamp(40px, 5vw, 72px))." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section padding. Uses counted with git grep in the site's source at 6368b2a: 40 lines set the class section. One page overrides it (clamp(40px, 5vw, 72px))." } } }, @@ -1694,10 +1694,10 @@ "$description": "Vertical padding of a compact page section.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:282", + "source": "myfirstbitcoin.org@6368b2a, .section--tight padding", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section--tight padding. Uses counted with git grep over src at 6368b2a: 3 lines set the class section--tight (ActionStrip, BreakingNews, StatsBanner)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .section--tight padding. Uses counted with git grep in the site's source at 6368b2a: 3 lines set the class section--tight." } } }, @@ -1707,10 +1707,10 @@ "$description": "Space between a section's header (title and intro) and its content.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EventsCalendar.tsx:91", + "source": "myfirstbitcoin.org@6368b2a, margin-bottom (inline, section headers)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 13 lines carry clamp(32px, 4vw, 56px), in 11 files (EventsCalendar, FaqAccordion, ImageGallery, ImpactChart, StatsBanner, TeamGrid and others)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 13 lines carry clamp(32px, 4vw, 56px), in 11 files." } } }, @@ -1720,10 +1720,10 @@ "$description": "Space before a section's closing call to action.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlogCarousel.tsx:297", + "source": "myfirstbitcoin.org@6368b2a, margin-top (inline, section calls to action)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 11 lines carry clamp(32px, 4vw, 48px), in 10 files (BlogCarousel, Countdown, EventsGrid, StatsBanner, TeamGrid and others)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 11 lines carry clamp(32px, 4vw, 48px), in 10 files." } } } @@ -1735,10 +1735,10 @@ "$description": "Small radius: focus rings, small tags.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:90", + "source": "myfirstbitcoin.org@6368b2a, --r-sm", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 2 lines read var(--r-sm)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep in the site's source at 6368b2a: 2 lines read var(--r-sm)." } } }, @@ -1748,10 +1748,10 @@ "$description": "Medium radius: inputs, small media, menus.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:91", + "source": "myfirstbitcoin.org@6368b2a, --r-md", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 20 lines read var(--r-md). The Brand Book's own specimen label chips are drawn with 8px (918:2998): slide chrome, not a rule." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep in the site's source at 6368b2a: 20 lines read var(--r-md). The Brand Book's own specimen label chips are drawn with 8px (918:2998): slide chrome, not a rule." } } }, @@ -1761,10 +1761,10 @@ "$description": "Large radius: cards and media frames.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:92", + "source": "myfirstbitcoin.org@6368b2a, --r-lg", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The card radius. Uses counted with git grep over src at 6368b2a: 48 lines read var(--r-lg). The Brand Book's specimen surfaces on Type Relationships (918:2993, 918:3005, 918:3020) use 12px: slide chrome, not a rule." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. The card radius. Uses counted with git grep in the site's source at 6368b2a: 48 lines read var(--r-lg). The Brand Book's specimen surfaces on Type Relationships (918:2993, 918:3005, 918:3020) use 12px: slide chrome, not a rule." } } }, @@ -1774,10 +1774,10 @@ "$description": "Pill radius: buttons and chips. In Tailwind, use rounded-full.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:93", + "source": "myfirstbitcoin.org@6368b2a, --r-pill", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep over src at 6368b2a: 9 lines read var(--r-pill)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. For interface elements (cards, buttons, inputs, images) only: never on brand shapes (parallelograms, spotlights, books, the highlighter), whose corners the Brand Book draws sharp. Uses counted with git grep in the site's source at 6368b2a: 9 lines read var(--r-pill)." } } } @@ -1796,10 +1796,10 @@ "$description": "Shadow under images and video on light backgrounds. None on dark backgrounds.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Features.tsx:114", + "source": "myfirstbitcoin.org@6368b2a, box-shadow (inline, media frames)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(43, 28, 88, 0.25), purple-400 at 25%. Uses counted with git grep over src at 6368b2a: 6 lines carry it exactly (Features, VideoSplit, SupporterStories, VideoEmbed, ContactCard, Hero), always none on dark surfaces; one more uses 18% (EducatorSignupForm.tsx:180)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(43, 28, 88, 0.25), purple-400 at 25%. Uses counted with git grep in the site's source at 6368b2a: 6 lines carry it exactly, always none on dark surfaces; one more uses 18%." } } }, @@ -1816,10 +1816,10 @@ "$description": "Shadow of a control that sits on an image or video, such as a play button.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/Hero.tsx:551", + "source": "myfirstbitcoin.org@6368b2a, box-shadow (inline, video play buttons)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.4), black at 40%. Uses counted with git grep over src at 6368b2a: 7 lines (Hero, VideoSplit, VideoCarousel, VideoEmbed, ReportEmbed, VideoTile, the news article page)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.4), black at 40%. Uses counted with git grep in the site's source at 6368b2a: 7 lines." } } }, @@ -1836,10 +1836,10 @@ "$description": "Shadow of a modal or lightbox above the page.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1134", + "source": "myfirstbitcoin.org@6368b2a, .mfb-overlay__dialog box-shadow", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.5), black at 50%. Uses counted with git grep over src at 6368b2a: 3 lines (global.css, JourneyScoreboard, TransparencyFlow); one more uses 60% (global.css:1194)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. rgba(0, 0, 0, 0.5), black at 50%. Uses counted with git grep in the site's source at 6368b2a: 3 lines; one more uses 60% (.mfb-lightbox__img)." } } } @@ -1856,10 +1856,10 @@ "$description": "Easing for hovers, presses, color changes and reveals.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:100", + "source": "myfirstbitcoin.org@6368b2a, --ease-out", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-out. Uses counted with git grep over src at 6368b2a: 82 lines read var(--ease-out). Not Tailwind's ease-out, which is a different curve." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-out. Uses counted with git grep in the site's source at 6368b2a: 82 lines read var(--ease-out). Not Tailwind's ease-out, which is a different curve." } } }, @@ -1874,10 +1874,10 @@ "$description": "Easing for carousel slides.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:101", + "source": "myfirstbitcoin.org@6368b2a, --ease-in-out", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-in-out. Uses counted with git grep over src at 6368b2a: 7 lines read var(--ease-in-out), all carousel tracks. Not Tailwind's ease-in-out, which is a different curve." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --ease-in-out. Uses counted with git grep in the site's source at 6368b2a: 7 lines read var(--ease-in-out), all carousel tracks. Not Tailwind's ease-in-out, which is a different curve." } } } @@ -1889,10 +1889,10 @@ "$description": "Hover, press and color transitions.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:102", + "source": "myfirstbitcoin.org@6368b2a, --dur-fast", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-fast. Uses counted with git grep over src at 6368b2a: 77 lines read var(--dur-fast)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-fast. Uses counted with git grep in the site's source at 6368b2a: 77 lines read var(--dur-fast)." } } }, @@ -1902,10 +1902,10 @@ "$description": "Small state changes, such as a carousel pager dot growing.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:103", + "source": "myfirstbitcoin.org@6368b2a, --dur-med", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-med. Uses counted with git grep over src at 6368b2a: 5 lines read var(--dur-med)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --dur-med. Uses counted with git grep in the site's source at 6368b2a: 5 lines read var(--dur-med)." } } }, @@ -1915,10 +1915,10 @@ "$description": "A carousel track sliding to the next item (with easing.in-out).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:197", + "source": "myfirstbitcoin.org@6368b2a, transition (inline, carousel tracks)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 6 lines carry 520ms (BlogCarousel, VideoCarousel, VerticalCarousel, Testimonials, EducatorCarousel, TestimonialCards)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 6 lines carry 520ms." } } }, @@ -1928,10 +1928,10 @@ "$description": "Scroll reveal: an element fades in and rises 24px into place (with easing.out). None under reduced motion.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:533", + "source": "myfirstbitcoin.org@6368b2a, .reveal transition", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .reveal (global.css:530-535), with a reduced-motion fallback (global.css:553-561). Uses counted with git grep over src at 6368b2a: 105 lines set the class reveal. The site's --dur-slow (600ms, global.css:104) is never read and is not imported." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's .reveal transition, with a reduced-motion fallback. Uses counted with git grep in the site's source at 6368b2a: 105 lines set the class reveal. The site's --dur-slow (600ms) is never read and is not imported." } } } @@ -1943,10 +1943,10 @@ "$description": "Maximum width of the page container, gutter included (content is 1152px wide at the largest gutter).", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:96", + "source": "myfirstbitcoin.org@6368b2a, --container", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container, the max width of its .container rule (global.css:275). Uses counted with git grep over src at 6368b2a: 50 lines set the class container." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The site's --container, the max width of its .container rule. Uses counted with git grep in the site's source at 6368b2a: 50 lines set the class container." } } }, @@ -1956,10 +1956,10 @@ "$description": "Maximum width of full-width frames such as the header, the footer and wide blocks.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/BlockWrapper.tsx:18", + "source": "myfirstbitcoin.org@6368b2a, max-width (inline, wide frames)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 6 lines carry 1440px (BlockWrapper, Content, Roadmap, Header, Footer, the page hero grid)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 6 lines carry 1440px, among them the header, the footer and the page hero grid." } } }, @@ -1969,10 +1969,10 @@ "$description": "Maximum width of running text and of a centered section header.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:131", + "source": "myfirstbitcoin.org@6368b2a, article width", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The width of the article element (global.css:131), which holds the running text of news articles; the news page sets it inside a 760px column (src/pages/news/[...slug].astro:304). Uses counted with git grep over src at 6368b2a: the same 720px is also the max width of 19 centered section headers in the blocks (Features.tsx:133 and 18 others)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. The width of the site's article rule, which holds the running text of news articles; the news page sets it inside a 760px column (.article-content). Uses counted with git grep in the site's source at 6368b2a: the same 720px is also the max width of 19 centered section headers." } } } @@ -1984,10 +1984,10 @@ "$description": "Sticky page header.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:381", + "source": "myfirstbitcoin.org@6368b2a, .mfb-header z-index", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 1 line (the sticky header); the site's other layers are -1 to 5, 999 and 1000." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 1 line (the sticky header); the site's other layers are -1 to 5, 999 and 1000." } } }, @@ -1997,10 +1997,10 @@ "$description": "Navigation dropdowns, above the header.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/Header.astro:486", + "source": "myfirstbitcoin.org@6368b2a, .mfb-dropdown z-index", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 1 line (the navigation dropdowns)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 1 line (the navigation dropdowns)." } } }, @@ -2010,10 +2010,10 @@ "$description": "Modals, lightboxes and the mobile menu, above everything.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/styles/global.css:1115", + "source": "myfirstbitcoin.org@6368b2a, .mfb-overlay z-index", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 8 lines carry z-index 1000, in 6 files (global.css, ImageGallery, JourneyScoreboard, TestimonialCards, TransparencyFlow, VideoCarousel)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 8 lines carry z-index 1000, in 6 files." } } } @@ -2025,10 +2025,10 @@ "$description": "Video and wide media.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/VideoTile.astro:50", + "source": "myfirstbitcoin.org@6368b2a, .video-tile aspect-ratio", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 7 lines set aspect-ratio 16 / 9 (14 more content entries choose '16:9' for a video block)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 7 lines set aspect-ratio 16 / 9 (14 more content entries choose '16:9' for a video block)." } } }, @@ -2038,10 +2038,10 @@ "$description": "Landscape photos.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/ImageGallery.tsx:168", + "source": "myfirstbitcoin.org@6368b2a, aspect-ratio (inline, gallery images)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 4 lines set aspect-ratio 4 / 3." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 4 lines set aspect-ratio 4 / 3." } } }, @@ -2051,10 +2051,10 @@ "$description": "Portrait photos of people.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/EducatorCarousel.tsx:107", + "source": "myfirstbitcoin.org@6368b2a, aspect-ratio (inline, educator photos)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 3 lines set aspect-ratio 4 / 5 (EducatorCarousel, EventsGrid, SplitContent)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 3 lines set aspect-ratio 4 / 5." } } }, @@ -2064,10 +2064,10 @@ "$description": "Square images and avatars.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/SupporterStories.tsx:84", + "source": "myfirstbitcoin.org@6368b2a, aspect-ratio (inline, supporter photos)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 3 lines set a square aspect ratio: 1 / 1 at SupporterStories.tsx:84 and ArticleImage.astro:79, and '1' at TeamGrid.tsx:201." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 3 lines set a square aspect ratio, two as 1 / 1 and one as '1'." } } } @@ -2079,10 +2079,10 @@ "$description": "Smallest width and height of an icon button or any other tap target.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/components/blocks/VideoCarousel.tsx:409", + "source": "myfirstbitcoin.org@6368b2a, width and height (inline, icon buttons)", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep over src at 6368b2a: 9 elements set both width and height to 44 (ImageGallery, JourneyScoreboard, ProgramGrid, TestimonialCards, TransparencyFlow, VerticalCarousel, VideoCarousel, and two in global.css)." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Uses counted with git grep in the site's source at 6368b2a: 9 elements set both width and height to 44." } } }, @@ -2092,13 +2092,13 @@ "$description": "Distance between a link and its underline.", "$extensions": { "mfb": { - "source": "myfirstbitcoin.org@6368b2a:src/pages/news/[...slug].astro:342", + "source": "myfirstbitcoin.org@6368b2a, .article-prose a text-underline-offset", "sourceKind": "declared", "verifiedOn": "2026-10-06", - "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Only the offset is imported: the orange-400 link text in the same rule (line 340) breaks the contrast rule (918:2588). Uses counted with git grep over src at 6368b2a: 2 lines, both on the news article page." + "note": "The Brand Book is silent here, so the value is declared from the live website (main branch at 6368b2a), which was built following the Brand Book. Only the offset is imported: the orange-400 link text in the same rule (.article-prose a color) breaks the contrast rule (918:2588). Uses counted with git grep in the site's source at 6368b2a: 2 lines, both on the news article page." } } } }, - "$verification": "Colour and the IBM Plex Sans families, sizes and weights are checked against Figma page 262:2 by scripts/verify-figma.mjs, which counts rendered solid fills and text styles. That is the ONLY machine-checkable provenance in this file. Every token carries $extensions.mfb.sourceKind: rendered-fill or rendered-text (checkable), prose (stated in the Brand Book as English, a human transcribed it, unverifiable by machine), measured (read off the Brand Book's vector geometry or a node's rendered fill, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), declared (not in the Brand Book), or unverified (provenance never established). The source rule: where the Brand Book defines a value, it wins. Where it is silent, a value may be declared from the live website, which was built following the Brand Book: sourceKind declared, source myfirstbitcoin.org@:: on the site's main branch. A website value that contradicts the Brand Book is never declared. Any other declared value has source declared and says in its note why it exists. Colour roles (colorRole) and shape tones (shapeTone) are aliases of palette colours, and the build refuses one that is not. Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." + "$verification": "Colour and the IBM Plex Sans families, sizes and weights are checked against Figma page 262:2 by scripts/verify-figma.mjs, which counts rendered solid fills and text styles. That is the ONLY machine-checkable provenance in this file. Every token carries $extensions.mfb.sourceKind: rendered-fill or rendered-text (checkable), prose (stated in the Brand Book as English, a human transcribed it, unverifiable by machine), measured (read off the Brand Book's vector geometry or a node's rendered fill, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), declared (not in the Brand Book), or unverified (provenance never established). The source rule: where the Brand Book defines a value, it wins. Where it is silent, a value may be declared from the live website, which was built following the Brand Book: sourceKind declared, source myfirstbitcoin.org@, : a commit on the site's main branch and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline, with where it is used). The site's source repository is private, so no file or line of it is named here; the deciders named in CONTRIBUTING keep the exact lines outside this repository. A website value that contradicts the Brand Book is never declared. Any other declared value has source declared and says in its note why it exists. Colour roles (colorRole) and shape tones (shapeTone) are aliases of palette colours, and the build refuses one that is not. Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." }