Skip to content
Open
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
8 changes: 8 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
{
"permissions": {
"allow": [
"mcp__PenPot__high_level_overview",
"mcp__PenPot__penpot_api_info",
"mcp__PenPot__execute_code",
"mcp__PenPot__export_shape"
]
},
"hooks": {
"SessionStart": [
{
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/design-roundtrip.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Design Round-Trip (advisory)

# Advisory reverse-sync fidelity gate (docs/DESIGN_SYNC_PLAN.md §4 R1).
# Runs the seed → export → reconcile → diff round-trip and asserts an
# empty diff for round-trippable tokens with the hostile set restored
# byte-identical. Deliberately NON-gating: the Penpot MCP is pre-beta and
# the live export can't run in CI, so a regression here should surface as
# a signal, not block merges. (The reconcile/reader unit tests in the
# normal `npm run test` job ARE gating — this job covers the end-to-end
# round-trip against the simulated post-seed graph.)

on:
pull_request:
branches: [main]
paths:
- 'tokens/**'
- 'scripts/sync-penpot-*.ts'
- 'scripts/read-penpot.ts'
- 'scripts/penpot-reconcile.ts'
- 'scripts/check-design-roundtrip.ts'
- '.github/workflows/design-roundtrip.yml'

permissions:
contents: read

jobs:
round-trip:
name: Reverse-sync fidelity (advisory)
runs-on: ubuntu-latest
# Advisory: surface a regression without blocking the PR.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check:design-roundtrip
550 changes: 550 additions & 0 deletions docs/DESIGN_SYNC_PLAN.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions docs/DESIGN_SYSTEM_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,20 @@ reasonably adjust belong here — not structural CSS.
| `playback.json` | transport-btn min-width (28→40px), font-size (0.7→1rem), home-btn min-size (36→44px), time-label font-size |
| `tools-menu.json` | btn min-height (34→38px), popover min-width (240→260px), item font-size (0.75→0.82rem), layout-btn min-height (30→36px) |

Tier 2 (info-panel, help) can be added in a follow-up once the
pipeline is proven. Tier 3 (loading, download, tour) stays hardcoded.
Tier 2 (info-panel, help) **token files now exist** —
`tokens/components/info-panel.json` and `tokens/components/help.json`,
added by reverse-sync Phase R2 (see
[`DESIGN_SYNC_PLAN.md`](DESIGN_SYNC_PLAN.md) §4). They carry the
`max-width` / `body-max-height-expanded` / `body-padding` (info-panel)
and `panel-width` / `panel-max-height` / `trigger-size` (help) values
with their tablet / phone-portrait modes, and round-trip through the
fidelity gate. The **forward CSS wiring** (consuming
`var(--component-info-panel-*)` / `var(--component-help-*)` in
`src/styles/{info-panel,help}.css`) is the remaining Tier-2 step — it
is fiddlier than Tier-1 (the help trigger changes shape across
breakpoints and the help panel clamps width/height with `min()`), so
it is deliberately staged after the token files land. Tier 3 (loading,
download, tour) stays hardcoded.

**Tasks:**
- [ ] Create `tokens/components/` directory
Expand Down Expand Up @@ -864,6 +876,13 @@ Phase 6 (STYLE_GUIDE auto-update) — after Phase 1a + 1b

## Future Work (not part of this plan)

> The **reverse direction** (Penpot → repo) and how the pipeline could
> scale to complete panels and full-screen layouts are designed in a
> companion plan: [`DESIGN_SYNC_PLAN.md`](DESIGN_SYNC_PLAN.md). Several
> items below (reverse-sync script, Penpot component library, layout
> configuration, Tier 2 tokens, spacing-scale activation) are sequenced
> there as phases R0–R4.

These items depend on prerequisites that don't exist yet:

- **Penpot component library** — Build the Tier 1 component frames
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"build:qa": "npx tsx scripts/build-qa-index.ts",
"build:privacy-page": "npx tsx scripts/build-privacy-page.ts",
"check:privacy-page": "npx tsx scripts/build-privacy-page.ts --check",
"check:design-roundtrip": "npx tsx scripts/check-design-roundtrip.ts",
"dev:functions": "wrangler pages dev public --ip 0.0.0.0 --port 8788",
"db:migrate": "wrangler d1 migrations apply CATALOG_DB --local",
"db:seed": "tsx scripts/seed-catalog.ts",
Expand Down
222 changes: 222 additions & 0 deletions scripts/check-design-roundtrip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* scripts/check-design-roundtrip.ts — reverse-sync fidelity gate (advisory).
*
* The acceptance gate from `docs/DESIGN_SYNC_PLAN.md` §2:
*
* seed (repo → Penpot) → export (Penpot → JSON) → normalize → diff
*
* must produce an **empty diff** for round-trippable tokens, and the
* round-trip-hostile set (`calc`/`number`/composite) must be restored
* byte-identical. This is the symmetric twin of the seeders' idempotency
* check (0 created / 0 updated on re-run).
*
* The live MCP export can't run in CI (the Penpot server is pre-beta and
* its gateway requires interactive approval), so this check runs the
* gate against a **simulated** Penpot graph built from the seeders' own
* output — i.e. it proves `reconcile ∘ seed = identity` on the
* round-trippable set, which is exactly what correctness of the reverse
* exporter requires. When a live export fixture is available, the same
* reconcile + diff runs against it unchanged.
*
* Wired as **advisory** CI (`.github/workflows/design-roundtrip.yml`,
* continue-on-error) per §4 R1. Exits non-zero on any diff so it is also
* runnable as a local gate: `npm run check:design-roundtrip`.
*
* CLI:
* npm run check:design-roundtrip # simulated post-seed graph
* npm run check:design-roundtrip -- --graph FILE # a real exported graph
*
* `--graph FILE` feeds the same reconcile + diff a *real* Penpot export
* (channel A `read-penpot.ts` output, or channel B native export), so the
* live round-trip is validated with zero extra machinery. The file may
* be a bare `PenpotGraph` or the `{ result, log }` wrapper the MCP
* `execute_code` tool returns.
*/

import { readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname, basename } from 'node:path'
import { fileURLToPath } from 'node:url'

import { buildGlobalTokenSpecs, GLOBAL_SET_NAME } from './sync-penpot-global.ts'
import { buildComponentTokenSets } from './sync-penpot-components.ts'
import { buildModeOverrides } from './sync-penpot-modes.ts'
import {
reconcile,
titleCaseHyphenated,
collectTokenNames,
isRoundTrippable,
MODES_EXT_KEY,
type PenpotGraph,
type PenpotSet,
type RepoTokenFile,
} from './penpot-reconcile.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '..')
const GLOBAL_PATH = resolve(REPO_ROOT, 'tokens/global.json')
const COMPONENTS_DIR = resolve(REPO_ROOT, 'tokens/components')

function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, 'utf-8'))
}

function componentFiles(): string[] {
return readdirSync(COMPONENTS_DIR)
.filter((f) => f.endsWith('.json'))
.sort()
.map((f) => resolve(COMPONENTS_DIR, f))
}

function baseSetNameForFile(path: string): string {
return dirname(path) === COMPONENTS_DIR
? `Components/${titleCaseHyphenated(basename(path, '.json'))}`
: GLOBAL_SET_NAME
}

/**
* Build the Penpot graph that the seeders would produce — the "export"
* input to the round-trip — from the same source files.
*/
function buildSeededGraph(): PenpotGraph {
const sets: PenpotSet[] = []
// Global set
const { specs: globalSpecs } = buildGlobalTokenSpecs()
sets.push({ name: GLOBAL_SET_NAME, tokens: globalSpecs })
// Components/* sets
const { plans } = buildComponentTokenSets()
for (const plan of plans) sets.push({ name: plan.name, tokens: plan.specs })
// Modes/* sets + themes
const { modeSets, themes } = buildModeOverrides()
for (const ms of modeSets) sets.push({ name: ms.name, tokens: ms.specs })
return {
file: 'TerraViz - Design System',
sets,
themes: themes.map((t) => ({ group: t.group, name: t.name, activeSets: t.sets })),
}
}

/** Hostile tokens the gate asserts are restored byte-identical from repo. */
function collectHostile(repoFiles: RepoTokenFile[]): { file: string; path: string }[] {
const hostile: { file: string; path: string }[] = []
for (const f of repoFiles) {
walkLeaves(f.json, [], (node, pathArr) => {
const name = pathArr.join('.')
if (!isRoundTrippable(node.$type, node.$value)) {
hostile.push({ file: f.label, path: name })
}
// hostile mode overrides count too
const ext = node.$extensions
if (isObj(ext) && isObj(ext[MODES_EXT_KEY])) {
const modes = ext[MODES_EXT_KEY] as Record<string, unknown>
for (const [k, v] of Object.entries(modes)) {
if (k !== 'default' && !isRoundTrippable(node.$type, v)) {
hostile.push({ file: f.label, path: `${name}@${k}` })
}
}
}
})
}
return hostile
}

function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}

function walkLeaves(
node: unknown,
path: string[],
visit: (n: { $value: unknown; $type: unknown; $extensions?: unknown }, p: string[]) => void,
): void {
if (!isObj(node)) return
if ('$value' in node && '$type' in node) {
visit(node as never, path)
return
}
for (const k of Object.keys(node)) walkLeaves(node[k], [...path, k], visit)
}

/** Load a real exported graph, unwrapping the MCP `{ result, log }` shape. */
function loadGraphFile(path: string): PenpotGraph {
const raw = readJson(path) as Record<string, unknown>
const graph = (raw && typeof raw === 'object' && 'result' in raw ? raw.result : raw) as PenpotGraph
if (!graph || !Array.isArray(graph.sets)) {
throw new Error(`${path}: not a PenpotGraph (no "sets" array)`)
}
return graph
}

function main(): void {
const graphArgIdx = process.argv.indexOf('--graph')
const graphPath = graphArgIdx >= 0 ? process.argv[graphArgIdx + 1] : undefined

const repoFiles: RepoTokenFile[] = [
{ label: 'tokens/global.json', baseSetName: GLOBAL_SET_NAME, json: readJson(GLOBAL_PATH) },
...componentFiles().map((p) => ({
label: `tokens/components/${basename(p)}`,
baseSetName: baseSetNameForFile(p),
json: readJson(p),
})),
]

const graph = graphPath ? loadGraphFile(graphPath) : buildSeededGraph()
console.log(
graphPath
? `Source: exported graph ${graphPath} (file=${graph.file ?? 'unknown'})\n`
: 'Source: simulated post-seed graph (reconcile ∘ seed = identity)\n',
)
const result = reconcile(repoFiles, graph)

let mismatches = 0
console.log('Reverse-sync round-trip fidelity (seed → export → reconcile → diff)\n')
for (let i = 0; i < repoFiles.length; i++) {
const original = JSON.stringify(repoFiles[i]!.json)
const reconciled = JSON.stringify(result.files[i]!.json)
const ok = original === reconciled
if (!ok) mismatches++
console.log(` ${ok ? 'PASS' : 'FAIL'} ${repoFiles[i]!.label}`)
if (!ok) {
const diff = firstDiff(repoFiles[i]!.json, result.files[i]!.json)
console.log(` first divergence: ${diff}`)
}
}

// Hostile-set assertion: each hostile token must equal the repo value
// in the reconciled output (restored, not dropped or overwritten).
const hostile = collectHostile(repoFiles)
const tokenCount = repoFiles.reduce((n, f) => n + collectTokenNames(f.json).size, 0)
console.log(
`\n ${tokenCount} tokens across ${repoFiles.length} files; ` +
`${hostile.length} hostile values asserted restored.`,
)

const newInPenpot = result.warnings.filter((w) => w.kind === 'new-in-penpot')
const otherWarnings = result.warnings.filter((w) => w.kind !== 'new-in-penpot')
if (newInPenpot.length) {
console.log(`\n ${newInPenpot.length} token(s) present in Penpot but not in repo schema (flagged, not added):`)
for (const w of newInPenpot) console.log(` ${w.path}`)
}
if (otherWarnings.length) {
console.log(`\n ${otherWarnings.length} reconcile warning(s):`)
for (const w of otherWarnings) console.log(` [${w.kind}] ${w.path}${w.mode ? '@' + w.mode : ''} — ${w.detail}`)
}

if (mismatches > 0) {
console.error(`\n✗ ${mismatches} file(s) did not round-trip with an empty diff.`)
process.exit(1)
}
console.log('\n✓ Empty round-trip diff for all files; hostile set restored.')
}

/** Locate the first JSON pointer where two trees diverge (for diagnostics). */
function firstDiff(a: unknown, b: unknown, path = ''): string {
if (JSON.stringify(a) === JSON.stringify(b)) return '(none)'
if (isObj(a) && isObj(b)) {
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return firstDiff(a[k], b[k], `${path}/${k}`)
}
}
return `${path || '/'}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`
}

main()
Loading
Loading