Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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-<element>-on-<surface>`: `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-<level>`, and `--mfb-leading-body-long` for long reading (declared from the website, under review) | `leading-<level>`, for example `leading-h1`; `leading-body-long` |
| Letter spacing per level | `--mfb-tracking-<level>` | `tracking-<level>` |
| Fluid sizes for web pages | `--mfb-size-<level>-fluid` (h1, h2, h3, h4, h5, h6, quote, label) | `text-<level>-fluid` |
Expand All @@ -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

Expand Down
23 changes: 23 additions & 0 deletions brand-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ In CSS: `var(--mfb-<role>)`, 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.
Expand Down
12 changes: 12 additions & 0 deletions brand.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions scripts/brand-spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)})`);

Expand Down
16 changes: 11 additions & 5 deletions scripts/build-design-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-' },
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 <element>-on-<light|dark|orange> and is not a palette or Tailwind color`);
// A dark theme color is mfb-dark-<use>: 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 <element>-on-<light|dark|orange>, a dark theme color mfb-dark-<use>, and neither is a palette or Tailwind color`);
}
}
for (const [twKey, entries] of Object.entries(tw3Extend)) {
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -654,6 +659,7 @@ const readme = [
`| What | brand.css | Tailwind 3 and 4 |`,
`|------|-----------|------------------|`,
`| Color roles | ${code('--mfb-<element>-on-<surface>')}: ${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-<level>')}, and ${code('--mfb-leading-body-long')} for long reading (declared from the website, under review) | ${code('leading-<level>')}, for example ${code('leading-h1')}; ${code('leading-body-long')} |`,
`| Letter spacing per level | ${code('--mfb-tracking-<level>')} | ${code('tracking-<level>')} |`,
`| Fluid sizes for web pages | ${code('--mfb-size-<level>-fluid')} (${keysOf('fontSizeFluid').join(', ')}) | ${code('text-<level>-fluid')} |`,
Expand Down
Loading
Loading