diff --git a/.github/workflows/design.yml b/.github/workflows/design.yml index 40c94db..654b2bc 100644 --- a/.github/workflows/design.yml +++ b/.github/workflows/design.yml @@ -78,7 +78,7 @@ jobs: fi echo "Version bump to $head_version: merging this pull request releases v$head_version." else - changed=$(git diff --name-only "$base" HEAD -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$base" HEAD -- README.md brand-spec.md brand-check.mjs brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ -n "$changed" ]; then echo "::warning::Published files change without a version bump, so merging releases nothing: $changed" else @@ -130,7 +130,7 @@ jobs: tag_sha=$(printf '%s\n' "$tag_lines" | tail -n 1 | cut -f1) if [ "$tag_sha" = "$GITHUB_SHA" ] || [ "$before_version" = "$version" ]; then echo "v$version is already tagged: nothing to release." - changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') + changed=$(git diff --name-only "$BEFORE" "$GITHUB_SHA" -- README.md brand-spec.md brand-check.mjs brand.css index.js supergraphics.css tailwind.js theme.css tokens.json | tr '\n' ' ') if [ "$tag_sha" != "$GITHUB_SHA" ] && [ -n "$changed" ]; then echo "::warning::Published files changed without a version bump, so they are in no release until the next one: $changed" fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f803748..612ac1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,41 @@ # Contributing to @myfirstbitcoin/design This repository is the single source of the My First Bitcoin brand as code: the design tokens, -the build that turns them into the published package, the written brand specification, and the -check against the Brand Book in Figma. The Brand Book in Figma is where the brand is decided; -`tokens.json` mirrors it and is edited only to match it. +the build that turns them into the published package, the written brand specification, the +brand guard that projects run, and the check against the Brand Book in Figma. The Brand Book in +Figma is where the brand is decided; `tokens.json` mirrors it and is edited only to match it, +under the source rule below. + +## The source rule + +1. **Brand Book first.** Where the Brand Book in Figma (file `mFIc75UUSyftaqnNUQgjLX`, page + `262:2`) defines a value, its value is the token, cited by node (`"source": "figma:"`). +2. **Where the Brand Book is silent**, the value used on the live website, myfirstbitcoin.org, + may become the token. The website was built following the Brand Book, so what it does where + the book says nothing is the best evidence there is. Such a token is declared with its origin + on the site's main branch: `"sourceKind": "declared"` and + `"source": "myfirstbitcoin.org@, "`, with the number of uses in its note. + The CSS name is what carries the value in the CSS and pages the site serves, which are public: + a custom property (`myfirstbitcoin.org@6368b2a, --r-lg`), a selector and property + (`myfirstbitcoin.org@6368b2a, .article-prose line-height`), or a property set inline, with + where it is used (`box-shadow (inline, media frames)`). The site's source repository is + private, so no file, directory or line of it is named in this repository; the deciders named + under Releases keep the exact lines outside it. +3. **Never a website value that contradicts the Brand Book**, however often the site uses it: a + slant other than the book's angle, a highlighter other than the book's thin line, duotone + photos, a font or a color outside the book, all-caps text, orange text on a light background. + `brand-spec.md` lists the ones found so far under "Not imported from the website". +4. **Where the site has no single value** (several near-duplicates with no convention), no token + is made until someone picks one. +5. **The deciders named under Releases decide** every brand change, as for any pull request here. + +Every token carries `$extensions.mfb.sourceKind`, defined in `$verification` at the end of +`tokens.json`: `rendered-fill` or `rendered-text` (checked by the Figma check), `prose` (stated in +the Brand Book in words), `measured` (read off the Brand Book's vector geometry or a node's +rendered fill, with the node ids in the note), `declared` (not in the Brand Book: a website value +with its site source, or a value this package recommends, with `"source": "declared"` and the +reason in the note), or `unverified`. Color roles and shape tones are aliases of palette colors +(`{color.black}`), and the build refuses one that is not. Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all contributions, by people and AI assistants alike. @@ -16,13 +48,17 @@ contributions, by people and AI assistants alike. | `package.json` | Name, version, exports and the `files` list (published) | Yes: the version is how a release happens | | `src/supergraphics.canon.css` | The supergraphics canon, the origin of the published supergraphics (see below) | Yes, by pull request, with no added comment | | `src/brand-spec.template.md` | The prose of the brand specification | Yes | +| `brand-check.mjs` | The brand guard projects run after their build (published) | Yes, with its test in `scripts/brand-check.test.mjs` | | `scripts/` | Build, spec generator and checks | Yes | | `README.md`, `brand.css`, `theme.css`, `tailwind.js`, `index.js`, `supergraphics.css` | Generated package files (published) | No: rebuild them | -| `brand-spec.md` | Generated brand specification (public, not part of the package) | No: regenerate it | +| `brand-spec.md` | Generated brand specification (published, so consumers and their AI tools read the rules from `node_modules`) | No: regenerate it | What consumers install is exactly the list in `package.json`'s `files`, plus `package.json` -itself: `README.md`, `brand.css`, `index.js`, `supergraphics.css`, `tailwind.js`, `theme.css` -and `tokens.json`. Nothing else in this repository reaches them. +itself: `README.md`, `brand-spec.md`, `brand-check.mjs`, `brand.css`, `index.js`, +`supergraphics.css`, `tailwind.js`, `theme.css` and `tokens.json`. Nothing else in this +repository reaches them. +`brand-spec.md` is there so that a consumer, and the AI tools working in its repository, read +the brand rules at `node_modules/@myfirstbitcoin/design/brand-spec.md`, at the version they pin. `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 @@ -40,14 +76,24 @@ node scripts/check.mjs # the rebuild check that GitHub Actions ``` Commit the source change and the regenerated files together. The build refuses to write -anything if the canon's geometry disagrees with `tokens.json`, and the spec generator refuses if -a token it needs is missing. +anything if the canon's geometry disagrees with `tokens.json`, if a geometry token is neither +gated against the canon nor listed as token-only, if a tokens.json group has no entry in its +`GROUP_MAP` (where each group is emitted), if a new Tailwind key would equal a Tailwind 3 or 4 +default, or if a font weight would change Tailwind's value for its name (`font-medium` is 500). +The spec generator refuses if a token it needs, or its source, is missing. + +A new token group needs an entry in `GROUP_MAP` in `scripts/build-design-package.mjs`: its +Tailwind 4 namespace, its Tailwind 3 key and its `brand.css` prefix, or `null` where it is not +emitted. New Tailwind keys are `mfb-` prefixed, type levels or color roles +(`-on-`), so that no release changes a utility a project already uses. ## Checks on every pull request - **rebuild-check** (`scripts/check.mjs`): rebuilds into a temporary directory and fails if any - committed output differs from a fresh build, if the build or the spec generator refuses, or if - `package.json` gains scripts or dependencies. It also runs on every push to `master`. + committed output differs from a fresh build, if the build or the spec generator refuses, if + `package.json` gains scripts or dependencies, or if `brand-check.mjs` misses a planted + violation or reports a clean line (`scripts/brand-check.test.mjs`). It also runs on every push + to `master`. - **version-check**: if the pull request changes the version in `package.json`, the new version must be above the newest `v*` tag and must not already be tagged. If published files change without a version bump, it warns: merging would release nothing. @@ -68,7 +114,8 @@ release only when they raise their pin. ## The brand-change loop 1. Patrick changes the Brand Book in Figma and tells Quentin, or the Monday check flags a - difference between Figma and `tokens.json`. + difference between Figma and `tokens.json`, or a value the Brand Book is silent on is + declared from the website under the source rule. 2. The admin session opens a pull request with the change to `tokens.json` and the rebuild. 3. Quentin or Patrick decides. 4. Merging a pull request that raises the version releases it. @@ -88,6 +135,17 @@ It is not run in GitHub Actions, and no Figma token is stored in this repository check runs it outside GitHub. `--status FILE` writes the full result as JSON for that job, and `--summary` writes a short Markdown summary (to `$GITHUB_STEP_SUMMARY` when that is set). +## The brand guard + +`brand-check.mjs` is published, and projects run it after their build +(`node node_modules/@myfirstbitcoin/design/brand-check.mjs`; the README says how). It needs +Node.js and nothing else, and reads every value it compares with from the `tokens.json` next to +it, so a token change reaches the guard without editing it. It generalises the guards that two +of My First Bitcoin's sites already ran. A rule that cannot see enough to be sure (which +background a text sits on, say) is listed in `DEFAULT_WARN` and only warns unless a project passes +`--error`. When you change a rule, add a planted line for it to `scripts/brand-check.test.mjs` +(and a clean line for anything it must not report); `scripts/check.mjs` runs that test. + ## The supergraphics canon `src/supergraphics.canon.css` is the origin of the supergraphics primitives. It began as a @@ -100,9 +158,10 @@ Keep it free of added comments: any byte added here changes the published file. here, by pull request; the internal copy is then brought into line, and a weekly check compares the two. -The canon, `tokens.json` and the README template keep the punctuation they were published with, -em-dashes included, because rewording them changes published files. Such rewording belongs in a -release pull request. +The canon and `tokens.json` keep the punctuation they were published with, em-dashes included, +because rewording them changes published files. Such rewording belongs in a release pull request. +The README template (in `scripts/build-design-package.mjs`) was reworded in the v1.4.0 release +and follows the conventions in [AGENTS.md](AGENTS.md): new published prose carries no em-dash. ## Where the history lives diff --git a/README.md b/README.md index f226174..d7040c9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,43 @@ # @myfirstbitcoin/design -The My First Bitcoin brand as code: a Tailwind preset, CSS variables, and raw design tokens. Generated from the canonical `tokens.json` (Figma → `sync-brand.js`). Do not edit generated files by hand. +The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the supergraphics primitives, the raw design tokens and the written brand specification. Every file is generated by `scripts/build-design-package.mjs` in [MyFirstBitcoin/mfb-design](https://github.com/MyFirstBitcoin/mfb-design), from that repository's `tokens.json`, which mirrors the Brand Book in Figma and changes only by pull request. Do not edit generated files by hand. ## Install (git dependency) ```json -"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.3.0" } +"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.4.0" } ``` +Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile). + +## Upgrading from v1.3.0 + +Make these changes in the same commit that raises your pin: + +- **Delete any local `.highlighter` band** (for example a `linear-gradient(transparent 80%, ...)` background on `.highlighter`), and any local rule that changes its `display` or `text-align`. The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color. +- **Remove any padding your project adds around `.sg-para-pattern` to make room for the slant.** Each row now insets itself by half its height times tan(13°), so its first shape is no longer clipped; extra padding moves the rows further right. +- **Set `--sg-aspect` on parallelogram frames too.** The photo scale in `.sg-para-frame` was a fixed 1.12; it is now `1 + tan(--sg-angle-base) / --sg-aspect`, the smallest scale that covers the frame, so it depends on the frame's real shape. +- **`supergraphics.css` sets more variables in `:root`**: the font families, sizes and weights, the halftone colors, the color roles (`--mfb--on-`) and the line height and letter spacing per level (`--mfb-leading-`, `--mfb-tracking-`), besides the palette and the geometry. If you override any of them, do it after importing `supergraphics.css`, or import the file into a cascade layer. +- `.sg-cover__title` reads `--mfb-size-h2`. The `--mfb-font-h2` variable it used to read was never defined by this package; set `font-size` on the title instead. +- **Tailwind 4 projects that restrict a theme namespace**, for example with `--font-weight-*: initial` or `--radius-*: initial`, must put that reset in its own `@theme` block before importing `theme.css`: placed after the import, it removes the package's values too, and no utility is generated for them. + +```css +@import "tailwindcss"; +@theme { --font-weight-*: initial; } +@import "@myfirstbitcoin/design/theme.css"; +``` + +- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in `tokens.json` now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to. +- **Text on orange is black, buttons included, and the supergraphics classes now set it.** `.sg-cta` sets `var(--mfb-link-on-orange)`, `.sg-bg-orange` `var(--mfb-body-on-orange)` and `.sg-cover--orange` (with its `.sg-cover__title`) `var(--mfb-heading-on-orange)`: black, 9.2:1 on orange-300, where they set white (2.3:1, below the 4.5:1 that body text and buttons need). Delete any local white `color` on them, which would bring the old contrast back; a local black one can go too. +- **Text on the light supergraphics surfaces is black.** `.sg-bg-white`, `.sg-bg-gray` and `.sg-info-bar--on-light` set `var(--mfb-body-on-light)` (black) instead of gray-900. +- **The info bar's date follows its surface, and the line under it is a solid color.** On `.sg-info-bar--on-light`, `.sg-info-bar__date` is `var(--mfb-heading-on-light)` (black) instead of orange-300 (2.3:1 on white). A bar without a variant has no background of its own: its date stays orange-300 on purple surfaces and photos, and is black when the bar sits directly in `.sg-bg-white` or `.sg-bg-gray` (where orange-300 fails the contrast rule) or in one of the package's orange surfaces, `.sg-bg-orange`, `.sg-cover--orange` and the orange spotlight, book and halftone frames (where an orange date could not be seen). On any other light or orange surface, give the bar a variant. The date's `small` loses its 75% opacity: it is `var(--mfb-muted-on-light)` (gray-700, 5.1:1) on the light bar and `var(--mfb-muted-on-dark)` (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without a variant it keeps the date's color, fully opaque. +- **`.sg-headline` and `.sg-cover__title` set line height 1 and no letter spacing** (`--mfb-leading-h1` and `--mfb-tracking-h1`; `h2` for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Line height 1 is the book's for H1 to H4 and quotes. `.sg-headline` sets no size, so a headline set at the H5 or H6 size also takes `.sg-headline--h5` or `.sg-headline--h6`, new in this release, which set the book's 1.1 (`--mfb-leading-h5`, `--mfb-leading-h6`). Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box. +- **`.sg-label` is Regular (400), not Medium, with line height 1.2 and no letter spacing** (`--mfb-leading-label`, `--mfb-tracking-label`), instead of 1.5px. Its size stays 18px. `.sg-body` reads `--mfb-leading-body`, the same 1.2 as before, and now sets `--mfb-tracking-body` (none), so it no longer takes a letter spacing from its parent. +- **A line height for long reading, `--mfb-leading-body-long` (`leading-body-long`), 1.7.** It is declared from the website's article text, because the Brand Book only sets body copy next to a heading (`--mfb-leading-body`, 1.2). It is under review, and nothing uses it until a page adopts it. +- The supergraphics classes that still differ from the Brand Book are listed in `brand-spec.md` ("Supergraphics classes that differ from the Brand Book"). +- **A test that snapshots the `--mfb-*` variables of `brand.css` whose value is a hex** must refresh its snapshot: this release adds `--mfb-halftone-highlight`, `--mfb-halftone-highlight-full`, `--mfb-halftone-shadow`. The other new variables carry no hex; they reference the palette with `var()`. +- **Everything else is added, not changed.** Every new Tailwind utility is `mfb-` prefixed, a type level (`leading-h1`, `tracking-h1`, `text-h1-fluid`), a color role (`text-heading-on-light`) or `font-regular`. The weights `font-normal`, `font-medium` and `font-semibold` restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below. + ## Use - Tailwind 3 ```js @@ -26,7 +56,7 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; ## Use - plain CSS variables ```css -@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-gradient-brand) ... */ +@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-weight-medium) ... */ ``` ## Use - supergraphics (brand geometry primitives) @@ -35,9 +65,119 @@ export default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] }; @import '@myfirstbitcoin/design/supergraphics.css'; ``` -The signature MFB shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, `sg-para-pattern`, `highlighter` band. **Never hand-roll these shapes in a page** — the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. +The signature My First Bitcoin shapes as ready-made classes: `sg-para` / `sg-para-frame` (13° parallelograms), `sg-para-pattern`, `sg-spotlight` / `sg-spotlight-frame` (13°+24° corner cuts), `sg-book` / `sg-book-frame` / `sg-book-stack`, `sg-cover`, `sg-halftone-cutout`, and the `highlighter`. **Never hand-roll these shapes in a page**: the brand angle is exactly 13° (`--sg-angle-base`) and hand-rolled copies drift. The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups. + +The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: `.sg-cta`'s 1px letter spacing and 40px radius, `.sg-label`'s 18px size, the type sizes and SemiBold date of `.sg-info-bar`, the 6px corners of `.sg-para-frame` and `.sg-para-accent`, the pattern's spacing and the logo's height. `brand-spec.md` lists every such class and what to set. + +### Set --sg-aspect on every frame + +`sg-spotlight-frame`, `sg-book-frame`, `sg-halftone-cutout` and `sg-para-frame` compute their cuts (and the parallelogram frame its photo scale) from `--sg-aspect`, the frame's width divided by its height. The default, 1.5, is right only for a frame of that shape. Set it to the frame's real ratio, for example `style="--sg-aspect: 0.75"` on a 3:4 frame; otherwise the cuts are not at 13° and 24°, and a parallelogram frame's photo may not cover the frame. + +### The highlighter + +Wrap the one word you emphasize in a heading: `

Open Source Education

`. It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, drawn beneath the glyphs (not a band), under 80% of the word, centered. The line is orange-300, and white inside the orange surfaces `sg-bg-orange`, `sg-spotlight--orange`, `sg-cover--orange`, `sg-spotlight-frame--orange`, `sg-book-frame--orange` and `sg-halftone-cutout--orange-bg`. The word keeps the heading's color. On another orange surface, or on a mid grey such as gray-500, set `--sg-highlighter-color: var(--mfb-white)` on the section. On a light grey such as gray-200 (`sg-bg-gray`) keep the orange line: a white one cannot be seen there. One word per heading, never more. The span is an `inline-block`, and the line's length and thickness are computed from the span's own width: do not override its `display` or `text-align`. Its measurements are the `--sg-highlighter-*` variables (coverage, aspect, offset, shape), from the geometry tokens of the same names. + +## Layout, motion and color roles + +Where the Brand Book defines a value, the token takes it. Where the book is silent (spacing, radius, shadow, motion, widths), the value is declared from the live website, myfirstbitcoin.org, which was built following the Brand Book; each such token's `$extensions.mfb.source` names the website commit and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline), for example `myfirstbitcoin.org@6368b2a, --r-lg`. A website value that contradicts the Brand Book is never imported. `brand-spec.md` lists every value with its source. + +| What | brand.css | Tailwind 3 and 4 | +|------|-----------|------------------| +| Color roles | `--mfb--on-`: `heading-on-light`, `body-on-light`, `muted-on-light`, `link-on-light`, `link-underline-on-light`, `border-on-light`, `highlighter-on-light`, `logo-on-light`, `heading-on-dark`, `body-on-dark`, `muted-on-dark`, `link-on-dark`, `highlighter-on-dark`, `logo-on-dark`, `heading-on-orange`, `body-on-orange`, `link-on-orange`, `highlighter-on-orange`, `logo-on-orange` | `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light` ... | +| Line height per level | `--mfb-leading-`, and `--mfb-leading-body-long` for long reading (declared from the website, under review) | `leading-`, for example `leading-h1`; `leading-body-long` | +| Letter spacing per level | `--mfb-tracking-` | `tracking-` | +| Fluid sizes for web pages | `--mfb-size--fluid` (h1, h2, h3, h4, h5, h6, quote, label) | `text--fluid` | +| Spacing scale | `--mfb-space-1`, `--mfb-space-2`, `--mfb-space-3`, `--mfb-space-4`, `--mfb-space-6`, `--mfb-space-8`, `--mfb-space-12`, `--mfb-space-16`, `--mfb-space-24`, `--mfb-space-32` | Tailwind's own `p-1` to `p-32`: the same values, so the package adds none | +| Semantic spacing | `--mfb-space-heading-to-body`, `--mfb-space-quote-to-label`, `--mfb-space-gutter`, `--mfb-space-section`, `--mfb-space-section-tight`, `--mfb-space-header-gap`, `--mfb-space-block-end` | `py-mfb-section`, `px-mfb-gutter`, `mt-mfb-heading-to-body` ... | +| Radius (interface elements only, never brand shapes) | `--mfb-radius-sm`, `--mfb-radius-md`, `--mfb-radius-lg`, `--mfb-radius-pill` | `rounded-mfb-sm`, `rounded-mfb-md`, `rounded-mfb-lg`; `rounded-full` for the pill | +| Shadow | `--mfb-shadow-media`, `--mfb-shadow-media-control`, `--mfb-shadow-overlay` | `shadow-mfb-media`, `shadow-mfb-media-control`, `shadow-mfb-overlay` | +| Easing | `--mfb-ease-out`, `--mfb-ease-in-out` | `ease-mfb-out`, `ease-mfb-in-out` | +| Duration | `--mfb-duration-fast`, `--mfb-duration-med`, `--mfb-duration-slide`, `--mfb-duration-reveal` | `duration-mfb-fast`, `duration-mfb-med`, `duration-mfb-slide`, `duration-mfb-reveal` | +| Container widths | `--mfb-container-page`, `--mfb-container-wide`, `--mfb-container-measure` (px) | `max-w-mfb-page`, `max-w-mfb-wide`, `max-w-mfb-measure` (rem) | +| Logo size and clear space | `--mfb-logo-min-width`, `--mfb-logo-min-width-print`, `--mfb-logo-aspect`, `--mfb-logo-min-height`, `--mfb-logo-clearspace`, `--mfb-logo-edge-margin` | none | +| Shape tones (a shape one step lighter than its base) | `--mfb-shape-on-purple-300`, `--mfb-shape-on-orange-300`, `--mfb-shape-on-gray-700` | none | +| Layers | `--mfb-z-header`, `--mfb-z-dropdown`, `--mfb-z-overlay` | none | +| Media ratios | `--mfb-ratio-video`, `--mfb-ratio-photo`, `--mfb-ratio-portrait`, `--mfb-ratio-square` | none (Tailwind has `aspect-video` and `aspect-square`) | +| Interface | `--mfb-ui-touch-target`, `--mfb-ui-link-underline-offset` | none | + +- **New utilities never replace Tailwind's.** Keys are `mfb-` prefixed (`rounded-mfb-md`, not `rounded-md`; `ease-mfb-out`, not `ease-out`, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default, and a font weight that would change Tailwind's value for its name. +- **In 1.x, `text-h1` sets only the size.** Add `leading-h1` yourself (and `tracking-h1` where something else sets tracking). Folding line height into `text-h1` would change every existing page, so it waits for a major release. +- **New `brand.css` variables reference the palette with `var()`** and add no hex: roles are `var(--mfb-black)` and the like, shadows `color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)`. `theme.css` and `tailwind.js` carry the same colors as hex, so Tailwind's opacity modifiers work. +- **Tailwind 4 emits a theme variable only when something uses it.** To read `var(--mfb-radius-md)` or another new variable in your own CSS, import `brand.css` as well, or use the utility. +- **Shadows in Tailwind 4: prefer the `shadow-mfb-*` utilities.** Tailwind compiles them to a hex color with alpha. A `var(--mfb-shadow-*)` from `brand.css` goes through Tailwind 4's CSS compiler, which gives it a fallback without `color-mix()` for browsers that lack it (Chrome before 111, Safari before 16.2), and that fallback draws the shadow fully opaque. +- **index.js** exports `colorRoles`, `shapeTones`, `fontSizeFluid`, `lineHeight`, `letterSpacing`, `logo`, `spacing`, `space`, `radius`, `shadow`, `easing`, `duration`, `container`, `zIndex`, `mediaRatio`, `ui` next to the existing `colors`, `fontFamily`, `fontSize` and `fontWeight`. + +## Brand check + +`brand-check.mjs` ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's `tokens.json`, so it follows the version you pin. + +```sh +node node_modules/@myfirstbitcoin/design/brand-check.mjs [options] +``` + +| Option | What it does | +|--------|--------------| +| `--src DIR` | Source to read, repeatable or comma-separated (default `src`). Comments are ignored. | +| `--dist DIR` | Build output whose CSS (style sheets, ` +`, + 'src/styles/legacy.scss': `.x { + // legacy value was #fff + color: var(--mfb-black); +} +`, + // Built CSS that drifts: every declaration of either angle is checked, wherever it sits. + 'dist/_astro/drift.css': ` +.page { --sg-angle-base: 10deg; } @built-angle +@property --sg-angle-alt { syntax: ""; inherits: true; initial-value: 20deg; } @built-angle +.extra{--sg-angle-base:calc(var(--sg-angle-alt) / 2.4)} @built-angle +:root { --color-purple-300: #422c70; --color-teal-500: oklch(70% .1 180); } @built-color +.y { text-transform: uppercase; } @built-uppercase +.btn { @media (width >= 48rem) { font-variant-caps: all-small-caps; } } @built-uppercase +.code { font-family: var(--font-mono); } @built-font +.quote { font: italic 1rem/1.2 Georgia, serif; } @built-font +.eyebrow.uppercase { text-transform: uppercase; } @built-uppercase +`, + 'dist/index.html': ` + @built-angle +
@built-angle +`, + // Clean controls: what a correct Tailwind 4 build emits (theme.css's --font-mono, the base + // styles' --default-mono-font-family, utility definitions, nested variants) ... + 'dist/_astro/tw4.css': `@layer theme { + :root, :host { + --font-sans: "IBM Plex Sans", Arial, sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --color-purple-300: #422C70; + --color-heading-on-light: #000; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + html, :host { font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif); } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + } +} +@layer utilities { + .font-mono { font-family: var(--font-mono); } + .uppercase { text-transform: uppercase; } + .uppercase\\! { text-transform: uppercase !important; } + .md\\:uppercase { @media (width >= 48rem) { text-transform: uppercase; } } + .group-hover\\:uppercase { &:is(:where(.group):hover *) { @media (hover: hover) { text-transform: uppercase; } } } +} +:root { --mfb-font-mono: "IBM Plex Mono", monospace; --sg-angle-base: 13deg; --sg-angle-alt: 24deg; } +@property --sg-angle-base { syntax: ""; inherits: true; initial-value: 13deg; } +@property --sg-angle-alt { syntax: ""; inherits: true; initial-value: 24.0deg; } +`, + // ... and a minified Tailwind 3 build, where the word "uppercase" in a comment is enough for + // the scanner to emit .uppercase and its variants, and the preset's mono reaches code elements. + 'dist/_astro/tw3.css': '@font-face{font-family:"IBM Plex Mono";font-weight:400;src:url(data:font/woff2;base64,d09GMgAB) format("woff2")}' + + 'code,kbd,samp,pre{font-family:"IBM Plex Mono",monospace;font-feature-settings:normal;font-size:1em}' + + '.\\!uppercase{text-transform:uppercase!important}.uppercase{text-transform:uppercase}.font-mono{font-family:"IBM Plex Mono",monospace}' + + '@media (min-width:768px){.md\\:uppercase{text-transform:uppercase}}.\\[\\&\\>\\*\\]\\:uppercase>*{text-transform:uppercase}' + + '.group:hover .group-hover\\:uppercase{text-transform:uppercase}\n', +}; + +// Files that must not be reported at all. +const CLEAN = new Set(['src/ui/Clean.astro', 'src/styles/legacy.scss', 'dist/_astro/tw4.css', 'dist/_astro/tw3.css']); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-check-test-')); +const failures = []; +const run = (args, cwd = tmp) => spawnSync(process.execPath, [CHECK, ...args], { cwd, encoding: 'utf8' }); +const parse = (out) => + out.split('\n').map((l) => l.match(/^brand-check: (?:warning: )?([^:\s][^:]*?)(?::(\d+))?: \[([\w-]+)\]/)).filter(Boolean) + .map((m) => ({ file: m[1], line: m[2] ? Number(m[2]) : null, rule: m[3] })); + +try { + const expected = []; + for (const [file, text] of Object.entries(PLANTED)) { + const lines = text.split('\n'); + const clean = lines.map((l, i) => { + const m = l.match(/\s+@([\w-]+)$/); + if (m) expected.push({ file, line: i + 1, rule: m[1] }); + return m ? l.slice(0, m.index) : l; + }); + fs.mkdirSync(path.join(tmp, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(tmp, file), clean.join('\n')); + } + + // 1. Every planted violation is reported on its line, and nothing in a clean file is. + const r = run([]); + const got = parse(r.stderr + r.stdout); + if (r.status !== 1) failures.push(`planted tree: exit ${r.status}, expected 1`); + for (const e of expected) { + if (!got.some((g) => g.file === e.file && g.line === e.line && g.rule === e.rule)) failures.push(`missed: ${e.file}:${e.line} [${e.rule}]`); + } + for (const g of got) { + if (CLEAN.has(g.file)) failures.push(`false positive: ${g.file}:${g.line} [${g.rule}]`); + else if (g.line === null || !expected.some((e) => e.file === g.file && e.line === g.line)) failures.push(`unexpected: ${g.file}:${g.line} [${g.rule}]`); + } + + // 2. The clean files alone pass: the clean source, and the Tailwind 3 and 4 builds. + fs.rmSync(path.join(tmp, 'dist/_astro/drift.css')); + fs.rmSync(path.join(tmp, 'dist/index.html')); + const clean = run(['--src', 'src/ui/Clean.astro']); + if (clean.status !== 0) failures.push(`clean files: exit ${clean.status}, expected 0: ${clean.stderr.trim()}`); + + // 3. A build without the angle is reported. + const tw4 = path.join(tmp, 'dist/_astro/tw4.css'); + const tw4Text = fs.readFileSync(tw4, 'utf8'); + fs.writeFileSync(tw4, tw4Text.replace(/--sg-angle-base: 13deg;|@property --sg-angle-base[^}]*\}/g, '')); + if (!parse(run(['--src', 'src/ui/Clean.astro']).stderr).some((g) => g.rule === 'built-angle' && g.line === null)) failures.push('missed: built CSS without --sg-angle-base [built-angle]'); + fs.writeFileSync(tw4, tw4Text); + + // 4. An allowlist entry silences its findings; an unused entry is reported; a reason is required. + fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([ + { file: 'src/ui/Card.tsx', reason: 'test: a third-party component' }, + { file: 'src/styles/site.css', rule: 'color-literal', match: '422C70', reason: 'test' }, + { file: 'src/none.css', reason: 'test: unused' }, + ])); + const a = run([]); + const ga = parse(a.stderr); + if (ga.some((g) => g.file === 'src/ui/Card.tsx')) failures.push('allowlist: a whole-file entry did not silence the file'); + if (ga.some((g) => g.file === 'src/styles/site.css' && g.line === 3)) failures.push('allowlist: a rule and match entry did not silence its finding'); + if (!ga.some((g) => g.file === 'src/styles/site.css' && g.line === 4)) failures.push('allowlist: a rule and match entry silenced another finding'); + if (!/allowlist entry 3 .*matched nothing/.test(a.stderr)) failures.push('allowlist: an unused entry was not reported'); + fs.writeFileSync(path.join(tmp, 'brand-check.allow.json'), JSON.stringify([{ file: 'src/x.css' }])); + if (run([]).status !== 2) failures.push('allowlist: an entry without a reason was accepted'); + fs.rmSync(path.join(tmp, 'brand-check.allow.json')); + + // 5. --warn reports a rule without failing; the default warnings fail only under --error. + const w = run(['--src', 'src/ui', '--warn', 'off-palette-utility,uppercase,font-family,weight,angle,raw-value,translucent-shape,gradient,named-color,color-filter,color-literal,hand-rolled-highlighter,clip-path']); + if (w.status !== 0) failures.push(`--warn: exit ${w.status}, expected 0: ${w.stderr.split('\n').filter((l) => !l.includes('warning')).join(' ')}`); + fs.mkdirSync(path.join(tmp, 'src2')); + fs.writeFileSync(path.join(tmp, 'src2/a.css'), '.x { padding: 24px; }\n.y { letter-spacing: 0.1em; }\n'); + const dw = run(['--src', 'src2', '--source-only']); + if (dw.status !== 0 || !/warning: src2\/a\.css:1: \[raw-spacing\]/.test(dw.stderr) || !/warning: src2\/a\.css:2: \[letter-spacing\]/.test(dw.stderr)) + failures.push(`default warning: exit ${dw.status}, expected 0 with a raw-spacing and a letter-spacing warning`); + if (run(['--src', 'src2', '--source-only', '--error', 'raw-spacing']).status !== 1) failures.push('--error raw-spacing did not fail'); + if (run(['--src', 'src2', '--source-only', '--error', 'letter-spacing']).status !== 1) failures.push('--error letter-spacing did not fail'); + + // 6. No built CSS, and --source-only. + fs.rmSync(path.join(tmp, 'dist'), { recursive: true }); + if (!parse(run(['--src', 'src/ui/Clean.astro']).stderr).some((g) => g.rule === 'built-missing')) failures.push('missed: no built CSS [built-missing]'); + if (run(['--src', 'src/ui/Clean.astro', '--source-only']).status !== 0) failures.push('--source-only still read the build'); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} + +if (failures.length) { + console.error('brand-check test: FAIL'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log(`brand-check test: OK (${Object.keys(PLANTED).length} files, ${CLEAN.size} of them clean controls, every rule, the allowlist, --warn, --error, --source-only)`); diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index b6e0472..5441892 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -9,11 +9,14 @@ // template. Rules cannot be generated and should not be; values must be, so the spec can never // disagree with the tokens it says it comes from. // -// brand-spec.md is committed and public, but it is NOT in package.json's `files`, so it is not -// part of what consumers install. +// brand-spec.md is committed and public, and it is in package.json's `files` (and exports, as +// ./brand-spec.md), so consumers install it with the package and read the rules at the version +// they pin. // // It refuses to write when: // - a token the prose depends on is absent (a blank where a brand value belongs); +// - a website source is not "myfirstbitcoin.org@, ", or names a file, a +// directory or a line of the site's private source repository; // - the template names a placeholder this script does not fill, or leaves one unfilled; // - the result contains an em-dash, a machine path or a date stamp (the public file carries none). @@ -75,15 +78,124 @@ const values = { ANGLE: need('geometry', 'angle-base').replace('deg', DEGREE), BASE_RATIO: need('geometry', 'base-h-ratio'), HALFTONE_HI: need('geometry', 'halftone-highlight'), + HALFTONE_HI_FULL: need('geometry', 'halftone-highlight-full'), HALFTONE_LO: need('geometry', 'halftone-shadow'), + HL_COVERAGE: need('geometry', 'highlighter-coverage'), + HL_ASPECT: need('geometry', 'highlighter-aspect'), + HL_OFFSET: need('geometry', 'highlighter-offset'), + HL_SHAPE: need('geometry', 'highlighter-shape'), ORANGE: need('color', 'orange-300'), - GRAY900: need('color', 'gray-900'), }; +// ---- sources ---- +// Every value in the new sections is shown with where it comes from: a Brand Book node, the +// website CSS name it was declared from (where the book is silent), or this package's +// recommendation. tokens.json carries the full note; the spec shows the short form. +// A website source is "myfirstbitcoin.org@, ", optionally followed by where an +// inline property is used in parentheses. The site's source repository is private, so a source +// that names a file, a directory or a line of it is refused. +const SITE_RE = /^myfirstbitcoin\.org@([0-9a-f]{7,40}), (.+?)(?: \((.+)\))?$/; +const PRIVATE_PATH_RE = /\/|\w\.(?:astro|tsx|jsx|ts|js|mjs|css|scss)(?![\w-])|:\d/; +const siteCommits = new Set(); +const sourceOf = (group, name) => { + const tok = (tokens[group] || {})[name]; + const m = tok?.$extensions?.mfb; + if (!m?.source) { + missing.push(`${group}.${name} ($extensions.mfb.source)`); + return ''; + } + if (m.source.startsWith('figma:')) return `Brand Book \`${m.source.slice(6)}\`${m.sourceKind === 'measured' ? ', measured' : ''}`; + if (m.source.startsWith('myfirstbitcoin.org')) { + const site = m.source.match(SITE_RE); + if (!site || PRIVATE_PATH_RE.test(m.source.replace(/^myfirstbitcoin\.org@/, ''))) { + missing.push(`${group}.${name} (a website source as myfirstbitcoin.org@, , with no file or line)`); + return ''; + } + siteCommits.add(site[1]); + return `website \`${site[2]}\`${site[3] ? ` (${site[3]})` : ''}`; + } + if (m.source === 'declared') return "this package's recommendation"; + return `\`${m.source}\``; +}; +const paletteName = (v) => (typeof v === 'string' ? v.match(/^\{color\.([\w-]+)\}$/)?.[1] : undefined); +const colorOf = (where, v) => { + const name = paletteName(v); + if (!name || !tokens.color[name]) { + missing.push(`${where} (an alias of a palette color)`); + return ''; + } + return `${name} (\`${tokens.color[name].$value}\`)`; +}; +const shadowText = (where, v) => { + const color = colorOf(`${where}.color`, v?.color).replace(/ \(.*\)$/, ''); + return `\`${v?.offsetX} ${v?.offsetY} ${v?.blur} ${v?.spread}\`, ${color} at ${Math.round(Number(v?.opacity) * 100)}%`; +}; +const rows = (group, required, fmt) => groupEntries(group, required).map(([name, tok]) => fmt(name, tok)).join('\n'); + values.COLOR_ROWS = groupEntries('color', true) - .map(([name, tok]) => `| ${name} | ${tok.$value} | ${cell(tok.$description)} |`) + .map(([name, tok]) => { + const cmyk = tok.$extensions?.mfb?.cmyk; + return `| ${name} | ${tok.$value} | ${Array.isArray(cmyk) ? cmyk.join(' ') : ''} | ${cell(tok.$description)} |`; + }) .join('\n'); +values.BLACK = need('color', 'black'); +values.HALFTONE_SCALE = need('geometry', 'halftone-scale'); +values.PATTERN_STEP = need('geometry', 'pattern-row-step'); +values.PATTERN_GAP = need('geometry', 'pattern-row-gap'); +values.PARA_EDGE = need('geometry', 'para-edge-ratio'); +values.SPACE_HEADING_BODY = need('space', 'heading-to-body'); +values.SPACE_HEADING_BODY_SRC = sourceOf('space', 'heading-to-body'); +values.SPACE_QUOTE_LABEL = need('space', 'quote-to-label'); +values.SPACE_QUOTE_LABEL_SRC = sourceOf('space', 'quote-to-label'); +values.LOGO_MIN_W = need('logo', 'min-width'); +values.LOGO_MIN_W_PRINT = need('logo', 'min-width-print'); +values.LOGO_MIN_H = need('logo', 'min-height'); +values.LOGO_ASPECT = need('logo', 'aspect'); +values.LOGO_CLEAR = need('logo', 'clearspace'); +values.LOGO_MARGIN = need('logo', 'edge-margin'); +values.LOGO_ICON_H = need('geometry', 'logo-icon-h'); +for (const surface of ['light', 'dark', 'orange']) { + values[`LOGO_ON_${surface.toUpperCase()}`] = paletteName(need('colorRole', `logo-on-${surface}`)) || ''; +} + +values.TYPE_ROWS = groupEntries('fontSize', true) + .map(([level, tok]) => { + const lh = need('lineHeight', level); + const ls = need('letterSpacing', level); + const fluid = tokens.fontSizeFluid?.[level]; + return `| ${level} | ${tok.$value} | ${lh} | ${ls} | ${fluid ? `\`${fluid.$value}\` (${sourceOf('fontSizeFluid', level)})` : `fixed, ${tok.$value}`} |`; + }) + .join('\n'); + +values.BODY_LH = need('lineHeight', 'body'); +values.BODY_LONG_LH = need('lineHeight', 'body-long'); +values.BODY_LONG_SRC = sourceOf('lineHeight', 'body-long'); + +values.COLOR_ROLE_ROWS = rows('colorRole', true, (name, tok) => + `| \`${name}\` | ${colorOf(`colorRole.${name}`, tok.$value)} | ${cell(tok.$description)} | ${sourceOf('colorRole', name)} |`); +values.SHAPE_TONE_ITEMS = rows('shapeTone', true, (name, tok) => + `- On ${name}: ${colorOf(`shapeTone.${name}`, tok.$value).replace(/ \(.*\)$/, '')} (${sourceOf('shapeTone', name)})`); + +const valueRow = (group, prefix) => (name, tok) => + `| \`${prefix}${name}\` | \`${tok.$value}\` | ${cell(tok.$description)} | ${sourceOf(group, name)} |`; +values.SPACE_SCALE_ITEMS = groupEntries('spaceScale', true).map(([k, tok]) => `${k}: ${tok.$value}`).join(', '); +const scaleSources = groupEntries('spaceScale', true).map(([k]) => sourceOf('spaceScale', k)); +values.SPACE_SCALE_SRC = scaleSources.length ? `${scaleSources[0]} to ${scaleSources.at(-1).replace(/^website /, '')}` : ''; +values.SPACE_ROWS = rows('space', true, valueRow('space', '--mfb-space-')); +values.CONTAINER_ROWS = rows('container', true, valueRow('container', '--mfb-container-')); +values.RADIUS_ROWS = rows('radius', true, valueRow('radius', '--mfb-radius-')); +values.SHADOW_ROWS = rows('shadow', true, (name, tok) => + `| \`--mfb-shadow-${name}\` | ${shadowText(`shadow.${name}`, tok.$value)} | ${cell(tok.$description)} | ${sourceOf('shadow', name)} |`); +values.EASING_ROWS = rows('easing', true, (name, tok) => + `| \`--mfb-ease-${name}\` | \`cubic-bezier(${[].concat(tok.$value).join(', ')})\` | ${cell(tok.$description)} | ${sourceOf('easing', name)} |`); +values.DURATION_ROWS = rows('duration', true, valueRow('duration', '--mfb-duration-')); +values.Z_ROWS = rows('zIndex', true, valueRow('zIndex', '--mfb-z-')); +values.RATIO_ROWS = rows('mediaRatio', true, valueRow('mediaRatio', '--mfb-ratio-')); +values.UI_ROWS = rows('ui', true, valueRow('ui', '--mfb-ui-')); +values.SITE_COMMIT = [...siteCommits].join(', '); +if (siteCommits.size !== 1) missing.push(`website sources: expected one commit across the declared tokens, found ${siteCommits.size} (${values.SITE_COMMIT || 'none'})`); + const gradients = groupEntries('gradient', false); values.GRADIENT_ITEMS = gradients.length ? gradients diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 134b949..272adec 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -17,9 +17,10 @@ // It does NOT write package.json: the version, exports and files list are edited by hand in a // pull request, and a version bump is what releases (see .github/workflows/design.yml). // -// The emitted header strings ("by build-design-package.mjs", "Canon: shared/supergraphics.css") -// are kept exactly as they were before the build moved into this repository, so the published -// files stay byte-identical. +// The emitted header strings ("by build-design-package.mjs") are kept as they were before the +// build moved into this repository, so a release changes only what it means to change. The +// supergraphics.css header names the canon in this repository (src/supergraphics.canon.css) +// since v1.4.0. import fs from 'fs'; import path from 'path'; @@ -55,6 +56,10 @@ const GEOM_VARS = { 'aspect': '--sg-aspect', 'logo-icon-h': '--sg-logo-icon-h', 'base-h-ratio': '--sg-base-h-ratio', + 'highlighter-coverage': '--sg-highlighter-coverage', + 'highlighter-aspect': '--sg-highlighter-aspect', + 'highlighter-offset': '--sg-highlighter-offset', + 'highlighter-shape': '--sg-highlighter-shape', }; const geomMismatch = []; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { @@ -78,6 +83,299 @@ if (geomMismatch.length) { process.exit(1); } +// Geometry the canon does not read yet. These tokens record what the Brand Book draws, but the +// canon still uses other values (a 10px pattern gap, 1.25 and 1.55 row steps), so they are NOT +// emitted as --sg-* variables: a prelude value the canon ignores would be a package that +// disagrees with itself. Each one is listed here with its reason, and the build refuses any +// geometry token that is neither gated against the canon (GEOM_VARS), a halftone color, nor +// listed here, so no new geometry reaches the package unseen. +const GEOM_TOKEN_ONLY = { + 'para-edge-ratio': 'the base shape; the canon sizes parallelograms by their container', + 'pattern-row-step': 'the canon steps progressive rows by 1.25 and 1.55', + 'pattern-row-gap': 'the canon separates pattern rows by 10px', + 'halftone-scale': 'a setting of the tool that makes the halftone, not a CSS value', +}; + +// ---- GROUP MAP AND NAMING GATE ---- +// One table: each tokens.json group added after v1.3.0 -> its Tailwind 4 theme.css prefix -> its +// Tailwind 3 theme.extend key and key prefix -> its brand.css prefix. null means "not emitted +// there". Keys in tokens.json are bare; the prefix is added per output. The build refuses a +// tokens.json group that is in neither this table nor ORIGINAL_GROUPS, so a group added later +// cannot leak into a Tailwind namespace unseen. +// +// Naming rules (they keep every Tailwind default intact in Tailwind 3 and 4): +// - every new utility key starts with mfb-, except type levels (text-h1-fluid, leading-h1, +// tracking-h1) and color roles (text-heading-on-light), whose names no Tailwind default has; +// - no breakpoints, screens, bare roots (--spacing, --radius, --shadow, --container), --default-* +// or sub-properties of an existing key are emitted; +// - existing keys (colors, fontSize, fontWeight) are never changed. +const ORIGINAL_GROUPS = ['color', 'gradient', 'geometry', 'fontFamily', 'fontWeight', 'fontSize']; +const GROUP_MAP = { + // Tailwind 4 namespace Tailwind 3 key key prefix suffix brand.css prefix + lineHeight: { tw4: '--leading-', tw3: 'lineHeight', twKey: '', suffix: '', css: '--mfb-leading-' }, + letterSpacing: { tw4: '--tracking-', tw3: 'letterSpacing', twKey: '', suffix: '', css: '--mfb-tracking-' }, + fontSizeFluid: { tw4: '--text-', tw3: 'fontSize', twKey: '', suffix: '-fluid', css: '--mfb-size-' }, + colorRole: { tw4: '--color-', tw3: 'colors', twKey: '', suffix: '', css: '--mfb-' }, + shapeTone: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-shape-on-' }, + logo: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-logo-' }, + spaceScale: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-space-' }, + space: { tw4: '--spacing-', tw3: 'spacing', twKey: 'mfb-', suffix: '', css: '--mfb-space-' }, + radius: { tw4: '--radius-', tw3: 'borderRadius', twKey: 'mfb-', suffix: '', css: '--mfb-radius-' }, + shadow: { tw4: '--shadow-', tw3: 'boxShadow', twKey: 'mfb-', suffix: '', css: '--mfb-shadow-' }, + easing: { tw4: '--ease-', tw3: 'transitionTimingFunction', twKey: 'mfb-', suffix: '', css: '--mfb-ease-' }, + duration: { tw4: '--transition-duration-', tw3: 'transitionDuration', twKey: 'mfb-', suffix: '', css: '--mfb-duration-' }, + container: { tw4: '--container-', tw3: 'maxWidth', twKey: 'mfb-', suffix: '', css: '--mfb-container-' }, + zIndex: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-z-' }, + mediaRatio: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ratio-' }, + ui: { tw4: null, tw3: null, twKey: '', suffix: '', css: '--mfb-ui-' }, +}; +// Keys of a mapped group that stay out of Tailwind, with the reason. +const TW_SKIP = { radius: { pill: 'Tailwind already has rounded-full' } }; + +// Tailwind defaults a new key must never equal (Tailwind 4.3.3 theme and Tailwind 3.4.19 config). +const TW4_DEFAULTS = { + '--radius-': 'xs sm md lg xl 2xl 3xl 4xl', + '--shadow-': '2xs xs sm md lg xl 2xl inner', + '--ease-': 'in out in-out', + '--container-': '3xs 2xs xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl', + '--leading-': 'tight snug normal relaxed loose', + '--tracking-': 'tighter tight normal wide wider widest', + '--text-': 'xs sm base lg xl 2xl 3xl 4xl 5xl 6xl 7xl 8xl 9xl', + '--transition-duration-': '', + '--spacing-': '', + '--color-': '', +}; +const TW3_DEFAULTS = { + borderRadius: 'none sm DEFAULT md lg xl 2xl 3xl full', + boxShadow: 'sm DEFAULT md lg xl 2xl inner none', + transitionDuration: 'DEFAULT 0 75 100 150 200 300 500 700 1000', + transitionTimingFunction: 'DEFAULT linear in out in-out', + lineHeight: '3 4 5 6 7 8 9 10 none tight snug normal relaxed loose', + letterSpacing: 'tighter tight normal wide wider widest', + maxWidth: '0 none xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl full min max fit prose', + fontSize: 'xs sm base lg xl 2xl 3xl 4xl 5xl 6xl 7xl 8xl 9xl', + spacing: 'px', + colors: 'inherit current transparent black white', +}; +const STATIC_KEYWORDS = new Set('full none px auto screen min max fit prose DEFAULT inner linear inherit current transparent'.split(' ')); +const TW_COLOR_FAMILIES = ('red orange amber yellow lime green emerald teal cyan sky blue indigo violet purple fuchsia pink ' + + 'rose slate gray zinc neutral stone mauve olive mist taupe').split(' '); + +// Value helpers for the new groups. A role or a shape tone is an alias of a palette color, and a +// shadow's color is too: the build resolves the alias and refuses one that is not in tokens.color. +const gateProblems = []; +const aliasOf = (v) => (typeof v === 'string' ? v.match(/^\{color\.([\w-]+)\}$/)?.[1] : undefined); +const paletteRef = (where, v) => { + const name = aliasOf(v); + if (!name || !tokens.color[name]) { + gateProblems.push(`${where}: "${typeof v === 'string' ? v : JSON.stringify(v)}" is not an alias of a palette color ({color.})`); + return null; + } + return name; +}; +const pct = (opacity) => `${Math.round(Number(opacity) * 100)}%`; +const shadowValue = (where, v, target) => { + const name = paletteRef(`${where}.color`, v?.color); + const o = Number(v?.opacity); + if (!(o > 0 && o <= 1)) gateProblems.push(`${where}.opacity: ${v?.opacity} is not between 0 and 1`); + if (!name) return ''; + const color = target === 'css' ? `var(--mfb-${name})` : tokens.color[name].$value; + return `${v.offsetX} ${v.offsetY} ${v.blur} ${v.spread} color-mix(in srgb, ${color} ${pct(o)}, transparent)`; +}; +const remOf = (px) => { + const m = String(px).match(/^(\d+(?:\.\d+)?)px$/); + return m ? `${Number(m[1]) / 16}rem` : null; +}; +// The value of a token of a mapped group, for one output: 'css' (brand.css: palette by var()), +// 'tw' (theme.css and tailwind.js: palette as a literal hex, containers in rem) or 'raw' +// (index.js: resolved values, containers in px as in tokens.json). +const valueFor = (group, key, tok, target) => { + const where = `${group}.${key}`; + if (group === 'colorRole' || group === 'shapeTone') { + const name = paletteRef(where, tok.$value); + if (!name) return ''; + return target === 'css' ? `var(--mfb-${name})` : tokens.color[name].$value; + } + if (group === 'shadow') return shadowValue(where, tok.$value, target === 'css' ? 'css' : 'tw'); + if (group === 'easing') { + const v = tok.$value; + if (!Array.isArray(v) || v.length !== 4 || v.some((n) => typeof n !== 'number')) { + gateProblems.push(`${where}: a cubicBezier token needs four numbers`); + return ''; + } + return `cubic-bezier(${v.join(', ')})`; + } + if (group === 'container' && target === 'tw') { + const rem = remOf(tok.$value); + if (!rem) gateProblems.push(`${where}: "${tok.$value}" is not a px value (Tailwind 4 sorts px containers before rem ones)`); + return rem || ''; + } + return target === 'raw' && typeof tok.$value === 'number' ? tok.$value : String(tok.$value); +}; + +// R1: every group is known. +for (const group of Object.keys(tokens).filter((k) => !k.startsWith('$'))) { + if (!ORIGINAL_GROUPS.includes(group) && !GROUP_MAP[group]) { + gateProblems.push(`tokens.json group "${group}" has no entry in GROUP_MAP (scripts/build-design-package.mjs): say where it is emitted before adding it`); + } +} +for (const group of Object.keys(GROUP_MAP)) { + if (!tokens[group] || typeof tokens[group] !== 'object') gateProblems.push(`GROUP_MAP names "${group}", which tokens.json lacks`); + for (const [k, tok] of Object.entries(tokens[group] || {})) { + if (k.startsWith('$')) gateProblems.push(`${group}.${k}: groups are flat; no $-keys inside a group`); + else if (!tok || typeof tok !== 'object' || !('$value' in tok)) gateProblems.push(`${group}.${k}: not a token (no $value)`); + } +} +// Every geometry token is gated against the canon, a halftone color, or listed as token-only. +for (const [k, tok] of Object.entries(tokens.geometry || {})) { + const halftoneColor = k.startsWith('halftone-') && tok.$type === 'color'; + if (!GEOM_VARS[k] && !halftoneColor && !GEOM_TOKEN_ONLY[k]) { + gateProblems.push(`geometry.${k}: add it to GEOM_VARS (gated against the canon) or to GEOM_TOKEN_ONLY with the reason it is not emitted`); + } +} +for (const k of Object.keys(GEOM_TOKEN_ONLY)) { + if (!tokens.geometry?.[k]) gateProblems.push(`GEOM_TOKEN_ONLY names geometry.${k}, which tokens.json lacks`); + if (new RegExp(`--sg-${k}\\s*:`).test(sgCanon)) gateProblems.push(`the canon declares --sg-${k}: move geometry.${k} from GEOM_TOKEN_ONLY to GEOM_VARS so it is gated`); +} + +// Type levels: line height, letter spacing and fluid sizes exist only for fontSize levels, and a +// fluid size grows up to the Brand Book size and no further (the cap is the book's, the floor +// and slope are the website's). A level variant (LEVEL_VARIANTS) is a second value for one +// level, named -, with the reason; its Tailwind key is a type level's, so it needs no +// mfb- prefix. +const LEVELS = Object.keys(tokens.fontSize || {}); +const LEVEL_VARIANTS = { + lineHeight: { 'body-long': 'body text in long reading (articles); the Brand Book sets only short body copy' }, +}; +for (const group of ['lineHeight', 'letterSpacing', 'fontSizeFluid']) { + for (const k of Object.keys(tokens[group] || {})) { + const variant = LEVEL_VARIANTS[group]?.[k]; + if (variant && LEVELS.includes(k.split('-')[0])) continue; + if (!LEVELS.includes(k)) gateProblems.push(`${group}.${k}: not a fontSize level (${LEVELS.join(', ')}) nor a level variant listed in LEVEL_VARIANTS`); + } +} +for (const [group, variants] of Object.entries(LEVEL_VARIANTS)) { + for (const k of Object.keys(variants)) { + if (!tokens[group]?.[k]) gateProblems.push(`LEVEL_VARIANTS names ${group}.${k}, which tokens.json lacks`); + } +} +for (const [k, tok] of Object.entries(tokens.fontSizeFluid || {})) { + const m = String(tok.$value).match(/^clamp\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)\)$/); + const cap = tokens.fontSize?.[k]?.$value; + if (!m) gateProblems.push(`fontSizeFluid.${k}: "${tok.$value}" is not clamp(min, preferred, max)`); + else if (m[3].trim() !== cap) gateProblems.push(`fontSizeFluid.${k}: its maximum ${m[3].trim()} is not the Brand Book size fontSize.${k} (${cap})`); +} + +// Emitted names: Tailwind 4 variables, Tailwind 3 keys and brand.css variables. +const tw4Lines = []; // [name, value] +const tw3Extend = {}; // key -> { name: value } +const cssLines = []; // [name, value] +const indexExports = {}; // group -> { key: value } +const emitGroup = (group) => { + const map = GROUP_MAP[group]; + const skip = TW_SKIP[group] || {}; + for (const [k, tok] of Object.entries(tokens[group] || {})) { + cssLines.push([`${map.css}${k}${map.suffix}`, valueFor(group, k, tok, 'css')]); + (indexExports[group] ||= {})[k] = valueFor(group, k, tok, 'raw'); + if (skip[k]) continue; + if (map.tw4) tw4Lines.push([`${map.tw4}${map.twKey}${k}${map.suffix}`, valueFor(group, k, tok, 'tw')]); + if (map.tw3) ((tw3Extend[map.tw3] ||= {})[`${map.twKey}${k}${map.suffix}`] = valueFor(group, k, tok, 'tw')); + } +}; +// One call per group, in the order they appear in the outputs. +emitGroup('colorRole'); +emitGroup('shapeTone'); +emitGroup('fontSizeFluid'); +emitGroup('lineHeight'); +emitGroup('letterSpacing'); +emitGroup('logo'); +emitGroup('spaceScale'); +emitGroup('space'); +emitGroup('radius'); +emitGroup('shadow'); +emitGroup('easing'); +emitGroup('duration'); +emitGroup('container'); +emitGroup('zIndex'); +emitGroup('mediaRatio'); +emitGroup('ui'); +for (const group of Object.keys(GROUP_MAP)) { + if (!indexExports[group] && Object.keys(tokens[group] || {}).length) gateProblems.push(`GROUP_MAP group "${group}" is never emitted (add an emitGroup call)`); +} + +// (a), (b), (c): no Tailwind default, static keyword or numeric key; (e): no bare root, breakpoint, +// default or sub-property. +const defaultsFor = (table, prefix) => new Set(String(table[prefix] ?? '').split(' ').filter(Boolean)); +for (const [name] of tw4Lines) { + const ns = Object.keys(TW4_DEFAULTS).find((p) => name.startsWith(p)); + if (!ns) { gateProblems.push(`${name}: not in a Tailwind 4 namespace this build may emit`); continue; } + const key = name.slice(ns.length); + if (!key) gateProblems.push(`${name}: a bare Tailwind 4 root changes a default utility`); + if (defaultsFor(TW4_DEFAULTS, ns).has(key)) gateProblems.push(`${name}: "${key}" is a Tailwind 4 default in ${ns}*`); + if (STATIC_KEYWORDS.has(key) || /^\d/.test(key)) gateProblems.push(`${name}: "${key}" is a static keyword or starts with a digit`); + if (key.includes('--')) gateProblems.push(`${name}: a sub-property (--) changes an existing utility`); + if (/^--(breakpoint|default|animate)-/.test(name)) gateProblems.push(`${name}: breakpoints, defaults and animations are never emitted`); + if (ns === '--color-' && (!/-on-(light|dark|orange)$/.test(key) || TW_COLOR_FAMILIES.some((f) => key.startsWith(`${f}-`)) || tokens.color[key])) { + gateProblems.push(`${name}: a color role is named -on- and is not a palette or Tailwind color`); + } +} +for (const [twKey, entries] of Object.entries(tw3Extend)) { + const defaults = new Set(String(TW3_DEFAULTS[twKey] ?? '').split(' ').filter(Boolean)); + for (const key of Object.keys(entries)) { + if (defaults.has(key)) gateProblems.push(`tailwind.js ${twKey}.${key}: a Tailwind 3 default`); + if (STATIC_KEYWORDS.has(key) || /^\d/.test(key)) gateProblems.push(`tailwind.js ${twKey}.${key}: a static keyword or starts with a digit`); + if (twKey === 'fontSize' && (tokens.fontSize || {})[key]) gateProblems.push(`tailwind.js fontSize.${key}: an existing key is frozen in 1.x`); + if (twKey === 'colors' && (tokens.color[key] || TW_COLOR_FAMILIES.includes(key))) gateProblems.push(`tailwind.js colors.${key}: an existing color key is frozen in 1.x`); + } +} +// Font weights. fontWeight is an original group, emitted outside GROUP_MAP (theme.css +// --font-weight-*, the Tailwind 3 preset's fontWeight, both extending Tailwind's scale). A key +// Tailwind already has (normal, medium, semibold...) may only restate Tailwind's own value, so no +// token edit can change what font-medium means in a project. Tailwind 3.4.19 and 4.3.3 agree. +const TW_FONT_WEIGHT = { thin: 100, extralight: 200, light: 300, normal: 400, medium: 500, semibold: 600, bold: 700, extrabold: 800, black: 900 }; +const weightKeys = Object.entries(tokens.fontWeight || {}).map(([k, tok]) => [k, tok.$value]); +if (tokens.fontWeight?.regular && !tokens.fontWeight.normal) weightKeys.push(['normal', tokens.fontWeight.regular.$value]); +for (const [k, v] of weightKeys) { + if (k in TW_FONT_WEIGHT && Number(v) !== TW_FONT_WEIGHT[k]) { + gateProblems.push(`fontWeight.${k}: ${v} would change Tailwind's font-${k} (${TW_FONT_WEIGHT[k]}); a weight key Tailwind already has keeps Tailwind's value`); + } +} +// (d) disjointness. +const keysOf = (group) => Object.keys(tokens[group] || {}); +const overlap = (a, b) => a.filter((k) => b.includes(k)); +const textKeys = [...LEVELS, ...keysOf('fontSizeFluid').map((k) => `${k}-fluid`)]; +for (const [a, b, why] of [ + [keysOf('colorRole'), textKeys, 'color roles and text sizes'], + [keysOf('colorRole'), keysOf('shadow'), 'color roles and shadows'], + [keysOf('shadow'), Object.keys(tokens.color), 'shadows and palette colors'], + [keysOf('space'), keysOf('container'), 'spacing and containers (Tailwind 4 max-w reads --spacing-* first)'], + [keysOf('space'), keysOf('spaceScale'), 'semantic spacing and the spacing scale (both are --mfb-space-*)'], +]) { + for (const k of overlap(a, b)) gateProblems.push(`"${k}" is a key of both ${why}`); +} +// brand.css names are unique, including against the original variables. +const originalCss = [ + ...Object.keys(tokens.color).map((k) => `--mfb-${k}`), + ...Object.keys(tokens.fontSize || {}).map((k) => `--mfb-size-${k}`), + ...Object.keys(tokens.fontWeight || {}).map((k) => `--mfb-weight-${k}`), + ...Object.keys(tokens.fontFamily || {}).map((k) => `--mfb-font-${k}`), + '--mfb-font-sans', '--mfb-gradient-brand', '--mfb-gradient', + ...Object.keys(tokens.geometry || {}).filter((k) => k.startsWith('halftone-')).map((k) => `--mfb-${k}`), +]; +const seenCss = new Set(originalCss); +for (const [name, value] of cssLines) { + if (seenCss.has(name)) gateProblems.push(`brand.css ${name}: emitted twice`); + seenCss.add(name); + if (name === '--mfb-side') gateProblems.push('brand.css --mfb-side: a name projects define locally'); + if (/#[0-9a-f]{3,8}\b/i.test(value)) gateProblems.push(`brand.css ${name}: a new variable references the palette with var(), never a hex (${value})`); +} + +if (gateProblems.length) { + console.error('REFUSING TO BUILD: the new token groups break a naming or value rule.'); + for (const p of new Set(gateProblems)) console.error(` - ${p}`); + process.exit(1); +} + fs.mkdirSync(OUT_DIR, { recursive: true }); const written = []; const write = (name, content) => { @@ -103,17 +401,76 @@ for (const [k, tok] of Object.entries(tokens.fontFamily || {})) fontFamily[k] = if (fontFamily.body) fontFamily.sans = fontFamily.body; const fontSize = {}; for (const [k, tok] of Object.entries(tokens.fontSize || {})) fontSize[k] = tok.$value; +// Font weights as numbers (400, 500, 600). Tailwind calls 400 `normal`, tokens.json calls it +// `regular`: the Tailwind outputs carry both names with the same value (as `sans` mirrors +// `body` above), so `font-regular` and `font-normal` both give the brand's regular weight. +// The weights EXTEND Tailwind's scale; they never replace it, so font-bold and the rest keep working. +const fontWeight = {}; +for (const [k, tok] of Object.entries(tokens.fontWeight || {})) fontWeight[k] = tok.$value; +const twFontWeight = {}; +for (const [k, v] of Object.entries(fontWeight)) twFontWeight[k] = String(v); +if (fontWeight.regular !== undefined && !('normal' in fontWeight)) twFontWeight.normal = String(fontWeight.regular); const gradient = tokens.gradient?.brand?.$value; +// Halftone colors (geometry group, $type color): book-specified values that are deliberately +// NOT palette tokens, so they get their own --mfb-halftone-* names and stay out of the +// Tailwind color utilities. supergraphics.css reads them in .sg-halftone-cutout. +const halftone = Object.entries(tokens.geometry || {}) + .filter(([k, tok]) => k.startsWith('halftone-') && tok.$type === 'color') + .map(([k, tok]) => [`--mfb-${k}`, tok.$value]); const ff = (arr) => arr.map((f) => (/\s/.test(f) ? `"${f}"` : f)).join(', '); +// The supergraphics prelude (written in section 4b) is assembled here, before any file is +// written, so a canon that reads a variable the prelude lacks stops the build with nothing written. +const SG_PRELUDE_GROUPS = ['colorRole', 'lineHeight', 'letterSpacing']; +let sgPrelude = + '/* Generated by build-design-package.mjs - DO NOT EDIT BY HAND.\n' + + ' Canon: src/supergraphics.canon.css + token prelude from tokens.json.\n' + + ' Geometry below is asserted equal to the canon at build time; a mismatch fails the build. */\n' + + ':root {\n'; +for (const [name, tok] of Object.entries(tokens.color)) sgPrelude += ` --mfb-${name}: ${tok.$value};\n`; +if (gradient) sgPrelude += ` --mfb-gradient: ${gradient};\n`; +// The canon's type classes (.sg-headline, .sg-cta ...) read the font, size and weight +// variables, so the prelude carries them. Deprecated families (mono) are left out: nothing in +// the canon uses them, and brand.css still serves them for compatibility. +const isDeprecated = (tok) => /^DEPRECATED/i.test(String(tok?.$status || '')); +for (const [k, v] of Object.entries(fontFamily)) { + const tok = tokens.fontFamily[k] || tokens.fontFamily.body; // `sans` mirrors body + if (!isDeprecated(tok)) sgPrelude += ` --mfb-font-${k}: ${ff(v)};\n`; +} +for (const [k, tok] of Object.entries(tokens.fontSize || {})) sgPrelude += ` --mfb-size-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(fontWeight)) sgPrelude += ` --mfb-weight-${k}: ${v};\n`; +for (const [cssVar, v] of halftone) sgPrelude += ` ${cssVar}: ${v};\n`; +for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) { + sgPrelude += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; +} +// The canon's text colors, line heights and letter spacing read the color roles and the type +// relationships (the Brand Book's), so the prelude carries those groups as brand.css writes them. +for (const group of SG_PRELUDE_GROUPS) { + const map = GROUP_MAP[group]; + for (const [k, tok] of Object.entries(tokens[group] || {})) sgPrelude += ` ${map.css}${k}${map.suffix}: ${valueFor(group, k, tok, 'css')};\n`; +} +sgPrelude += '}\n\n'; +// Every --mfb-* variable the canon reads must be in the prelude, so the file stays standalone. +const preludeVars = new Set([...sgPrelude.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1])); +const unset = [...new Set([...sgCanon.matchAll(/var\((--mfb-[\w-]+)/g)].map((m) => m[1]))].filter((v) => !preludeVars.has(v)); +if (unset.length) { + console.error('REFUSING TO BUILD: the canon reads variables the supergraphics prelude does not set:'); + for (const v of unset) console.error(` - ${v}`); + process.exit(1); +} + // ---- 1. tailwind.js - Tailwind 3 preset (also usable in TW4 via @config) ---- +// The new groups (GROUP_MAP) extend Tailwind's scales under their own keys; colors and fontSize +// keep every existing key and value, and gain only the color roles and the fluid sizes. const preset = { theme: { extend: { - colors: { ...mfb, mfb }, // both conventions: bg-purple-400 and bg-mfb-purple-400 + colors: { ...mfb, mfb, ...(tw3Extend.colors || {}) }, // both conventions: bg-purple-400 and bg-mfb-purple-400; roles at the top level fontFamily, - fontSize, + fontSize: { ...fontSize, ...(tw3Extend.fontSize || {}) }, + fontWeight: twFontWeight, ...(gradient ? { backgroundImage: { 'brand-gradient': gradient } } : {}), + ...Object.fromEntries(Object.entries(tw3Extend).filter(([k]) => k !== 'colors' && k !== 'fontSize')), }, }, }; @@ -130,6 +487,8 @@ for (const [name, tok] of Object.entries(tokens.color)) { } for (const [k, v] of Object.entries(fontFamily)) theme += ` --font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) theme += ` --text-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(twFontWeight)) theme += ` --font-weight-${k}: ${v};\n`; +for (const [name, v] of tw4Lines) theme += ` ${name}: ${v};\n`; theme += '}\n'; write('theme.css', theme); @@ -139,7 +498,10 @@ for (const [name, tok] of Object.entries(tokens.color)) css += ` --mfb-${name}: if (gradient) css += ` --mfb-gradient-brand: ${gradient};\n`; for (const [k, v] of Object.entries(fontFamily)) css += ` --mfb-font-${k}: ${ff(v)};\n`; for (const [k, tok] of Object.entries(tokens.fontSize || {})) css += ` --mfb-size-${k}: ${tok.$value};\n`; +for (const [k, v] of Object.entries(fontWeight)) css += ` --mfb-weight-${k}: ${v};\n`; +for (const [cssVar, v] of halftone) css += ` ${cssVar}: ${v};\n`; for (const [tokenName, cssVar] of Object.entries(GEOM_VARS)) css += ` ${cssVar}: ${tokens.geometry[tokenName].$value};\n`; +for (const [name, v] of cssLines) css += ` ${name}: ${v};\n`; css += '}\n'; write('brand.css', css); @@ -155,60 +517,249 @@ if (path.join(OUT_DIR, 'tokens.json') !== TOKENS_PATH) { // defines --color-*), so the --mfb-* variables the supergraphics reference must be // self-provided. Public pages cannot reach @mfb/shared, so this package is how they get the // primitives instead of hand-rolling wrong angles. -let sgPrelude = - '/* Generated by build-design-package.mjs - DO NOT EDIT BY HAND.\n' + - ' Canon: 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 ---- +// Named exports only. colors, fontFamily, fontSize and fontWeight are unchanged; colors stays the +// palette. Colors in colorRoles and shapeTones are resolved hex values; containers stay in px. +const INDEX_EXPORTS = [ + ['colorRoles', 'colorRole'], + ['shapeTones', 'shapeTone'], + ['fontSizeFluid', 'fontSizeFluid'], + ['lineHeight', 'lineHeight'], + ['letterSpacing', 'letterSpacing'], + ['logo', 'logo'], + ['spacing', 'spaceScale'], + ['space', 'space'], + ['radius', 'radius'], + ['shadow', 'shadow'], + ['easing', 'easing'], + ['duration', 'duration'], + ['container', 'container'], + ['zIndex', 'zIndex'], + ['mediaRatio', 'mediaRatio'], + ['ui', 'ui'], +]; write( 'index.js', - `// @myfirstbitcoin/design - programmatic access to MFB brand tokens.\n` + + `// @myfirstbitcoin/design - programmatic access to My First Bitcoin brand tokens.\n` + `export { default as tailwindPreset } from './tailwind.js';\n` + `export const colors = ${JSON.stringify(mfb, null, 2)};\n` + `export const fontFamily = ${JSON.stringify(fontFamily, null, 2)};\n` + `export const fontSize = ${JSON.stringify(fontSize, null, 2)};\n` + - (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') + `export const fontWeight = ${JSON.stringify(fontWeight, null, 2)};\n` + + (gradient ? `export const gradientBrand = ${JSON.stringify(gradient)};\n` : '') + + INDEX_EXPORTS.map(([name, group]) => `export const ${name} = ${JSON.stringify(indexExports[group] || {}, null, 2)};\n`).join('') ); // ---- 6. package.json is hand-maintained and no longer written here. ---- // ---- 7. README.md ---- -// This template is unchanged from before the move, so the published README stays -// byte-identical. Its one dash in the supergraphics paragraph is written as a unicode -// escape (backslash, u, 2014) so that this source file itself carries no literal em-dash; rewording it is a -// published change and belongs in a release pull request. -write( - 'README.md', - `# @myfirstbitcoin/design\n\n` + - `The My First Bitcoin brand as code: a Tailwind preset, CSS variables, and raw design tokens. ` + - `Generated from the canonical \`tokens.json\` (Figma → \`sync-brand.js\`). Do not edit generated files by hand.\n\n` + - `## Install (git dependency)\n\n` + - `\`\`\`json\n"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v${VERSION}" }\n\`\`\`\n\n` + - `## Use - Tailwind 3\n\n` + - `\`\`\`js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n\`\`\`\n\n` + - `## Use - Tailwind 4\n\n` + - `\`\`\`css\n/* global.css */\n@import '@myfirstbitcoin/design/theme.css';\n\`\`\`\n\n` + - `## Use - plain CSS variables\n\n` + - `\`\`\`css\n@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-gradient-brand) ... */\n\`\`\`\n\n` + - `## Use - supergraphics (brand geometry primitives)\n\n` + - `\`\`\`css\n@import '@myfirstbitcoin/design/supergraphics.css';\n\`\`\`\n\n` + - `The signature MFB shapes as ready-made classes: \`sg-para\` / \`sg-para-frame\` (13° parallelograms), ` + - `\`sg-spotlight\` / \`sg-spotlight-frame\` (13°+24° corner cuts), \`sg-book\` / \`sg-book-frame\` / \`sg-book-stack\`, ` + - `\`sg-cover\`, \`sg-halftone-cutout\`, \`sg-para-pattern\`, \`highlighter\` band. ` + - `**Never hand-roll these shapes in a page** \u2014 the brand angle is exactly 13° (\`--sg-angle-base\`) and hand-rolled copies drift. ` + - `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.\n\n` + - `Both utility conventions are served: top-level (preferred for new pages) and mfb- prefixed (legacy, e.g. roadmap). Utilities use the brand palette at the top level (e.g. \`bg-purple-400\`, \`text-orange-300\`, \`text-gray-900\`, \`text-h1\`), overriding Tailwind's default purple/orange/gray with MFB brand values. Other defaults (red, blue, etc.) are untouched. Plain CSS variables are namespaced \`--mfb-*\` to avoid collisions.\n\n` + - `## Heavy brand assets\n\nLogos, badges, and the brand book PDF live in [MyFirstBitcoin/mfb-brand](https://github.com/MyFirstBitcoin/mfb-brand).\n` -); +// The README is published, so its prose follows the public conventions in AGENTS.md (no +// em-dashes, "My First Bitcoin" in full). Brand values in it (angles, weights) are read from +// tokens.json, like every other output, so the README cannot disagree with the tokens. +const deg = (name) => String(tokens.geometry[name].$value).replace('deg', '°'); +const ANGLE = deg('angle-base'); +const ANGLE_ALT = deg('angle-alt'); +const FENCE = '```'; +const code = (s) => '`' + s + '`'; +const weightList = Object.entries(fontWeight) + .map(([k, v]) => `${code(`--mfb-weight-${k}`)} (${v})`) + .join(', '); +const halftoneList = halftone.map(([cssVar]) => code(cssVar)).join(', '); +const readme = [ + `# @myfirstbitcoin/design`, + ``, + `The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the supergraphics primitives, the raw design tokens and the written brand specification. ` + + `Every file is generated by ${code('scripts/build-design-package.mjs')} in [MyFirstBitcoin/mfb-design](https://github.com/MyFirstBitcoin/mfb-design), ` + + `from that repository's ${code('tokens.json')}, which mirrors the Brand Book in Figma and changes only by pull request. Do not edit generated files by hand.`, + ``, + `## Install (git dependency)`, + ``, + `${FENCE}json\n"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v${VERSION}" }\n${FENCE}`, + ``, + `Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile).`, + ``, + `## Upgrading from v1.3.0`, + ``, + `Make these changes in the same commit that raises your pin:`, + ``, + `- **Delete any local ${code('.highlighter')} band** (for example a ${code('linear-gradient(transparent 80%, ...)')} background on ${code('.highlighter')}), and any local rule that changes its ${code('display')} or ${code('text-align')}. ` + + `The package now draws the Brand Book's line itself, and a local band would draw on top of it. On orange surfaces it no longer paints a white band or turns the word white: it draws a white line, and the word keeps the heading's color.`, + `- **Remove any padding your project adds around ${code('.sg-para-pattern')} to make room for the slant.** Each row now insets itself by half its height times tan(${ANGLE}), so its first shape is no longer clipped; extra padding moves the rows further right.`, + `- **Set ${code('--sg-aspect')} on parallelogram frames too.** The photo scale in ${code('.sg-para-frame')} was a fixed 1.12; it is now ${code('1 + tan(--sg-angle-base) / --sg-aspect')}, the smallest scale that covers the frame, so it depends on the frame's real shape.`, + `- **${code('supergraphics.css')} sets more variables in ${code(':root')}**: the font families, sizes and weights, the halftone colors, the color roles (${code('--mfb--on-')}) and the line height and letter spacing per level (${code('--mfb-leading-')}, ${code('--mfb-tracking-')}), besides the palette and the geometry. If you override any of them, do it after importing ${code('supergraphics.css')}, or import the file into a cascade layer.`, + `- ${code('.sg-cover__title')} reads ${code('--mfb-size-h2')}. The ${code('--mfb-font-h2')} variable it used to read was never defined by this package; set ${code('font-size')} on the title instead.`, + `- **Tailwind 4 projects that restrict a theme namespace**, for example with ${code('--font-weight-*: initial')} or ${code('--radius-*: initial')}, must put that reset in its own ${code('@theme')} block before importing ${code('theme.css')}: placed after the import, it removes the package's values too, and no utility is generated for them.`, + ``, + `${FENCE}css\n@import "tailwindcss";\n@theme { --font-weight-*: initial; }\n@import "@myfirstbitcoin/design/theme.css";\n${FENCE}`, + ``, + `- **Headings and body text on light backgrounds are black.** The written spec said purple-300 headings and gray-900 text; the Brand Book draws both in black (Type Relationships, 918:2990), and the spec and the color descriptions in ${code('tokens.json')} now say so. No variable changed value: a project that colors its headings purple-300 or its text gray-900 changes it when it chooses to.`, + `- **Text on orange is black, buttons included, and the supergraphics classes now set it.** ${code('.sg-cta')} sets ${code('var(--mfb-link-on-orange)')}, ${code('.sg-bg-orange')} ${code('var(--mfb-body-on-orange)')} and ${code('.sg-cover--orange')} (with its ${code('.sg-cover__title')}) ${code('var(--mfb-heading-on-orange)')}: black, 9.2:1 on orange-300, where they set white (2.3:1, below the 4.5:1 that body text and buttons need). Delete any local white ${code('color')} on them, which would bring the old contrast back; a local black one can go too.`, + `- **Text on the light supergraphics surfaces is black.** ${code('.sg-bg-white')}, ${code('.sg-bg-gray')} and ${code('.sg-info-bar--on-light')} set ${code('var(--mfb-body-on-light)')} (black) instead of gray-900.`, + `- **The info bar's date follows its surface, and the line under it is a solid color.** On ${code('.sg-info-bar--on-light')}, ${code('.sg-info-bar__date')} is ${code('var(--mfb-heading-on-light)')} (black) instead of orange-300 (2.3:1 on white). A bar without a variant has no background of its own: its date stays orange-300 on purple surfaces and photos, and is black when the bar sits directly in ${code('.sg-bg-white')} or ${code('.sg-bg-gray')} (where orange-300 fails the contrast rule) or in one of the package's orange surfaces, ${code('.sg-bg-orange')}, ${code('.sg-cover--orange')} and the orange spotlight, book and halftone frames (where an orange date could not be seen). On any other light or orange surface, give the bar a variant. The date's ${code('small')} loses its 75% opacity: it is ${code('var(--mfb-muted-on-light)')} (gray-700, 5.1:1) on the light bar and ${code('var(--mfb-muted-on-dark)')} (gray-400, 8.3:1 on purple-400, where it was orange-300 at 75%, 4.2:1) on the dark bar; without a variant it keeps the date's color, fully opaque.`, + `- **${code('.sg-headline')} and ${code('.sg-cover__title')} set line height 1 and no letter spacing** (${code('--mfb-leading-h1')} and ${code('--mfb-tracking-h1')}; ${code('h2')} for the cover title), the Brand Book's, instead of 1.05 and -0.03em. Line height 1 is the book's for H1 to H4 and quotes. ${code('.sg-headline')} sets no size, so a headline set at the H5 or H6 size also takes ${code('.sg-headline--h5')} or ${code('.sg-headline--h6')}, new in this release, which set the book's ${tokens.lineHeight.h5.$value} (${code('--mfb-leading-h5')}, ${code('--mfb-leading-h6')}). Lines of a headline sit a little closer, and each headline is a little wider (0.03em per letter): check headlines that only just fitted their box.`, + `- **${code('.sg-label')} is Regular (400), not Medium, with line height 1.2 and no letter spacing** (${code('--mfb-leading-label')}, ${code('--mfb-tracking-label')}), instead of 1.5px. Its size stays 18px. ${code('.sg-body')} reads ${code('--mfb-leading-body')}, the same 1.2 as before, and now sets ${code('--mfb-tracking-body')} (none), so it no longer takes a letter spacing from its parent.`, + `- **A line height for long reading, ${code('--mfb-leading-body-long')} (${code('leading-body-long')}), ${tokens.lineHeight['body-long'].$value}.** It is declared from the website's article text, because the Brand Book only sets body copy next to a heading (${code('--mfb-leading-body')}, ${tokens.lineHeight.body.$value}). It is under review, and nothing uses it until a page adopts it.`, + `- The supergraphics classes that still differ from the Brand Book are listed in ${code('brand-spec.md')} ("Supergraphics classes that differ from the Brand Book").`, + `- **A test that snapshots the ${code('--mfb-*')} variables of ${code('brand.css')} whose value is a hex** must refresh its snapshot: this release adds ${halftoneList}. The other new variables carry no hex; they reference the palette with ${code('var()')}.`, + `- **Everything else is added, not changed.** Every new Tailwind utility is ${code('mfb-')} prefixed, a type level (${code('leading-h1')}, ${code('tracking-h1')}, ${code('text-h1-fluid')}), a color role (${code('text-heading-on-light')}) or ${code('font-regular')}. The weights ${code('font-normal')}, ${code('font-medium')} and ${code('font-semibold')} restate Tailwind's own values (400, 500, 600), and the build refuses a weight that would change one. None of them replaces a Tailwind default or an existing key. See "Layout, motion and color roles" below.`, + ``, + `## Use - Tailwind 3`, + ``, + `${FENCE}js\n// tailwind.config.mjs\nimport mfb from '@myfirstbitcoin/design/tailwind';\nexport default { presets: [mfb], content: ['./src/**/*.{astro,html,js,ts}'] };\n${FENCE}`, + ``, + `## Use - Tailwind 4`, + ``, + `${FENCE}css\n/* global.css */\n@import '@myfirstbitcoin/design/theme.css';\n${FENCE}`, + ``, + `## Use - plain CSS variables`, + ``, + `${FENCE}css\n@import '@myfirstbitcoin/design/brand.css'; /* var(--mfb-purple-400), var(--mfb-weight-medium) ... */\n${FENCE}`, + ``, + `## Use - supergraphics (brand geometry primitives)`, + ``, + `${FENCE}css\n@import '@myfirstbitcoin/design/supergraphics.css';\n${FENCE}`, + ``, + `The signature My First Bitcoin shapes as ready-made classes: ${code('sg-para')} / ${code('sg-para-frame')} (${ANGLE} parallelograms), ${code('sg-para-pattern')}, ` + + `${code('sg-spotlight')} / ${code('sg-spotlight-frame')} (${ANGLE}+${ANGLE_ALT} corner cuts), ${code('sg-book')} / ${code('sg-book-frame')} / ${code('sg-book-stack')}, ` + + `${code('sg-cover')}, ${code('sg-halftone-cutout')}, and the ${code('highlighter')}. ` + + `**Never hand-roll these shapes in a page**: the brand angle is exactly ${ANGLE} (${code('--sg-angle-base')}) and hand-rolled copies drift. ` + + `The file is standalone (token prelude included), so it works with theme.css-only Tailwind 4 setups.`, + ``, + `The classes read the Brand Book's color roles and type relationships, so text on orange is black and headlines have no tracking. Some still carry values the Brand Book does not have, and a later release will change them: ${code('.sg-cta')}'s 1px letter spacing and 40px radius, ${code('.sg-label')}'s 18px size, the type sizes and SemiBold date of ${code('.sg-info-bar')}, the 6px corners of ${code('.sg-para-frame')} and ${code('.sg-para-accent')}, the pattern's spacing and the logo's height. ${code('brand-spec.md')} lists every such class and what to set.`, + ``, + `### Set --sg-aspect on every frame`, + ``, + `${code('sg-spotlight-frame')}, ${code('sg-book-frame')}, ${code('sg-halftone-cutout')} and ${code('sg-para-frame')} compute their cuts (and the parallelogram frame its photo scale) ` + + `from ${code('--sg-aspect')}, the frame's width divided by its height. The default, ${tokens.geometry.aspect.$value}, is right only for a frame of that shape. ` + + `Set it to the frame's real ratio, for example ${code('style="--sg-aspect: 0.75"')} on a 3:4 frame; otherwise the cuts are not at ${ANGLE} and ${ANGLE_ALT}, ` + + `and a parallelogram frame's photo may not cover the frame.`, + ``, + `### The highlighter`, + ``, + `Wrap the one word you emphasize in a heading: ${code('

Open Source Education

')}. ` + + `It draws the Brand Book's highlighter (Figma node 918:2874): a thin line at the baseline, drawn beneath the glyphs (not a band), under ${tokens.geometry['highlighter-coverage'].$value} of the word, centered. ` + + `The line is orange-300, and white inside the orange surfaces ${['sg-bg-orange', 'sg-spotlight--orange', 'sg-cover--orange', 'sg-spotlight-frame--orange', 'sg-book-frame--orange'].map(code).join(', ')} and ${code('sg-halftone-cutout--orange-bg')}. The word keeps the heading's color. ` + + `On another orange surface, or on a mid grey such as gray-500, set ${code('--sg-highlighter-color: var(--mfb-white)')} on the section. ` + + `On a light grey such as gray-200 (${code('sg-bg-gray')}) keep the orange line: a white one cannot be seen there. One word per heading, never more. ` + + `The span is an ${code('inline-block')}, and the line's length and thickness are computed from the span's own width: do not override its ${code('display')} or ${code('text-align')}. ` + + `Its measurements are the ${code('--sg-highlighter-*')} variables (coverage, aspect, offset, shape), from the geometry tokens of the same names.`, + ``, + `## Layout, motion and color roles`, + ``, + `Where the Brand Book defines a value, the token takes it. Where the book is silent (spacing, radius, shadow, motion, widths), the value is declared from the live website, myfirstbitcoin.org, which was built following the Brand Book; ` + + `each such token's ${code('$extensions.mfb.source')} names the website commit and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline), for example ${code(tokens.radius.lg.$extensions.mfb.source)}. A website value that contradicts the Brand Book is never imported. ${code('brand-spec.md')} lists every value with its source.`, + ``, + `| What | brand.css | Tailwind 3 and 4 |`, + `|------|-----------|------------------|`, + `| Color roles | ${code('--mfb--on-')}: ${keysOf('colorRole').map((k) => code(k)).join(', ')} | ${code('text-heading-on-light')}, ${code('decoration-link-underline-on-light')}, ${code('border-border-on-light')} ... |`, + `| Line height per level | ${code('--mfb-leading-')}, and ${code('--mfb-leading-body-long')} for long reading (declared from the website, under review) | ${code('leading-')}, for example ${code('leading-h1')}; ${code('leading-body-long')} |`, + `| Letter spacing per level | ${code('--mfb-tracking-')} | ${code('tracking-')} |`, + `| Fluid sizes for web pages | ${code('--mfb-size--fluid')} (${keysOf('fontSizeFluid').join(', ')}) | ${code('text--fluid')} |`, + `| Spacing scale | ${keysOf('spaceScale').map((k) => code(`--mfb-space-${k}`)).join(', ')} | Tailwind's own ${code('p-1')} to ${code('p-32')}: the same values, so the package adds none |`, + `| Semantic spacing | ${keysOf('space').map((k) => code(`--mfb-space-${k}`)).join(', ')} | ${code('py-mfb-section')}, ${code('px-mfb-gutter')}, ${code('mt-mfb-heading-to-body')} ... |`, + `| Radius (interface elements only, never brand shapes) | ${keysOf('radius').map((k) => code(`--mfb-radius-${k}`)).join(', ')} | ${keysOf('radius').filter((k) => !(TW_SKIP.radius || {})[k]).map((k) => code(`rounded-mfb-${k}`)).join(', ')}; ${code('rounded-full')} for the pill |`, + `| Shadow | ${keysOf('shadow').map((k) => code(`--mfb-shadow-${k}`)).join(', ')} | ${keysOf('shadow').map((k) => code(`shadow-mfb-${k}`)).join(', ')} |`, + `| Easing | ${keysOf('easing').map((k) => code(`--mfb-ease-${k}`)).join(', ')} | ${keysOf('easing').map((k) => code(`ease-mfb-${k}`)).join(', ')} |`, + `| Duration | ${keysOf('duration').map((k) => code(`--mfb-duration-${k}`)).join(', ')} | ${keysOf('duration').map((k) => code(`duration-mfb-${k}`)).join(', ')} |`, + `| Container widths | ${keysOf('container').map((k) => code(`--mfb-container-${k}`)).join(', ')} (px) | ${keysOf('container').map((k) => code(`max-w-mfb-${k}`)).join(', ')} (rem) |`, + `| Logo size and clear space | ${keysOf('logo').map((k) => code(`--mfb-logo-${k}`)).join(', ')} | none |`, + `| Shape tones (a shape one step lighter than its base) | ${keysOf('shapeTone').map((k) => code(`--mfb-shape-on-${k}`)).join(', ')} | none |`, + `| Layers | ${keysOf('zIndex').map((k) => code(`--mfb-z-${k}`)).join(', ')} | none |`, + `| Media ratios | ${keysOf('mediaRatio').map((k) => code(`--mfb-ratio-${k}`)).join(', ')} | none (Tailwind has ${code('aspect-video')} and ${code('aspect-square')}) |`, + `| Interface | ${keysOf('ui').map((k) => code(`--mfb-ui-${k}`)).join(', ')} | none |`, + ``, + `- **New utilities never replace Tailwind's.** Keys are ${code('mfb-')} prefixed (${code('rounded-mfb-md')}, not ${code('rounded-md')}; ${code('ease-mfb-out')}, not ${code('ease-out')}, which is a different curve), type levels or color roles. The build refuses a key that equals a Tailwind 3 or 4 default, and a font weight that would change Tailwind's value for its name.`, + `- **In 1.x, ${code('text-h1')} sets only the size.** Add ${code('leading-h1')} yourself (and ${code('tracking-h1')} where something else sets tracking). Folding line height into ${code('text-h1')} would change every existing page, so it waits for a major release.`, + `- **New ${code('brand.css')} variables reference the palette with ${code('var()')}** and add no hex: roles are ${code('var(--mfb-black)')} and the like, shadows ${code('color-mix(in srgb, var(--mfb-purple-400) 25%, transparent)')}. ${code('theme.css')} and ${code('tailwind.js')} carry the same colors as hex, so Tailwind's opacity modifiers work.`, + `- **Tailwind 4 emits a theme variable only when something uses it.** To read ${code('var(--mfb-radius-md)')} or another new variable in your own CSS, import ${code('brand.css')} as well, or use the utility.`, + `- **Shadows in Tailwind 4: prefer the ${code('shadow-mfb-*')} utilities.** Tailwind compiles them to a hex color with alpha. A ${code('var(--mfb-shadow-*)')} from ${code('brand.css')} goes through Tailwind 4's CSS compiler, which gives it a fallback without ${code('color-mix()')} for browsers that lack it (Chrome before 111, Safari before 16.2), and that fallback draws the shadow fully opaque.`, + `- **index.js** exports ${INDEX_EXPORTS.map(([name]) => code(name)).join(', ')} next to the existing ${code('colors')}, ${code('fontFamily')}, ${code('fontSize')} and ${code('fontWeight')}.`, + ``, + `## Brand check`, + ``, + `${code('brand-check.mjs')} ships in this package: a guard that a project runs after its build, with Node.js and nothing else. It reads every value from this package's ${code('tokens.json')}, so it follows the version you pin.`, + ``, + `${FENCE}sh\nnode node_modules/@myfirstbitcoin/design/brand-check.mjs [options]\n${FENCE}`, + ``, + `| Option | What it does |`, + `|--------|--------------|`, + `| ${code('--src DIR')} | Source to read, repeatable or comma-separated (default ${code('src')}). Comments are ignored. |`, + `| ${code('--dist DIR')} | Build output whose CSS (style sheets, ${code('