From 0d18355421d844a135e4fb6a03f00e5c9afb54d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Berenger?= <105193+benoitberenger@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:56 -0500 Subject: [PATCH] v1.5.0: a dark theme derived from the palette (MFB-971) A darkTheme group: twelve colors for an interface with a dark mode, each an alias of a palette color, under a new provenance kind, derived. Surface purple-400, raised surface purple-300, text white, muted gray-400, lines gray-600, links, accent and focus orange-300, black on orange. The primary button keeps purple-200 with a white label and gets a 1px gray-400 outline (primary-edge), because purple-200 alone is 1.7:1 on purple-400. Emitted as --mfb-dark-* in brand.css, mfb-dark-* colors in Tailwind 3 and 4, darkTheme in index.js, and a section of brand-spec.md. The Figma census counts derived tokens apart. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 7 +- README.md | 5 +- brand-spec.md | 23 +++++ brand.css | 12 +++ index.js | 14 +++ package.json | 2 +- scripts/brand-spec.mjs | 3 + scripts/build-design-package.mjs | 16 +++- scripts/verify-figma.mjs | 6 +- src/brand-spec.template.md | 12 +++ tailwind.js | 14 ++- theme.css | 12 +++ tokens.json | 160 ++++++++++++++++++++++++++++++- 13 files changed, 272 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 612ac1a..80f8576 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,8 +34,11 @@ Every token carries `$extensions.mfb.sourceKind`, defined in `$verification` at 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. +reason in the note), `derived` (a theme built only from palette colors, adding no color, chosen by +the deciders, with the reason and the contrasts in the note: the dark theme), or `unverified`. +Color roles, shape tones and the dark theme are aliases of palette colors (`{color.black}`), and +the build refuses one that is not. The Figma check reads the palette, not the aliases, so a +derived token adds nothing it must find in Figma. Propose every change by pull request. The conventions in [AGENTS.md](AGENTS.md) apply to all contributions, by people and AI assistants alike. diff --git a/README.md b/README.md index d7040c9..dfc96b6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The My First Bitcoin brand as code: a Tailwind preset, CSS variables, the superg ## Install (git dependency) ```json -"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.4.0" } +"dependencies": { "@myfirstbitcoin/design": "github:MyFirstBitcoin/mfb-design#v1.5.0" } ``` Pin a tag. A new release reaches your project only when you raise the pin (and refresh the lockfile). @@ -84,6 +84,7 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile | What | brand.css | Tailwind 3 and 4 | |------|-----------|------------------| | Color roles | `--mfb--on-`: `heading-on-light`, `body-on-light`, `muted-on-light`, `link-on-light`, `link-underline-on-light`, `border-on-light`, `highlighter-on-light`, `logo-on-light`, `heading-on-dark`, `body-on-dark`, `muted-on-dark`, `link-on-dark`, `highlighter-on-dark`, `logo-on-dark`, `heading-on-orange`, `body-on-orange`, `link-on-orange`, `highlighter-on-orange`, `logo-on-orange` | `text-heading-on-light`, `decoration-link-underline-on-light`, `border-border-on-light` ... | +| Dark theme (derived from the palette, for an interface with a dark mode) | `--mfb-dark-surface`, `--mfb-dark-surface-raised`, `--mfb-dark-text`, `--mfb-dark-text-muted`, `--mfb-dark-line`, `--mfb-dark-link`, `--mfb-dark-accent`, `--mfb-dark-on-accent`, `--mfb-dark-focus`, `--mfb-dark-primary`, `--mfb-dark-on-primary`, `--mfb-dark-primary-edge` | `bg-mfb-dark-surface`, `text-mfb-dark-text-muted`, `border-mfb-dark-primary-edge` ... | | 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` | @@ -105,7 +106,7 @@ Where the Brand Book defines a value, the token takes it. Where the book is sile - **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`. +- **index.js** exports `colorRoles`, `shapeTones`, `darkTheme`, `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 diff --git a/brand-spec.md b/brand-spec.md index c3dd830..d7a1be1 100644 --- a/brand-spec.md +++ b/brand-spec.md @@ -85,6 +85,29 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw - On orange-300: orange-200 (Brand Book `918:3614`, measured) - On gray-700: gray-600 (Brand Book `918:3616`, measured) +### Dark theme + +For an interface that offers a dark mode, such as the My First Bitcoin App. The Brand Book has no dark theme and the website has only dark sections, so these colors are **derived**: each is a palette color, no color is added, and the brand deciders chose them. They follow the color roles on dark (white text, gray-400 for muted text, orange-300 for links) on a brand purple surface. + +| Variable | Color | Use | Why, and contrast | +|----------|-------|-----|-------------------| +| `--mfb-dark-surface` | purple-400 (`#2B1C58`) | Page background of a dark theme. | Chosen by the brand deciders named in CONTRIBUTING. Brand purple, like the website's dark sections and footer. White 14.9:1, gray-400 8.3:1, orange-300 6.5:1 on it. | +| `--mfb-dark-surface-raised` | purple-300 (`#422C70`) | Cards, sheets and menus on the dark surface. | One step lighter than the surface, as the Brand Book steps shapes on a base (918:3604). White 11.5:1, gray-400 6.4:1, orange-300 5.0:1 on it. | +| `--mfb-dark-text` | white (`#FFFFFF`) | Headings and body text on the dark surfaces. | The same color as colorRole.body-on-dark and heading-on-dark (measured). 14.9:1 on surface, 11.5:1 on surface-raised. | +| `--mfb-dark-text-muted` | gray-400 (`#C0C0D0`) | Secondary text on the dark surfaces (captions, meta), never body copy. | Chosen by the brand deciders named in CONTRIBUTING. The same color as colorRole.muted-on-dark, a real palette color rather than white at 72%. 8.3:1 on surface, 6.4:1 on surface-raised. | +| `--mfb-dark-line` | gray-600 (`#88889C`) | Hairlines and dividers on the dark surfaces. | A mid grey, like border-on-light on the light side. 4.3:1 on surface, 3.3:1 on surface-raised: above 3:1 on both. | +| `--mfb-dark-link` | orange-300 (`#F7941F`) | Link text on the dark surfaces, underlined. | Chosen by the brand deciders named in CONTRIBUTING. The same color as colorRole.link-on-dark, as on the website. No brand purple is readable as text on purple-400 (purple-200 is 1.7:1), and no new color is added. 6.5:1 on surface, 5.0:1 on surface-raised. | +| `--mfb-dark-accent` | orange-300 (`#F7941F`) | Accent fills and small emphasis (badges, the Bitcoin glyph, highlights) on the dark surfaces. | The supporting accent of the Brand Book, unchanged. Text on it is on-accent. | +| `--mfb-dark-on-accent` | black (`#000000`) | Text on an accent fill. | The same color as colorRole.body-on-orange (measured): dark text on every orange. 9.2:1 on orange-300. | +| `--mfb-dark-focus` | orange-300 (`#F7941F`) | Focus rings on the dark surfaces. | The link color, so focus reads as interactive. 6.5:1 on surface, 5.0:1 on surface-raised: above 3:1 for a focus indicator. | +| `--mfb-dark-primary` | purple-200 (`#5E378E`) | Primary button fill on the dark surfaces, always with primary-edge. | The light-theme primary family, one step lighter than surface-raised. Alone it barely stands apart: 1.7:1 on surface and 1.3:1 on surface-raised, so it is never used without primary-edge. | +| `--mfb-dark-on-primary` | white (`#FFFFFF`) | Label of a primary button. | White on purple-200 is 8.7:1. | +| `--mfb-dark-primary-edge` | gray-400 (`#C0C0D0`) | A thin outline (1px) around a primary button on the dark surfaces. | The deciders asked for the primary button to stand apart on purple-400 inside the palette. A thin outline, rather than a lighter fill: no brand purple is lighter than purple-200, and a white fill would make the button the same color as the text. The outline is 8.3:1 on surface, 6.4:1 on surface-raised and 4.9:1 against the fill: above 3:1 everywhere (WCAG 1.4.11). | + +**The primary button on dark is purple-200 with a white label and a 1px `--mfb-dark-primary-edge` outline.** Purple-200 alone is 1.7:1 on the surface; the outline sets it apart (8.3:1) without a new color. A light page keeps its primary as before. + +In CSS: `var(--mfb-dark-surface)`. In Tailwind: `bg-mfb-dark-surface`, `text-mfb-dark-text-muted`, `border-mfb-dark-primary-edge`. The website's dark sections keep using the color roles. + ### Gradients - **brand** (`linear-gradient(135deg, #2B1C58 0%, #5E378E 100%)`): Brand gradient (purple-400 → purple-200). **Deprecated:** this gradient appears nowhere in the canonical Figma file. It only exists in the Variables collection we bootstrapped ourselves. Kept for compatibility; phase out. diff --git a/brand.css b/brand.css index d06eca1..cd72b9c 100644 --- a/brand.css +++ b/brand.css @@ -68,6 +68,18 @@ --mfb-shape-on-purple-300: var(--mfb-purple-200); --mfb-shape-on-orange-300: var(--mfb-orange-200); --mfb-shape-on-gray-700: var(--mfb-gray-600); + --mfb-dark-surface: var(--mfb-purple-400); + --mfb-dark-surface-raised: var(--mfb-purple-300); + --mfb-dark-text: var(--mfb-white); + --mfb-dark-text-muted: var(--mfb-gray-400); + --mfb-dark-line: var(--mfb-gray-600); + --mfb-dark-link: var(--mfb-orange-300); + --mfb-dark-accent: var(--mfb-orange-300); + --mfb-dark-on-accent: var(--mfb-black); + --mfb-dark-focus: var(--mfb-orange-300); + --mfb-dark-primary: var(--mfb-purple-200); + --mfb-dark-on-primary: var(--mfb-white); + --mfb-dark-primary-edge: var(--mfb-gray-400); --mfb-size-h1-fluid: clamp(44px, 6vw, 70px); --mfb-size-h2-fluid: clamp(36px, 5vw, 58px); --mfb-size-h3-fluid: clamp(30px, 4vw, 48px); diff --git a/index.js b/index.js index 3d427b1..2665516 100644 --- a/index.js +++ b/index.js @@ -89,6 +89,20 @@ export const shapeTones = { "orange-300": "#FBB040", "gray-700": "#88889C" }; +export const darkTheme = { + "surface": "#2B1C58", + "surface-raised": "#422C70", + "text": "#FFFFFF", + "text-muted": "#C0C0D0", + "line": "#88889C", + "link": "#F7941F", + "accent": "#F7941F", + "on-accent": "#000000", + "focus": "#F7941F", + "primary": "#5E378E", + "on-primary": "#FFFFFF", + "primary-edge": "#C0C0D0" +}; export const fontSizeFluid = { "h1": "clamp(44px, 6vw, 70px)", "h2": "clamp(36px, 5vw, 58px)", diff --git a/package.json b/package.json index e651d07..fa65d0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@myfirstbitcoin/design", - "version": "1.4.0", + "version": "1.5.0", "description": "My First Bitcoin brand design tokens: Tailwind preset (v3 + v4), CSS variables, and raw tokens. Generated from the canonical brand tokens.json.", "type": "module", "license": "UNLICENSED", diff --git a/scripts/brand-spec.mjs b/scripts/brand-spec.mjs index 5441892..3b6df5c 100644 --- a/scripts/brand-spec.mjs +++ b/scripts/brand-spec.mjs @@ -115,6 +115,7 @@ const sourceOf = (group, name) => { return `website \`${site[2]}\`${site[3] ? ` (${site[3]})` : ''}`; } if (m.source === 'declared') return "this package's recommendation"; + if (m.sourceKind === 'derived') return 'derived from the palette'; return `\`${m.source}\``; }; const paletteName = (v) => (typeof v === 'string' ? v.match(/^\{color\.([\w-]+)\}$/)?.[1] : undefined); @@ -174,6 +175,8 @@ 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.DARK_THEME_ROWS = rows('darkTheme', true, (name, tok) => + `| \`--mfb-dark-${name}\` | ${colorOf(`darkTheme.${name}`, tok.$value)} | ${cell(tok.$description)} | ${cell(tok.$extensions?.mfb?.note)} |`); values.SHAPE_TONE_ITEMS = rows('shapeTone', true, (name, tok) => `- On ${name}: ${colorOf(`shapeTone.${name}`, tok.$value).replace(/ \(.*\)$/, '')} (${sourceOf('shapeTone', name)})`); diff --git a/scripts/build-design-package.mjs b/scripts/build-design-package.mjs index 272adec..bf5cfb7 100644 --- a/scripts/build-design-package.mjs +++ b/scripts/build-design-package.mjs @@ -117,6 +117,7 @@ const GROUP_MAP = { 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-' }, + darkTheme: { tw4: '--color-', tw3: 'colors', twKey: 'mfb-dark-', suffix: '', css: '--mfb-dark-' }, 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-' }, @@ -161,7 +162,7 @@ const STATIC_KEYWORDS = new Set('full none px auto screen min max fit prose DEFA 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 +// Value helpers for the new groups. A role, a shape tone or a dark theme color 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); @@ -191,7 +192,7 @@ const remOf = (px) => { // (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') { + if (group === 'colorRole' || group === 'shapeTone' || group === 'darkTheme') { const name = paletteRef(where, tok.$value); if (!name) return ''; return target === 'css' ? `var(--mfb-${name})` : tokens.color[name].$value; @@ -285,6 +286,7 @@ const emitGroup = (group) => { // One call per group, in the order they appear in the outputs. emitGroup('colorRole'); emitGroup('shapeTone'); +emitGroup('darkTheme'); emitGroup('fontSizeFluid'); emitGroup('lineHeight'); emitGroup('letterSpacing'); @@ -315,8 +317,10 @@ for (const [name] of tw4Lines) { 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`); + // A dark theme color is mfb-dark-: prefixed, so it can never equal a Tailwind or palette color. + const darkKey = key.startsWith('mfb-dark-') && tokens.darkTheme?.[key.slice('mfb-dark-'.length)]; + if (ns === '--color-' && !darkKey && (!/-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-, a dark theme color mfb-dark-, and neither is a palette or Tailwind color`); } } for (const [twKey, entries] of Object.entries(tw3Extend)) { @@ -521,10 +525,11 @@ 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. +// palette. Colors in colorRoles, shapeTones and darkTheme are resolved hex values; containers stay in px. const INDEX_EXPORTS = [ ['colorRoles', 'colorRole'], ['shapeTones', 'shapeTone'], + ['darkTheme', 'darkTheme'], ['fontSizeFluid', 'fontSizeFluid'], ['lineHeight', 'lineHeight'], ['letterSpacing', 'letterSpacing'], @@ -654,6 +659,7 @@ const readme = [ `| What | brand.css | Tailwind 3 and 4 |`, `|------|-----------|------------------|`, `| Color roles | ${code('--mfb--on-')}: ${keysOf('colorRole').map((k) => code(k)).join(', ')} | ${code('text-heading-on-light')}, ${code('decoration-link-underline-on-light')}, ${code('border-border-on-light')} ... |`, + `| Dark theme (derived from the palette, for an interface with a dark mode) | ${keysOf('darkTheme').map((k) => code(`--mfb-dark-${k}`)).join(', ')} | ${code('bg-mfb-dark-surface')}, ${code('text-mfb-dark-text-muted')}, ${code('border-mfb-dark-primary-edge')} ... |`, `| 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')} |`, diff --git a/scripts/verify-figma.mjs b/scripts/verify-figma.mjs index 34d4689..d8eab65 100644 --- a/scripts/verify-figma.mjs +++ b/scripts/verify-figma.mjs @@ -71,7 +71,7 @@ for (const [name, tok] of Object.entries(tokens.color)) tokenHex[tok.$value.toUp // It reports rather than fails, because most tokens are honestly unverifiable: the Brand Book // states the 13 degree angle as English prose, and no API returns that as data. const TOKEN_GROUPS = Object.keys(tokens).filter((k) => !k.startsWith('$')); -const census = { byKind: {}, unverifiable: [], measured: [], declared: [], noProvenance: [] }; +const census = { byKind: {}, unverifiable: [], measured: [], declared: [], derived: [], noProvenance: [] }; for (const group of TOKEN_GROUPS) { for (const [name, tok] of Object.entries(tokens[group])) { const mfb = (tok.$extensions || {}).mfb; @@ -81,6 +81,7 @@ for (const group of TOKEN_GROUPS) { if (kind === 'prose') census.unverifiable.push(`${ref} (${mfb.source})`); else if (kind === 'measured') census.measured.push(`${ref} (${mfb.source})`); else if (kind === 'declared') census.declared.push(ref); + else if (kind === 'derived') census.derived.push(ref); else if (kind === 'unverified' || kind === 'no-provenance') census.noProvenance.push(ref); } } @@ -190,7 +191,7 @@ const result = { knownOpenQuestions: [ `coverage: ${census.machineCheckable}/${census.total} tokens are machine-checkable against Figma; ` + `${census.unverifiable.length} are prose in the brand book, ${census.measured.length} are measured from its vectors, ${census.declared.length} are declared ` + - `downstream (what Figma owes), ${census.noProvenance.length} have no established provenance`, + `downstream (what Figma owes), ${census.derived.length} are derived from the palette, ${census.noProvenance.length} have no established provenance`, 'IBM Plex Mono (fontFamily.mono) is deprecated in tokens.json: absent from the Brand Book, kept for compatibility, phase out', 'Brand gradient (gradient.brand) is deprecated in tokens.json: absent from the Brand Book, kept for compatibility, phase out', ], @@ -215,6 +216,7 @@ if (flags.json) { console.log(` ${census.unverifiable.length} prose (stated in the brand book as English; no API returns these)`); console.log(` ${census.measured.length} measured from the brand book's vector geometry, by hand or by an AI session through the Figma MCP, node ids in each note; no machine re-checks them (re-measure if those slides change)`); console.log(` ${census.declared.length} declared downstream, which is what Figma owes: ${census.declared.join(', ') || 'none'}`); + console.log(` ${census.derived.length} derived from palette colors (a theme, no new color; the palette itself is what Figma checks)`); if (census.noProvenance.length) console.log(` ${census.noProvenance.length} with NO established provenance: ${census.noProvenance.slice(0, 6).join(', ')}${census.noProvenance.length > 6 ? ' ...' : ''}`); console.log(` Token colors: ${colorTotal - missing.length}/${colorTotal} present in brand book`); diff --git a/src/brand-spec.template.md b/src/brand-spec.template.md index 31f95e5..74ca56a 100644 --- a/src/brand-spec.template.md +++ b/src/brand-spec.template.md @@ -49,6 +49,18 @@ In CSS: `var(--mfb-)`, for example `var(--mfb-heading-on-light)`. In Tailw {{SHAPE_TONE_ITEMS}} +### Dark theme + +For an interface that offers a dark mode, such as the My First Bitcoin App. The Brand Book has no dark theme and the website has only dark sections, so these colors are **derived**: each is a palette color, no color is added, and the brand deciders chose them. They follow the color roles on dark (white text, gray-400 for muted text, orange-300 for links) on a brand purple surface. + +| Variable | Color | Use | Why, and contrast | +|----------|-------|-----|-------------------| +{{DARK_THEME_ROWS}} + +**The primary button on dark is purple-200 with a white label and a 1px `--mfb-dark-primary-edge` outline.** Purple-200 alone is 1.7:1 on the surface; the outline sets it apart (8.3:1) without a new color. A light page keeps its primary as before. + +In CSS: `var(--mfb-dark-surface)`. In Tailwind: `bg-mfb-dark-surface`, `text-mfb-dark-text-muted`, `border-mfb-dark-primary-edge`. The website's dark sections keep using the color roles. + ### Gradients {{GRADIENT_ITEMS}} diff --git a/tailwind.js b/tailwind.js index 4d36997..9a46535 100644 --- a/tailwind.js +++ b/tailwind.js @@ -70,7 +70,19 @@ export default { "body-on-orange": "#000000", "link-on-orange": "#000000", "highlighter-on-orange": "#FFFFFF", - "logo-on-orange": "#000000" + "logo-on-orange": "#000000", + "mfb-dark-surface": "#2B1C58", + "mfb-dark-surface-raised": "#422C70", + "mfb-dark-text": "#FFFFFF", + "mfb-dark-text-muted": "#C0C0D0", + "mfb-dark-line": "#88889C", + "mfb-dark-link": "#F7941F", + "mfb-dark-accent": "#F7941F", + "mfb-dark-on-accent": "#000000", + "mfb-dark-focus": "#F7941F", + "mfb-dark-primary": "#5E378E", + "mfb-dark-on-primary": "#FFFFFF", + "mfb-dark-primary-edge": "#C0C0D0" }, "fontFamily": { "heading": [ diff --git a/theme.css b/theme.css index a831678..7a1da6a 100644 --- a/theme.css +++ b/theme.css @@ -70,6 +70,18 @@ --color-link-on-orange: #000000; --color-highlighter-on-orange: #FFFFFF; --color-logo-on-orange: #000000; + --color-mfb-dark-surface: #2B1C58; + --color-mfb-dark-surface-raised: #422C70; + --color-mfb-dark-text: #FFFFFF; + --color-mfb-dark-text-muted: #C0C0D0; + --color-mfb-dark-line: #88889C; + --color-mfb-dark-link: #F7941F; + --color-mfb-dark-accent: #F7941F; + --color-mfb-dark-on-accent: #000000; + --color-mfb-dark-focus: #F7941F; + --color-mfb-dark-primary: #5E378E; + --color-mfb-dark-on-primary: #FFFFFF; + --color-mfb-dark-primary-edge: #C0C0D0; --text-h1-fluid: clamp(44px, 6vw, 70px); --text-h2-fluid: clamp(36px, 5vw, 58px); --text-h3-fluid: clamp(30px, 4vw, 48px); diff --git a/tokens.json b/tokens.json index 4e0d16e..786695b 100644 --- a/tokens.json +++ b/tokens.json @@ -1423,6 +1423,164 @@ } } }, + "darkTheme": { + "surface": { + "$type": "color", + "$value": "{color.purple-400}", + "$description": "Page background of a dark theme.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "Chosen by the brand deciders named in CONTRIBUTING. Brand purple, like the website's dark sections and footer. White 14.9:1, gray-400 8.3:1, orange-300 6.5:1 on it." + } + } + }, + "surface-raised": { + "$type": "color", + "$value": "{color.purple-300}", + "$description": "Cards, sheets and menus on the dark surface.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "One step lighter than the surface, as the Brand Book steps shapes on a base (918:3604). White 11.5:1, gray-400 6.4:1, orange-300 5.0:1 on it." + } + } + }, + "text": { + "$type": "color", + "$value": "{color.white}", + "$description": "Headings and body text on the dark surfaces.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The same color as colorRole.body-on-dark and heading-on-dark (measured). 14.9:1 on surface, 11.5:1 on surface-raised." + } + } + }, + "text-muted": { + "$type": "color", + "$value": "{color.gray-400}", + "$description": "Secondary text on the dark surfaces (captions, meta), never body copy.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "Chosen by the brand deciders named in CONTRIBUTING. The same color as colorRole.muted-on-dark, a real palette color rather than white at 72%. 8.3:1 on surface, 6.4:1 on surface-raised." + } + } + }, + "line": { + "$type": "color", + "$value": "{color.gray-600}", + "$description": "Hairlines and dividers on the dark surfaces.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "A mid grey, like border-on-light on the light side. 4.3:1 on surface, 3.3:1 on surface-raised: above 3:1 on both." + } + } + }, + "link": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Link text on the dark surfaces, underlined.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "Chosen by the brand deciders named in CONTRIBUTING. The same color as colorRole.link-on-dark, as on the website. No brand purple is readable as text on purple-400 (purple-200 is 1.7:1), and no new color is added. 6.5:1 on surface, 5.0:1 on surface-raised." + } + } + }, + "accent": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Accent fills and small emphasis (badges, the Bitcoin glyph, highlights) on the dark surfaces.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The supporting accent of the Brand Book, unchanged. Text on it is on-accent." + } + } + }, + "on-accent": { + "$type": "color", + "$value": "{color.black}", + "$description": "Text on an accent fill.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The same color as colorRole.body-on-orange (measured): dark text on every orange. 9.2:1 on orange-300." + } + } + }, + "focus": { + "$type": "color", + "$value": "{color.orange-300}", + "$description": "Focus rings on the dark surfaces.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The link color, so focus reads as interactive. 6.5:1 on surface, 5.0:1 on surface-raised: above 3:1 for a focus indicator." + } + } + }, + "primary": { + "$type": "color", + "$value": "{color.purple-200}", + "$description": "Primary button fill on the dark surfaces, always with primary-edge.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The light-theme primary family, one step lighter than surface-raised. Alone it barely stands apart: 1.7:1 on surface and 1.3:1 on surface-raised, so it is never used without primary-edge." + } + } + }, + "on-primary": { + "$type": "color", + "$value": "{color.white}", + "$description": "Label of a primary button.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "White on purple-200 is 8.7:1." + } + } + }, + "primary-edge": { + "$type": "color", + "$value": "{color.gray-400}", + "$description": "A thin outline (1px) around a primary button on the dark surfaces.", + "$extensions": { + "mfb": { + "source": "derived", + "sourceKind": "derived", + "verifiedOn": "2026-10-09", + "note": "The deciders asked for the primary button to stand apart on purple-400 inside the palette. A thin outline, rather than a lighter fill: no brand purple is lighter than purple-200, and a white fill would make the button the same color as the text. The outline is 8.3:1 on surface, 6.4:1 on surface-raised and 4.9:1 against the fill: above 3:1 everywhere (WCAG 1.4.11)." + } + } + } + }, "logo": { "min-width": { "$type": "dimension", @@ -2100,5 +2258,5 @@ } } }, - "$verification": "Colour and the IBM Plex Sans families, sizes and weights are checked against Figma page 262:2 by scripts/verify-figma.mjs, which counts rendered solid fills and text styles. That is the ONLY machine-checkable provenance in this file. Every token carries $extensions.mfb.sourceKind: rendered-fill or rendered-text (checkable), prose (stated in the Brand Book as English, a human transcribed it, unverifiable by machine), measured (read off the Brand Book's vector geometry or a node's rendered fill, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), declared (not in the Brand Book), or unverified (provenance never established). The source rule: where the Brand Book defines a value, it wins. Where it is silent, a value may be declared from the live website, which was built following the Brand Book: sourceKind declared, source myfirstbitcoin.org@, : a commit on the site's main branch and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline, with where it is used). The site's source repository is private, so no file or line of it is named here; the deciders named in CONTRIBUTING keep the exact lines outside this repository. A website value that contradicts the Brand Book is never declared. Any other declared value has source declared and says in its note why it exists. Colour roles (colorRole) and shape tones (shapeTone) are aliases of palette colours, and the build refuses one that is not. Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." + "$verification": "Colour and the IBM Plex Sans families, sizes and weights are checked against Figma page 262:2 by scripts/verify-figma.mjs, which counts rendered solid fills and text styles. That is the ONLY machine-checkable provenance in this file. Every token carries $extensions.mfb.sourceKind: rendered-fill or rendered-text (checkable), prose (stated in the Brand Book as English, a human transcribed it, unverifiable by machine), measured (read off the Brand Book's vector geometry or a node's rendered fill, by hand or by an AI session through the Figma MCP, with the node ids in the note; no machine re-checks it), declared (not in the Brand Book), derived (a theme built only from palette colours, adding no colour, decided by the deciders named in CONTRIBUTING; each note says why and gives the contrasts), or unverified (provenance never established). The source rule: where the Brand Book defines a value, it wins. Where it is silent, a value may be declared from the live website, which was built following the Brand Book: sourceKind declared, source myfirstbitcoin.org@, : a commit on the site's main branch and the CSS that carries the value in what the site serves (a custom property, a selector and property, or a property set inline, with where it is used). The site's source repository is private, so no file or line of it is named here; the deciders named in CONTRIBUTING keep the exact lines outside this repository. A website value that contradicts the Brand Book is never declared. Any other declared value has source declared and says in its note why it exists. Colour roles (colorRole), shape tones (shapeTone) and the dark theme (darkTheme) are aliases of palette colours, and the build refuses one that is not. Geometry added 2026-08-19: it had never been in the pipeline, which is why five different slant angles reached production." }