From 1fa441fc4dc1463f23095c2d14be0f156c9afcdc Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:46:41 +0000 Subject: [PATCH 1/6] Add the supergraphics canon as src/supergraphics.canon.css A byte-for-byte copy of supergraphics.css from @mfb/shared (sha256 9e8a9630...9324), with nothing added: the build appends it unchanged to the published supergraphics.css, so any added byte would change a published file. Its origin is recorded in CONTRIBUTING.md and in the build script, not in the file. Co-Authored-By: Claude Opus 5.5 --- src/supergraphics.canon.css | 538 ++++++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 src/supergraphics.canon.css diff --git a/src/supergraphics.canon.css b/src/supergraphics.canon.css new file mode 100644 index 0000000..b897d9e --- /dev/null +++ b/src/supergraphics.canon.css @@ -0,0 +1,538 @@ +/* MFB 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 + 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 + height. Never alter angle. Vary scale. Never uniform spacing. + ═══════════════════════════════════════════════════════════════════ */ + +.sg-para { + transform: skewX(calc(-1 * var(--sg-angle-base))); +} +.sg-para-content { + transform: skewX(var(--sg-angle-base)); +} + +/* Photo frame — clips content into parallelogram shape */ +.sg-para-frame { + transform: skewX(calc(-1 * var(--sg-angle-base))); + overflow: hidden; + border-radius: 6px; +} +.sg-para-frame img, +.sg-para-frame .sg-photo-zone { + transform: skewX(var(--sg-angle-base)) scale(1.12); + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Decorative accent parallelograms (absolute positioned) */ +.sg-para-accent { + position: absolute; + transform: skewX(calc(-1 * var(--sg-angle-base))); + border-radius: 6px; + pointer-events: none; +} +/* Solid palette fills only — Rule 3 bans opacity/rgba on brand shapes. + The book's pattern pages layer solid lighter/darker tones of one hue. */ +.sg-para-accent--purple-light { + background: var(--mfb-purple-200); +} +.sg-para-accent--purple-dark { + background: var(--mfb-purple-400); +} +.sg-para-accent--orange-light { + background: var(--mfb-orange-200); +} +.sg-para-accent--white { + background: var(--mfb-gray-400); +} + +/* ═══════════════════════════════════════════════════════════════════ + SPOTLIGHTS & BOOKS — Brand book p.35 "Spotlights & Books Construction" + Shared geometry vars. The 13° base angle (same as the logo icon) is + written literally via CSS tan() so the book value lives in the code. + --sg-aspect must be set to the container's W/H ratio for true angles + (default 1.5 = 3:2). + Rule 3: solid palette fills only — no opacity/rgba layering. + ═══════════════════════════════════════════════════════════════════ */ + +:root { + --sg-angle-base: 13deg; /* book-mandated base angle (= logo icon) */ + --sg-angle-alt: 24deg; /* spotlight second cut — MUST differ from base */ + --sg-aspect: 1.5; /* container width/height; override per usage */ + --sg-logo-icon-h: 40px; /* logo icon height (= .sg-logo) — brand base unit */ + --sg-base-h-ratio: 2; /* Rules 6+7: base height = 2x logo icon */ + --sg-base-h: calc(var(--sg-base-h-ratio) * var(--sg-logo-icon-h)); +} + +.sg-spotlight { + position: relative; + overflow: hidden; +} + +/* Purple spotlight — two solid beams, edges 13° / 24° from vertical. + Horizontal run of a beam edge = tan(angle)/aspect (as % of width). */ +.sg-spotlight--purple { + background: var(--mfb-purple-400); +} +.sg-spotlight--purple::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: var(--mfb-purple-300); + clip-path: polygon(0 0, 66% 0, + calc(66% - tan(var(--sg-angle-base)) / var(--sg-aspect) * 100%) 100%, + 0 100%); + pointer-events: none; +} +.sg-spotlight--purple::after { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: var(--mfb-purple-200); + clip-path: polygon(0 0, 44% 0, + calc(44% - tan(var(--sg-angle-alt)) / var(--sg-aspect) * 100%) 100%, + 0 100%); + pointer-events: none; +} + +/* Orange spotlight — same construction, warm palette */ +.sg-spotlight--orange { + background: var(--mfb-orange-400); +} +.sg-spotlight--orange::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: var(--mfb-orange-300); + clip-path: polygon(0 0, 66% 0, + calc(66% - tan(var(--sg-angle-base)) / var(--sg-aspect) * 100%) 100%, + 0 100%); + pointer-events: none; +} +.sg-spotlight--orange::after { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: var(--mfb-orange-200); + clip-path: polygon(0 0, 44% 0, + calc(44% - tan(var(--sg-angle-alt)) / var(--sg-aspect) * 100%) 100%, + 0 100%); + pointer-events: none; +} + +/* ═══════════════════════════════════════════════════════════════════ + SPOTLIGHT FRAME (image content frame) + Per Brand Book Rule 5: spotlight = lifestyle / event / community / + social-promotional content (NOT educational — that's parallelogram). + Per Rule 6: "two diagonal cuts at different angles" creates the + wedge shape. Logo B icon's 13° angle is the canonical tilt. + Use: +
+ +
+ Renders the image clipped into the spotlight wedge over a colored bg. + ═══════════════════════════════════════════════════════════════════ */ + +.sg-spotlight-frame { + position: relative; + overflow: hidden; + isolation: isolate; + min-height: var(--sg-base-h); + /* Spotlight wedge — Rule 6 (p.35): two diagonal cuts at DIFFERENT angles + (24° top-left cut, 13° bottom-right cut), corner cuts as drawn in the + book. Angle math: cut depth on an edge = tan(angle) * aspect * run. */ + --sg-wedge: polygon( + 0 calc(tan(var(--sg-angle-alt)) * var(--sg-aspect) * 50%), + 50% 0, + 100% 0, + 100% calc(100% - tan(var(--sg-angle-base)) * var(--sg-aspect) * 65%), + 35% 100%, + 0 100% + ); +} +.sg-spotlight-frame--purple { background: var(--mfb-purple-400, #2B1C58); } +.sg-spotlight-frame--orange { background: var(--mfb-orange-300, #F7941F); } +.sg-spotlight-frame--white { background: var(--mfb-white, #FFFFFF); } + +.sg-spotlight-frame > img, +.sg-spotlight-frame > .sg-photo-zone { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + clip-path: var(--sg-wedge); +} + +/* ═══════════════════════════════════════════════════════════════════ + BOOK PANEL — Brand book p.35: "two diagonal cuts with the SAME angle." + Stable, structured, stackable — distinct from the Spotlight (different + angles). Both cuts run at the 13° base angle, so the cut edges are + parallel. Base height = 2x logo icon (--sg-base-h). + ═══════════════════════════════════════════════════════════════════ */ + +.sg-book-frame { + position: relative; + overflow: hidden; + isolation: isolate; + min-height: var(--sg-base-h); + --sg-book-cut: polygon( + 0 calc(tan(var(--sg-angle-base)) * var(--sg-aspect) * 60%), + 60% 0, + 100% 0, + 100% calc(100% - tan(var(--sg-angle-base)) * var(--sg-aspect) * 60%), + 40% 100%, + 0 100% + ); +} +.sg-book-frame--purple { background: var(--mfb-purple-400, #2B1C58); } +.sg-book-frame--orange { background: var(--mfb-orange-300, #F7941F); } +.sg-book-frame--white { background: var(--mfb-white, #FFFFFF); } + +.sg-book-frame > img, +.sg-book-frame > .sg-photo-zone { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + clip-path: var(--sg-book-cut); +} + +/* Solid book band (no photo) — for Book Patterns */ +.sg-book { + clip-path: polygon( + 0 calc(tan(var(--sg-angle-base)) * var(--sg-aspect) * 60%), + 60% 0, + 100% 0, + 100% calc(100% - tan(var(--sg-angle-base)) * var(--sg-aspect) * 60%), + 40% 100%, + 0 100% + ); + background: var(--mfb-purple-300); +} + +/* Book Pattern (p.35) — books stacked vertically at VARYING heights. + Callers set per-child heights; defaults give the book's varied rhythm. */ +.sg-book-stack { + display: flex; + flex-direction: column; + gap: 6px; +} +.sg-book-stack > .sg-book { height: var(--sg-base-h); } +.sg-book-stack > .sg-book:nth-child(2n) { height: calc(var(--sg-base-h) * 0.65); } +.sg-book-stack > .sg-book:nth-child(3n) { height: calc(var(--sg-base-h) * 1.3); } + +/* ═══════════════════════════════════════════════════════════════════ + HALFTONE CUTOUT (portrait inside spotlight + halftone treatment) + Per Brand Book Rule 4: photos go INSIDE spotlight (or parallelogram) + frames, halftone-treated. Cutout portraits use highlights #F7E6FF and + shadows #45265B — book-specified standalone values, NOT palette tokens + (they are not purple-200/purple-400; do not "correct" them to tokens). + + Construction (exact book colors, layered blend): + ::before — solid #45265B underlay (screen-blend floor = shadows) + img — grayscale, mix-blend-mode: screen over the underlay + ::after — #F7E6FF field + #45265B dot grid, multiply (= highlight + cap + newspaper-dot texture; Rule 4 forbids smooth duotone) + All three layers share the spotlight wedge clip. + Use: +
+ +
+ ═══════════════════════════════════════════════════════════════════ */ + +.sg-halftone-cutout { + position: relative; + overflow: hidden; + /* Rule 4: cutouts sit on purple-300 or orange-300 */ + background: var(--mfb-purple-300, #422C70); + isolation: isolate; + --sg-wedge: polygon( + 0 calc(tan(var(--sg-angle-alt)) * var(--sg-aspect) * 50%), + 50% 0, + 100% 0, + 100% calc(100% - tan(var(--sg-angle-base)) * var(--sg-aspect) * 65%), + 35% 100%, + 0 100% + ); +} +.sg-halftone-cutout--orange-bg { background: var(--mfb-orange-300, #F7941F); } +.sg-halftone-cutout--deep-bg { background: var(--mfb-purple-400, #2B1C58); } +/* legacy alias (was mislabeled "purple-light"); kept for consumers */ +.sg-halftone-cutout--purple-light-bg { background: var(--mfb-purple-300, #422C70); } + +.sg-halftone-cutout::before { + content: ''; + position: absolute; + inset: 0; + background: #45265B; /* book-specified shadow value (not a palette token) */ + clip-path: var(--sg-wedge); +} + +.sg-halftone-cutout > img { + position: relative; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + clip-path: var(--sg-wedge); + filter: grayscale(1) contrast(1.15); + mix-blend-mode: screen; /* blacks fall to the #45265B floor below */ +} + +.sg-halftone-cutout::after { + content: ''; + position: absolute; + 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-size: 5px 5px; + mix-blend-mode: multiply; + clip-path: var(--sg-wedge); + pointer-events: none; +} + +/* ═══════════════════════════════════════════════════════════════════ + COLOR BLOCKS + Solid backgrounds and split-tone compositions. + ═══════════════════════════════════════════════════════════════════ */ + +.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-gradient { background: var(--mfb-gradient); color: var(--mfb-white); } + +/* ═══════════════════════════════════════════════════════════════════ + EVENT INFO BAR + Consistent bottom bar: bold orange date left, location right. + Thin orange border-top separator. + ═══════════════════════════════════════════════════════════════════ */ + +.sg-info-bar { + position: absolute; + bottom: 0; left: 0; right: 0; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 20px 48px; + font-family: 'IBM Plex Sans', sans-serif; + border-top: 3px solid var(--mfb-orange-300); + z-index: 10; +} +/* Solid fills — Rule 3 bans translucent brand fills */ +.sg-info-bar--on-dark { + color: var(--mfb-white); + background: var(--mfb-purple-400); +} +.sg-info-bar--on-light { + color: var(--mfb-gray-900); + background: var(--mfb-white); +} +.sg-info-bar__date { + font-size: 32px; + font-weight: 600; /* 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; + color: inherit; + opacity: 0.75; + margin-top: 2px; +} +.sg-info-bar__location { + text-align: right; + font-size: 17px; + font-weight: 400; + line-height: 1.3; +} + +/* ═══════════════════════════════════════════════════════════════════ + PHOTO ZONE — placeholder for future photo integration + ═══════════════════════════════════════════════════════════════════ */ + +.sg-photo-zone { + background: linear-gradient(135deg, var(--mfb-purple-300), var(--mfb-purple-200)); + display: flex; + align-items: center; + justify-content: center; +} + +/* ═══════════════════════════════════════════════════════════════════ + TYPOGRAPHY HELPERS + Large-format typography for social graphics. + ═══════════════════════════════════════════════════════════════════ */ + +.sg-headline { + font-family: 'IBM Plex Sans', sans-serif; + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.03em; /* book heading tracking */ +} +.sg-body { + font-family: 'IBM Plex Sans', sans-serif; + font-weight: 400; + line-height: 1.2; /* book specimen body LH */ +} +.sg-label { + font-family: 'IBM Plex Sans', sans-serif; + font-weight: 500; + font-size: 18px; + /* no text-transform — brand book p.26 forbids uppercase */ + letter-spacing: 1.5px; +} + +/* ═══════════════════════════════════════════════════════════════════ + LOGO POSITIONING + ═══════════════════════════════════════════════════════════════════ */ + +.sg-logo { + position: absolute; + z-index: 10; + height: 40px; + width: auto; +} +.sg-logo--top-left { top: 40px; left: 44px; } +.sg-logo--top-right { top: 40px; right: 44px; } + +/* ═══════════════════════════════════════════════════════════════════ + CTA BUTTON + ═══════════════════════════════════════════════════════════════════ */ + +.sg-cta { + display: inline-block; + background: var(--mfb-orange-300); + color: var(--mfb-white); + padding: 14px 40px; + border-radius: 40px; + font-family: 'IBM Plex Sans', sans-serif; + font-size: 20px; + font-weight: 600; + /* no text-transform — brand book p.26 forbids uppercase */ + letter-spacing: 1px; +} +/* CTA on orange backgrounds — use white/purple instead */ +.sg-cta--on-orange { + background: var(--mfb-white); + color: var(--mfb-purple-400); +} + +/* ═══════════════════════════════════════════════════════════════════ + HIGHLIGHTER VARIANT — for orange backgrounds where default is invisible + ═══════════════════════════════════════════════════════════════════ */ + +.sg-spotlight--orange .highlighter { + background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + color: var(--mfb-white); +} +.sg-bg-orange .highlighter { + background-image: linear-gradient(transparent 80%, var(--mfb-white) 80%); + color: var(--mfb-white); +} + +/* ═══════════════════════════════════════════════════════════════════ + COVER — Brand book Rule 8 (cover master): + solid flat fill, tiny logo in the top corner, short Title-Case title + near the bottom, left-aligned. Optional 6px orange top rule. + Use: +
+ +

Education That Empowers

+
+ ═══════════════════════════════════════════════════════════════════ */ + +.sg-cover { + position: relative; + overflow: hidden; + background: var(--mfb-purple-300); + color: var(--mfb-white); +} +.sg-cover--orange { background: var(--mfb-orange-300); } +.sg-cover--deep { background: var(--mfb-purple-400); } +.sg-cover--rule { border-top: 6px solid var(--mfb-orange-300); } + +.sg-cover__logo { + position: absolute; + top: 40px; + left: 44px; + height: var(--sg-logo-icon-h); + width: auto; +} +.sg-cover__title { + position: absolute; + bottom: 44px; + left: 44px; + 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); + line-height: 1.05; + letter-spacing: -0.03em; + /* Title Case, short — no text-transform (p.26 forbids uppercase) */ +} + +/* ═══════════════════════════════════════════════════════════════════ + PARALLELOGRAM PATTERN — Brand book p.30 "Parallelogram Patterns": + horizontal rows of parallelograms; small positional offsets between + rows create rhythm; rows may scale progressively; staggered/irregular + layouts allowed. Spacing must NOT be uniform. + Use: +
+
+
+
+ ... +
+
...
+
+ Children default to solid brand fills; override background per child. + ═══════════════════════════════════════════════════════════════════ */ + +.sg-para-pattern { + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.sg-para-pattern__row { + display: flex; + align-items: stretch; + height: var(--sg-base-h); +} +.sg-para-pattern__row > .sg-para { + background: var(--mfb-purple-200); + margin-right: 18px; +} +/* non-uniform spacing + positional offsets between rows (book: "small + positional offsets ... create visual rhythm") */ +.sg-para-pattern__row > .sg-para:nth-child(2n) { margin-right: 34px; } +.sg-para-pattern__row > .sg-para:nth-child(3n) { margin-right: 10px; } +.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); } From ccabce40554828d055fa8fdf9101c792f5ad0014 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:46:41 +0000 Subject: [PATCH 2/6] Move the package build into the repository scripts/build-design-package.mjs is the build that used to run outside this repository, made repository-relative: - root tokens.json is the source; src/supergraphics.canon.css is the canon; - the geometry gate still runs before any file is written; - it no longer writes package.json (the version is edited by hand and a version bump is what releases); the README install pin reads it; - the emitted headers and the README template are unchanged, so all published files rebuild byte-identical to master. Usage: node scripts/build-design-package.mjs [OUT_DIR] Co-Authored-By: Claude Opus 5.5 --- scripts/build-design-package.mjs | 213 +++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 scripts/build-design-package.mjs diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs new file mode 100644 index 0000000..f7b67b6 --- /dev/null +++ b/scripts/build-design-package.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +// Builds the @myfirstbitcoin/design package from this repository's tokens.json. +// +// Sources (all in this repository): +// tokens.json the design tokens, the single source of every value +// package.json hand-maintained; only its version is read here (install pin in README) +// src/supergraphics.canon.css the supergraphics canon, a byte copy of supergraphics.css in +// @mfb/shared (see CONTRIBUTING.md); appended unchanged +// +// Usage: node scripts/build-design-package.mjs [OUT_DIR] +// OUT_DIR defaults to the repository root. scripts/check.mjs passes a temporary directory. +// +// Emits into OUT_DIR: tailwind.js (Tailwind 3 preset), theme.css (Tailwind 4 @theme), +// brand.css (plain CSS variables), supergraphics.css, index.js, README.md, and tokens.json +// (a verbatim copy, skipped when OUT_DIR is the repository root because it is the source). +// 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 the published +// files stay byte-identical. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT_DIR = path.resolve(process.argv[2] || ROOT); +const TOKENS_PATH = path.join(ROOT, 'tokens.json'); +const SG_SRC = path.join(ROOT, 'src', 'supergraphics.canon.css'); + +const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; +if (!/^\d+\.\d+\.\d+$/.test(String(VERSION))) { + console.error(`REFUSING TO BUILD: package.json version "${VERSION}" is not of the form X.Y.Z.`); + process.exit(1); +} + +const tokens = JSON.parse(fs.readFileSync(TOKENS_PATH, 'utf8')); +const sgCanon = fs.readFileSync(SG_SRC, 'utf8'); + +// GEOMETRY GATE. It runs before ANY file is written, so a refusal never leaves a +// half-written package (tailwind.js and theme.css are written first, so a gate placed +// later would have left them behind). +// +// The canon declares its own --sg-* geometry in a :root block, and because the canon is +// appended AFTER the token prelude, its values win in the cascade. Emitting geometry without +// checking would produce a package whose prelude and body disagree, silently. So the build +// REFUSES when the canon and tokens.json disagree: geometry has one origin (the tokens.json +// geometry group) and the canon may not drift from it. An audit once found pages hand-rolling +// a 10 degree slant instead of 13; this gate is what stops the next one. +const GEOM_VARS = { + 'angle-base': '--sg-angle-base', + 'angle-alt': '--sg-angle-alt', + 'aspect': '--sg-aspect', + 'logo-icon-h': '--sg-logo-icon-h', + 'base-h-ratio': '--sg-base-h-ratio', +}; +const geomMismatch = []; +for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { + const tok = (tokens.geometry || {})[tokenName]; + if (!tok) { geomMismatch.push(`${tokenName}: absent from tokens.json geometry group`); continue; } + // Anchor to a line start: the variable NAME also appears mid-line inside var() references + // (e.g. `transform: skewX(calc(-1 * var(--sg-angle-base)))`), and an unanchored match + // finds the first reference rather than the declaration. + const m = sgCanon.match(new RegExp(`^\\s*${cssVar}\\s*:\\s*([^;]+);`, 'm')); + if (!m) { geomMismatch.push(`${cssVar}: not declared in the canon`); continue; } + const canonVal = m[1].trim(); + const tokenVal = String(tok.$value).trim(); + if (canonVal !== tokenVal) { + geomMismatch.push(`${cssVar}: canon says "${canonVal}", tokens.json says "${tokenVal}"`); + } +} +if (geomMismatch.length) { + console.error('REFUSING TO BUILD: geometry canon disagrees with tokens.json.'); + console.error(' tokens.json is the origin. Correct the canon, or the token, then rebuild.'); + for (const m of geomMismatch) console.error(` - ${m}`); + process.exit(1); +} + +fs.mkdirSync(OUT_DIR, { recursive: true }); +const written = []; +const write = (name, content) => { + fs.writeFileSync(path.join(OUT_DIR, name), content); + written.push(name); +}; + +// ---- Build nested color tree: mfb.purple.400, mfb.gray.900, mfb.black ... ---- +const mfb = {}; +for (const [name, tok] of Object.entries(tokens.color)) { + const m = name.match(/^(purple|orange|gray)-(\d+)$/); + if (m) { + const [, fam, shade] = m; + (mfb[fam] = mfb[fam] || {})[shade] = tok.$value; + } else { + mfb[name] = tok.$value; // black, white + } +} +const fontFamily = {}; +for (const [k, tok] of Object.entries(tokens.fontFamily || {})) fontFamily[k] = tok.$value; +// `sans` mirrors the body font (IBM Plex Sans) so `font-sans` yields the brand font, +// matching how My First Bitcoin pages are written (they override Tailwind's default sans). +if (fontFamily.body) fontFamily.sans = fontFamily.body; +const fontSize = {}; +for (const [k, tok] of Object.entries(tokens.fontSize || {})) fontSize[k] = tok.$value; +const gradient = tokens.gradient?.brand?.$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) ---- +const preset = { + theme: { + extend: { + colors: { ...mfb, mfb }, // both conventions: bg-purple-400 and bg-mfb-purple-400 + fontFamily, + fontSize, + ...(gradient ? { backgroundImage: { 'brand-gradient': gradient } } : {}), + }, + }, +}; +write( + 'tailwind.js', + `// Generated from tokens.json by build-design-package.mjs - DO NOT EDIT BY HAND.\n/** @type {import('tailwindcss').Config} */\nexport default ${JSON.stringify(preset, null, 2)};\n` +); + +// ---- 2. theme.css - Tailwind 4 @theme block ---- +let theme = '/* Generated from tokens.json - DO NOT EDIT BY HAND. Tailwind 4 @theme. */\n@theme {\n'; +for (const [name, tok] of Object.entries(tokens.color)) { + theme += ` --color-${name}: ${tok.$value};\n`; // top-level convention (bg-purple-400) + theme += ` --color-mfb-${name}: ${tok.$value};\n`; // mfb- prefixed convention (bg-mfb-purple-400) +} +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`; +theme += '}\n'; +write('theme.css', theme); + +// ---- 3. brand.css - framework-agnostic CSS custom properties ---- +let css = '/* Generated from tokens.json - DO NOT EDIT BY HAND. Plain CSS variables. */\n:root {\n'; +for (const [name, tok] of Object.entries(tokens.color)) css += ` --mfb-${name}: ${tok.$value};\n`; +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 [tokenName, cssVar] of Object.entries(GEOM_VARS)) css += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; +css += '}\n'; +write('brand.css', css); + +// ---- 4. tokens.json (verbatim copy of the source; nothing to do when building in place) ---- +if (path.join(OUT_DIR, 'tokens.json') !== TOKENS_PATH) { + fs.copyFileSync(TOKENS_PATH, path.join(OUT_DIR, 'tokens.json')); + written.push('tokens.json'); +} + +// ---- 4b. supergraphics.css - brand geometry primitives (13 degree system) ---- +// The canon (src/supergraphics.canon.css) is shipped verbatim, PLUS a token-derived :root +// prelude so the file is standalone: Tailwind 4 consumers import theme.css (which only +// 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: shared/supergraphics.css (@mfb/shared) + 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`; +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 ---- +write( + 'index.js', + `// @myfirstbitcoin/design - programmatic access to MFB 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` + + `export const fontSize = ${JSON.stringify(fontSize, null, 2)};\n` + + (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') +); + +// ---- 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` +); + +console.log(`Built @myfirstbitcoin/design v${VERSION} -> ${OUT_DIR}`); +console.log('Files written:', written.sort().join(', ')); From 39b170f683bcea09eab822a96cf9e5d18afd73f4 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:46:41 +0000 Subject: [PATCH 3/6] Generate the public brand specification from a template scripts/brand-spec.mjs fills src/brand-spec.template.md with values read from tokens.json and writes brand-spec.md at the root. brand-spec.md is public but not in package.json's files, so consumers install nothing new. The prose is the rule set of the former generator, cleaned for a public repository: no date stamps, no machine paths, no internal system or database names, no restart or token-expiry lines, no em-dashes, and the stale lines corrected (CSS variable names, logo file names, the highlighter class). The generator refuses to write on a missing token, an unfilled placeholder, or an em-dash, machine path or date stamp in the result. Co-Authored-By: Claude Opus 5.5 --- brand-spec.md | 313 +++++++++++++++++++++++++++++++++++++ scripts/brand-spec.mjs | 138 ++++++++++++++++ src/brand-spec.template.md | 285 +++++++++++++++++++++++++++++++++ 3 files changed, 736 insertions(+) create mode 100644 brand-spec.md create mode 100644 scripts/brand-spec.mjs create mode 100644 src/brand-spec.template.md diff --git a/brand-spec.md b/brand-spec.md new file mode 100644 index 0000000..c2b5bf2 --- /dev/null +++ b/brand-spec.md @@ -0,0 +1,313 @@ + + +# My First Bitcoin Brand Specification + +> Values are generated from `tokens.json`; the rules are written against the Brand Book Figma file `mFIc75UUSyftaqnNUQgjLX`. Do not edit this file by hand: change `src/brand-spec.template.md` or `tokens.json`, then run `node scripts/brand-spec.mjs` (see Section 5). + +## 0. Authority + +**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. + +**Canonical rule pages (Brand Book node IDs):** + +- Type Relationships: `918:2990` · Type Misuse: `918:2841` +- 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` +- Cover master: `918:2798` +- Brand-in-use posters: `918:3514` · Program cards: `918:3508` · Merch: `918:3502` + +## 1. Tokens + +### Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| purple-300 | #422C70 | Primary purple: brand hero color, backgrounds, headers | +| 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 | +| 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-700 | #6C6C7D | Neutral: muted text, placeholders | +| gray-600 | #88889C | Neutral: disabled text, borders | +| 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 | + +### Gradients + +- **brand** (`linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)`): Brand gradient (purple-400 → purple-200). **Deprecated:** this gradient appears nowhere in the canonical Figma file. It only exists in the Variables collection we bootstrapped ourselves. Kept for compatibility; phase out. + +### Typography + +| Role | Family | Notes | +|------|--------|-------| +| heading | IBM Plex Sans, Arial, sans-serif | Headings, titles, display text: Medium weight | +| body | IBM Plex Sans, Arial, sans-serif | Body text, paragraphs: Regular weight | +| mono | IBM Plex Mono, monospace | Code, data, technical content. **Deprecated:** IBM Plex Mono appears nowhere in the canonical Figma file (all 26 tabs checked, incl. type specimen + Archive). Kept for compatibility with live pages using font-mono; phase out during page work. Do not use in new designs. | + +### Font Weights + +- **regular**: 400 (Body text, labels) +- **medium**: 500 (Headings H1-H6, quotes) +- **semibold**: 600 (Strong emphasis, CTAs) + +### Font Sizes + +| Level | Size | Notes | +|-------|------|-------| +| h1 | 70px | Heading 1: IBM Plex Sans Medium, line-height: 1 | +| h2 | 58px | Heading 2: IBM Plex Sans Medium, line-height: 1 | +| h3 | 48px | Heading 3: IBM Plex Sans Medium, line-height: 1 | +| h4 | 40px | Heading 4: IBM Plex Sans Medium, line-height: 1 | +| h5 | 36px | Heading 5: IBM Plex Sans Medium, line-height: 1.1 | +| h6 | 32px | Heading 6: IBM Plex Sans Medium, line-height: 1.1 | +| body | 18px | Body text: IBM Plex Sans Regular, line-height: 1.2 | +| label | 22px | Labels: IBM Plex Sans Regular, line-height: 1.2 | +| quote | 40px | Block quotes: IBM Plex Sans Medium, line-height: 1 | + +### 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) and `tailwind.js` (Tailwind 3) carry the same values. + +## 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.* + +### Rule 1. Case: Title Case headings, sentence case body, no ALL-CAPS + +**Source:** Brand Book *Type Relationships* (`918:2990`) + *Type Misuse* (`918:2841`, which explicitly crosses out uppercase text). + +**Do:** +- **Headings (H1 to H6, titles, kickers, section meta, folios, running heads):** Title Case. Capitalize the first letter of every major word; minor words (*of, the, and, to, in, on, for, a, an, at, by, with*) stay lowercase unless first or last word +- **Body text, paragraphs:** sentence case (capital at sentence start + proper nouns only) +- **URL callbacks:** lowercase (`myfirstbitcoin.org`) + +**Don't:** +- ALL-CAPS anywhere, at any type size +- `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 + +**Source:** Brand Book *Highlighter* (`918:2874`): "Apply it to only one word per heading, covering up to 80% of the word length." + +**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) +- 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 + +### Rule 3. No translucent colors on brand shapes + +**Source:** brand design review: translucent parallelogram / spotlight fills are a drift pattern, not a brand primitive. + +**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") + +**Don't:** +- `opacity: 0.x` on brand shape elements +- Translucent stacking as a substitute for hierarchy + +### Rule 4. Photos: halftone (NOT duotone), inside a brand frame + +**Source:** Brand Book *Halftones* (`918:3730`) + *Halftones in use* (`918:3790`). + +**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 `#F7E6FF`, shadows `#45265B`, on purple-300 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 + +**Don't:** +- Apply halftone to covers, logos, icons, UI, or small/highly detailed images (destroys clarity) +- Ship a smooth two-color duotone and call it halftone + +### Rule 5. Shape family follows content genre + +**Source:** Brand Book *Parallelogram* (`918:4212`) + *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." + +**Do:** +- Pick the shape from the content TYPE +- Portrait features of individuals → spotlight frame with halftone cutout +- Program showcases, certificate templates, book covers → parallelogram frame + +**Don't:** +- Pick a shape because "it looks balanced on the spread" +- Wrap educational content in spotlight frames, or wrap lifestyle content in parallelograms + +### Rule 6. Spotlight vs Book construction + +**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3716`), 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. + +**Do:** +- Fixed **13°** angle (matches logo B icon) +- Tilt always goes left-to-right +- Element stays inside the frame (no overflow clips) + +**Don't:** +- Alter the 13° angle +- Right-to-left tilt (misuse `918:2941`) +- Repeat spotlights as columns (that's Book Pattern use) + +### Rule 7. Parallelogram construction + +**Source:** Brand Book *Parallelogram Patterns* (`918:4126`), 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 +- Staggered or irregular: variety is the point + +**Don't:** +- Alter the 13° angle or distort proportions +- Use uniform scale + spacing (Brand Book misuse `918:3958`: "Avoid uniform scale and spacing") +- Place other shapes in front of parallelogram frames + +### Rule 8. Cover: solid color + small corner logo + short title + +**Source:** Brand Book *Cover master* (`918:2798`) + cover-template samples from the brand design review (*Independent Bitcoin Education*, *What Is Money and Do We Need It?*). + +**Do:** +- Solid-color background (purple-300, purple-400, or orange-300, as a single flat fill) +- Tiny My First Bitcoin logo in a top corner +- Short Title-Case title, left-aligned, near the bottom +- Optional: 6px orange-300 top rule (a My First Bitcoin identity signal) + +**Don't:** +- Supergraphic overlays on covers (no spotlight beam, no parallelogram stack) +- Translucent accents (Rule 3) +- Full-bleed hero photos +- ALL-CAPS edition markers ("MY FIRST BITCOIN · 2026/27") +- Add decorative heroes: restraint reads as seriousness and IS the brand signal + +### Rule 9. Terminology + +**Public copy always uses:** +- **Bitcoin** (never "crypto" or "cryptocurrency") +- **My First Bitcoin** written in full (never "MFB", which is internal shorthand only) +- **nodes** for network communities (never chapters, franchises, branches, affiliates) +- **independent educators** (never "our educators": students belong to their educators, not to the org) +- **Ambassador** for top-tier educators who completed the full flywheel arc +- **myfirstbitcoin.org** (never `.io`, `.com`, or ALL-CAPS domain) + +**₿ symbol:** always orange (`#F7941F`), rendered with Unicode ₿, never generic coin imagery. + +**Flywheel stages (in order):** Precoiner → Bitcoin Student → Bitcoin Certified → Bitcoiner → Bitcoin Educator → Bitcoin Certified Educator → Bitcoin Community Leader → Ambassador. + +### Rule 10. Data accuracy precedes design + +**Source:** brand design review: reports must verify every number, name, and entity against source systems before the visual. + +**Do:** +- Cross-check node names, educator names and community leaders against the network records +- Cross-check financial figures against the finance records +- Use `[CHECK: X]` placeholder tokens for uncertain values +- Freshness-stamp data-heavy artifacts ("As of YYYY-MM-DD") + +**Don't:** +- Guess numbers or entity names +- Publish with stale data; prefer `[pending]` placeholders visible on the page + +### Rule 11. Number-dense artifacts need image breathers + +**Source:** brand design review: *"The eye gets lazy after too many numbers. Images refresh the attention."* + +**Do:** +- Max 2 data-dense spreads back-to-back before an image breather +- Use event/community photos inside spotlight frames, halftoned if portrait-focused (Rules 4 & 5) +- Image breathers double as pre-promotion for the next event/phase + +### Rule 12. CTAs must include actionable links + +**Source:** brand design review: *"Include link for registration."* + +**Do:** +- Direct registration URLs on every event callout +- QR code for print contexts +- Specific-path CTAs ("Enroll now" → URL, "Register" → URL) + +**Don't:** +- Generic "Learn more at myfirstbitcoin.org" without a path +- Event name + dates without a registration link + +## 3. Color Application Quick-Reference + +### 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) | + +### Rules + +- **Links:** orange-300, underline on hover +- **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 +- **Gradient direction:** always 135deg (top-left to bottom-right); reserve gradient for hero sections / UI surfaces, not 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 + +### Photography + +Real people in real educational settings, warm natural lighting, diverse representation. No stock photography, no generic business imagery. + +## 4. Pre-action Checklist (before writing any brand artifact) + +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) +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) + +## 5. Regeneration + +This file is generated by `scripts/brand-spec.mjs` from `tokens.json` and the prose in `src/brand-spec.template.md`. **Edit the template or the tokens**, not this file. + +**To regenerate** (Node.js 22, from the repository root): + +``` +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. diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs new file mode 100644 index 0000000..b6e0472 --- /dev/null +++ b/scripts/brand-spec.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Generates brand-spec.md, the written brand specification, from tokens.json and the prose in +// src/brand-spec.template.md. +// +// Usage: node scripts/brand-spec.mjs [OUT_DIR] +// OUT_DIR defaults to the repository root. scripts/check.mjs passes a temporary directory. +// +// Values are READ from tokens.json; only the prose around them is written by hand, in the +// 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. +// +// It refuses to write when: +// - a token the prose depends on is absent (a blank where a brand value belongs); +// - 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). + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT_DIR = path.resolve(process.argv[2] || ROOT); +const TEMPLATE = path.join(ROOT, 'src', 'brand-spec.template.md'); +const FIGMA_FILE = 'mFIc75UUSyftaqnNUQgjLX'; // My First Bitcoin_Full Brand Assets (the Brand Book) + +const EM_DASH = String.fromCharCode(0x2014); +const DEGREE = String.fromCharCode(0xb0); + +const fail = (msg, details = []) => { + console.error(`REFUSING TO WRITE brand-spec.md: ${msg}`); + for (const d of details) console.error(` - ${d}`); + process.exit(1); +}; + +const tokens = JSON.parse(fs.readFileSync(path.join(ROOT, 'tokens.json'), 'utf8')); +const template = fs.readFileSync(TEMPLATE, 'utf8'); + +// ---- text helpers ---- +// Token descriptions in tokens.json use an em-dash as a separator (for example between +// "Primary purple" and "brand hero color"). tokens.json is published as it is, so the spec +// rewrites each one as a colon. +const clean = (s) => String(s ?? '').replace(new RegExp(`\\s*${EM_DASH}\\s*`, 'g'), ': ').trim(); +const cell = (s) => clean(s).replace(/\|/g, '\\|'); +// "$status": "DEPRECATED 2026-07-14: text" is rendered as "**Deprecated:** text". The date is +// history (it stays in tokens.json); the public spec carries no date stamps. +const status = (tok) => { + if (!tok.$status) return ''; + const m = String(tok.$status).match(/^([A-Za-z]+)(?:\s+\d{4}-\d{2}-\d{2})?\s*:\s*(.*)$/s); + const label = m ? m[1][0].toUpperCase() + m[1].slice(1).toLowerCase() : 'Status'; + return `**${label}:** ${clean(m ? m[2] : tok.$status)}`; +}; + +// ---- required values ---- +const missing = []; +const need = (group, name) => { + const tok = (tokens[group] || {})[name]; + const v = tok ? tok.$value : undefined; + if (v === undefined || v === null || String(v).trim() === '') { + missing.push(`${group}.${name}`); + return ''; + } + return String(v); +}; +const groupEntries = (group, required) => { + const entries = Object.entries(tokens[group] || {}); + if (required && entries.length === 0) missing.push(`${group} (the whole group)`); + return entries; +}; + +const values = { + FIGMA_FILE, + ANGLE: need('geometry', 'angle-base').replace('deg', DEGREE), + BASE_RATIO: need('geometry', 'base-h-ratio'), + HALFTONE_HI: need('geometry', 'halftone-highlight'), + HALFTONE_LO: need('geometry', 'halftone-shadow'), + ORANGE: need('color', 'orange-300'), + GRAY900: need('color', 'gray-900'), +}; + +values.COLOR_ROWS = groupEntries('color', true) + .map(([name, tok]) => `| ${name} | ${tok.$value} | ${cell(tok.$description)} |`) + .join('\n'); + +const gradients = groupEntries('gradient', false); +values.GRADIENT_ITEMS = gradients.length + ? gradients + .map(([name, tok]) => { + const st = status(tok); + return `- **${name}** (\`${tok.$value}\`): ${clean(tok.$description)}${st ? `. ${st}` : ''}`; + }) + .join('\n') + : 'No gradient tokens.'; + +values.FONT_FAMILY_ROWS = groupEntries('fontFamily', true) + .map(([name, tok]) => { + const family = Array.isArray(tok.$value) ? tok.$value.join(', ') : tok.$value; + const st = status(tok); + return `| ${name} | ${family} | ${cell(tok.$description)}${st ? `. ${st.replace(/\|/g, '\\|')}` : ''} |`; + }) + .join('\n'); + +values.FONT_WEIGHT_ITEMS = groupEntries('fontWeight', true) + .map(([name, tok]) => `- **${name}**: ${tok.$value} (${clean(tok.$description)})`) + .join('\n'); + +values.FONT_SIZE_ROWS = groupEntries('fontSize', true) + .map(([name, tok]) => `| ${name} | ${tok.$value} | ${cell(tok.$description)} |`) + .join('\n'); + +if (missing.length) { + fail('tokens.json lacks values the spec depends on; refusing to emit a blank where a brand value belongs.', missing); +} + +// ---- fill the template ---- +const unknown = new Set(); +const md = template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (whole, key) => { + if (!(key in values)) { unknown.add(key); return whole; } + return values[key]; +}); +if (unknown.size) fail('the template names placeholders this script does not fill.', [...unknown]); +if (/\{\{|\}\}/.test(md)) fail('the output still contains "{{" or "}}" (a malformed placeholder in the template).'); + +// ---- public-file guards ---- +const problems = []; +md.split('\n').forEach((line, i) => { + if (line.includes(EM_DASH)) problems.push(`line ${i + 1}: em-dash`); + if (/\/home\/|\/Users\/|~\//.test(line)) problems.push(`line ${i + 1}: machine path`); + if (/\b(19|20)\d\d-\d\d-\d\d\b/.test(line)) problems.push(`line ${i + 1}: date stamp`); +}); +if (problems.length) fail('the result would carry text a public file must not.', problems); + +fs.mkdirSync(OUT_DIR, { recursive: true }); +const out = path.join(OUT_DIR, 'brand-spec.md'); +fs.writeFileSync(out, md); +console.log(`Wrote ${out}`); diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md new file mode 100644 index 0000000..7d04405 --- /dev/null +++ b/src/brand-spec.template.md @@ -0,0 +1,285 @@ + + +# My First Bitcoin Brand Specification + +> Values are generated from `tokens.json`; the rules are written against the Brand Book Figma file `{{FIGMA_FILE}}`. Do not edit this file by hand: change `src/brand-spec.template.md` or `tokens.json`, then run `node scripts/brand-spec.mjs` (see Section 5). + +## 0. Authority + +**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. + +**Canonical rule pages (Brand Book node IDs):** + +- Type Relationships: `918:2990` · Type Misuse: `918:2841` +- 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` +- Cover master: `918:2798` +- Brand-in-use posters: `918:3514` · Program cards: `918:3508` · Merch: `918:3502` + +## 1. Tokens + +### Colors + +| Token | Hex | Usage | +|-------|-----|-------| +{{COLOR_ROWS}} + +### Gradients + +{{GRADIENT_ITEMS}} + +### Typography + +| Role | Family | Notes | +|------|--------|-------| +{{FONT_FAMILY_ROWS}} + +### Font Weights + +{{FONT_WEIGHT_ITEMS}} + +### Font Sizes + +| Level | Size | Notes | +|-------|------|-------| +{{FONT_SIZE_ROWS}} + +### 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) and `tailwind.js` (Tailwind 3) carry the same values. + +## 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.* + +### Rule 1. Case: Title Case headings, sentence case body, no ALL-CAPS + +**Source:** Brand Book *Type Relationships* (`918:2990`) + *Type Misuse* (`918:2841`, which explicitly crosses out uppercase text). + +**Do:** +- **Headings (H1 to H6, titles, kickers, section meta, folios, running heads):** Title Case. Capitalize the first letter of every major word; minor words (*of, the, and, to, in, on, for, a, an, at, by, with*) stay lowercase unless first or last word +- **Body text, paragraphs:** sentence case (capital at sentence start + proper nouns only) +- **URL callbacks:** lowercase (`myfirstbitcoin.org`) + +**Don't:** +- ALL-CAPS anywhere, at any type size +- `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 + +**Source:** Brand Book *Highlighter* (`918:2874`): "Apply it to only one word per heading, covering up to 80% of the word length." + +**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) +- 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 + +### Rule 3. No translucent colors on brand shapes + +**Source:** brand design review: translucent parallelogram / spotlight fills are a drift pattern, not a brand primitive. + +**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") + +**Don't:** +- `opacity: 0.x` on brand shape elements +- Translucent stacking as a substitute for hierarchy + +### Rule 4. Photos: halftone (NOT duotone), inside a brand frame + +**Source:** Brand Book *Halftones* (`918:3730`) + *Halftones in use* (`918:3790`). + +**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 +- **Full portraits** (subject fills frame): highlights `#EEEEEE`, 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:** +- Apply halftone to covers, logos, icons, UI, or small/highly detailed images (destroys clarity) +- Ship a smooth two-color duotone and call it halftone + +### Rule 5. Shape family follows content genre + +**Source:** Brand Book *Parallelogram* (`918:4212`) + *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." + +**Do:** +- Pick the shape from the content TYPE +- Portrait features of individuals → spotlight frame with halftone cutout +- Program showcases, certificate templates, book covers → parallelogram frame + +**Don't:** +- Pick a shape because "it looks balanced on the spread" +- Wrap educational content in spotlight frames, or wrap lifestyle content in parallelograms + +### Rule 6. Spotlight vs Book construction + +**Source:** Brand Book *Construction* (`918:3632`), *Book Panel* (`918:3716`), 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. + +**Do:** +- Fixed **{{ANGLE}}** angle (matches logo B icon) +- Tilt always goes left-to-right +- Element stays inside the frame (no overflow clips) + +**Don't:** +- Alter the {{ANGLE}} angle +- Right-to-left tilt (misuse `918:2941`) +- Repeat spotlights as columns (that's Book Pattern use) + +### Rule 7. Parallelogram construction + +**Source:** Brand Book *Parallelogram Patterns* (`918:4126`), 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 +- Staggered or irregular: variety is the point + +**Don't:** +- Alter the {{ANGLE}} angle or distort proportions +- Use uniform scale + spacing (Brand Book misuse `918:3958`: "Avoid uniform scale and spacing") +- Place other shapes in front of parallelogram frames + +### Rule 8. Cover: solid color + small corner logo + short title + +**Source:** Brand Book *Cover master* (`918:2798`) + cover-template samples from the brand design review (*Independent Bitcoin Education*, *What Is Money and Do We Need It?*). + +**Do:** +- Solid-color background (purple-300, purple-400, or orange-300, as a single flat fill) +- Tiny My First Bitcoin logo in a top corner +- Short Title-Case title, left-aligned, near the bottom +- Optional: 6px orange-300 top rule (a My First Bitcoin identity signal) + +**Don't:** +- Supergraphic overlays on covers (no spotlight beam, no parallelogram stack) +- Translucent accents (Rule 3) +- Full-bleed hero photos +- ALL-CAPS edition markers ("MY FIRST BITCOIN · 2026/27") +- Add decorative heroes: restraint reads as seriousness and IS the brand signal + +### Rule 9. Terminology + +**Public copy always uses:** +- **Bitcoin** (never "crypto" or "cryptocurrency") +- **My First Bitcoin** written in full (never "MFB", which is internal shorthand only) +- **nodes** for network communities (never chapters, franchises, branches, affiliates) +- **independent educators** (never "our educators": students belong to their educators, not to the org) +- **Ambassador** for top-tier educators who completed the full flywheel arc +- **myfirstbitcoin.org** (never `.io`, `.com`, or ALL-CAPS domain) + +**₿ symbol:** always orange (`{{ORANGE}}`), rendered with Unicode ₿, never generic coin imagery. + +**Flywheel stages (in order):** Precoiner → Bitcoin Student → Bitcoin Certified → Bitcoiner → Bitcoin Educator → Bitcoin Certified Educator → Bitcoin Community Leader → Ambassador. + +### Rule 10. Data accuracy precedes design + +**Source:** brand design review: reports must verify every number, name, and entity against source systems before the visual. + +**Do:** +- Cross-check node names, educator names and community leaders against the network records +- Cross-check financial figures against the finance records +- Use `[CHECK: X]` placeholder tokens for uncertain values +- Freshness-stamp data-heavy artifacts ("As of YYYY-MM-DD") + +**Don't:** +- Guess numbers or entity names +- Publish with stale data; prefer `[pending]` placeholders visible on the page + +### Rule 11. Number-dense artifacts need image breathers + +**Source:** brand design review: *"The eye gets lazy after too many numbers. Images refresh the attention."* + +**Do:** +- Max 2 data-dense spreads back-to-back before an image breather +- Use event/community photos inside spotlight frames, halftoned if portrait-focused (Rules 4 & 5) +- Image breathers double as pre-promotion for the next event/phase + +### Rule 12. CTAs must include actionable links + +**Source:** brand design review: *"Include link for registration."* + +**Do:** +- Direct registration URLs on every event callout +- QR code for print contexts +- Specific-path CTAs ("Enroll now" → URL, "Register" → URL) + +**Don't:** +- Generic "Learn more at myfirstbitcoin.org" without a path +- Event name + dates without a registration link + +## 3. Color Application Quick-Reference + +### 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) | + +### Rules + +- **Links:** orange-300, underline on hover +- **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 +- **Gradient direction:** always 135deg (top-left to bottom-right); reserve gradient for hero sections / UI surfaces, not 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 + +### Photography + +Real people in real educational settings, warm natural lighting, diverse representation. No stock photography, no generic business imagery. + +## 4. Pre-action Checklist (before writing any brand artifact) + +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) +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) + +## 5. Regeneration + +This file is generated by `scripts/brand-spec.mjs` from `tokens.json` and the prose in `src/brand-spec.template.md`. **Edit the template or the tokens**, not this file. + +**To regenerate** (Node.js 22, from the repository root): + +``` +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. From b8be01c3e8696a80ff7fb6422207779908711a91 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:46:41 +0000 Subject: [PATCH 4/6] Add the rebuild check and the Figma check scripts/check.mjs rebuilds the package and the spec into a temporary directory and fails when any committed output differs, when the build or the spec generator refuses, or when package.json gains scripts or dependencies (a git dependency's prepare runs on every consumer install). scripts/verify-figma.mjs checks tokens.json against the Brand Book page in Figma (colors as rendered fills, font sizes and weights as rendered text styles). It reads Figma only: the comparison with other repositories and the staleness block of the former script are left out. --summary writes a Markdown summary, --status FILE the JSON result. It is not run in GitHub Actions. Co-Authored-By: Claude Opus 5.5 --- scripts/check.mjs | 82 +++++++++++++ scripts/verify-figma.mjs | 249 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 scripts/check.mjs create mode 100644 scripts/verify-figma.mjs diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..329be1e --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// The rebuild check. Run it before opening a pull request; GitHub Actions runs it on every +// pull request and every push to master (the rebuild-check job in .github/workflows/design.yml). +// +// Usage: node scripts/check.mjs +// +// It rebuilds the package and the brand spec into a temporary directory, then compares every +// file the build produced with the committed copy at the repository root. It exits 1 when: +// - the build refuses (the geometry gate: src/supergraphics.canon.css disagrees with tokens.json); +// - the spec generator refuses (a missing token, an unfilled placeholder, or public-file text); +// - any committed output differs from a fresh build (someone edited tokens.json, the canon, the +// 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. +// It changes nothing in the repository. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const GENERATORS = ['scripts/build-design-package.mjs', 'scripts/brand-spec.mjs']; +const FORBIDDEN_PKG_FIELDS = [ + 'scripts', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'bundleDependencies', + 'bundledDependencies', +]; + +const problems = []; + +// ---- package.json must stay inert ---- +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +for (const field of FORBIDDEN_PKG_FIELDS) { + if (field in pkg) problems.push(`package.json has "${field}"; it must not (see the comment in scripts/check.mjs)`); +} + +// ---- rebuild into a temporary directory ---- +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mfb-design-check-')); +try { + for (const gen of GENERATORS) { + const r = spawnSync(process.execPath, [path.join(ROOT, gen), tmp], { cwd: ROOT, encoding: 'utf8' }); + if (r.status !== 0) { + process.stderr.write(r.stdout || ''); + process.stderr.write(r.stderr || ''); + problems.push(`${gen} failed (exit ${r.status ?? r.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'); + const stale = []; + for (const name of produced) { + const fresh = fs.readFileSync(path.join(tmp, name)); + let committed = null; + try { committed = fs.readFileSync(path.join(ROOT, name)); } catch { /* missing */ } + if (committed === null) stale.push(`${name} (missing from the repository)`); + else if (!fresh.equals(committed)) stale.push(name); + } + if (stale.length) { + problems.push( + 'committed outputs differ from a fresh build: ' + stale.join(', ') + + '. Run `node scripts/build-design-package.mjs && node scripts/brand-spec.mjs` and commit the result.' + ); + } + + if (problems.length) { + console.error('check: FAIL'); + for (const p of problems) console.error(` - ${p}`); + process.exitCode = 1; + } else { + console.log(`check: OK (${produced.length} outputs match a fresh build: ${produced.join(', ')})`); + } +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} diff --git a/scripts/verify-figma.mjs b/scripts/verify-figma.mjs new file mode 100644 index 0000000..a16a549 --- /dev/null +++ b/scripts/verify-figma.mjs @@ -0,0 +1,249 @@ +#!/usr/bin/env node +// verify-figma.mjs +// Checks tokens.json against the LIVE Figma Brand Book (the canonical source of truth for the brand). +// +// Why this exists: nothing else closes the Figma -> tokens.json loop. The build only READS +// tokens.json; the Figma Variables REST endpoint is Enterprise-gated (403 on Pro); and the Figma +// MCP variable tools need a live desktop selection, which a headless job does not have. This +// script instead fetches the Brand Book page over the plain files REST API (works on Pro) and +// checks that every color token is actually present as a rendered solid fill, that every font +// size and weight token is present as a rendered text style, and that the brand typeface +// appears. It also reports heavily used non-token colors as drift suspects. +// +// It reads Figma only: no private repository, no other data. It is not run in GitHub Actions +// (there is no Figma token in this repository); a weekly job outside GitHub runs it. +// +// Usage: FIGMA_TOKEN=... node scripts/verify-figma.mjs [--json] [--summary] [--status FILE] +// --json print the full result as JSON instead of the text report +// --summary also write a short Markdown summary: appended to $GITHUB_STEP_SUMMARY when +// that is set (GitHub Actions), printed to stdout otherwise +// --status FILE also write the full result as JSON to FILE (for a scheduled job's health check) +// +// Exit codes: 0 = PASS, 1 = DRIFT (a token is absent from the Brand Book), 2 = error. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FILE_KEY = 'mFIc75UUSyftaqnNUQgjLX'; // My First Bitcoin_Full Brand Assets +const BRAND_BOOK_PAGE = '262:2'; // 59-frame designer monograph +const NON_TOKEN_THRESHOLD = 10; // report non-token solid colors used at least this often +const FETCH_TIMEOUT_MS = 120000; + +// ---- arguments ---- +const args = process.argv.slice(2); +const flags = { json: false, summary: false, status: null }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--json') flags.json = true; + else if (a === '--summary') flags.summary = true; + else if (a === '--status') { + flags.status = args[++i]; + if (!flags.status || flags.status.startsWith('--')) { + console.error('ERROR: --status needs a file path.'); + process.exit(2); + } + } else if (a.startsWith('--status=')) flags.status = a.slice('--status='.length); + else { + console.error(`ERROR: unknown argument ${a}`); + console.error('Usage: FIGMA_TOKEN=... node scripts/verify-figma.mjs [--json] [--summary] [--status FILE]'); + process.exit(2); + } +} + +const FIGMA_TOKEN = process.env.FIGMA_TOKEN; +if (!FIGMA_TOKEN) { + console.error('ERROR: FIGMA_TOKEN is not set (a read-only Figma token that can view the Brand Book).'); + process.exit(2); +} + +const tokens = JSON.parse(fs.readFileSync(path.join(ROOT, 'tokens.json'), 'utf8')); +const tokenHex = {}; +for (const [name, tok] of Object.entries(tokens.color)) tokenHex[tok.$value.toUpperCase()] = name; + +// ---- COVERAGE CENSUS ---- +// This check used to iterate tokens.color and nothing else, so its PASS spoke for 17 of 33 +// tokens while reading as though it spoke for the brand. The census derives its scope from the +// artifact: it walks EVERY token group, so adding a token adds a line to this report +// automatically, and coverage cannot drift away from what it claims to cover unnoticed. +// +// 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: [] }; +for (const group of TOKEN_GROUPS) { + for (const [name, tok] of Object.entries(tokens[group])) { + const mfb = (tok.$extensions || {}).mfb; + const kind = mfb ? mfb.sourceKind : 'no-provenance'; + census.byKind[kind] = (census.byKind[kind] || 0) + 1; + const ref = `${group}.${name}`; + if (kind === 'prose') census.unverifiable.push(`${ref} (${mfb.source})`); + else if (kind === 'declared') census.declared.push(ref); + else if (kind === 'unverified' || kind === 'no-provenance') census.noProvenance.push(ref); + } +} +census.total = TOKEN_GROUPS.reduce((n, g) => n + Object.keys(tokens[g]).length, 0); +census.machineCheckable = (census.byKind['rendered-fill'] || 0) + (census.byKind['rendered-text'] || 0); + +// ---- fetch the Brand Book page ---- +let doc; +try { + const res = await fetch(`https://api.figma.com/v1/files/${FILE_KEY}/nodes?ids=${BRAND_BOOK_PAGE}`, { + headers: { 'X-Figma-Token': FIGMA_TOKEN }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + console.error(`ERROR: Figma API ${res.status} (token expired or file moved?)`); + process.exit(2); + } + doc = await res.json(); +} catch (e) { + console.error(`ERROR: could not read the Figma file (${e.message})`); + process.exit(2); +} +const root = doc.nodes?.[BRAND_BOOK_PAGE]?.document; +if (!root) { + console.error(`ERROR: Brand Book page ${BRAND_BOOK_PAGE} not found in file ${FILE_KEY}.`); + process.exit(2); +} + +const toHex = (c) => + '#' + [c.r, c.g, c.b].map((v) => Math.round(v * 255).toString(16).padStart(2, '0').toUpperCase()).join(''); + +const solid = new Map(); +const fonts = new Map(); +const sizes = new Map(); +const weights = new Map(); +const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1); + +(function walk(n) { + for (const f of [...(n.fills || []), ...(n.strokes || [])]) { + if (f.visible === false) continue; + if (f.type === 'SOLID' && f.color) bump(solid, toHex(f.color)); + } + const st = n.style || {}; + if (st.fontFamily) bump(fonts, st.fontFamily); + // Sizes and weights ride the same walk. When this loop read fontFamily and nothing else, + // 12 tokens were reported as having no established provenance although every one of them + // is in the Brand Book as a rendered text style. + if (st.fontSize) bump(sizes, Math.round(st.fontSize)); + if (st.fontWeight) bump(weights, st.fontWeight); + for (const ov of Object.values(n.styleOverrideTable || {})) { + if (ov.fontFamily) bump(fonts, ov.fontFamily); + if (ov.fontSize) bump(sizes, Math.round(ov.fontSize)); + if (ov.fontWeight) bump(weights, ov.fontWeight); + } + for (const c of n.children || []) walk(c); +})(root); + +// ---- checks ---- +const missing = []; +const present = {}; +for (const [hex, name] of Object.entries(tokenHex)) { + const n = solid.get(hex) || 0; + present[name] = n; + if (n === 0) missing.push(`${name} (${hex})`); +} + +const suspects = [...solid.entries()] + .filter(([hex, n]) => !(hex in tokenHex) && n >= NON_TOKEN_THRESHOLD) + .sort((a, b) => b[1] - a[1]) + .map(([hex, n]) => `${hex} x${n}`); + +// Read the family from the token being verified rather than hard-coding it, so a change of +// typeface in tokens.json is checked as the new typeface. +const bodyFamily = tokens.fontFamily?.body?.$value?.[0] || 'IBM Plex Sans'; +const sansOk = (fonts.get(bodyFamily) || 0) > 0; + +const missingType = []; +const typeUsage = {}; +for (const [name, tok] of Object.entries(tokens.fontSize || {})) { + const px = parseInt(String(tok.$value), 10); + const n = sizes.get(px) || 0; + typeUsage[`fontSize.${name}`] = n; + if (n === 0) missingType.push(`fontSize.${name} (${tok.$value})`); +} +for (const [name, tok] of Object.entries(tokens.fontWeight || {})) { + const w = parseInt(String(tok.$value), 10); + const n = weights.get(w) || 0; + typeUsage[`fontWeight.${name}`] = n; + if (n === 0) missingType.push(`fontWeight.${name} (${tok.$value})`); +} + +const colorTotal = Object.keys(tokenHex).length; +const typeTotal = Object.keys(typeUsage).length; +const result = { + checkedAt: new Date().toISOString(), + fileKey: FILE_KEY, + page: BRAND_BOOK_PAGE, + pass: missing.length === 0 && missingType.length === 0 && sansOk, + missingTokenColors: missing, + missingTypeTokens: missingType, + typeTokenUsage: typeUsage, + tokenColorUsage: present, + nonTokenColorSuspects: suspects, + fonts: Object.fromEntries([...fonts.entries()].sort((a, b) => b[1] - a[1])), + bodyFamilyChecked: bodyFamily, + 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 ` + + `downstream (what Figma owes), ${census.noProvenance.length} have no established provenance`, + 'IBM Plex Mono not found in the current brand book type specimen (pending design ruling)', + 'Brand gradient #2B1C58->#5E378E not found as a fill on primary pages (pending design ruling)', + ], +}; + +if (flags.status) { + const dir = path.dirname(path.resolve(flags.status)); + fs.mkdirSync(dir, { recursive: true }); + // Write then rename, so a reader never sees a half-written status file. + const tmp = `${flags.status}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(result, null, 2) + '\n'); + fs.renameSync(tmp, flags.status); +} + +if (flags.json) { + console.log(JSON.stringify(result, null, 2)); +} else { + console.log(`Brand token verification vs Figma (${result.checkedAt.slice(0, 10)})`); + // Print the census FIRST. A reader who sees only "PASS" reasonably concludes the brand is + // 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.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 ? ' ...' : ''}`); + console.log(` Token colors: ${colorTotal - missing.length}/${colorTotal} present in brand book`); + if (missing.length) console.log(` MISSING (DRIFT!): ${missing.join(', ')}`); + console.log(` Type tokens: ${typeTotal - missingType.length}/${typeTotal} present in brand book`); + if (missingType.length) console.log(` MISSING TYPE (DRIFT!): ${missingType.join(', ')}`); + console.log(` ${bodyFamily} present: ${sansOk ? 'yes' : 'NO (DRIFT!)'}`); + if (suspects.length) console.log(` Non-token colors >= ${NON_TOKEN_THRESHOLD}x (mockup noise, review if new): ${suspects.slice(0, 8).join(', ')}`); + if (flags.status) console.log(` Status written: ${flags.status}`); + console.log(result.pass ? 'PASS' : 'FAIL'); +} + +if (flags.summary) { + const lines = [ + `### Figma check: ${result.pass ? 'PASS' : 'FAIL'}`, + '', + `Brand Book \`${FILE_KEY}\`, page \`${BRAND_BOOK_PAGE}\`, read ${result.checkedAt}.`, + '', + '| Check | Result |', + '|-------|--------|', + `| Token colors present | ${colorTotal - missing.length}/${colorTotal} |`, + `| Type tokens present | ${typeTotal - missingType.length}/${typeTotal} |`, + `| ${bodyFamily} present | ${sansOk ? 'yes' : 'no'} |`, + `| Machine-checkable coverage | ${census.machineCheckable}/${census.total} tokens |`, + ]; + if (missing.length) lines.push('', `Missing colors: ${missing.join(', ')}`); + if (missingType.length) lines.push('', `Missing type tokens: ${missingType.join(', ')}`); + if (suspects.length) lines.push('', `Non-token colors used ${NON_TOKEN_THRESHOLD} times or more (review if new): ${suspects.slice(0, 8).join(', ')}`); + const md = lines.join('\n') + '\n'; + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); + else process.stdout.write('\n' + md); +} + +process.exit(result.pass ? 0 : 1); From 00734afea3229161d527941b72383d2c4c3bb943 Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:46:41 +0000 Subject: [PATCH 5/6] Add the design workflow and CONTRIBUTING.md .github/workflows/design.yml runs rebuild-check on every event, version-check on pull requests, and release on pushes to master (it tags package.json's version when it has no tag yet and creates a GitHub Release). Only actions/checkout (v7.0.1, 3d3c42e5) and actions/setup-node (v7.0.0, 82076278), pinned by full SHA; workflow token read-only, write only in the release job; no workflow-level paths filter; no Figma check and no secret. CONTRIBUTING.md describes the files, the checks, the release rule, the brand-change loop, the Figma check, the supergraphics canon's origin and where the earlier history lives. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/design.yml | 116 +++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 .github/workflows/design.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml new file mode 100644 index 0000000..f97803d --- /dev/null +++ b/.github/workflows/design.yml @@ -0,0 +1,116 @@ +# Checks and releases for @myfirstbitcoin/design. CONTRIBUTING.md explains the flow. +# +# rebuild-check every event the committed outputs equal a fresh build (scripts/check.mjs) +# version-check pull requests a changed version is above the newest v* tag and not yet tagged +# release push to master if package.json's version has no tag yet, tag it and publish a release +# +# There is deliberately no workflow-level `paths:` filter. A workflow skipped that way leaves its +# required checks waiting as "Expected", which would block every pull request that touches no +# brand file. +# +# Only GitHub-owned actions are used, each pinned to a full commit SHA (the release tag it came +# from is in the comment). The token is read-only except in the release job. + +name: design + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + rebuild-check: + name: rebuild-check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + package-manager-cache: false + - name: Rebuild and compare (stale outputs, geometry gate, missing tokens, inert package.json) + run: node scripts/check.mjs + + version-check: + name: version-check + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # A pull request is checked out as its merge commit. Depth 2 brings the merge commit's + # first parent, which is exactly the master it was merged onto. Tags are not fetched by + # the checkout, so they are read from the remote below. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 2 + - name: Check package.json's version against the tags + run: | + set -euo pipefail + base=$(git rev-parse HEAD^1) + head_version=$(node -p 'require("./package.json").version') + base_version=$(git show "$base:package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version') + tags=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed 's#.*refs/tags/##') + newest=$(printf '%s\n' "$tags" | sed -n 's/^v\([0-9]*\.[0-9]*\.[0-9]*\)$/\1/p' | sort -V | tail -n 1) + echo "package.json version: master $base_version, this pull request $head_version. Newest tag: v${newest:-none}." + + if [ "$head_version" != "$base_version" ]; then + if ! printf '%s' "$head_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error file=package.json::The version \"$head_version\" is not of the form X.Y.Z." + exit 1 + fi + if printf '%s\n' "$tags" | grep -qxF "v$head_version"; then + echo "::error file=package.json::The tag v$head_version already exists. Tags never move, so choose a new version." + exit 1 + fi + if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' "$newest" "$head_version" | sort -V | tail -n 1)" != "$head_version" ]; then + echo "::error file=package.json::The version $head_version is not above the newest tag, v$newest." + exit 1 + 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' ' ') + if [ -n "$changed" ]; then + echo "::warning::Published files change without a version bump, so merging releases nothing: $changed" + else + echo "No version change, and no published file changes." + fi + fi + + release: + name: release + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: rebuild-check + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Tag and release package.json's version if it is new + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error file=package.json::The version \"$version\" is not of the form X.Y.Z." + exit 1 + fi + if git ls-remote --tags --refs origin "refs/tags/v$version" | grep -q .; then + echo "v$version is already tagged: nothing to release." + exit 0 + fi + # GitHub creates the tag server side, as a lightweight tag on this commit, like v1.0.0 to v1.3.0. + gh release create "v$version" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "v$version" --generate-notes + echo "Released v$version at $GITHUB_SHA." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..31083d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# 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. + +Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all +contributions, by people and AI assistants alike. + +## What is in the repository + +| Path | What it is | Edit it by hand? | +|------|------------|------------------| +| `tokens.json` | The design tokens (published as is) | Yes, only to match the Brand Book | +| `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 (see below) | Only together with its original | +| `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 | + +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. + +`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 +dependency's `prepare` script, after installing its dependencies, on every consumer install. +`scripts/check.mjs` fails if one appears. + +## Making a change + +The scripts need Node.js 22 and nothing else (there is no `npm install`). From the repository root: + +``` +node scripts/build-design-package.mjs # rewrites the generated package files +node scripts/brand-spec.mjs # rewrites brand-spec.md +node scripts/check.mjs # the rebuild check that GitHub Actions runs +``` + +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. + +## 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`. +- **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. + +## Releases + +A release happens only when a merged pull request raises the version in `package.json`. The +author proposes the version and whoever approves the pull request accepts it. On the push to +`master`, the `release` job creates the tag `vX.Y.Z` on that commit and a GitHub Release with +generated notes. If the version is already tagged, it does nothing, so merges without a version +bump release nothing. + +Tags never move and are never deleted. A wrong release is followed by a new one. Consumers pin a +tag, for example `"@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0"`, and see a +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`. +2. The admin session opens a pull request with the change to `tokens.json` and the rebuild. +3. Quentin decides. +4. Merging a pull request that raises the version releases it. +5. The Monday digest line shows which projects are behind the newest tag. + +## The Figma check + +`scripts/verify-figma.mjs` reads the Brand Book page in Figma and checks that every color token +is present there as a rendered fill and every font size and weight token as a rendered text +style. It needs a read-only Figma token in `FIGMA_TOKEN`: + +``` +FIGMA_TOKEN=... node scripts/verify-figma.mjs [--json] [--summary] [--status FILE] +``` + +It is not run in GitHub Actions, and no Figma token is stored in this repository. The Monday +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 supergraphics canon + +`src/supergraphics.canon.css` is a byte-for-byte copy of `supergraphics.css` in `@mfb/shared`, +My First Bitcoin's internal shared package, where the supergraphics are maintained against the +Brand Book. The build appends it unchanged to the published `supergraphics.css`, after a prelude +generated from `tokens.json`, and refuses to build when the canon's geometry (`--sg-angle-base` +and the other `--sg-*` values) disagrees with the tokens. + +Keep it a pure copy, with no added comment: any byte added here changes the published file. When +one copy changes, change the other in the same step; 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. + +## Where the history lives + +This repository's history starts with the first generated package, `v1.0.0`. The build, the +spec generator and the Figma check moved into it later, without their history. That earlier +history, and the history of `tokens.json` before it was published here, stays in My First +Bitcoin's private `brand-tokens` repository, which is kept and never deleted. It was +deliberately not imported into this public repository. From 1cf0ef439a013fade7a55b714ca54a6e0756fe5f Mon Sep 17 00:00:00 2001 From: MFB Ops Agent Date: Sun, 27 Sep 2026 01:57:16 +0000 Subject: [PATCH 6/6] Fix the verifiers' findings: release job, inert package.json, prose - design.yml release job: read the tag with git ls-remote --exit-code, so an unreadable remote fails closed instead of releasing; act only on the push that raised the version (a later push never tags); refuse a version that is not above the newest v* tag; warn when published files change without a bump. Full history is fetched so github.event.before is available. - check.mjs: package.json may not have peerDependencies or peerDependenciesMeta either (npm 7+ installs peer dependencies). - CONTRIBUTING: Quentin accepts the version (no approval rule yet); the release job's push rule; the canon is the origin in this repository. - brand-spec template: correct what theme.css and tailwind.js carry; replace the stale gradient rule with the deprecation. brand-spec.md regenerated. - verify-figma: the mono and gradient notes say they are deprecated. The 8 published files are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/design.yml | 50 +++++++++++++++++++++++++++++--- CONTRIBUTING.md | 26 +++++++++-------- brand-spec.md | 4 +-- scripts/build-design-package.mjs | 5 ++-- scripts/check.mjs | 2 ++ scripts/verify-figma.mjs | 4 +-- src/brand-spec.template.md | 4 +-- 7 files changed, 71 insertions(+), 24 deletions(-) diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml index f97803d..40c94db 100644 --- a/.github/workflows/design.yml +++ b/.github/workflows/design.yml @@ -2,7 +2,8 @@ # # rebuild-check every event the committed outputs equal a fresh build (scripts/check.mjs) # version-check pull requests a changed version is above the newest v* tag and not yet tagged -# release push to master if package.json's version has no tag yet, tag it and publish a release +# release push to master if this push raised package.json's version to a new one above the +# newest v* tag, tag this commit and publish a release # # There is deliberately no workflow-level `paths:` filter. A workflow skipped that way leaves its # required checks waiting as "Expected", which would block every pull request that touches no @@ -94,12 +95,17 @@ jobs: permissions: contents: write steps: + # The full history makes the push's previous master head (github.event.before) available, + # so the job can tell whether this push is the one that raised the version. The repository + # is small. Tags are still read from the remote, which is the authority. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Tag and release package.json's version if it is new + fetch-depth: 0 + - name: Tag and release package.json's version if this push raised it env: GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} run: | set -euo pipefail version=$(node -p 'require("./package.json").version') @@ -107,10 +113,46 @@ jobs: echo "::error file=package.json::The version \"$version\" is not of the form X.Y.Z." exit 1 fi - if git ls-remote --tags --refs origin "refs/tags/v$version" | grep -q .; then - echo "v$version is already tagged: nothing to release." + before_version=$(git show "$BEFORE:package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version') + echo "package.json version: before this push $before_version, now $version." + + # --exit-code gives 2 when the tag is absent. Any other failure stops the job, so an + # unreadable remote never leads to a release. The last line is the tagged commit (the + # peeled line, for an annotated tag). + rc=0 + tag_lines=$(git ls-remote --exit-code --tags origin "refs/tags/v$version" "refs/tags/v$version^{}") || rc=$? + if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then + echo "::error::Could not read the tags from origin (git ls-remote exit $rc), so nothing was released." + exit 1 + fi + + if [ "$rc" -eq 0 ]; then + 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' ' ') + 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 + exit 0 + fi + echo "::error file=package.json::This push changes the version to $version, but v$version already exists. Tags never move, so nothing was released." + exit 1 + fi + + if [ "$before_version" = "$version" ]; then + echo "::warning::v$version is not tagged, but this push did not raise the version, so this commit is not tagged. Re-run the release job of the push that raised it." exit 0 fi + + # This push raised the version. Tags cannot be undone, so the rule that version-check + # applies to pull requests is applied again here: a direct push to master has no other gate. + newest=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed -n 's#.*refs/tags/v\([0-9]*\.[0-9]*\.[0-9]*\)$#\1#p' | sort -V | tail -n 1) + if [ -n "$newest" ] && [ "$(printf '%s\n%s\n' "$newest" "$version" | sort -V | tail -n 1)" != "$version" ]; then + echo "::error file=package.json::The version $version is not above the newest tag, v$newest, so nothing was released." + exit 1 + fi + # GitHub creates the tag server side, as a lightweight tag on this commit, like v1.0.0 to v1.3.0. gh release create "v$version" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "v$version" --generate-notes echo "Released v$version at $GITHUB_SHA." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31083d0..bd22f72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ contributions, by people and AI assistants alike. |------|------------|------------------| | `tokens.json` | The design tokens (published as is) | Yes, only to match the Brand Book | | `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 (see below) | Only together with its original | +| `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 | | `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 | @@ -55,10 +55,11 @@ a token it needs is missing. ## Releases A release happens only when a merged pull request raises the version in `package.json`. The -author proposes the version and whoever approves the pull request accepts it. On the push to -`master`, the `release` job creates the tag `vX.Y.Z` on that commit and a GitHub Release with -generated notes. If the version is already tagged, it does nothing, so merges without a version -bump release nothing. +author proposes the version, and Quentin accepts it when he decides on the pull request. On the +push to `master`, the `release` job creates the tag `vX.Y.Z` on that commit and a GitHub Release +with generated notes. It acts only on the push that raised the version, and checks again that +the version is above the newest `v*` tag. If the version is already tagged, it does nothing, so +merges without a version bump release nothing. Tags never move and are never deleted. A wrong release is followed by a new one. Consumers pin a tag, for example `"@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0"`, and see a @@ -89,14 +90,15 @@ check runs it outside GitHub. `--status FILE` writes the full result as JSON for ## The supergraphics canon -`src/supergraphics.canon.css` is a byte-for-byte copy of `supergraphics.css` in `@mfb/shared`, -My First Bitcoin's internal shared package, where the supergraphics are maintained against the -Brand Book. The build appends it unchanged to the published `supergraphics.css`, after a prelude -generated from `tokens.json`, and refuses to build when the canon's geometry (`--sg-angle-base` -and the other `--sg-*` values) disagrees with the tokens. +`src/supergraphics.canon.css` is the origin of the supergraphics primitives. It began as a +byte-for-byte copy of `supergraphics.css` in `@mfb/shared`, My First Bitcoin's internal shared +package, which keeps an identical copy. The build appends it unchanged to the published +`supergraphics.css`, after a prelude generated from `tokens.json`, and refuses to build when the +canon's geometry (`--sg-angle-base` and the other `--sg-*` values) disagrees with the tokens. -Keep it a pure copy, with no added comment: any byte added here changes the published file. When -one copy changes, change the other in the same step; a weekly check compares the two. +Keep it free of added comments: any byte added here changes the published file. Change the canon +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 diff --git a/brand-spec.md b/brand-spec.md index c2b5bf2..89050f7 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) and `tailwind.js` (Tailwind 3) carry the same values. +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`. ## 2. Binding Rules (The 12) @@ -274,7 +274,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font - **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 -- **Gradient direction:** always 135deg (top-left to bottom-right); reserve gradient for hero sections / UI surfaces, not covers +- **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/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index f7b67b6..134b949 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -4,8 +4,9 @@ // Sources (all in this repository): // tokens.json the design tokens, the single source of every value // package.json hand-maintained; only its version is read here (install pin in README) -// src/supergraphics.canon.css the supergraphics canon, a byte copy of supergraphics.css in -// @mfb/shared (see CONTRIBUTING.md); appended unchanged +// src/supergraphics.canon.css the supergraphics canon, the origin of the primitives; it began +// as a byte copy of supergraphics.css in @mfb/shared, which keeps +// an identical copy (see CONTRIBUTING.md); appended unchanged // // Usage: node scripts/build-design-package.mjs [OUT_DIR] // OUT_DIR defaults to the repository root. scripts/check.mjs passes a temporary directory. diff --git a/scripts/check.mjs b/scripts/check.mjs index 329be1e..93e4ef7 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -28,6 +28,8 @@ const FORBIDDEN_PKG_FIELDS = [ 'dependencies', 'devDependencies', 'optionalDependencies', + 'peerDependencies', // npm 7 and later installs peer dependencies in the consumer's project + 'peerDependenciesMeta', 'bundleDependencies', 'bundledDependencies', ]; diff --git a/scripts/verify-figma.mjs b/scripts/verify-figma.mjs index a16a549..a5d251c 100644 --- a/scripts/verify-figma.mjs +++ b/scripts/verify-figma.mjs @@ -190,8 +190,8 @@ const result = { `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 ` + `downstream (what Figma owes), ${census.noProvenance.length} have no established provenance`, - 'IBM Plex Mono not found in the current brand book type specimen (pending design ruling)', - 'Brand gradient #2B1C58->#5E378E not found as a fill on primary pages (pending design ruling)', + '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', ], }; diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 7d04405..0b13475 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) and `tailwind.js` (Tailwind 3) carry the same values. +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`. ## 2. Binding Rules (The 12) @@ -246,7 +246,7 @@ This package's `brand.css` exposes every color as `var(--mfb-)`, the font - **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 -- **Gradient direction:** always 135deg (top-left to bottom-right); reserve gradient for hero sections / UI surfaces, not covers +- **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