diff --git a/.claude/settings.json b/.claude/settings.json index ed28e8f6e..c2ac1e938 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": [ { diff --git a/.github/workflows/design-roundtrip.yml b/.github/workflows/design-roundtrip.yml new file mode 100644 index 000000000..806958a81 --- /dev/null +++ b/.github/workflows/design-roundtrip.yml @@ -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 diff --git a/docs/DESIGN_SYNC_PLAN.md b/docs/DESIGN_SYNC_PLAN.md new file mode 100644 index 000000000..7204cf7a4 --- /dev/null +++ b/docs/DESIGN_SYNC_PLAN.md @@ -0,0 +1,550 @@ +# Design Sync — Reverse Direction & Scaling Plan + +**Status: draft for review.** No code is built by this plan and no +`tokens/*.json` file is changed as part of authoring it. This document +designs the **reverse** half of the design-system sync (Penpot → repo) +and sketches how the pipeline could scale to complete panels and +full-screen layouts. It is the companion to +[`DESIGN_SYSTEM_PLAN.md`](DESIGN_SYSTEM_PLAN.md), which remains the +architecture-of-record for the forward direction (repo → Penpot) and +the token model. Where the two overlap, `DESIGN_SYSTEM_PLAN.md` owns +the token schema and build; **this doc owns the round-trip**. + +--- + +## Context + +The forward direction is shipped. `tokens/global.json` + +`tokens/components/*.json` (W3C Design Tokens) build through Style +Dictionary into the gitignored `src/styles/tokens.css`, and three +**seeder** scripts push those tokens *into* Penpot through the Penpot +MCP `execute_code` tool: + +| Seeder | Seeds | Penpot target | +|---|---|---| +| `scripts/sync-penpot-global.ts` | `tokens/global.json` (color + dimension) | `Global` set | +| `scripts/sync-penpot-components.ts` | `tokens/components/*.json` | `Components/{Browse,Chat,Playback,Tools-Menu}` sets | +| `scripts/sync-penpot-modes.ts` | `com.tokens-studio.modes` blocks | `Modes/*` sets + `Default`/`Tablet`/`Phone Portrait`/`Mobile Native` themes | + +The **reverse** direction — a designer changing a value, panel, or +layout in Penpot and that change flowing back into `tokens/*.json` as a +reviewed PR — does **not** exist. `DESIGN_SYSTEM_PLAN.md` defers it +explicitly: + +> Round-trip in the other direction (Penpot → JSON export). The +> designer-side export-and-commit flow described above remains the +> canonical channel for now; a future script will diff Penpot's +> exported JSON against `tokens/*.json` to streamline review. + +That manual "export-and-commit" flow is the only reverse channel today, +and it is the friction this plan removes. The long-term ambition is a +low-friction two-way sync covering **complete panels and full-screen +layouts across desktop, tablet, phone-portrait, and the Tauri +mobile-native axis** — but that ambition has a worth-it ceiling, drawn +honestly in §3. + +--- + +## §1 Current-state audit + +### The live MCP tool surface is four tools + +The Penpot MCP server (pre-beta, wired at account level + local +`.mcp.json`) exposes exactly four tools: + +| Tool | Purpose | Direction | +|---|---|---| +| `high_level_overview` | Usage instructions for the Penpot API | read | +| `penpot_api_info` | API-doc introspection for a type/member | read | +| `execute_code` | Run arbitrary JS in the Penpot plugin context | read **and** write | +| `export_shape` | Render a shape to PNG/SVG | read (raster/vector) | + +There are **no** granular typed token / component / layout MCP tools. +Every capability — read or write — funnels through `execute_code` +against the `penpot.library.local.tokens` object graph (`tokens.sets`, +`set.tokens`, `token.value`, `tokens.themes`, `theme.activeSets`). This +is the single most important audit finding: the MCP is a **code-execution +bridge**, not a typed API, so the reverse path is not blocked on any +missing tool — it is the *same bridge* the seeders already use, run in +the read direction. + +> **Live probe — blocked by the gateway approval policy, not by +> design.** This audit was assembled from the loaded MCP tool schemas +> and the seeders' proven read patterns. The empirical read-only probe +> (enumerate the live sets / tokens / themes, confirm data shapes) was +> attempted in the R0 implementation session and is **blocked by the +> managed MCP gateway**: the session config pins all four Penpot tools +> to `permission_policy: "always_ask"`, and an automated cloud session +> has no interactive approval channel, so every call — even read-only +> `penpot_api_info` — is rejected in 0 s with "MCP tool call requires +> approval". The connection and auth are themselves healthy (the +> Penpot streamable-HTTP server at `design.penpot.app/mcp/stream` held +> a live session for ~440 s). The repo's `.claude/settings.json` +> allowlist does **not** override the gateway policy — in web sessions +> a repo can't self-grant connector access. Two unblock paths, both +> verified feasible: (a) set the Penpot connector's tools to +> "Always allow" (or run the probe from an interactive session where +> the prompt can be answered); (b) call the Penpot MCP **directly** over +> HTTPS with the account MCP key (`?userToken=…`), bypassing the gateway +> — egress to `design.penpot.app` is open and the `initialize` / +> `tools/list` / `execute_code` handshake works. +> +> **Resolved (2026-06-15).** The probe was run via path (b) — a direct +> streamable-HTTP read with the account MCP key — and **succeeded**. The +> live `TerraViz - Design System` file returned all 8 seeded sets +> (`Global`, `Components/*`, `Modes/*`) and all 4 themes **with their +> `activeSets`** (the §6 unverified read path — now confirmed). The +> exported graph matched `PenpotGraph` exactly, and feeding it through +> the reconcile produced an **empty diff** against all five repo files. +> R0's empirical gate is therefore **met** (see "Implementation +> status"). + +### What the MCP can and can't do + +| Capability | Status | Notes | +|---|---|---| +| Read all token sets + values | ✅ via `execute_code` | Seeders already do this for idempotency | +| Read themes + active-set composition | ⚠️ verify live | `theme.activeSets` read path unverified; see risk below | +| Write/upsert tokens | ✅ proven | The three seeders | +| Read component frames / boards geometry | ⚠️ unverified | Needed only for §3 scaling; probe in R4 | +| Export a board image | ✅ `export_shape` | Useful for visual diffing, not token data | +| Typed token CRUD without JS | ❌ | No such tool; everything is `execute_code` | + +### The reverse path is a mirror of the seeders + +The seeders already *read* the token graph (find an existing set, read +`token.value`, compare for idempotent upsert). An exporter inverts the +write step: read the same graph, emit it as JSON, reconcile it to the +repo shape. Same files, same naming map, opposite direction. Concretely, +the JSON-path ↔ Penpot-token-name map the seeders apply forward +(`component.browse.panel-width` ↔ Penpot token `component.browse.panel-width` +↔ CSS `--component-browse-panel-width`) is the exact map the exporter +applies in reverse. + +### Round-trip-hostile tokens (repo stays authoritative) + +Some tokens **cannot faithfully round-trip** through Penpot because the +seeders deliberately skip them — Penpot's `addToken` rejects the value. +The repo must remain the source of truth for these, and the exporter +must restore them from the existing repo file rather than trust Penpot: + +| Hostile class | Example | Why | +|---|---|---| +| `calc(...)` dimensions | `component.chat.panel-max-height = calc(100vh - 8rem)` | Penpot `addToken` → "Value not valid" | +| `number` / fractional weight | `component.chat.msg-line-height = 1.55`; `ui.scale = 1` | No unitless-number token variant in Penpot | +| Composite (hand-maintained) | `--glass-border: 1px solid var(--color-surface-border-subtle)` | Lives in `multi-mode-css.mjs`, never a token | + +A second, subtler hazard: the CSS **build** wraps values in +`calc(... * var(--ui-scale))` and floors touch targets with +`max(44px, …)` — but that wrapping lives in `tokens/multi-mode-css.mjs`, +**not** in the tokens. It never reaches Penpot, so it must never appear +in exported JSON. The exporter reads raw token values; the reconcile +step must assert no `var(--ui-scale)` / `max(` artifacts leak in. + +### Mode/theme inversion + +`sync-penpot-modes.ts` *flattens* each token's +`$extensions["com.tokens-studio.modes"]` block into separate `Modes/*` +override sets plus composing themes. The exporter must do the inverse: +read the `Modes/Tablet` / `Modes/Phone-Portrait` / `Modes/Mobile-Native` +sets and **re-nest** their overrides back under each base token's +`$extensions["com.tokens-studio.modes"]`, with `default` taken from the +base set. The theme composition (which set wins where — phone-portrait +inherits tablet) is the rule that tells the exporter which mode key an +override belongs to. + +--- + +## §2 Reverse-sync design *(priority — the core of this plan)* + +### Source of truth, per artifact + +The round-trip is not symmetric ownership. Different artifacts have +different owners, and the reconcile step enforces this: + +| Artifact | Source of truth | On conflict | +|---|---|---| +| Designer-facing visual values (colors, radii, panel dims, font weights) | **Penpot** (designer-owned) | Take Penpot; surface in PR diff | +| `calc(...)` / `number` / composite tokens | **Repo** (engineer-owned) | Keep repo; warn that Penpot can't express it | +| UI-scale / touch-floor wrapping | **Build** (`multi-mode-css.mjs`) | Never a token; strip if seen | +| Token *structure* (which tokens exist, naming) | **Repo** (the schema) | New token in Penpot → flag, don't auto-add silently | +| Layout / DOM / animation / structural CSS | **Code** (non-goal to sync) | Out of scope (see §3) | + +### Two channels, one reconcile + +Per the approved direction, the plan supports **both** a primary +MCP-driven channel and a no-agent fallback. Crucially they converge on a +**single reconcile + diff + PR step** — designers never hand-edit repo +JSON, and there is exactly one place that knows the repo shape. + +**The reliability contract sits on channel B, not A** (Decision §5). +The MCP is pre-beta and the `execute_code` bridge is the only +programmatic read, so the agent-driven channel A is treated as an +*accelerant* — preferred when an agent is present — while the +channel-agnostic reconcile core must be **fully runnable from native +export alone**. When the MCP flakes, the designer's Tokens → Export +still produces a correct PR. "MCP primary" therefore means *preferred +when available*, not *required*. + +``` +(A) MCP exporter ──┐ + ├──► reconcile ──► three-way diff ──► PR +(B) Native export ─┘ (normalize) +``` + +**Channel A — MCP exporter (primary).** For each seeder, a mirror +`read-penpot-*.ts` (or a `--read` mode on the existing script) emits +**reader** plugin JS. An agent (a Claude Code session) runs it through +`execute_code`, receives the token/set/theme graph as JSON, hands it to +the reconcile step, writes `tokens/*.json`, runs `npm run tokens`, and +opens a **draft** PR (Decision §5). Lowest designer friction — the +designer says "sync my Penpot changes" and reviews a PR — at the cost of +an agent in the loop. + +**Channel B — Native export (fallback).** The designer uses Penpot's +built-in Tokens → Export JSON, drops the file(s) in a watched path, and +a script feeds them to the *same* reconcile step. No agent or MCP +dependency, but a manual export click and a slightly different input +shape to normalize. + +| | A: MCP exporter | B: Native export | +|---|---|---| +| Designer friction | Lowest ("sync it") | One manual export step | +| Dependencies | Agent + live MCP (pre-beta) | None beyond Penpot | +| Input shape | Plugin-graph JSON (we control) | Penpot's export format (we don't) | +| Failure mode | MCP instability / wrong focused file | Stale/partial export, human error | +| Role | **Primary** | Fallback / no-agent path | + +### Normalization (the one reconcile step) + +Both channels produce *some* Tokens-Studio-shaped JSON; the reconcile +module maps it to the canonical repo shape: + +1. **Re-nest modes** — fold `Modes/*` overrides back under each token's + `$extensions["com.tokens-studio.modes"]` (the §1 inversion). +2. **Restore the hostile set** — copy `calc`/`number`/composite tokens + verbatim from the current repo file; never accept a Penpot value for + them. +3. **Strip build artifacts** — reject any `var(--ui-scale)` / `max(` / + `blur(` wrapping that leaked from CSS rather than tokens. +4. **Canonical sort + format** — match the existing JSON ordering so a + no-op sync produces an empty diff (the same canonicalization + discipline the locale codegen uses, so Weblate-style whitespace + churn doesn't appear). + +### Drift / conflict handling + +A two-way "designer wins" overwrite silently loses concurrent engineer +edits. The plan uses a **three-way** model: + +- **Base** = current `tokens/*.json` in the repo. +- **Incoming** = the Penpot export (channel A or B). +- **Ancestor** = the last-synced repo state, **anchored in git** — + *not* a duplicate snapshot directory. Each successful sync records the + producing commit's SHA + timestamp in a tiny + `tokens/.penpot-sync-state.json`; the reconcile step recovers the + ancestor with `git show :tokens/`. This gives full + three-way detection without committing a redundant copy of every + token that would double the diff of every sync (Decision §5). + +Resolution: + +- Token changed only in Penpot → take Penpot. +- Token changed only in repo since last sync → keep repo, don't clobber. +- Changed in both → real conflict: keep repo for hostile tokens (warn), + otherwise take Penpot but **flag it loudly in the PR body** for human + review. +- Token present in Penpot but absent from repo schema → flag, do not + auto-add (schema is repo-owned). + +### Round-trip fidelity verification (the acceptance gate) + +The exporter is correct iff it is the exact inverse of the seeder on the +round-trippable set. The gate: + +``` +seed (repo → Penpot) → export (Penpot → JSON) → normalize → diff vs repo +``` + +must produce an **empty diff** for round-trippable tokens, and must +assert the hostile set is **byte-identical** to the repo (proving the +exporter restored rather than dropped them). This is the symmetric twin +of the seeders' idempotency check (0 created / 0 updated on re-run), and +it is the merge gate for every reverse-sync change. + +--- + +## §3 Scaling architecture *(sketch, not a full spec)* + +The token round-trip above is the tractable core. "Complete panels and +full-screen layouts across multiple dimensions" is the ambition — this +section sketches how it *could* map, and draws the line where it stops +being worth it. + +### What maps cleanly + +| App concept | Penpot concept | Sync direction | +|---|---|---| +| Dimensional/visual token | Token in a set | Two-way (this plan) | +| Responsive breakpoint *value* | Token mode | Two-way (this plan) | +| Desktop/tablet/phone/native axis | Theme (`Default`/`Tablet`/…) | Two-way (this plan) | +| Panel *visual spec* (a complete panel's tokens) | A component + its token set | Two-way (extends Tier-2, R2) | +| Board-level breakpoint geometry (panel size at a breakpoint) | A board / variant per dimension | One-way Penpot→tokens at best | + +The multi-dimension axis already has a home: Penpot **themes** ↔ CSS +`@media` / `.mobile-native`. Adding the Tauri mobile-native axis is more +of the same — it is already a seeded theme. Complete *panels* are a +quantitative extension of Tier-1: more component sets, same machinery. + +### What stays in code (and why) + +`DESIGN_SYSTEM_PLAN.md` already declares these out of scope, and this +plan keeps them there: + +- Layout logic (grid templates, flex direction, absolute positioning). +- Animation / transition definitions. +- JS-driven state classes and structural CSS (display, overflow, + z-index, pointer-events). +- The `@media` block *structure* itself (only breakpoint values are + tokens). + +The app is vanilla TS with floating glass overlays positioned by +hand-tuned CSS and ResizeObserver coordination (see CLAUDE.md "UI Layout +& Panel Coordination"). That positioning logic is behavioural, not +declarative, and does not have a faithful Penpot representation. + +### Where this stops being worth it (honest non-goals) + +- **Generating DOM/layout from Penpot boards.** A Penpot board is a + frozen rectangle; the app's panels are reactive, breakpoint-aware, and + ResizeObserver-driven. Round-tripping geometry would mean either + dumbing the app down to static frames or building a board→CSS compiler + that re-derives the reactive behaviour — neither pays for itself. +- **Pixel-exact board ↔ CSS reconciliation.** Sub-pixel and + `calc()`/`env()` realities (safe-area insets, `dvh`, UI-scale) have no + Penpot equivalent. Chasing pixel parity fights the build's own + intentional wrapping. +- **Tier-3 surfaces** (loading, download, tour) — stable, rarely + redesigned; not worth tokenizing or syncing. + +The defensible ceiling: **tokens and breakpoint geometry live in Penpot; +layout logic lives in code.** Panels scale as token sets; full-screen +*layout* does not become a Penpot artifact. + +--- + +## §4 Sequencing + +A phased, low-risk rollout. Each phase has a definition of done; nothing +generalizes before the round-trip is proven on one slice. + +| Phase | Scope | Done when | +|---|---|---| +| **R0 — validation slice** | Live MCP probe; hand-run reverse of **`Components/Playback`** through `execute_code`; manual reconcile | `seed → export → diff` is empty for Playback's round-trippable tokens; hostile set unchanged; live theme/`activeSets` read confirmed | +| **R1 — generalize** | `read-penpot-*.ts` mirrors for all seeded sets; the shared reconcile module; fidelity test wired as **advisory** CI | Reverse sync runs for global + all Tier-1 + modes; fidelity gate green; one channel (A or B) end-to-end | +| **R2 — Tier-2** | info-panel + help: extend seeders **and** exporter together | Tier-2 tokens round-trip with the same fidelity gate | +| **R3 — spacing scale** | Activate `--space-*`, migrate raw `rem` (independent of reverse sync) | `--space-*` live in CSS; tokens round-trip | +| **R4 — layout sketch → spike** | Probe board/variant read; decide build-vs-non-goal per §3 | A written go/no-go; likely **no-go** on layout generation | + +**Dependencies.** R1 depends on R0 (don't generalize an unproven +inverse). R2 depends on R1 (stable pipeline). **R3 (spacing scale) is an +independent parallel track, scheduled by appetite — deliberately *not* +pulled earlier** (Decision §5): it is a churny migration touching every +CSS file, and front-loading it would inject unrelated risk and diff-noise +into the R0/R1 reverse-sync validation. R4 is gated on R1–R3 and may be +declared not-worth-it outright. The critical path is R0 → R1 → R2. + +**The concrete first slice is `Components/Playback`** — a full component +set that exercises dimension modes (tablet overrides) but, unlike chat, +carries no `calc`/`number` hostile tokens, so it isolates the reverse +mechanism from the restore-from-repo path. It is the smallest honest +proof that the reverse direction works against the live MCP. + +### Implementation status (R0 / R1 / R2) + +The reverse-sync **code** is landed and green; the **live empirical +probe** is the one remaining R0 item, blocked on an operator action +(§1, §7) rather than on design. + +| Artifact | File | State | +|---|---|---| +| Channel-A reader (exporter) | [`scripts/read-penpot.ts`](../scripts/read-penpot.ts) | Emits one read-only `execute_code` plugin that dumps the whole local token graph (`PenpotGraph`), file-guarded on `currentFile`. | +| Shared reconcile / normalize | [`scripts/penpot-reconcile.ts`](../scripts/penpot-reconcile.ts) | The one reconcile step (§2). Pure, channel-agnostic, unit-tested. | +| Advisory fidelity gate | [`scripts/check-design-roundtrip.ts`](../scripts/check-design-roundtrip.ts) + [`.github/workflows/design-roundtrip.yml`](../.github/workflows/design-roundtrip.yml) | `npm run check:design-roundtrip`; non-gating CI. | +| Unit tests | `scripts/penpot-reconcile.test.ts`, `scripts/read-penpot.test.ts` | Gating, in the normal `npm run test` job. | + +Two design choices made during implementation, both narrowing §2 to its +simplest faithful form: + +- **One reader, not three.** The seeders split by *write* concern; the + *read* is uniform over a single `penpot.library.local.tokens` graph, + and the reconcile needs the whole graph at once (modes live in + separate `Modes/*` sets). So `read-penpot.ts` is a single reader that + mirrors all seeded sets — the cleaner of the two shapes §2 allowed + ("`read-penpot-*.ts` … or a `--read` mode"). +- **Overlay, don't reconstruct.** Reconcile deep-clones the repo JSON as + a structural template and overlays only round-trippable *values* read + from Penpot. Structure (which tokens exist, naming, authored key + order) is repo-owned, so this makes the empty-diff gate trivially + correct, *restores* the hostile set for free (hostile values are never + overlaid), and re-nests mode overrides in place. New Penpot tokens are + flagged, never auto-added. + +The fidelity gate runs the §2 acceptance check +(`seed → export → reconcile → diff`) against a **simulated** post-seed +graph built from the seeders' own output — proving `reconcile ∘ seed = +identity` on the round-trippable set (122 tokens across 5 files, 4 +hostile values asserted restored byte-identical). The live export +(channel A or B) feeds the *same* reconcile + diff via +`check-design-roundtrip.ts --graph `. + +**R0 — done (live-validated 2026-06-15).** The live read-only enumerate +ran against the focused `TerraViz - Design System` file (§1). Both R0 +acceptance conditions are met empirically: + +- The exported graph matched `PenpotGraph` exactly, and running it + through the reconcile produced an **empty diff** for all five repo + files with the 4 hostile values restored. +- The `theme.activeSets` read path returned all four themes' set + composition — the §6 risk is retired. (Note: Penpot returns + `activeSets` in its own internal order, not the seeder's written + order. This is harmless for the reverse path — the reconcile maps mode + overrides to `Modes/` sets by *name*, never by theme order + — but it does mean the *forward* modes seeder's order-sensitive + idempotency compare can report a spurious "recreate"; tracked for the + seeder, not the exporter.) + +Two real-world observations from the live run, both handled correctly: + +- The live Global set is **missing the four `facet-color.*` tokens** the + repo has — the design file predates them. The reconcile kept the repo + values and emitted `missing-in-export` warnings rather than dropping + them (the §2 "exists only in repo → keep repo" rule, validated live). + Forward-sync follow-up: re-run `sync-penpot-global.ts` to seed + `facet-color`. +- Penpot reports weight tokens with type `fontWeights` (its TokenType), + the repo uses W3C `fontWeight`. The reconcile keys round-trippability + off the **repo** `$type` and matches export tokens by name, so the + singular/plural difference is a non-issue. + +**R2 — Tier-2 (info-panel + help), offline-validated.** The two Tier-2 +component token sets are landed — +[`tokens/components/info-panel.json`](../tokens/components/info-panel.json) +and [`tokens/components/help.json`](../tokens/components/help.json), +with values extracted from `src/styles/{info-panel,help}.css` and +matching the `DESIGN_SYSTEM_PLAN.md` Tier-2 table. No machinery changed: +the seeders (`sync-penpot-components`, `sync-penpot-modes`), the reader +(`read-penpot`), the reconcile core, and the fidelity gate all +auto-discover `tokens/components/*.json`, so the new files flow through +unchanged. The gate now passes **128 tokens across 7 files** with **6 +hostile values** asserted restored byte-identical — the +2 over R1 are +the `info-panel.max-width` and `help.panel-width` tablet overrides, +both `calc(100vw - 1.5rem)` (Penpot can't store `calc`, so the repo +stays authoritative and the reconcile restores them). Tablet / +phone-portrait overrides fold into the existing `Modes/Tablet` / +`Modes/Phone-Portrait` sets; both new base sets activate under every +theme. Per §4 R2 this is the `reconcile ∘ seed = identity` proof on the +**simulated** post-seed graph — fully offline. Two follow-ups remain, +both noted, neither blocking the gate: + +1. **Live R2 round-trip confirmation** — run `read-penpot` through the + connector against the real file and feed it to + `check:design-roundtrip --graph`. Blocked on the same connector + issue as the R0 `facet-color` re-seed (§7). +2. **Forward CSS wiring** — consume `var(--component-info-panel-*)` / + `var(--component-help-*)` in the stylesheets. Owned by + `DESIGN_SYSTEM_PLAN.md`; staged after the token files because the + help trigger changes shape across breakpoints and the help panel + clamps with `min()`, so the swap is not a one-liner. The build + already emits the correct wrapped values + (`calc(340px * var(--ui-scale))`, the floored `max(48px, …)` touch + targets, the `@media` mode blocks), so the wiring is a verified-safe + swap when scheduled. + +--- + +## §5 Decisions + +- **Reverse sync is a mirror of the seeders**, not a new subsystem — + same files, same naming map, inverted direction. +- **Both channels, one reconcile** — MCP exporter primary, native export + fallback, converging on a single normalize + three-way-diff + PR step. +- **Repo is authoritative for the hostile set** (`calc`/`number`/ + composite) and for token *structure*; the exporter restores rather + than trusts Penpot for these. +- **Empty round-trip diff is the acceptance gate** — the symmetric twin + of the seeders' idempotency check. +- **Scaling stops at tokens + breakpoint geometry in Penpot**; layout, + animation, and structural CSS stay in code. + +**Resolved in review (2026-06-15)** — the five questions previously in §7: + +- **Reliability contract on channel B, not A.** Agent-in-the-loop is + accepted for the *preferred* path, but the reconcile core must run + fully from native export alone; the MCP exporter is an accelerant, not + a hard dependency. ("MCP primary" = preferred when available.) +- **Auto-open a *draft* PR per sync** — branch `design-sync/`, + CODEOWNERS-routed to a design-system reviewer, draft so a human always + promotes it. No-op (empty-diff) syncs open no PR. +- **Three-way diff with a git-anchored ancestor** — record the last sync + commit SHA in `tokens/.penpot-sync-state.json` and recover the ancestor + via `git show`, rather than committing a duplicate snapshot directory. +- **§3 ceiling held conservative** — tokens + breakpoint geometry in + Penpot, layout in code; R4 is a written go/no-go spike (expected + no-go), not a board → CSS build. +- **R3 (spacing scale) stays a parallel track**, scheduled by appetite; + the critical path is R0 → R1 → R2. + +--- + +## §6 Risks & mitigations + +| Risk | Impact | Mitigation | +|---|---|---| +| Pre-beta MCP instability / API drift | Exporter breaks silently | Fallback channel B; pin the probed API surface in R0; advisory (not gating) CI | +| `theme.activeSets` read unreliable (cf. `addSet` was broken on the seeded version) | Wrong mode inversion | Verify in R0 before trusting theme composition; fall back to reading `Modes/*` sets by name | +| Wrong focused Penpot file | Export reads the wrong library | Guard on `penpot.currentFile?.name` before any read (same operating note the seeders use) | +| Designer value silently overwrites engineer edit | Lost work | Three-way diff with git-anchored ancestor; loud PR flagging | +| Build-wrapping leaks into JSON | `var(--ui-scale)`/`max(` in tokens | Reconcile step strips + asserts; fidelity gate catches | +| Scope creep into layout generation | Unbounded effort | §3 non-goals; R4 is a go/no-go spike, not a build | + +--- + +## §7 Open questions + +The five planning questions from the first draft were **resolved in the +2026-06-15 review** and folded into §5 (Decisions). None remain blocking +for the Phase R0 implementation session — the R0/R1 code is landed and +green (see "Implementation status" in §4). The one prerequisite carried +forward is operational, not a decision: run the live read-only MCP probe +and confirm the `theme.activeSets` read path before trusting theme +composition (§6). + +That probe was blocked by the managed MCP gateway pinning the Penpot +tools to `always_ask` (§1), which an automated cloud session can't +satisfy, and was **resolved 2026-06-15** by calling the Penpot +streamable-HTTP server directly with the account MCP key +(`?userToken=…`). It returned the full graph, confirmed the +`theme.activeSets` read path, and round-tripped to an empty diff. No +design decision above changed; the only follow-ups are operational (a +forward re-seed for `facet-color`; the forward seeder's order-sensitive +theme compare). For routine syncs, prefer the gateway path with the +connector set to "Always allow", or an interactive session — the +direct-HTTP route is the no-agent fallback. + +--- + +## References + +- [`DESIGN_SYSTEM_PLAN.md`](DESIGN_SYSTEM_PLAN.md) — forward direction, + token model, build pipeline (architecture-of-record). +- [`COMPONENT_BRIEF.md`](COMPONENT_BRIEF.md) — token-to-property mapping. +- [`DESIGN_TOOL_GETTING_STARTED.md`](DESIGN_TOOL_GETTING_STARTED.md) — + Penpot setup. +- [`../tokens/README.md`](../tokens/README.md) — developer token + workflow. +- Seeders: [`../scripts/sync-penpot-global.ts`](../scripts/sync-penpot-global.ts), + [`sync-penpot-components.ts`](../scripts/sync-penpot-components.ts), + [`sync-penpot-modes.ts`](../scripts/sync-penpot-modes.ts). +- Build: [`../tokens/style-dictionary.config.mjs`](../tokens/style-dictionary.config.mjs), + [`../tokens/multi-mode-css.mjs`](../tokens/multi-mode-css.mjs). diff --git a/docs/DESIGN_SYSTEM_PLAN.md b/docs/DESIGN_SYSTEM_PLAN.md index d65ae29d4..702a3a945 100644 --- a/docs/DESIGN_SYSTEM_PLAN.md +++ b/docs/DESIGN_SYSTEM_PLAN.md @@ -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 @@ -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 diff --git a/package.json b/package.json index 0f7da2349..9f999a629 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-design-roundtrip.ts b/scripts/check-design-roundtrip.ts new file mode 100644 index 000000000..eb2a9ac7b --- /dev/null +++ b/scripts/check-design-roundtrip.ts @@ -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 + 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 { + 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 + 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() diff --git a/scripts/penpot-reconcile.test.ts b/scripts/penpot-reconcile.test.ts new file mode 100644 index 000000000..46695eb6e --- /dev/null +++ b/scripts/penpot-reconcile.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest' +import { + reconcile, + reconcileFile, + isRoundTrippable, + isBuildArtifact, + modeSetName, + titleCaseHyphenated, + collectTokenNames, + type PenpotGraph, + type RepoTokenFile, +} from './penpot-reconcile.ts' + +function graph(sets: PenpotGraph['sets']): PenpotGraph { + return { file: 'TerraViz - Design System', sets, themes: [] } +} + +describe('isRoundTrippable', () => { + it('accepts Penpot-storable types with plain values', () => { + expect(isRoundTrippable('color', '#4da6ff')).toBe(true) + expect(isRoundTrippable('dimension', '28px')).toBe(true) + expect(isRoundTrippable('fontWeight', '600')).toBe(true) + }) + + it('rejects the hostile set', () => { + expect(isRoundTrippable('number', '1.55')).toBe(false) // no Penpot number type + expect(isRoundTrippable('dimension', 'calc(100vh - 8rem)')).toBe(false) // Penpot rejects calc + }) + + it('rejects leaked build artifacts', () => { + expect(isRoundTrippable('dimension', 'calc(28px * var(--ui-scale))')).toBe(false) + expect(isRoundTrippable('dimension', 'max(44px, 2rem)')).toBe(false) + }) + + it('rejects non-string and unknown types', () => { + expect(isRoundTrippable('dimension', 5 as unknown)).toBe(false) + expect(isRoundTrippable('shadow', '0 0 1px')).toBe(false) + }) +}) + +describe('isBuildArtifact', () => { + it('flags ui-scale wrapping, max() floors, and blur()', () => { + expect(isBuildArtifact('var(--ui-scale)')).toBe(true) + expect(isBuildArtifact('max( 44px , 3rem )')).toBe(true) + expect(isBuildArtifact('blur(12px)')).toBe(true) + }) + it('does not flag plain or calc values', () => { + expect(isBuildArtifact('28px')).toBe(false) + expect(isBuildArtifact('calc(100vh - 8rem)')).toBe(false) + }) +}) + +describe('name helpers', () => { + it('title-cases hyphenated keys and maps mode set names', () => { + expect(titleCaseHyphenated('phone-portrait')).toBe('Phone-Portrait') + expect(modeSetName('tablet')).toBe('Modes/Tablet') + expect(modeSetName('mobile-native')).toBe('Modes/Mobile-Native') + }) +}) + +describe('reconcileFile — base value overlay', () => { + const repoJson = { + component: { + x: { + size: { $value: '28px', $type: 'dimension', $description: 'btn' }, + max: { $value: 'calc(100vh - 8rem)', $type: 'dimension' }, + lh: { $value: '1.55', $type: 'number' }, + }, + }, + } + const file: RepoTokenFile = { label: 'x.json', baseSetName: 'Components/X', json: repoJson } + + it('takes the designer-changed Penpot value (designer wins)', () => { + const g = graph([ + { name: 'Components/X', tokens: [{ name: 'component.x.size', type: 'dimension', value: '40px' }] }, + ]) + const out = reconcileFile(file, g) as never + expect((out as any).json.component.x.size.$value).toBe('40px') + }) + + it('never overlays hostile tokens — calc and number keep the repo value', () => { + const g = graph([ + { + name: 'Components/X', + tokens: [ + // even if Penpot somehow held values for these, they must be ignored + { name: 'component.x.max', type: 'dimension', value: '50vh' }, + { name: 'component.x.lh', type: 'number', value: '2' }, + ], + }, + ]) + const out = reconcileFile(file, g) as any + expect(out.json.component.x.max.$value).toBe('calc(100vh - 8rem)') + expect(out.json.component.x.lh.$value).toBe('1.55') + }) + + it('rejects an exported build-artifact value and warns', () => { + const g = graph([ + { + name: 'Components/X', + tokens: [{ name: 'component.x.size', type: 'dimension', value: 'calc(28px * var(--ui-scale))' }], + }, + ]) + const out = reconcileFile(file, g) as any + expect(out.json.component.x.size.$value).toBe('28px') // kept repo + expect(out.warnings.map((w: any) => w.kind)).toContain('build-artifact-rejected') + }) + + it('warns when a round-trippable token is absent from the export', () => { + const g = graph([{ name: 'Components/X', tokens: [] }]) + const out = reconcileFile(file, g) as any + expect(out.json.component.x.size.$value).toBe('28px') + expect(out.warnings.find((w: any) => w.kind === 'missing-in-export')?.path).toBe('component.x.size') + }) +}) + +describe('reconcileFile — mode overlay (the §1 mode inversion)', () => { + const repoJson = { + component: { + chat: { + 'panel-width': { + $value: '380px', + $type: 'dimension', + $extensions: { + 'com.tokens-studio.modes': { + default: '380px', + tablet: 'calc(100vw - 1.5rem)', // hostile override + 'phone-portrait': '100%', + }, + }, + }, + }, + }, + } + const file: RepoTokenFile = { label: 'chat.json', baseSetName: 'Components/Chat', json: repoJson } + + it('overlays round-trippable mode overrides from Modes/* and keeps hostile ones', () => { + const g = graph([ + { name: 'Components/Chat', tokens: [{ name: 'component.chat.panel-width', type: 'dimension', value: '400px' }] }, + { name: 'Modes/Phone-Portrait', tokens: [{ name: 'component.chat.panel-width', type: 'dimension', value: '90%' }] }, + // Modes/Tablet intentionally has no entry — the tablet override is calc (hostile) + ]) + const out = reconcileFile(file, g) as any + const modes = out.json.component.chat['panel-width'].$extensions['com.tokens-studio.modes'] + expect(out.json.component.chat['panel-width'].$value).toBe('400px') // base overlaid + expect(modes.default).toBe('400px') // default mirrors base + expect(modes.tablet).toBe('calc(100vw - 1.5rem)') // hostile kept + expect(modes['phone-portrait']).toBe('90%') // round-trippable overlaid + }) + + it('warns when a round-trippable mode override is missing from its mode set', () => { + const g = graph([ + { name: 'Components/Chat', tokens: [{ name: 'component.chat.panel-width', type: 'dimension', value: '380px' }] }, + // Modes/Phone-Portrait missing entirely + ]) + const out = reconcileFile(file, g) as any + expect(out.warnings.find((w: any) => w.kind === 'missing-mode-in-export')?.mode).toBe('phone-portrait') + }) +}) + +describe('reconcile — whole-graph concerns', () => { + const repoFiles: RepoTokenFile[] = [ + { + label: 'a.json', + baseSetName: 'Components/A', + json: { component: { a: { size: { $value: '10px', $type: 'dimension' } } } }, + }, + ] + + it('produces an empty diff for an identity graph', () => { + const g = graph([{ name: 'Components/A', tokens: [{ name: 'component.a.size', type: 'dimension', value: '10px' }] }]) + const { files } = reconcile(repoFiles, g) + expect(JSON.stringify(files[0]!.json)).toBe(JSON.stringify(repoFiles[0]!.json)) + }) + + it('flags a token present in Penpot but absent from the repo schema (not auto-added)', () => { + const g = graph([ + { + name: 'Components/A', + tokens: [ + { name: 'component.a.size', type: 'dimension', value: '10px' }, + { name: 'component.a.brand-new', type: 'dimension', value: '99px' }, + ], + }, + ]) + const { files, warnings } = reconcile(repoFiles, g) + const newWarn = warnings.find((w) => w.kind === 'new-in-penpot') + expect(newWarn?.path).toBe('component.a.brand-new') + // schema is repo-owned: the new token is NOT written into the file + expect(collectTokenNames(files[0]!.json).has('component.a.brand-new')).toBe(false) + }) +}) diff --git a/scripts/penpot-reconcile.ts b/scripts/penpot-reconcile.ts new file mode 100644 index 000000000..b2c055f62 --- /dev/null +++ b/scripts/penpot-reconcile.ts @@ -0,0 +1,310 @@ +/** + * scripts/penpot-reconcile.ts — the one reconcile / normalize step for + * the reverse design-sync (Penpot → repo). + * + * This is the channel-agnostic core described in + * `docs/DESIGN_SYNC_PLAN.md` §2 ("Two channels, one reconcile"). Both + * the MCP exporter (channel A, `read-penpot.ts`) and Penpot's native + * Tokens → Export (channel B) produce *some* Tokens-Studio-shaped token + * graph; this module folds that graph back onto the canonical repo JSON + * shape so a no-op sync produces an empty diff. + * + * Design choice — **overlay, don't reconstruct.** Token *structure* + * (which tokens exist, their naming, their authored key order) is + * repo-owned (§2 "Source of truth, per artifact"). So the reconcile + * never rebuilds the W3C tree from a flat Penpot token list. It starts + * from the current repo JSON as a template, deep-clones it, and overlays + * only the round-trippable *values* read from Penpot. Three consequences + * fall out for free: + * + * 1. Authored key order is preserved, so canonicalization is a no-op + * and the empty-diff acceptance gate (§2) is trivially correct. + * 2. The round-trip-hostile set (`calc(...)`, `number`, composites) + * is *restored* rather than dropped — those values are simply never + * overlaid, so they keep their repo value (§1 "Round-trip-hostile + * tokens"). No special copy-back path is needed. + * 3. Build-wrapping artifacts (`var(--ui-scale)`, `max(`, `blur(`) + * that live in `tokens/multi-mode-css.mjs` and never reach Penpot + * are asserted-against: if one appears in an exported value it is + * rejected with a warning instead of being written into a token. + * + * Mode inversion (§1 "Mode/theme inversion") is handled the same way: + * a token's `$extensions["com.tokens-studio.modes"]` block is walked in + * place, and each non-`default` override value is overlaid from the + * matching `Modes/` set. `default` mirrors the base `$value`. + * + * This module is pure (no fs, no git, no MCP) so it is unit-testable and + * shared by both channels and by the advisory fidelity check + * (`check-design-roundtrip.ts`). + */ + +export interface PenpotToken { + name: string + type: string + value: string + description?: string +} + +export interface PenpotSet { + name: string + tokens: PenpotToken[] +} + +export interface PenpotTheme { + group: string + name: string + activeSets: string[] +} + +/** The shape `read-penpot.ts` returns from the live Penpot token graph. */ +export interface PenpotGraph { + file?: string | null + sets: PenpotSet[] + themes?: PenpotTheme[] +} + +/** A repo token file paired with the Penpot base set it maps to. */ +export interface RepoTokenFile { + /** Human label / path, e.g. `tokens/components/playback.json`. */ + label: string + /** Penpot base set name, e.g. `Global` or `Components/Playback`. */ + baseSetName: string + /** Parsed W3C JSON — the structural template (repo-owned). */ + json: unknown +} + +export interface ReconcileWarning { + file: string + /** Dotted token path, e.g. `component.chat.panel-width`. */ + path: string + /** Mode key when the warning is about a mode override, else omitted. */ + mode?: string + kind: + | 'missing-in-export' + | 'missing-mode-in-export' + | 'build-artifact-rejected' + | 'new-in-penpot' + detail: string +} + +export interface ReconcileFileResult { + label: string + baseSetName: string + json: unknown + warnings: ReconcileWarning[] +} + +export interface ReconcileResult { + files: ReconcileFileResult[] + warnings: ReconcileWarning[] +} + +export const MODES_EXT_KEY = 'com.tokens-studio.modes' + +/** W3C `$type`s Penpot can faithfully store (and therefore round-trip). */ +export const ROUND_TRIPPABLE_TYPES: ReadonlySet = new Set([ + 'color', + 'dimension', + 'fontWeight', +]) + +/** + * CSS-build wrapping that lives in `tokens/multi-mode-css.mjs`, never in + * a token, so it must never appear in an exported value. `calc(` is + * intentionally *not* here — `calc` is a legitimate (if hostile) token + * value handled by the round-trippable predicate; these three are pure + * build artifacts. + */ +const BUILD_ARTIFACT_RE = /var\(\s*--ui-scale\s*\)|\bmax\s*\(|\bblur\s*\(/ + +export function isBuildArtifact(value: string): boolean { + return BUILD_ARTIFACT_RE.test(value) +} + +/** + * A value round-trips iff its type is one Penpot stores AND the value is + * not a `calc(...)` expression (Penpot's `addToken` rejects them) and is + * not a leaked build artifact. `number` types (line-height, ui.scale) + * fail the type check — Penpot has no unitless-number TokenType. + */ +export function isRoundTrippable(w3cType: unknown, value: unknown): boolean { + if (typeof w3cType !== 'string' || !ROUND_TRIPPABLE_TYPES.has(w3cType)) return false + if (typeof value !== 'string') return false + if (value.includes('calc(')) return false + if (isBuildArtifact(value)) return false + return true +} + +export function titleCaseHyphenated(key: string): string { + return key + .split('-') + .map((part) => (part.length === 0 ? part : part[0]!.toUpperCase() + part.slice(1))) + .join('-') +} + +/** `tablet` → `Modes/Tablet`, mirroring `sync-penpot-modes.ts`. */ +export function modeSetName(modeKey: string): string { + return `Modes/${titleCaseHyphenated(modeKey)}` +} + +interface W3CTokenLike { + $value: unknown + $type: unknown + $extensions?: unknown +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function looksLikeW3CToken(node: Record): node is W3CTokenLike & Record { + return '$value' in node && '$type' in node +} + +/** Walk a W3C token tree, invoking `visit` on each leaf token node. */ +function walkTokens( + node: unknown, + path: string[], + visit: (node: W3CTokenLike & Record, path: string[]) => void, +): void { + if (!isPlainObject(node)) return + if (looksLikeW3CToken(node)) { + visit(node, path) + return + } + for (const key of Object.keys(node)) { + walkTokens(node[key], [...path, key], visit) + } +} + +/** Collect every dotted token name present in a repo file's tree. */ +export function collectTokenNames(json: unknown): Set { + const names = new Set() + walkTokens(json, [], (_node, path) => names.add(path.join('.'))) + return names +} + +/** + * Reconcile one repo file against the Penpot graph: deep-clone the repo + * JSON and overlay round-trippable values (base + mode overrides) read + * from Penpot. Returns the reconciled JSON plus any warnings. + */ +export function reconcileFile(file: RepoTokenFile, graph: PenpotGraph): ReconcileFileResult { + const warnings: ReconcileWarning[] = [] + const clone = structuredClone(file.json) + const tokensBySet = new Map( + graph.sets.map((s) => [s.name, new Map(s.tokens.map((t) => [t.name, t]))]), + ) + const baseTokens = tokensBySet.get(file.baseSetName) ?? new Map() + + walkTokens(clone, [], (node, pathArr) => { + const name = pathArr.join('.') + const w3cType = node.$type + + // 1) Base $value overlay (only when round-trippable). + if (isRoundTrippable(w3cType, node.$value)) { + const exported = baseTokens.get(name) + if (!exported) { + warnings.push({ + file: file.label, + path: name, + kind: 'missing-in-export', + detail: `token absent from Penpot set "${file.baseSetName}"; kept repo value`, + }) + } else if (isBuildArtifact(exported.value)) { + warnings.push({ + file: file.label, + path: name, + kind: 'build-artifact-rejected', + detail: `exported value "${exported.value}" carries a build artifact; kept repo value`, + }) + } else { + node.$value = exported.value + } + } + + // 2) Mode overrides — re-overlay each non-default mode in place. + const ext = node.$extensions + if (isPlainObject(ext) && isPlainObject(ext[MODES_EXT_KEY])) { + const modes = ext[MODES_EXT_KEY] as Record + for (const modeKey of Object.keys(modes)) { + const repoVal = modes[modeKey] + if (!isRoundTrippable(w3cType, repoVal)) continue // hostile mode value → keep repo + if (modeKey === 'default') { + // `default` mirrors the base value; overlay from the base set. + const exported = baseTokens.get(name) + if (exported && !isBuildArtifact(exported.value)) modes[modeKey] = exported.value + continue + } + const setName = modeSetName(modeKey) + const exported = tokensBySet.get(setName)?.get(name) + if (!exported) { + warnings.push({ + file: file.label, + path: name, + mode: modeKey, + kind: 'missing-mode-in-export', + detail: `override absent from Penpot set "${setName}"; kept repo value`, + }) + } else if (isBuildArtifact(exported.value)) { + warnings.push({ + file: file.label, + path: name, + mode: modeKey, + kind: 'build-artifact-rejected', + detail: `exported override "${exported.value}" carries a build artifact; kept repo value`, + }) + } else { + modes[modeKey] = exported.value + } + } + } + }) + + return { label: file.label, baseSetName: file.baseSetName, json: clone, warnings } +} + +/** + * Reconcile the full set of repo files against the Penpot graph. Also + * flags tokens present in Penpot but absent from the repo schema — the + * schema is repo-owned, so a new Penpot token is surfaced, never + * auto-added (§2 drift handling). + */ +export function reconcile(repoFiles: RepoTokenFile[], graph: PenpotGraph): ReconcileResult { + const files = repoFiles.map((f) => reconcileFile(f, graph)) + const warnings: ReconcileWarning[] = files.flatMap((f) => f.warnings) + + // New-in-Penpot detection: any base/mode-set token whose name is in no + // repo file is outside the repo schema. Mode sets legitimately repeat + // base token names, so a single union of repo names is the right check. + const repoNames = new Set() + for (const f of repoFiles) for (const n of collectTokenNames(f.json)) repoNames.add(n) + + const seenNew = new Set() + for (const set of graph.sets) { + for (const tok of set.tokens) { + if (repoNames.has(tok.name) || seenNew.has(tok.name)) continue + seenNew.add(tok.name) + warnings.push({ + file: '(schema)', + path: tok.name, + kind: 'new-in-penpot', + detail: `token "${tok.name}" exists in Penpot set "${set.name}" but not in the repo schema; not auto-added`, + }) + } + } + + return { files, warnings: dedupeWarnings(warnings) } +} + +function dedupeWarnings(warnings: ReconcileWarning[]): ReconcileWarning[] { + const seen = new Set() + const out: ReconcileWarning[] = [] + for (const w of warnings) { + const key = `${w.kind}|${w.file}|${w.path}|${w.mode ?? ''}` + if (seen.has(key)) continue + seen.add(key) + out.push(w) + } + return out +} diff --git a/scripts/read-penpot.test.ts b/scripts/read-penpot.test.ts new file mode 100644 index 000000000..10854a1f3 --- /dev/null +++ b/scripts/read-penpot.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { buildReaderCode, DESIGN_FILE_NAME } from './read-penpot.ts' + +describe('read-penpot reader code', () => { + it('is read-only — performs no token graph mutations', () => { + const code = buildReaderCode() + for (const mutator of ['addSet', 'addToken', 'toggleActive', 'addTheme', '.remove(', '.value =']) { + expect(code, `must not call ${mutator}`).not.toContain(mutator) + } + }) + + it('reads sets, tokens, and themes from the local token graph', () => { + const code = buildReaderCode() + expect(code).toContain('penpot.library.local.tokens') + expect(code).toContain('tokens.sets.map') + expect(code).toContain('tokens.themes.map') + expect(code).toContain('activeSets.map') + }) + + it('emits the PenpotGraph shape the reconcile step consumes', () => { + const code = buildReaderCode() + // returns { file, sets, themes }; tokens carry name/type/value/description + expect(code).toMatch(/return\s*{[\s\S]*file:[\s\S]*sets,[\s\S]*themes,[\s\S]*}/) + for (const field of ['name:', 'type:', 'value:', 'description:']) { + expect(code).toContain(field) + } + }) + + it('guards on the focused file by default and surfaces a mismatch', () => { + const code = buildReaderCode() + expect(code).toContain(JSON.stringify(DESIGN_FILE_NAME)) + expect(code).toContain("error: 'wrong focused file'") + expect(code).toContain('penpot.currentFile') + }) + + it('omits the guard when expectedFile is null', () => { + const code = buildReaderCode(null) + expect(code).not.toContain("error: 'wrong focused file'") + // still reports the focused file name in the payload + expect(code).toContain('file: penpot.currentFile') + }) +}) diff --git a/scripts/read-penpot.ts b/scripts/read-penpot.ts new file mode 100644 index 000000000..42fb98312 --- /dev/null +++ b/scripts/read-penpot.ts @@ -0,0 +1,108 @@ +/** + * scripts/read-penpot.ts — Channel-A reverse exporter (Penpot → JSON). + * + * The mirror of the three `sync-penpot-*.ts` seeders, run in the read + * direction. Where each seeder emits *writer* plugin JS that upserts + * tokens into Penpot via the MCP `execute_code` tool, this emits a + * single *reader* plugin JS that dumps the entire local token graph — + * every set, every token, every theme — back out as JSON. + * + * One reader, not three. The seeders are split by concern (global / + * components / modes) because their *write* plans differ. Reading is + * uniform: `penpot.library.local.tokens` is a single object graph, and + * the reverse reconcile (`penpot-reconcile.ts`) needs the whole graph + * at once anyway — mode overrides live in separate `Modes/*` sets that + * must be folded back under the base tokens. So a single pass that reads + * all seeded sets (Global + Components/* + Modes/*) is the faithful + * mirror of "all the seeders", and avoids three readers redundantly + * walking the same graph. (`docs/DESIGN_SYNC_PLAN.md` R1 allows either a + * per-seeder `read-penpot-*.ts` or "a --read mode on the existing + * script"; this unified reader is the cleaner shape.) + * + * The emitted JS returns a `PenpotGraph` (see `penpot-reconcile.ts`), + * which the reconcile step consumes directly. It is read-only: it never + * calls `addSet` / `addToken` / `toggleActive`, so it is safe to run + * against the live design file. + * + * File guard. Like the seeders' operating note, the reader guards on + * `penpot.currentFile?.name` so a wrong focused file is caught before + * its tokens are mistaken for the design system's. When a guard name is + * baked in (the default), a mismatch returns `{ error, expected, actual }` + * instead of a graph; pass `null` to skip the guard. + * + * CLI: + * npx tsx scripts/read-penpot.ts # print reader plugin code + * npx tsx scripts/read-penpot.ts --list # print emitted code metadata + */ + +import { isInvokedAsScript } from './lib/cli.ts' +import type { PenpotGraph } from './penpot-reconcile.ts' + +export type { PenpotGraph } + +/** + * The Penpot library file the seeders target (see the header comment in + * each `sync-penpot-*.ts`). Used as the default reader file guard. + */ +export const DESIGN_FILE_NAME = 'TerraViz - Design System' + +/** + * Build the read-only reader plugin JS. `expectedFile` guards on the + * focused file name; pass `null` to read whatever file is focused. + */ +export function buildReaderCode(expectedFile: string | null = DESIGN_FILE_NAME): string { + const guard = + expectedFile === null + ? '' + : ` +const expected = ${JSON.stringify(expectedFile)}; +const actual = penpot.currentFile ? penpot.currentFile.name : null; +if (actual !== expected) { + return { error: 'wrong focused file', expected, actual }; +} +` + return `// Generated by scripts/read-penpot.ts — READ-ONLY dump of the local +// Penpot token graph. Run via the MCP execute_code tool against the +// TerraViz - Design System file. Returns a PenpotGraph for the reverse +// reconcile (scripts/penpot-reconcile.ts). Performs no writes. +const tokens = penpot.library.local.tokens; +${guard}const sets = tokens.sets.map(s => ({ + name: s.name, + tokens: s.tokens.map(t => ({ + name: t.name, + type: t.type, + value: t.value, + description: t.description || undefined, + })), +})); +const themes = tokens.themes.map(t => ({ + group: t.group, + name: t.name, + activeSets: t.activeSets.map(s => s.name), +})); +return { + file: penpot.currentFile ? penpot.currentFile.name : null, + sets, + themes, +}; +` +} + +if (isInvokedAsScript(import.meta.url)) { + const arg = process.argv[2] + if (arg === '--list') { + const code = buildReaderCode() + process.stdout.write( + JSON.stringify( + { file: DESIGN_FILE_NAME, bytes: code.length, lines: code.split('\n').length }, + null, + 2, + ) + '\n', + ) + } else if (arg && arg !== '--code') { + process.stderr.write(`unknown arg: ${arg}\nusage: read-penpot.ts [--code | --list]\n`) + process.exit(2) + } else { + process.stdout.write(buildReaderCode()) + } +} diff --git a/scripts/sync-penpot-components.test.ts b/scripts/sync-penpot-components.test.ts index b6f0fcf1e..a64ba2782 100644 --- a/scripts/sync-penpot-components.test.ts +++ b/scripts/sync-penpot-components.test.ts @@ -27,6 +27,19 @@ describe('sync-penpot-components', () => { expect(planByName.has('Components/Tools-Menu')).toBe(true) }) + it('covers the Tier-2 component sets (info-panel, help) with their base values', () => { + expect(planByName.has('Components/Info-Panel')).toBe(true) + expect(planByName.has('Components/Help')).toBe(true) + + const info = planByName.get('Components/Info-Panel')! + expect(info.specs.find((s) => s.name === 'component.info-panel.max-width')?.value).toBe('340px') + expect(info.specs.find((s) => s.name === 'component.info-panel.body-padding')?.value).toBe('0.75rem') + + const help = planByName.get('Components/Help')! + expect(help.specs.find((s) => s.name === 'component.help.panel-width')?.value).toBe('640px') + expect(help.specs.find((s) => s.name === 'component.help.trigger-size')?.value).toBe('36px') + }) + it('mirrors the JSON path in token names', () => { const browse = planByName.get('Components/Browse')! const browseNames = new Set(browse.specs.map((s) => s.name)) diff --git a/scripts/sync-penpot-modes.test.ts b/scripts/sync-penpot-modes.test.ts index b54f3a931..148b7f6d9 100644 --- a/scripts/sync-penpot-modes.test.ts +++ b/scripts/sync-penpot-modes.test.ts @@ -44,6 +44,28 @@ describe('sync-penpot-modes', () => { expect(panelW?.value).toBe('100%') }) + it('folds Tier-2 (info-panel, help) overrides into the existing mode sets', () => { + const tablet = setByName.get('Modes/Tablet')! + expect( + tablet.specs.find((s) => s.name === 'component.info-panel.body-max-height-expanded')?.value, + ).toBe('40vh') + expect(tablet.specs.find((s) => s.name === 'component.help.trigger-size')?.value).toBe('48px') + expect(tablet.specs.find((s) => s.name === 'component.help.panel-max-height')?.value).toBe('70vh') + + const phone = setByName.get('Modes/Phone-Portrait')! + expect(phone.specs.find((s) => s.name === 'component.help.panel-width')?.value).toBe('100vw') + expect(phone.specs.find((s) => s.name === 'component.help.panel-max-height')?.value).toBe('100dvh') + expect(phone.specs.find((s) => s.name === 'component.help.trigger-size')?.value).toBe('40px') + }) + + it('skips the Tier-2 calc() tablet width overrides (repo stays authoritative)', () => { + const calcSkips = result.skipped + .filter((s) => s.mode === 'tablet' && /calc\(\)/i.test(s.reason)) + .map((s) => s.name) + expect(calcSkips).toContain('component.info-panel.max-width') + expect(calcSkips).toContain('component.help.panel-width') + }) + it('skips calc() override values with a stderr-style reason', () => { const skip = result.skipped.find( (s) => @@ -70,6 +92,8 @@ describe('sync-penpot-modes', () => { 'Global', 'Components/Browse', 'Components/Chat', + 'Components/Help', + 'Components/Info-Panel', 'Components/Playback', 'Components/Tools-Menu', ]) diff --git a/tokens/components/help.json b/tokens/components/help.json new file mode 100644 index 000000000..bc0bcad6d --- /dev/null +++ b/tokens/components/help.json @@ -0,0 +1,42 @@ +{ + "component": { + "help": { + "panel-width": { + "$value": "640px", + "$type": "dimension", + "$description": "Help panel width (desktop intent; CSS clamps it with min())", + "$extensions": { + "com.tokens-studio.modes": { + "default": "640px", + "tablet": "calc(100vw - 1.5rem)", + "phone-portrait": "100vw" + } + } + }, + "panel-max-height": { + "$value": "80vh", + "$type": "dimension", + "$description": "Help panel max-height (full-height sheet on portrait phones)", + "$extensions": { + "com.tokens-studio.modes": { + "default": "80vh", + "tablet": "70vh", + "phone-portrait": "100dvh" + } + } + }, + "trigger-size": { + "$value": "36px", + "$type": "dimension", + "$description": "Help trigger button size (min-height on desktop; square touch target on tablet/portrait)", + "$extensions": { + "com.tokens-studio.modes": { + "default": "36px", + "tablet": "48px", + "phone-portrait": "40px" + } + } + } + } + } +} diff --git a/tokens/components/info-panel.json b/tokens/components/info-panel.json new file mode 100644 index 000000000..0b5357834 --- /dev/null +++ b/tokens/components/info-panel.json @@ -0,0 +1,33 @@ +{ + "component": { + "info-panel": { + "max-width": { + "$value": "340px", + "$type": "dimension", + "$description": "Info panel max-width (desktop; full-bleed minus gutter at tablet)", + "$extensions": { + "com.tokens-studio.modes": { + "default": "340px", + "tablet": "calc(100vw - 1.5rem)" + } + } + }, + "body-max-height-expanded": { + "$value": "60vh", + "$type": "dimension", + "$description": "Expanded info-body scroll max-height", + "$extensions": { + "com.tokens-studio.modes": { + "default": "60vh", + "tablet": "40vh" + } + } + }, + "body-padding": { + "$value": "0.75rem", + "$type": "dimension", + "$description": "Info-body inline + bottom padding" + } + } + } +}