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
11 changes: 11 additions & 0 deletions .changeset/colour-ladders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@tangle-network/brand": minor
---

Add opt-in colour ladders and a semantic token layer.

`@tangle-network/brand/styles/ladders.css` defines 12 ramps of 12 steps, light and dark, from Radix Colors 3.0.0, with role aliases (`--gray-*`, `--accent-*`, `--success-*`, `--warning-*`, `--danger-*`, `--info-*`) and a per-domain ramp under `[data-domain]`.

`@tangle-network/brand/styles/system.css` maps every `tokens.css` family onto a ladder step and adds semantic tokens, role radii, type roles, motion and three shadow levels. Light pages become white and dark becomes a neutral mauve ladder. It is opt in: nothing changes for an app that does not import it.

`scripts/gen-ladders.mjs` generates both files; `pnpm --filter @tangle-network/brand gen:ladders` rewrites them.
21 changes: 21 additions & 0 deletions packages/brand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ Fonts are **not** bundled — see [Fonts](#fonts) below.
@import "@tangle-network/brand/styles/globals";
```

### Colour ladders (opt in)

```css
@import "@tangle-network/brand/styles";
@import "@tangle-network/brand/styles/ladders.css";
@import "@tangle-network/brand/styles/system.css";
```

`ladders.css` defines 12 ramps of 12 steps each, in light and dark, from Radix Colors 3.0.0: mauve (neutral), iris (brand), green, amber, red and blue (status), and plum, orange, grass, bronze, crimson and olive (domains).
Each step has one job: 1–2 backgrounds, 3–5 control fills, 6–8 borders, 9–10 solid fills, 11 secondary text and 12 primary text.
Role aliases (`--gray-*`, `--accent-*`, `--success-*`, `--warning-*`, `--danger-*`, `--info-*`) point at the ramps.
Setting `data-domain="tax"` (or another domain key) on an element gives its subtree `--domain-1..12`.

`system.css` maps every token family in `tokens.css` onto a ladder step and adds semantic tokens (`--bg-page`, `--line`, `--fg-muted`, `--accent-text`, `--ink`), role radii, type roles, motion and three shadow levels.
Light pages become white, and dark becomes a neutral mauve ladder.
Import it after `tokens.css`; it is opt in because it changes every surface of the app that loads it.

Both files are generated.
Change a value in `scripts/gen-ladders.mjs` or `scripts/radix-ramps.json`, then run `pnpm --filter @tangle-network/brand gen:ladders`.
An app that ships ahead of a release copies the two generated files verbatim, so its copy stays identical.

### Logo

```tsx
Expand Down
5 changes: 4 additions & 1 deletion packages/brand/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@
"./styles/tokens.css": "./src/styles/tokens.css",
"./styles/globals.css": "./src/styles/globals.css",
"./styles/theme.css": "./src/styles/theme.css",
"./styles/named-themes.css": "./src/styles/named-themes.css"
"./styles/named-themes.css": "./src/styles/named-themes.css",
"./styles/ladders.css": "./src/styles/ladders.css",
"./styles/system.css": "./src/styles/system.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"gen:ladders": "node scripts/gen-ladders.mjs",
"dev": "tsup src/index.ts --format esm --dts --watch"
},
"peerDependencies": {
Expand Down
338 changes: 338 additions & 0 deletions packages/brand/scripts/gen-ladders.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
#!/usr/bin/env node
// Writes ladders.css and system.css from radix-ramps.json.
//
// node scripts/gen-ladders.mjs [ramps.json] [out-dir]
//
// radix-ramps.json holds the @radix-ui/colors 3.0.0 values as static data, so
// the generated CSS has no runtime dependency. Apps that ship ahead of a brand
// release copy the two generated files verbatim; running this script is the
// only way to change a value, so every copy stays identical.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const here = path.dirname(fileURLToPath(import.meta.url))
const RAMPS_FILE = process.argv[2] ?? path.join(here, 'radix-ramps.json')
const OUT = process.argv[3] ?? path.join(here, '..', 'src', 'styles')
const R = JSON.parse(fs.readFileSync(RAMPS_FILE, 'utf8'))

const RAMPS = ['mauve', 'iris', 'green', 'amber', 'red', 'blue', 'plum', 'orange', 'grass', 'bronze', 'crimson', 'olive']
const ROLE = { gray: 'mauve', accent: 'iris', success: 'green', warning: 'amber', danger: 'red', info: 'blue' }
const DOMAIN = {
personal: 'plum',
hospitality: 'orange',
tax: 'grass',
law: 'blue',
procurement: 'bronze',
negotiation: 'crimson',
logistics: 'amber',
defense: 'olive',
}
for (const r of RAMPS) {
for (const t of ['light', 'dark']) {
if (!Array.isArray(R[r]?.[t]) || R[r][t].length !== 12) throw new Error(`${r} ${t}: expected 12 steps`)
}
}

const DARK_SEL = ':root,\n[data-sandbox-ui],\n[data-theme="dark"],\n.dark'
const LIGHT_SEL = '[data-sandbox-theme="vault"],\n[data-theme="light"],\n.light'

// ── ladders.css ────────────────────────────────────────────────────────────
const steps = (theme) =>
RAMPS.map((r) => R[r][theme].map((v, i) => ` --${r}-${i + 1}: ${v};`).join('\n')).join('\n')
const roles = Object.entries(ROLE)
.map(([role, ramp]) => Array.from({ length: 12 }, (_, i) => ` --${role}-${i + 1}: var(--${ramp}-${i + 1});`).join('\n'))
.join('\n')
const domainBlock = (sel, ramp) =>
`${sel} {\n${Array.from({ length: 12 }, (_, i) => ` --domain-${i + 1}: var(--${ramp}-${i + 1});`).join('\n')}\n --domain-bubble: ${R[ramp].light[10]};\n}`

const ladders = `/* Colour ladders. Generated by scripts/gen-ladders.mjs from @radix-ui/colors 3.0.0; do not edit by hand.
*
* Every ramp has 12 steps, and each step has one job:
* 1-2 app backgrounds
* 3-5 control fills: rest, hover, pressed
* 6-8 borders: subtle, default, hover
* 9-10 solid fills
* 11 readable secondary text
* 12 readable primary text
* Light and dark use the same names, so a component written against a step
* works in both themes. Dark is the :root default and light wins by source
* order, the same as tokens.css. The role aliases repeat in both blocks because
* a var() resolves where it is declared. */
${DARK_SEL} {
${steps('dark')}
${roles}
}
${LIGHT_SEL} {
${steps('light')}
${roles}
}

/* Domain ramp. Set data-domain on a cover, tile, avatar or listing and read
* --domain-1..12. An unknown domain gets the brand ramp. --domain-bubble is the
* light step 11 in both themes: white text on it passes 4.5:1 for every domain.
* A step 9 fill never carries text (white on orange 9 is 2.97:1). */
${domainBlock(':root,\n[data-domain]', 'iris')}
${Object.entries(DOMAIN).map(([d, r]) => domainBlock(`[data-domain="${d}"]`, r)).join('\n')}
`

// ── system.css ─────────────────────────────────────────────────────────────
const hex = (ref, theme) => {
if (ref.startsWith('#')) return ref
const [ramp, step] = ref.split('-')
return R[ramp][theme][Number(step) - 1]
}
const hsl = (h) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
let s = 0
let hue = 0
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
hue = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4
hue *= 60
}
return `${Math.round(hue)} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`
}

const W = '#ffffff'
// [token, light, dark, kind]. kind 'hsl' emits an "H S% L%" triplet for the
// shadcn layer; otherwise the value is var(--ramp-n) or a literal.
const MAP = [
// Product semantic tokens.
['bg-page', W, 'mauve-1'],
['bg-subtle', 'mauve-2', 'mauve-2'],
['bg-card', W, 'mauve-2'],
['bg-raised', W, 'mauve-3'],
['bg-control', 'mauve-3', 'mauve-3'],
['bg-control-hover', 'mauve-4', 'mauve-4'],
['bg-control-active', 'mauve-5', 'mauve-5'],
['line-subtle', 'mauve-6', 'mauve-6'],
['line', 'mauve-7', 'mauve-7'],
['line-strong', 'mauve-8', 'mauve-8'],
// Input borders: 3.30:1 on white and 3.45:1 on dark step 2, above the 3:1 floor for controls.
['line-field', 'mauve-9', 'mauve-9'],
['fg', 'mauve-12', 'mauve-12'],
['fg-muted', 'mauve-11', 'mauve-11'],
// Icons and disabled states only; 3.77:1 is below the body-text floor.
['fg-faint', 'mauve-10', 'mauve-10'],
['ink', 'mauve-12', 'mauve-12'],
['on-ink', W, 'mauve-1'],
['accent-solid', 'iris-9', 'iris-9'],
['accent-solid-hover', 'iris-10', 'iris-10'],
['accent-text', 'iris-11', 'iris-11'],
['accent-soft', 'iris-3', 'iris-3'],
['accent-soft-hover', 'iris-4', 'iris-4'],
['accent-line', 'iris-7', 'iris-7'],
// shadcn / sandbox-ui HSL layer.
['hsl-background', W, 'mauve-1', 'hsl'],
['hsl-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['hsl-card', W, 'mauve-2', 'hsl'],
['hsl-card-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['hsl-popover', W, 'mauve-3', 'hsl'],
['hsl-popover-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['hsl-primary', 'iris-9', 'iris-9', 'hsl'],
['hsl-primary-foreground', W, W, 'hsl'],
['hsl-secondary', 'mauve-3', 'mauve-3', 'hsl'],
['hsl-secondary-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['hsl-muted', 'mauve-3', 'mauve-3', 'hsl'],
['hsl-muted-foreground', 'mauve-11', 'mauve-11', 'hsl'],
['hsl-accent', 'mauve-4', 'mauve-4', 'hsl'],
['hsl-accent-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['hsl-destructive', 'red-11', 'red-4', 'hsl'],
['hsl-destructive-foreground', W, 'red-12', 'hsl'],
['hsl-border', 'mauve-6', 'mauve-6', 'hsl'],
// `border-input` is the field outline in the ui Input, so it takes the field step.
['hsl-input', 'mauve-9', 'mauve-9', 'hsl'],
['hsl-ring', 'iris-9', 'iris-10', 'hsl'],
['hsl-success', 'green-9', 'green-9', 'hsl'],
['hsl-warning', 'amber-9', 'amber-9', 'hsl'],
['hsl-info', 'blue-9', 'blue-9', 'hsl'],
['sidebar-background', 'mauve-2', 'mauve-2', 'hsl'],
['sidebar-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['sidebar-primary', 'iris-9', 'iris-9', 'hsl'],
['sidebar-primary-foreground', W, W, 'hsl'],
['sidebar-accent', 'mauve-4', 'mauve-4', 'hsl'],
['sidebar-accent-foreground', 'mauve-12', 'mauve-12', 'hsl'],
['sidebar-border', 'mauve-6', 'mauve-6', 'hsl'],
['sidebar-ring', 'iris-9', 'iris-10', 'hsl'],
// MD3 family.
['md3-surface', W, 'mauve-1'],
['md3-surface-dim', 'mauve-2', 'mauve-1'],
['md3-surface-bright', W, 'mauve-5'],
['md3-surface-container-lowest', W, 'mauve-1'],
['md3-surface-container-low', 'mauve-2', 'mauve-2'],
['md3-surface-container', W, 'mauve-2'],
['md3-surface-container-high', 'mauve-3', 'mauve-3'],
['md3-surface-container-highest', W, 'mauve-4'],
['md3-surface-variant', 'mauve-3', 'mauve-3'],
['md3-on-surface', 'mauve-12', 'mauve-12'],
['md3-on-surface-variant', 'mauve-11', 'mauve-11'],
['md3-primary', 'iris-11', 'iris-11'],
['md3-primary-dim', 'iris-10', 'iris-10'],
['md3-primary-container', 'iris-9', 'iris-9'],
['md3-on-primary', W, W],
['md3-on-primary-container', W, W],
['md3-outline', 'mauve-9', 'mauve-9'],
['md3-outline-variant', 'mauve-6', 'mauve-6'],
['md3-error', 'red-11', 'red-11'],
['md3-error-container', 'red-3', 'red-3'],
['md3-on-error-container', 'red-12', 'red-12'],
// Depth, bg, text and border families.
['depth-1', W, 'mauve-1'],
['depth-2', W, 'mauve-2'],
['depth-3', 'mauve-2', 'mauve-3'],
['depth-4', W, 'mauve-4'],
['bg-root', W, 'mauve-1'],
['bg-dark', 'mauve-2', 'mauve-1'],
['bg-elevated', W, 'mauve-3'],
['bg-section', 'mauve-2', 'mauve-1'],
['bg-input', W, 'mauve-2'],
['bg-hover', 'mauve-3', 'mauve-3'],
['bg-selection', 'iris-4', 'iris-5'],
['text-primary', 'mauve-12', 'mauve-12'],
['text-secondary', 'mauve-12', 'mauve-12'],
['text-muted', 'mauve-11', 'mauve-11'],
['text-dim', 'mauve-11', 'mauve-11'],
['border-subtle', 'mauve-6', 'mauve-6'],
['border-default', 'mauve-7', 'mauve-7'],
['border-hover', 'mauve-8', 'mauve-8'],
['border-accent', 'iris-7', 'iris-7'],
['border-accent-hover', 'iris-8', 'iris-8'],
['btn-primary-bg', 'iris-9', 'iris-9'],
['btn-primary-hover', 'iris-10', 'iris-10'],
['btn-primary-text', W, W],
['btn-cta-bg', 'iris-9', 'iris-9'],
['btn-cta-text', W, W],
['accent-surface-soft', 'iris-2', 'iris-2'],
['accent-surface-strong', 'iris-4', 'iris-4'],
// Status chip: step 3 fill, step 6 border. Its text is step 12 in light,
// because step 11 on step 3 measures 4.21-4.25:1 there, and step 11 in dark.
...['success:green', 'warning:amber', 'danger:red', 'info:blue', 'violet:iris', 'orange:orange', 'teal:grass'].flatMap(
(p) => {
const [n, r] = p.split(':')
return [
[`surface-${n}-bg`, `${r}-3`, `${r}-3`],
[`surface-${n}-border`, `${r}-6`, `${r}-6`],
[`surface-${n}-text`, `${r}-12`, `${r}-11`],
]
},
),
['surface-neutral-bg', 'mauve-3', 'mauve-3'],
['surface-neutral-border', 'mauve-6', 'mauve-6'],
['surface-neutral-text', 'mauve-11', 'mauve-11'],
// Status text set straight on the page: step 11 passes 4.5:1 on white and on dark steps 1-2.
['status-running', 'green-11', 'green-11'],
['status-creating', 'iris-11', 'iris-11'],
['status-stopped', 'amber-11', 'amber-11'],
['status-warm', 'orange-11', 'orange-11'],
['status-cold', 'blue-11', 'blue-11'],
['status-error', 'red-11', 'red-11'],
['status-deleted', 'mauve-11', 'mauve-11'],
['brand-primary', 'iris-9', 'iris-9'],
['brand-strong', 'iris-12', 'iris-3'],
['brand-vibrant', 'iris-9', 'iris-9'],
]

const emit = (theme) =>
MAP.map(([t, l, d, kind]) => {
const ref = theme === 'light' ? l : d
if (kind === 'hsl') return ` --${t}: ${hsl(hex(ref, theme))};`
return ` --${t}: ${ref.startsWith('#') ? ref : `var(--${ref})`};`
}).join('\n')

// Type roles use Tailwind's --text-<role> naming, so a `text-<role>` utility
// carries size, leading, tracking and weight together.
const TYPE = [
['display', 'clamp(2.5rem, 2rem + 3vw, 4rem)', '1.02', '-0.035em', '650'],
['hero', 'clamp(2.25rem, 2.2rem + 1.6vw, 3.5rem)', '1.05', '-0.03em', '650'],
['title', '2rem', '2.25rem', '-0.03em', '600'],
['page', '1.625rem', '1.875rem', '-0.02em', '600'],
['section', '1.375rem', '1.625rem', '-0.02em', '600'],
['lead', '1.125rem', '1.5rem', '0em', '600'],
['chat', '0.9375rem', '1.375rem', '0em', '400'],
['figure', '2.75rem', '2.75rem', '-0.02em', '600'],
]
const typeVars = TYPE.map(
([n, size, lh, tr, w]) =>
` --text-${n}: ${size};\n --text-${n}--line-height: ${lh};\n --text-${n}--letter-spacing: ${tr};\n --text-${n}--font-weight: ${w};`,
).join('\n')

const shared = `
/* The brand call to action. Use it once per page, on the primary consumer
* action. White text passes on every stop (5.37, 5.39 and 5.18:1). */
--cta: linear-gradient(90deg, #5b5bd6 0%, #6e56cf 55%, #8e4ec6 100%);
--cta-hover: linear-gradient(90deg, #5151cd 0%, #654dc4 55%, #8347b9 100%);
/* Radius by role. The numeric --radius-sm..xl scale is unchanged, so no
* existing surface re-rounds; new surfaces pick the role they play. */
--radius-tag: 4px;
--radius-chip: 8px;
--radius-field: 12px;
--radius-panel: 16px;
--radius-cover: 20px;
--radius-sheet: 24px;
--radius-pill: 999px;
/* Motion. */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1);
--dur-press: 120ms;
--dur-hover: 200ms;
--dur-card: 250ms;
--dur-sheet: 320ms;
--dur-reveal: 450ms;
/* Type. 12 px is the floor. */
--font-size-xs: 0.75rem;
${typeVars}`

const shadowsLight = `
/* Elevation. In light, shadow separates a card from the white page. */
--shadow-1: 0 0 0 1px rgb(33 31 38 / 0.03), 0 2px 6px rgb(33 31 38 / 0.05), 0 4px 8px rgb(33 31 38 / 0.08);
--shadow-2: 0 0 0 1px rgb(33 31 38 / 0.04), 0 6px 16px rgb(33 31 38 / 0.12);
--shadow-3: 0 8px 28px rgb(33 31 38 / 0.28);
--shadow-card: var(--shadow-1);
--shadow-dropdown: var(--shadow-2);
--shadow-overlay: var(--shadow-3);`
const shadowsDark = `
/* Elevation. In dark, a white hairline states the edge; only floating layers cast a shadow. */
--shadow-1: inset 0 0 0 1px rgb(255 255 255 / 0.06);
--shadow-2: inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 8px 24px rgb(0 0 0 / 0.5);
--shadow-3: inset 0 0 0 1px rgb(255 255 255 / 0.1), 0 16px 48px rgb(0 0 0 / 0.6);
--shadow-card: var(--shadow-1);
--shadow-dropdown: var(--shadow-2);
--shadow-overlay: var(--shadow-3);`

const system = `/* Semantic layer. Generated by scripts/gen-ladders.mjs; edit the MAP there, not this file.
*
* Requires ladders.css. Every token family (hsl, md3, depth, bg, text, border,
* surface, status, btn) points at a ladder step, so components built on these
* tokens re-skin without edits. Light pages are white and cards separate from
* them by shadow and media. Dark steps surfaces up in small lightness
* increments, draws hairline edges, and casts a shadow only under floating
* layers. Chrome is neutral (mauve); colour belongs to content and to one
* call to action per page. */
${DARK_SEL} {
${emit('dark')}${shared}${shadowsDark}
color-scheme: dark;
}
${LIGHT_SEL} {
${emit('light')}${shared}${shadowsLight}
color-scheme: light;
}
@media (prefers-reduced-motion: reduce) {
${`${DARK_SEL},\n${LIGHT_SEL}`.replace(/^/gm, ' ')} {
--dur-press: 0ms;
--dur-hover: 0ms;
--dur-card: 0ms;
--dur-sheet: 0ms;
--dur-reveal: 0ms;
}
}
`

fs.mkdirSync(OUT, { recursive: true })
fs.writeFileSync(path.join(OUT, 'ladders.css'), ladders)
fs.writeFileSync(path.join(OUT, 'system.css'), system)
console.log(`wrote ladders.css (${ladders.length} B) and system.css (${MAP.length} tokens per theme) to ${OUT}`)
Loading
Loading