diff --git a/docs/ledger/rows/BLD-001.json b/docs/ledger/rows/BLD-001.json index ce00451209..eedb27358e 100644 --- a/docs/ledger/rows/BLD-001.json +++ b/docs/ledger/rows/BLD-001.json @@ -4,16 +4,16 @@ "lane": "program", "priority": "P2", "size": "S", - "status": "open", + "status": "done", "source": { "file": "docs/EDITOR-NEXT.md", "ref": "Owner approval 2026-11-02: build the next-gen builder from the role and block contracts. BLD-001." }, - "detail": "B0 Align, pass BLD-001 (spec: docs/EDITOR-NEXT.md#bld-001). Token map to the 45 contract names. Scope: design system + lib/theme. Build: Generate one map from Frequency tokens (Commons, Commons night, Stage) to the contract groups: ground, ink, accent, line, status, band, shape, depth, type, rhythm, motion. Retire --th-* in element-library.md and theme-pack.json; point them at contract names. Done when: Pin test: every contract name resolves on Commons, Commons night and Stage; Computed values equal today's on the 31 design pages. Gates: check:tokens, check:theme-conformance, Design system adherence check, Pass probes, check:backlog, check:one-list, check:adr, check:id-collisions. After: nothing. Loop: build, validate, repair (3 cycles, classes in EDITOR-NEXT.md), record.", + "detail": "B0 Align, pass BLD-001 (spec: docs/EDITOR-NEXT.md#bld-001). Token map to the 45 contract names. Scope: design system + lib/theme. Build: Generate one map from Frequency tokens (Commons, Commons night, Stage) to the contract groups: ground, ink, accent, line, status, band, shape, depth, type, rhythm, motion. Retire --th-* in element-library.md and theme-pack.json; point them at contract names. Done when: Pin test: every contract name resolves on Commons, Commons night and Stage; Computed values equal today's on the 31 design pages. Gates: check:tokens, check:theme-conformance, Design system adherence check, Pass probes, check:backlog, check:one-list, check:adr, check:id-collisions. After: nothing. Loop: build, validate, repair (3 cycles, classes in EDITOR-NEXT.md), record.\n\n2026-11-04: the forty-five contract names now ride every element that re-declares Frequency's aliases (root, Stage surfaces, night sections, skinned subtrees), so a Stage section reads Stage values instead of inheriting the root's Commons ones; the pin test resolves every name in Commons, Commons night and Stage and compares it with the token it points at there (it failed on Stage before). The handoff's --th-* roles (element-library.md, theme-pack.json, and the editor seed) point at contract names in TH_TO_CONTRACT; the handoff files are read-only, so the retirement lives in that table. No computed value of an existing token moves; only --c-* names gain declarations.", "verify": { - "kind": "manual", - "checked": "2026-11-02", - "evidence": "Filed from the builder spec. The pass writes scripts/probes/builder/BLD-001.mjs, proves it fails before the change and passes after, and switches this row to kind cmd in the same PR." + "kind": "cmd", + "cmd": "node scripts/probes/builder/BLD-001.mjs" }, + "closed": "2026-11-04", "wave": "W7" } diff --git a/lib/theme/block-contract-tokens.test.ts b/lib/theme/block-contract-tokens.test.ts index 604239a5f5..924d5440aa 100644 --- a/lib/theme/block-contract-tokens.test.ts +++ b/lib/theme/block-contract-tokens.test.ts @@ -13,7 +13,7 @@ import { DESIGNER_TOKENS_CSS } from '@/lib/theme/designer' import { FREE_HUMAN_TOKENS_CSS } from '@/lib/theme/free-human' import { SERVICES_TOKENS_CSS } from '@/lib/theme/services' import { NEW_WORLD_TOKENS_CSS } from '@/lib/theme/new-world' -import { CONTRACT_THEME_IDS, CORE_TOKEN_NAMES, coreTokenMap, coreTokensCss, type ContractThemeId, type CoreTokenName } from '@/lib/theme/block-contract-tokens' +import { CONTRACT_THEME_IDS, CORE_TOKEN_NAMES, coreTokenMap, coreTokensCss, TH_TO_CONTRACT, type ContractThemeId, type CoreTokenName } from '@/lib/theme/block-contract-tokens' // THE PIN (LIVE-1856). The contract adds names; it must move no value. This resolves every contract name against the // sheets a live site wears today and compares it with the committed golden (block-contract-map.json). A sheet edit that @@ -113,7 +113,7 @@ function themeScope(id: ContractThemeId): Decls { } /** Replace every var() in a value, recursively, in the cascade scope the declaring element gives it. */ -function expand(value: string, theme: Decls, inTheme: boolean, depth = 0): string { +function expand(value: string, theme: Decls, inTheme: boolean, depth = 0, root: Decls = ROOT): string { if (depth > 24) throw new Error(`var() chain too deep: ${value}`) let out = '', i = 0 while (i < value.length) { @@ -130,9 +130,9 @@ function expand(value: string, theme: Decls, inTheme: boolean, depth = 0): strin const name = (comma < 0 ? inner : value.slice(at + 4, comma)).trim() const fb = comma < 0 ? null : value.slice(comma + 1, j - 1).trim() if (isExternal(name, theme)) out += `var(${name})` - else if (inTheme && theme.has(name)) out += expand(theme.get(name)!, theme, true, depth + 1) - else if (ROOT.has(name)) out += expand(ROOT.get(name)!, theme, false, depth + 1) - else if (fb !== null) out += expand(fb, theme, inTheme, depth + 1) + else if (inTheme && theme.has(name)) out += expand(theme.get(name)!, theme, true, depth + 1, root) + else if (root.has(name)) out += expand(root.get(name)!, theme, false, depth + 1, root) + else if (fb !== null) out += expand(fb, theme, inTheme, depth + 1, root) else out += `var(${name})` i = j } @@ -283,3 +283,76 @@ describe('core token contract (LIVE-1856)', () => { }) } }) + +// BLD-001. Frequency is three rooms, not one: Commons (the root by day), Commons night (the root under `.dark`) and +// Stage (a `data-surface="stage"` section, the marketing site). Each redefines the `--fq-*` tokens, and +// app/themes/frequency.css re-declares the aliases on each of those elements so they resolve there. The contract must +// ride the same elements: a name declared on the root alone inherits the root's computed Commons value into a Stage +// section. Here every contract name is resolved in each room the way the browser would, and must equal the token it +// points at, resolved in that same room. +describe('Frequency contract in every room (BLD-001)', () => { + const GLOBAL_ROOT = scopeOf(readFileSync('app/globals.css', 'utf8'), [':root']) + const FQ = [readFileSync('app/themes/frequency/core.css', 'utf8'), readFileSync('app/themes/frequency.css', 'utf8')] + const fq = (selectors: string[]): Decls => new Map(FQ.flatMap((css) => [...scopeOf(css, selectors)])) + const COMMONS = [':root[data-design="frequency"]', ':root[data-design="frequency"]:not(.dark)'] + const NIGHT = [':root[data-design="frequency"]', '.dark[data-design="frequency"]', ':root.dark[data-design="frequency"]'] + const STAGE = ['[data-design="frequency"] [data-surface="stage"]', ':root[data-design="frequency"] [data-surface="stage"]'] + const prelude = coreTokensCss('frequency').slice(0, coreTokensCss('frequency').indexOf('{')).split(',').map((x) => x.trim()) + const contract = new Map(Object.entries(coreTokenMap('frequency'))) + + const ROOMS: Record = { + commons: { root: new Map([...GLOBAL_ROOT, ...fq(COMMONS)]), element: null }, + 'commons night': { root: new Map([...GLOBAL_ROOT, ...fq(NIGHT)]), element: null }, + stage: { root: new Map([...GLOBAL_ROOT, ...fq(COMMONS)]), element: STAGE }, + } + + /** The computed value of a contract name in a room: declared on the room's element, or inherited from the root. */ + function computed(room: { root: Decls; element: string[] | null }, name: CoreTokenName): string { + const root = new Map([...room.root, ...(prelude.includes(COMMONS[0]) ? contract : [])]) + if (!room.element) return expand(root.get(name)!, new Map(), false, 0, root) + const element = new Map([...fq(room.element), ...(room.element.some((s) => prelude.includes(s)) ? contract : [])]) + return element.has(name) ? expand(element.get(name)!, element, true, 0, root) : expand(root.get(name)!, new Map(), false, 0, root) + } + + /** What the name points at, resolved where the room's own tokens live. */ + function today(room: { root: Decls; element: string[] | null }, name: CoreTokenName): string { + const element = room.element ? fq(room.element) : new Map() + return expand(contract.get(name)!, element, room.element !== null, 0, room.root) + } + + for (const [label, room] of Object.entries(ROOMS)) { + it(`every contract name resolves on ${label} to the value its token has there`, () => { + for (const n of CORE_TOKEN_NAMES) { + const v = computed(room, n) + expect(v, `${label} ${n}`).not.toBe('') + expect(v, `${label} ${n}`).toBe(today(room, n)) + } + }) + } + + it('the three rooms are told apart (Stage and night do not read Commons ground)', () => { + const canvas = Object.fromEntries(Object.entries(ROOMS).map(([k, r]) => [k, computed(r, '--c-canvas')])) + expect(new Set(Object.values(canvas)).size).toBe(3) + }) +}) + +describe('the handoff\'s --th-* roles point at contract names (BLD-001)', () => { + const sources = [ + 'design_handoff/frequency/guidelines/element-library.md', + 'design_handoff/frequency/assets/infographics/theme-pack.json', + 'lib/sites/editor/theme-contract.json', + ] + const used = new Set(sources.flatMap((f) => [...readFileSync(f, 'utf8').matchAll(/--th-[a-z0-9]+(?:-[a-z0-9]+)*/g)].map((m) => m[0]))) + + it('every --th-* name the handoff or the editor seed uses is mapped or marked theme-private', () => { + expect([...used].filter((n) => !(n in TH_TO_CONTRACT)).sort()).toEqual([]) + }) + + it('every mapping lands on one of the forty-five', () => { + for (const [th, c] of Object.entries(TH_TO_CONTRACT)) if (c !== null) expect(CORE_TOKEN_NAMES, th).toContain(c) + }) + + it('the six roles the element library names all carry a contract name', () => { + for (const th of ['--th-bg', '--th-text', '--th-muted', '--th-accent', '--th-on-accent', '--th-r']) expect(TH_TO_CONTRACT[th], th).not.toBeNull() + }) +}) diff --git a/lib/theme/block-contract-tokens.ts b/lib/theme/block-contract-tokens.ts index 17a72f999a..f7ae1b0ad3 100644 --- a/lib/theme/block-contract-tokens.ts +++ b/lib/theme/block-contract-tokens.ts @@ -46,9 +46,22 @@ export const CONTRACT_THEME_IDS = ['frequency', 'bold', 'wildheart', 'network', export type ContractThemeId = (typeof CONTRACT_THEME_IDS)[number] -/** The element each theme's tokens are declared on. Bold's code id is `menswork`; Default (`frequency`) wears the root. */ +/** Frequency declares its aliases on every element that redefines its `--fq-*` tokens (app/themes/frequency.css, + * "one rule, two surfaces"): the root (Commons, and Commons night under `.dark`), each Stage surface, a night section + * and a skinned subtree. A custom property resolves its var() where it is declared, so a contract name declared on + * the root alone would freeze Commons values into a Stage section. The contract rides the same elements (BLD-001). */ +const FREQUENCY_CONTRACT_SCOPE = [ + ':root[data-design="frequency"]', + '[data-design="frequency"] [data-surface="stage"]', + '[data-design="frequency"][data-surface="stage"]', + '[data-design="frequency"] [data-time="night"]', + '[data-design="frequency"] [data-skin]', + '[data-design="frequency"][data-skin]', +] as const + +/** The element each theme's tokens are declared on. Bold's code id is `menswork`; Frequency wears its alias elements. */ const SCOPE: Readonly> = { - frequency: ':root[data-design="frequency"]', + frequency: FREQUENCY_CONTRACT_SCOPE.join(','), bold: '[data-house-theme="menswork"]', wildheart: '[data-house-theme="wildheart"]', network: '[data-house-theme="network"]', @@ -74,3 +87,44 @@ export function coreTokensCss(theme: ContractThemeId): string { const map = coreTokenMap(theme) return `${SCOPE[theme]}{${CORE_TOKEN_NAMES.map((n) => `${n}:${map[n]}`).join(';')}}` } + +/** The handoff's editor roles (`--th-*`, design_handoff/frequency/guidelines/element-library.md and + * assets/infographics/theme-pack.json, plus the editor seed lib/sites/editor/theme-contract.json) pointed at the + * contract name that carries the same role (BLD-001). The handoff is read-only, so the retirement lives here: a reader + * of either file looks the name up in this table. `null` marks a role that is one theme's private vocabulary (a season + * colour, a gilt, an arch) and has no contract name; a role block never reads it. */ +export const TH_TO_CONTRACT: Readonly> = { + '--th-bg': '--c-canvas', + '--th-surface': '--c-surface', + '--th-raised': '--c-raised', + '--th-hairline': '--c-line', + '--th-text': '--c-text', + '--th-muted': '--c-text-muted', + '--th-secondary': '--c-text-muted', + '--th-accent': '--c-accent', + '--th-accent-hover': '--c-accent-hover', + '--th-accent-soft': '--c-accent-soft', + '--th-accent-bg': '--c-accent-bg', + '--th-accent-deep': '--c-accent-deep', + '--th-accent-text': '--c-accent-text', + '--th-on-accent': '--c-on-accent', + '--th-r': '--r-card', + '--th-clip-md': '--frame', + '--th-display': '--f-display', + '--th-dw': '--w-display', + '--th-body': '--f-body', + '--th-voice': '--f-voice', + '--th-quote': '--f-voice', + '--th-solid': '--c-accent', + '--th-on-solid': '--c-on-accent', + '--th-solid-hover': '--c-accent-hover', + '--th-hero': '--f-display', + '--th-clip-sm': null, + '--th-clip-lg': null, + '--th-mono': null, + '--th-season': null, + '--th-on-season': null, + '--th-gilt': null, + '--th-on-gilt': null, + '--th-arch': null, +} diff --git a/scripts/probes/builder/BLD-001.mjs b/scripts/probes/builder/BLD-001.mjs new file mode 100644 index 0000000000..6950ebd588 --- /dev/null +++ b/scripts/probes/builder/BLD-001.mjs @@ -0,0 +1,21 @@ +// BLD-001: the token map. The forty-five contract names (lib/theme/block-contract-tokens.ts) resolve in all three of +// Frequency's rooms, Commons, Commons night and Stage: the contract rides every element that re-declares Frequency's +// aliases, so a Stage section reads Stage values instead of the root's Commons ones. The handoff's --th-* roles point +// at contract names. The pin test resolves every name in each room and compares it with the token it points at there. +import fs from 'node:fs' +const bad = [] +const read = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : (bad.push(`missing ${f}`), '')) +const tokens = read('lib/theme/block-contract-tokens.ts') +for (const sel of ['[data-design="frequency"] [data-surface="stage"]', '[data-design="frequency"] [data-time="night"]', '[data-design="frequency"] [data-skin]']) { + if (!tokens.includes(`'${sel}'`)) bad.push(`the Frequency contract scope lacks ${sel}`) +} +if (!/frequency: FREQUENCY_CONTRACT_SCOPE\.join\(','\)/.test(tokens)) bad.push('the Frequency contract must be declared on every alias element, not the root alone') +if (!/export const TH_TO_CONTRACT/.test(tokens)) bad.push('no --th-* to contract-name map') +for (const [th, c] of [['--th-bg', '--c-canvas'], ['--th-text', '--c-text'], ['--th-muted', '--c-text-muted'], ['--th-accent', '--c-accent'], ['--th-on-accent', '--c-on-accent'], ['--th-r', '--r-card']]) { + if (!tokens.includes(`'${th}': '${c}'`)) bad.push(`${th} must point at ${c}`) +} +const test = read('lib/theme/block-contract-tokens.test.ts') +if (!/Frequency contract in every room \(BLD-001\)/.test(test)) bad.push('the per-room pin test is missing') +for (const room of ["commons:", "'commons night':", 'stage:']) if (!test.includes(room)) bad.push(`the pin test does not resolve ${room.replace(/[':]/g, '')}`) +if (!/every --th-\* name the handoff or the editor seed uses is mapped/.test(test)) bad.push('the --th-* coverage test is missing') +if (bad.length) { console.error(bad.join('\n')); process.exit(1) }