From 1eb6fc44a13caefba82ab620c0a3c142ddb3abe1 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:44:53 -0400 Subject: [PATCH 01/19] chore: start @directededges/specs-schema v0.30.0 development --- packages/schema/CHANGELOG.md | 9 +++++++++ packages/schema/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index d2511995..4d9c413f 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Specs schema will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +### Changed + +### Removed + + ## [0.29.0] - 2026-08-07 Numeric properties can now be marked nullable, matching the flexibility already available on strings, slots, and images. Horizontal text alignment now uses logical inline-axis direction (`START`/`END`) instead of physical `LEFT`/`RIGHT`, so specs read correctly regardless of writing direction. diff --git a/packages/schema/package.json b/packages/schema/package.json index 728a899a..3d81f760 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.29.0", + "version": "0.30.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", From 64c3585f7a30ccb136534ca8a29e772bcc7d8902 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:44:58 -0400 Subject: [PATCH 02/19] chore: start @directededges/specs-cli v0.27.0 development --- packages/cli/CHANGELOG.md | 9 +++++++++ packages/cli/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index fd265207..da024bc6 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +### Changed + +### Removed + + ## Unreleased ### Added diff --git a/packages/cli/package.json b/packages/cli/package.json index 2f392757..c16d667d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.26.0", + "version": "0.27.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", From 8392fe7bcd535f36168e5547225d1b05eee75d64 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:26:12 -0400 Subject: [PATCH 03/19] docs(adr): claim ADR 066 in INDEX Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TfAEBwDThqo1KpCL8GaruH --- adr/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/adr/INDEX.md b/adr/INDEX.md index 35a7f3e6..aa7b832c 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 066 | Lossless Key Formatting — Safe Key Grammar and Original-Name Preservation | | | 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | | | 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | | From 501a58a7552ce95a57b6d969292e88374047fd49 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:35:03 -0400 Subject: [PATCH 04/19] Point the engine link at its package inside the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs-from-figma moved src/ into packages/specs-from-figma/, so file:../specs-from-figma now resolves to the workspace root — private, no main — and the engine fails to import. Dev-time link only; the CLI's own dependency stays a published version range. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ELnRyPVrSVkXwqpPxH4Ci4 --- package-lock.json | 40 ++++++++++++++++++++++++++++++++++------ package.json | 2 +- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95286cd0..db097163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "packages/cli" ], "dependencies": { - "@directededges/specs-from-figma": "file:../specs-from-figma" + "@directededges/specs-from-figma": "file:../specs-from-figma/packages/specs-from-figma" }, "devDependencies": { "typescript": "^5.3.3", @@ -18,8 +18,17 @@ } }, "../specs-from-figma": { + "name": "specs-from-figma-monorepo", + "dev": true, + "optional": true, + "peer": true, + "workspaces": [ + "packages/*" + ] + }, + "../specs-from-figma/packages/specs-from-figma": { "name": "@directededges/specs-from-figma", - "version": "0.28.0", + "version": "0.29.0", "license": "PolyForm-Internal-Use-1.0.0", "dependencies": { "@directededges/specs-schema": "^0.29.0", @@ -32,13 +41,15 @@ "@figma/rest-api-spec": "^0.36.0", "@types/fs-extra": "^11.0.4", "@types/node": "^20.0.0", + "@types/ws": "^8.18.1", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.25.5", "eslint": "^9.39.5", "javascript-obfuscator": "^5.3.0", "typescript": "^5.3.2", "typescript-eslint": "^8.64.0", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "ws": "^8.21.0" } }, "node_modules/@directededges/specs-cli": { @@ -46,7 +57,7 @@ "link": true }, "node_modules/@directededges/specs-from-figma": { - "resolved": "../specs-from-figma", + "resolved": "../specs-from-figma/packages/specs-from-figma", "link": true }, "node_modules/@directededges/specs-schema": { @@ -1621,7 +1632,7 @@ }, "packages/cli": { "name": "@directededges/specs-cli", - "version": "0.26.0", + "version": "0.27.0", "license": "MIT", "dependencies": { "@directededges/specs-from-figma": "^0.28.0", @@ -1642,6 +1653,23 @@ "vitest": "^3.0.0" } }, + "packages/cli/node_modules/@directededges/specs-from-figma": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@directededges/specs-from-figma/-/specs-from-figma-0.28.0.tgz", + "integrity": "sha512-2Wn+YiOXBgNA+gWDy2h7kY8g7wlj0qg4e2G0S2zN91yrsvCIZn6S5zrInP1Z3RPHGXhBMlzUxEn/BZSxI/Kzbw==", + "license": "PolyForm-Internal-Use-1.0.0", + "dependencies": { + "@directededges/specs-schema": "^0.29.0", + "fs-extra": "^11.3.3", + "yaml": "^2.8.0" + } + }, + "packages/cli/node_modules/@directededges/specs-schema": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@directededges/specs-schema/-/specs-schema-0.29.0.tgz", + "integrity": "sha512-PfK3oeTdQbvlmuD6lAGHOOlkHvQCE0wFlqQERQQRN3w/42qRaBDK/8hMwJ/mB/wyDAg5GlB+uBA0p14jsksAZg==", + "license": "CC-BY-4.0" + }, "packages/cli/node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -2070,7 +2098,7 @@ }, "packages/schema": { "name": "@directededges/specs-schema", - "version": "0.29.0", + "version": "0.30.0", "license": "CC-BY-4.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/package.json b/package.json index 86dc1a22..296b7c12 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,6 @@ "vitest": "^3.1.1" }, "dependencies": { - "@directededges/specs-from-figma": "file:../specs-from-figma" + "@directededges/specs-from-figma": "file:../specs-from-figma/packages/specs-from-figma" } } From b119ea61b971abf06c440267659f8cf1834c7f55 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:45:42 -0400 Subject: [PATCH 05/19] =?UTF-8?q?ADR-066:=20Lossless=20key=20formatting=20?= =?UTF-8?q?=E2=80=94=20safe=20key=20grammar=20and=20Figma=20name=20preserv?= =?UTF-8?q?ation=20(#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(adr): claim ADR 066 in INDEX Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TfAEBwDThqo1KpCL8GaruH * docs(adr): 066 lossless key formatting (DRAFT) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TfAEBwDThqo1KpCL8GaruH * docs(adr): 066 rename to com.figma.name, narrow figmaKeys, fix safe char set Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TfAEBwDThqo1KpCL8GaruH * feat(schema)!: lossless key formatting — format.figmaKeys, safe key grammar, com.figma.name Implements ADR-066. BREAKING: FigmaAnatomyElementExtension.originalName renamed to name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TfAEBwDThqo1KpCL8GaruH * docs(066): NONE default, pass-through rule, coverage inventory Tune-up of ADR-066 following review: - Add NONE to format.figmaKeys and make it the default, so the safe key grammar, the divergence extension, and reversal are opt-in. NONE preserves today's behavior but not today's bytes — the originalName -> name rename applies unconditionally. - Add Decision 5: names already written in the destination format are passed through unformatted and record com.figma.name. Predicate is an idempotence test (formatKey(name, keys) === name) rather than six per-format grammars, excluding SAFE. - Correct the safe key grammar: a word is all letters or all digits, a name is never digit-initial. Makes "Badge count 2" safe and "Badge count2" unsafe. - State the word-splitting rule (spaces, case transitions, letter<->digit transitions) that makes reversal deterministic. - Add a coverage inventory of every keyed space in the schema, with the normative requirement that producers recurse into Composition.anatomy and SlotContent.anatomy. - Add an audit of key application surfaces in figma-from-specs, including the variant option values gap. - Suggest a follow-on `specs analyze keys` report. - Fix Semver: DEFAULT_CONFIG supplies NONE, and the required ResolvedConfig field is MAJOR-class. - Qualify Consequences that overclaimed against the NONE default and the wrapper-collapse trigger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * feat(066)!: default figmaKeys to NONE, correct the safe key grammar Realigns the implementation with the revised ADR-066. - Config.format.figmaKeys and ResolvedConfig gain NONE, and DEFAULT_CONFIG now supplies NONE rather than SENTENCE. The safe key grammar, the com.figma.name divergence extension, and reversal become opt-in; as previously implemented every catalog was opted in by default. - workspace.schema.json: figmaKeys enum gains NONE and defaults to it. - SafeKeySentence / SafeKeyTitle patterns corrected so a word is either all letters or all digits and a name is never digit-initial: ^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$ ^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$ "Badge count 2" is now safe (it was rejected by the old pattern despite being listed as safe in the ADR); "Badge count2" is now unsafe, since both format to badgeCount2 and only the spaced form reverses correctly. - name descriptions on FigmaAnatomyElementExtension and FigmaPropExtension now enumerate all triggers, including destination-format pass-through, and state that format divergence is not evaluated under NONE. - ADR: align Decision 5 Option A with the idempotence predicate in the Decision body. Producer work implied but not included here: the letter<->digit word-splitting rule and the pass-through test are specs-from-figma behavior. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * docs(066): accept ADR, update docs site, repair ADR workflow scripts - ADR-066 status DRAFT -> ACCEPTED; INDEX row moved to the Accepted table with highlights. All three gates pass: tsc build, schema validation (5/5), and strict type tests. - Docs site brought in line with the accepted ADR. These were written against the original draft and still documented SENTENCE as the default and the superseded grammar: - settings/figma-keys.md: NONE added and defaulted, new "Opting in" section framing the fidelity/spec-size trade, pass-through behavior - guides/key-formatting.md: corrected grammar (a word is all letters or all digits, never digit-initial), digit-run splitting rule, destination-format pass-through, and a "What is not covered" section - schema/config.md: type union, default column, DEFAULT_CONFIG snippet - settings/index.mdx: sample config notes that SENTENCE opts in - Repair two ADR workflow scripts broken by deb1325, which relocated them from .specify/scripts/bash/ to scripts/ without updating their path assumptions: - validate-schema.sh walked up three levels and resolved the schema dir outside the repo entirely; now resolves via git rev-parse and defaults to packages/schema/schema - check-prerequisites.sh sourced common.sh, deleted by the same commit; the helpers it used (repo root, branch, feature paths, branch validation) are inlined so it has no external dependency overview/releases.mdx deliberately unchanged — its originalName references are historical notes describing what shipped in 0.28.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * chore(adr): check for an existing PR before accepting The accept agent assumed a fresh ADR branch and unconditionally ran gh pr create at the end, so re-running it on a branch already in review would open a duplicate PR. - New step 2, before loading context or running gates: query gh pr list --head "$BRANCH" --state all and branch on the result. Open -> adopt its base and treat the run as an update; merged -> halt; closed-unmerged -> report and ask; none -> the original path. - Step 3 no longer halts flatly when the ADR is already ACCEPTED. It halts only when there is also nothing uncommitted; otherwise it skips the status flip and continues so follow-up changes still reach the existing PR. - Step 7 (release branch) is skipped when a PR exists — the PR's base is authoritative. - Step 8 creates a PR only when there isn't one, and otherwise offers to refresh the existing body after asking. Long bodies go via --body-file; an inline heredoc breaks on backticks and quotes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * feat(066): destination-format retention, NumberProp extensions, config template Refines ADR-066 against the implementation in specs-from-figma. - Decision 5 restated as three ordered tests: a name matching the origin convention formats and stays quiet; a name matching the destination convention (format.keys) is retained verbatim and recorded; a name matching neither is formatted and recorded. Only the destination retains — a PascalCase name with a KEBAB destination is reformatted like any divergent name, since formatting flattens within a convention rather than converting between them. The origin test runs first so a name satisfying both stays quiet. - NumberProp gains $extensions. It was the only prop type without it, so a number prop had nowhere to record its Figma name. The JSON schema already allowed $-prefixed keys via patternProperties; the TypeScript type did not. - specs.config.yaml template documents format.figmaKeys, commented out at its NONE default, matching how other defaulted settings appear. - Docs and changelogs updated to the destination-only rule. Verified against 69 components in specs-testing/workspaces/eg: output is byte-identical to the SENTENCE baseline, 149 preserved names, no key changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * feat(cli): add the keys analyzer (ADR-066) Reports Figma layer and property names that a formatted key cannot reconstruct, so they can be tidied at the source. Organized byComponent first — a designer edits one component at a time, so that is the shape of the checklist. byCause follows, for deciding what to fix in bulk, and byName last, because a name wrong in forty components is one decision rather than forty. Reads generated specs, so it sees a name only where the producer recorded it in $extensions['com.figma'].name. That requires format.figmaKeys to declare a convention; under the NONE default the report is empty, which is correct — no convention was declared, so nothing diverged from one. Causes are ordered most-specific first, since that is the edit to make. 'already-a-key' is last and is not a defect: it marks a name deliberately authored in a key convention, which the producer retains as written. Docs: new site page at cli/analyze/keys, added to the analyze overview table, output tree, invocation examples, and sidebar. Verified against 69 components in specs-testing/workspaces/eg — 948 names, 75 divergent across 52 components, with 'A11y label' appearing in 42 of them and a 'Label ' with trailing spaces surfacing as a genuine authoring defect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * refactor(cli): split keys byComponent by surface byComponent entries no longer carry a `surface` field on a combined `names` array. Each component now emits `props` and `anatomy` arrays, with the surface implied by which array an entry is in: egdsMenu: divergent: 3 props: - key: a11yLabel figmaName: A11y label cause: mixed-letter-digit anatomy: - key: egdsBottomSheet figmaName: EGDS Bottom Sheet cause: casing A surface with nothing to fix is omitted rather than emitted as an empty array, keeping the checklist quiet. `divergent` remains the total across both. summary, byCause, and byName are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * docs: note the byComponent surface split in the cli changelog Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y * fix(cli): stop the keys analyzer reporting collapse provenance as a defect $extensions['com.figma'].name has two independent triggers: format divergence (ADR-066) and wrapper-collapse provenance (ADR-058). The analyzer treated the presence of the field as proof a name had diverged, so it reported collapsed roots as naming problems even when their names were perfectly well-formed — 'Text' on egdsBody/egdsHeading/egdsText and 'Icon glyph' on egdsIcon all satisfy the SENTENCE grammar and reconstruct from their keys unaided. causeOf then compounded it by returning 'casing' as a catch-all, labelling those names a casing defect. The analyzer now reads metadata.config.format.figmaKeys from the spec and re-tests each recorded name against the safe key grammar, reporting only genuine failures. 'casing' is now a verdict reached after every other rule has been ruled out on a name already known to fail, not a fallback. This also makes the code agree with the docs, which already stated the report is empty under the NONE default — it was not, it listed the four collapsed roots. eg catalog: 75 divergent across 52 components becomes 71 across 49, with casing 28 -> 24. Every other cause is unchanged, which is the check that only the false positives went. Grammar patterns are local literals mirroring SafeKeySentence / SafeKeyTitle: importing the schema JSON did not survive esbuild bundling, and a runtime package path lookup is the wrong dependency for a single-file CLI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y --------- Co-authored-by: Claude Opus 5 --- .github/agents/Specs.adr.accept.agent.md | 38 +- adr/066-lossless-key-formatting.md | 634 ++++++++++++++++++ adr/INDEX.md | 1 + packages/cli/CHANGELOG.md | 3 + packages/cli/src/Config/ConfigTemplates.ts | 7 + packages/cli/src/analyzers/Keys.ts | 271 ++++++++ packages/cli/src/analyzers/index.ts | 2 + packages/cli/src/commands/AnalyzeCommand.ts | 2 +- .../cli/tests/unit/analyzers/Keys.test.ts | 194 ++++++ packages/schema/CHANGELOG.md | 11 + packages/schema/schema/component.schema.json | 25 +- packages/schema/schema/workspace.schema.json | 12 +- packages/schema/tests/Anatomy.test-d.ts | 19 +- packages/schema/tests/Config.test-d.ts | 16 +- packages/schema/tests/Props.test-d.ts | 15 + packages/schema/types/Anatomy.ts | 17 +- packages/schema/types/Config.ts | 25 +- packages/schema/types/Props.ts | 13 + scripts/check-prerequisites.sh | 55 +- scripts/validate-schema.sh | 14 +- site/astro.config.mjs | 1 + site/src/content/docs/cli/analyze/index.md | 3 + site/src/content/docs/cli/analyze/keys.md | 152 +++++ .../src/content/docs/guides/key-formatting.md | 103 ++- site/src/content/docs/schema/anatomy.md | 9 + site/src/content/docs/schema/config.md | 2 + site/src/content/docs/schema/props.md | 2 + .../settings/collapse-primitive-wrapper.md | 4 +- site/src/content/docs/settings/figma-keys.md | 56 ++ site/src/content/docs/settings/index.mdx | 2 + site/src/content/docs/settings/keys.md | 3 + 31 files changed, 1678 insertions(+), 33 deletions(-) create mode 100644 adr/066-lossless-key-formatting.md create mode 100644 packages/cli/src/analyzers/Keys.ts create mode 100644 packages/cli/tests/unit/analyzers/Keys.test.ts create mode 100644 site/src/content/docs/cli/analyze/keys.md create mode 100644 site/src/content/docs/settings/figma-keys.md diff --git a/.github/agents/Specs.adr.accept.agent.md b/.github/agents/Specs.adr.accept.agent.md index b04f9520..b5fe6b7e 100644 --- a/.github/agents/Specs.adr.accept.agent.md +++ b/.github/agents/Specs.adr.accept.agent.md @@ -17,12 +17,25 @@ You **MUST** consider the user input before proceeding (if not empty). - Use `BRANCH` directly as `ADR_NAME` (e.g., `011-icon-glyph-as-content`). - The branch name must match an ADR name pattern (starts with a number sequence followed by a hyphen, e.g., `011-`). If no ADR number is found, halt: "Branch does not appear to be an ADR branch." -2. **Load context**: - - **REQUIRED**: Read `$REPO_ROOT/adr/$ADR_NAME.md` — confirm Status is `DRAFT` (if already `ACCEPTED`, report and halt) +2. **Check for an existing PR — do this before any other work.** An ADR branch may already be in review from an earlier session; this command must never open a second PR for the same branch. + + ```bash + gh pr list --head "$BRANCH" --state all --json number,title,state,baseRefName,url + ``` + + - **An OPEN PR exists** → set `EXISTING_PR` to its number and `$RELEASE_BRANCH` to its `baseRefName`. This run is an *update*, not a creation. Skip step 7 (release-branch determination) — the existing PR's base is authoritative. Report the PR number and base to the user before continuing. + - **A MERGED PR exists** → halt: "This ADR branch was already merged in PR #N. Accepting again would need a new branch." + - **A CLOSED (unmerged) PR exists** → do not reopen or replace it silently. Report it and ask the user whether to open a new PR or reopen that one. + - **No PR exists** → normal path; `EXISTING_PR` is unset. + +3. **Load context**: + - **REQUIRED**: Read `$REPO_ROOT/adr/$ADR_NAME.md` — confirm Status is `DRAFT`. + - If already `ACCEPTED` **and** `EXISTING_PR` is set with no uncommitted changes, halt: "ADR is already accepted and PR #N is open — nothing to do." + - If already `ACCEPTED` and there *are* uncommitted changes, this is a follow-up edit: skip the status flip in step 5, and continue so the changes are validated, committed, and pushed to the existing PR. - Confirm that `types/`, `schema/`, `package.json`, and `CHANGELOG.md` have been modified by the implement agent (check git status or file timestamps) - - If no changes are detected, halt: "Run the implement agent first." + - If no changes are detected **and** `EXISTING_PR` is unset, halt: "Run the implement agent first." -3. **Re-run validation gates**: +4. **Re-run validation gates**: - Run: `tsc -p tsconfig.build.json --noEmit` - If exit code ≠ 0: halt and display errors. Do not set ACCEPTED. - Run: `scripts/validate-schema.sh` @@ -30,29 +43,34 @@ You **MUST** consider the user input before proceeding (if not empty). - Run: `tsc --noEmit --strict tests/*.test-d.ts` (if `tests/*.test-d.ts` files exist) - If exit code ≠ 0: halt and display errors. Do not set ACCEPTED. -4. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$ADR_NAME.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. +5. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$ADR_NAME.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. Skip if step 3 determined the ADR is already `ACCEPTED`. -5. **Update INDEX**: Read `adr/INDEX.md` and update the entry for this ADR: +6. **Update INDEX**: Read `adr/INDEX.md` and update the entry for this ADR: - Move the row from the **Draft** table to the **Accepted** table (descending by number). - Update the **Title** to match the final ADR heading (it may have changed during implementation). - Add a **Highlights** summary (max 144 characters) describing the key change as accepted. - If no entry exists in the Draft table (older ADR created before INDEX tracking), add the row directly to the Accepted table. -6. **Determine release branch**: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). +7. **Determine release branch** *(skip entirely if `EXISTING_PR` is set — use that PR's base)*: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). 1. Find active in-flight release branches with `git branch -r --list 'origin/release/*'`. 2. **Default: use the existing active release branch.** ADR branches are started from the current release branch, so the active branch is the correct target. Do not cross-reference the ADR's semver version against the branch name — the branch name reflects where the release *started*, not the final published version. 3. If exactly one release branch exists, use it as `$RELEASE_BRANCH` without asking. 4. If multiple release branches exist, pick the one the ADR branch was based on (`git merge-base --fork-point` or ask the user). 5. Only if **no** release branch exists at all, create one from `main` following the `release/-` convention, naming every package the release will publish. -7. **Create PR**: Commit any uncommitted changes (the status flip and INDEX update), push `$BRANCH`, and open a PR into the release branch using `gh pr create --base $RELEASE_BRANCH`. +8. **Push, and create the PR only if there isn't one**: Commit any uncommitted changes (the status flip and INDEX update) and push `$BRANCH`. + - **`EXISTING_PR` set** → the push updates that PR. Do **not** run `gh pr create`. Read the PR's current body (`gh pr view $EXISTING_PR --json body`) and, if it no longer describes what is on the branch, offer to update it with `gh pr edit $EXISTING_PR --body-file ` — ask first, since the user may have written it by hand. + - **`EXISTING_PR` unset** → open a PR into the release branch with `gh pr create --base $RELEASE_BRANCH`. + - Pass long PR bodies via `--body-file`, not an inline heredoc — bodies containing backticks and quotes break shell parsing. -8. **Report**: Confirm all gates passed, the ADR is ACCEPTED, and the PR has been created. List the next steps: +9. **Report**: Confirm all gates passed, the ADR is ACCEPTED, and state whether the PR was **created** or an **existing PR was updated** (with its number). List the next steps: - Review and merge the PR into the release branch - When all ADRs for the release are complete, merge `$RELEASE_BRANCH` into `main` and `npm publish` ## Key rules - This command only flips the ADR status — it does not apply any code changes. -- Status MUST only move to `ACCEPTED` after all three validation gates pass in step 3. +- Status MUST only move to `ACCEPTED` after all three validation gates pass in step 4. +- **Never open a second PR for a branch that already has one.** Step 2 runs before everything else for this reason. This command is re-runnable: an ADR branch may already be in review from an earlier session, and re-running must update that PR, not duplicate it. +- Verify repo and PR state by querying it — never infer from the conversation or assume a branch is fresh. - Use absolute paths for all file operations. diff --git a/adr/066-lossless-key-formatting.md b/adr/066-lossless-key-formatting.md new file mode 100644 index 00000000..67c951a3 --- /dev/null +++ b/adr/066-lossless-key-formatting.md @@ -0,0 +1,634 @@ +# ADR: Lossless key formatting — safe key grammar and Figma name preservation + +**Branch**: `066-lossless-key-formatting` +**Created**: 2026-08-10 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none — extends ADR-058)* + +--- + +## Context + +`config.format.keys` selects a naming convention (`SAFE`, `CAMEL`, `SNAKE`, `KEBAB`, `PASCAL`, `TRAIN`) that is applied to every authored key in a spec: `anatomy` keys, `props` keys, and every reference to them — `elements`, `propConfigurations`, `children`, `slotContent`, `instanceExamples`, `compositions`. + +Every format other than `SAFE` is a lossy projection. The formatted key is derived by splitting the source name into `[A-Za-z0-9]` words and re-joining them, which discards: + +- **Separator identity** — `Icon leading`, `Icon-leading`, and `Icon_leading` all become `iconLeading` / `icon-leading` / `icon_leading`. The source separator is unrecoverable. +- **Casing** — `URL field` and `Url field` both become `urlField`; `Icon Leading` and `Icon leading` are indistinguishable after formatting. +- **Non-alphanumeric characters** — `&`, `+`, `/`, `.`, `()`, and every non-ASCII letter are deleted outright, taking their word boundary with them: `Cut & paste` becomes `cutPaste`, and nothing in the spec records that a `&` was ever there. +- **Word boundaries adjacent to digits** — `Icon 2 leading` collapses to `icon2Leading`, and no rule recovers the boundary. + +Because the schema has no record of the name a key was derived from, a spec produced with any format other than `SAFE` cannot be rendered back into Figma faithfully. Figma layer names and component property names are the identity used to match existing nodes; a formatted key that no longer reconstructs the Figma name causes the render direction to create duplicates or fail to bind. + +Three things are missing from the contract: + +1. A stated **source name shape** on the Figma side, so producers and consumers agree on what an unformatted key looks like and what a formatted key reverses to. +2. A stated **safe key grammar** — the set of names that survive every `format.keys` value unchanged in meaning. +3. A place to record the **Figma name** whenever a key falls outside that grammar. + +The precedent for the third already exists: `AnatomyElement.$extensions['com.figma'].originalName` records the pre-collapse layer name for wrapper-collapsed elements (ADR-058, shipped in `0.28.0`). This ADR generalizes that field to all lossy key derivations, adds the equivalent to props, and renames it to match its widened meaning. + +--- + +## Decision Drivers + +- **Round-trip fidelity**: a spec must carry enough information to reconstruct the Figma name of every anatomy element and prop, under any `format.keys` value. +- **Mechanically verifiable contract** (Constitution IV): "which keys are safe" must be expressible in JSON Schema, not only in prose. +- **Quiet by default**: the preservation field must not appear on well-formed specs. A catalog that follows the safe grammar emits no extensions at all. +- **Formats must stay fit for purpose**: `CAMEL` and `PASCAL` exist to produce code identifiers. No widening of the safe character set may compromise that. +- **No logic in this package** (Constitution II): the schema declares the grammar and the fields; deriving and emitting them is producer behavior. +- **Types and schema symmetry** (Constitution I): every added field lands in both `types/` and `schema/`. +- **Minimal, intentional public API** (Constitution III): prefer one field with one meaning over parallel fields or a deprecated alias carried forward. +- **References must stay resolvable**: `elements`, `propConfigurations`, and other key references continue to point at formatted keys — no reference-site duplication of source names. + +--- + +## Options Considered + +### Decision 1 — Where the Figma name is preserved + +#### Option A: `$extensions['com.figma']` on the definition *(Selected)* + +Record the Figma name once, on the `anatomy` element definition and on the prop definition. Reference sites (`elements`, `propConfigurations`, …) continue to use the formatted key and resolve the Figma name through the definition. + +**Pros**: +- Reuses the field ADR-058 already established for exactly this purpose on `AnatomyElement`. +- Single source of truth — no chance of divergent values at reference sites. +- Absent on well-formed specs, satisfying "quiet by default". +- Optional field on both types — no reference-site schema churn. + +**Cons / Trade-offs**: +- Consumers must dereference the definition to recover the Figma name for a reference. + +--- + +#### Option B: A parallel key-map block on `Component` *(Rejected)* + +A top-level `keyMap: Record` mapping formatted key → Figma name. + +**Rejected because**: it introduces a second naming surface outside `$extensions`, with no namespace, and its entries are ambiguous across the anatomy and prop key spaces (a component may have an anatomy element and a prop that format to the same key). It also grows a block on every component rather than staying quiet by default. + +--- + +#### Option C: Emit unformatted keys and store the formatted key in extensions *(Rejected)* + +Invert the relationship — keys stay as Figma names, and the `format.keys` result is recorded in `$extensions`. + +**Rejected because**: `format.keys` exists so consumers can read keys in their own platform's convention directly. Moving the formatted value into extensions defeats the feature and breaks every existing consumer of formatted output. + +--- + +### Decision 2 — What that field is called + +#### Option A: Rename `originalName` → `name` *(Selected)* + +Within `$extensions['com.figma']`, the field becomes `name`. `AnatomyElement` is renamed; `FigmaPropExtension` gains the field under the new spelling. + +```yaml +# Before (ADR-058) +$extensions: + com.figma: + originalName: Leading icon + +# After +$extensions: + com.figma: + name: Leading icon +``` + +**Pros**: +- The namespace already carries the qualifier — `com.figma.name` reads as "this element's name in Figma", which is precisely the contract. +- `original` becomes inaccurate once the field generalizes. Under ADR-058 it meant *pre-collapse*; here it also means *pre-format*. "Original relative to what?" has two answers, and the shared answer is simply "in Figma". +- One field, one meaning, two reasons it may be present — rather than a qualifier that describes only one of them. +- Satisfies Constitution III: the smallest accurate public name. + +**Cons / Trade-offs**: +- Breaking rename of a field shipped in `0.28.0`. See Migration — the real footprint is three call sites in one repo. + +--- + +#### Option B: Add `name`, deprecate `originalName` *(Rejected)* + +Ship both spellings, remove `originalName` at the next MAJOR. + +**Rejected because**: it buys a compatibility window no consumer needs (see Migration) at the cost of a period where either spelling may appear, forcing every reader to check both and every producer to choose. The ADR would ship ambiguity to avoid four line edits. + +--- + +#### Option C: Keep `originalName` *(Rejected)* + +**Rejected because**: the field's meaning is being redefined by this ADR regardless. Retaining a qualifier that is accurate for only one of its two triggers embeds the confusion permanently to avoid a one-time rename. + +--- + +### Decision 3 — How the source-side name shape is declared + +Reversal needs a stated target: given `iconLeading`, a renderer can only reconstruct the Figma name if it knows what convention that file's names follow. Three ways to supply it. + +#### Option A: New `format.figmaKeys` config field, defaulting to `NONE` *(Selected)* + +Add a sibling to `format.keys` declaring the naming convention the Figma file itself uses, so `format.keys` describes the *output* convention and `format.figmaKeys` describes the *source* convention. Reversal is defined as: format the key back into `format.figmaKeys`. + +```yaml +format: + figmaKeys: SENTENCE # what the Figma file uses — new, defaults to NONE + keys: KEBAB # what the spec emits — existing +``` + +The accepted values are `NONE`, `SENTENCE`, and `TITLE`. `SENTENCE` and `TITLE` are the two conventions observed in practice for Figma layer and component-property names — deliberately narrower than `format.keys`; values are added when a real file requires them, not in anticipation. + +`NONE` is the default and means **no source convention is declared**. Under `NONE` the producer asserts nothing about the file's names, so the safe key grammar is not evaluated, no `name` is emitted for format divergence, and no reversal target exists. The ADR-058 wrapper-collapse trigger for `name` is unaffected — it does not depend on a declared convention. + +Everything this ADR adds is therefore **opt-in**: a catalog gets the grammar check, the divergence extension, and reversible rendering by declaring `figmaKeys: SENTENCE | TITLE`, and keeps today's *behavior* by leaving it alone. + +`NONE` is not byte-identical to today's output. The `originalName` → `name` rename applies unconditionally, so any catalog with a wrapper-collapsed element emits a differently-spelled extension under `NONE` too. What `NONE` preserves is the behavior — which names are formatted, which extensions are triggered — not the bytes. + +Opting in is a real trade, not a free upgrade. A catalog that declares `SENTENCE` buys lossless round-tripping and pays for it in spec complexity: every name outside the safe grammar, and every name already written in the destination format, grows a `$extensions` block. On a catalog with inconsistent Figma naming that is a lot of new noise in the output. That cost is the reason the default is `NONE` — the author decides whether the fidelity is worth the specs it produces. + +**Pros**: +- Reversal becomes a declared, symmetric pair rather than a hardcoded assumption. +- Files authored in `Title Case` become losslessly reversible without emitting `name` on every key. +- Defaulting to `NONE` makes the checks and preservation behavior opt-in. No existing catalog changes output, and no catalog is told its layer names are "unsafe" against a convention it never claimed. +- Both non-`NONE` values use single-space separators, so the safe grammar's character rules stay uniform and only its casing clause varies. + +**Cons / Trade-offs**: +- The default is the *un*safe setting: a catalog gets lossy keys until someone opts in. This is deliberate — the alternative is asserting a convention on the author's behalf and emitting extensions against an assumption they never made. +- A file with mixed conventions still diverges from whatever single value is declared, so `name` remains necessary as the escape hatch. +- A future file using an unlisted convention needs a schema change to declare it, rather than picking an already-present enum value. + +--- + +#### Option B: Hardcode sentence case in the schema documentation *(Rejected)* + +Declare in prose that Figma names are always assumed sentence case, with no config surface. + +**Rejected because**: it is wrong for real files. A library whose layer names are `Title Case` would emit `name` on every anatomy element and every prop, making the "quiet by default" driver unachievable for that catalog and burying genuine problems in noise. + +--- + +#### Option C: Infer the source convention per component from the observed names *(Rejected)* + +**Rejected because**: inference is producer logic with no stable contract — the same catalog could infer differently as components are added, and the schema would have no way to state what a spec's keys reverse to. It also violates Constitution II if expressed here. + +--- + +### Decision 4 — How wide the safe character set is + +#### Option A: ASCII letters and digits only *(Selected)* + +The safe set is `[A-Za-z0-9]` plus the single-space word separator. Every other character — `&`, `+`, `/`, `.`, `()`, punctuation, non-ASCII letters — is unsafe and routes to `com.figma.name`. + +**Pros**: +- Keeps `CAMEL` and `PASCAL` fit for purpose. A preserved `&` would emit `cut&Paste` as an anatomy key — valid JSON, unusable as the code identifier those formats exist to produce. +- Avoids ambiguous reversal. Transliterating `&` → `and` cannot be undone: `cutAndPaste` may have come from `Cut & paste` or from a literal `Cut and paste`. +- One rule covers all six output formats, rather than a per-format safe set consumers must track. + +**Cons / Trade-offs**: +- Common authoring characters (`&`, `+`, `/`) always trigger the extension, so catalogs using them are not extension-free. + +--- + +#### Option B: Widen the safe set to retain `&` and `+` *(Rejected)* + +Preserve selected symbols through formatting rather than deleting them. + +**Rejected because**: it violates the "formats must stay fit for purpose" driver. These characters can only survive `SAFE`; under `CAMEL`, `PASCAL`, `SNAKE`, `KEBAB`, and `TRAIN` they either produce illegal identifiers or must be dropped anyway — meaning the safe set would have to be defined per format, and no single grammar could describe it. + +--- + +#### Option C: Transliterate symbols to words (`&` → `and`, `+` → `plus`) *(Rejected)* + +**Rejected because**: it is lossy in the direction this ADR exists to fix. The transliteration is not injective, so the render direction cannot tell a transliterated key from a literal one, and it silently rewrites author intent into English-specific words. + +--- + +### Decision 5 — Names already written in the destination format + +A real Figma file is rarely uniform. A library that is largely `Sentence case` may still carry property names authored as code identifiers — `isDisabled`, `hasIcon` — because they were named for the engineers consuming them. Under `figmaKeys: SENTENCE`, `keys: CAMEL`, `isDisabled` is *unsafe* by the grammar (its first character is lowercase), yet `format.keys` leaves it exactly as it found it, and it is already the name the author wants on both sides. + +The forward direction is not the problem — `CAMEL` is idempotent over an already-camel name. The problem is **reversal**: a renderer told to reconstruct the Figma name by formatting `isDisabled` into `SENTENCE` produces `Is disabled` and renames a property the author deliberately spelled `isDisabled`. + +#### Option A: Detect names already authored as keys, retain them, and record `name` *(Selected)* + +A source name that fails the declared `figmaKeys` grammar but is itself a well-formed key in the declared `format.keys` convention is **already in the destination format**. It is retained verbatim, and `com.figma.name` is emitted so reversal restores it rather than re-deriving it. Names in some *other* key convention are not retained — they are formatted and recorded like any divergent name. The Decision states the tests exactly. + +```yaml +# figmaKeys: SENTENCE, keys: CAMEL +props: + iconLeading: # from "Icon leading" — SENTENCE-safe, formatted, quiet + type: glyph + isDisabled: # authored as "isDisabled" in Figma — already CAMEL + type: boolean + $extensions: + com.figma: + name: isDisabled # reversal is identity, not "Is disabled" +``` + +**Pros**: +- Answers the case directly: mixed-convention files stop being rewritten on render. +- Requires no new field — the escape hatch this ADR already adds carries it. +- The detection is a pure predicate over two declared grammars, not inference about the file. + +**Cons / Trade-offs**: +- **Detection alone cannot make it quiet.** `iconLeading` (formatted from `Icon leading`) and `isDisabled` (authored as-is) are both valid `CAMEL` and both invalid `SENTENCE`. Nothing in the emitted key distinguishes them, so the retained case *must* record `name`. Under a lossy `format.keys`, a catalog with many authored-key names will not be extension-free. +- Only meaningful when a convention is declared. Under `figmaKeys: NONE` there is no grammar to fail, so there is nothing to detect and no reversal to protect. + +--- + +#### Option B: Format everything, as today *(Rejected)* + +**Rejected because**: it makes the render direction actively destructive on exactly the files most likely to adopt it — the ones already naming properties for their code consumers. Renaming `isDisabled` to `Is disabled` is a silent, unrequested edit to the author's file. + +--- + +#### Option C: Pass through silently, without `name` *(Rejected)* + +**Rejected because**: it is undecidable at read time, as Option A's trade-off shows. A consumer seeing `isDisabled` cannot tell whether the Figma name is `isDisabled` or `Is disabled`, which is the precise ambiguity this ADR exists to eliminate. Quiet output is a driver, but not at the cost of the round-trip guarantee. + +This is not the same ambiguity as `figmaKeys: NONE`. Under `NONE` no convention is declared, so a consumer knows reversal is undefined and has no license to reconstruct anything — the spec makes no promise it cannot keep. Option C declares `SENTENCE`, invites a consumer to reverse into it, and then silently exempts some keys from that promise. Undeclared is safe; declared-and-selectively-violated is not. + +--- + +## Decision + +### The source convention — `format.figmaKeys` + +`format.figmaKeys` declares the naming convention the Figma file uses for layer names and component-property names. It is the target of reversal: a renderer reconstructs a Figma name by re-formatting the spec key into `format.figmaKeys`. + +| Value | Shape | Example | +|-------|-------|---------| +| `NONE` *(default)* | No convention declared — grammar not evaluated, no reversal target | — | +| `SENTENCE` | First word capitalized, rest lowercase, single spaces | `Icon leading` | +| `TITLE` | Every word capitalized, single spaces | `Icon Leading` | + +`SENTENCE` and `TITLE` are source conventions only; neither is accepted by `format.keys`, which describes what the spec emits. + +`NONE` is the default, and everything below is gated on a value other than `NONE`: + +- The safe key grammar is not evaluated. +- `com.figma.name` is not emitted for format divergence. The ADR-058 wrapper-collapse trigger is independent of `figmaKeys` and continues to emit it. +- Authored-key retention (below) does not apply — there is no grammar for a name to fail. +- Reversal is undefined. A renderer reading a spec produced under `NONE` has no declared target and MUST fall back to `com.figma.name` where present and the formatted key otherwise — today's behavior. + +Declaring `SENTENCE` or `TITLE` opts the catalog into all of it. + +### The safe key grammar + +*Applies only when `format.figmaKeys` is `SENTENCE` or `TITLE`.* + +A name is **round-trip safe** when it satisfies both the character rules and the casing rule for the declared `format.figmaKeys`. + +Character and word rules, identical for both `figmaKeys` values: + +- ASCII letters and digits only — no `&`, `+`, `/`, `.`, parentheses, punctuation, or non-ASCII letters. +- Exactly one space between words. No leading, trailing, or repeated spaces. +- A word is either all letters or all digits. A digit run is always its own word — `Badge count 2` is three words; `Badge count2` is not safe. +- The name does not begin with a digit — the first word is always a letter word. + +Casing rule, per declared convention: + +| `figmaKeys` | Pattern | +|-------------|---------| +| `SENTENCE` | `^[A-Z][a-z]*( ([a-z]+\|[0-9]+))*$` | +| `TITLE` | `^[A-Z][a-z]*( ([A-Z][a-z]*\|[0-9]+))*$` | + +### The word-splitting rule + +Reversal is only deterministic if formatting and reversal split words the same way. Both directions split on: + +- **Spaces**, in the source name. +- **Case transitions**, lower→upper (`iconLeading` → `icon` + `Leading`). +- **Letter↔digit transitions**, in both directions (`badgeCount2` → `badge` + `count` + `2`). + +The letter↔digit clause is what makes `Badge count 2` safe: it formats to `badgeCount2` and splits back to `badge` / `count` / `2`, recovering the boundary. It is also why `Badge count2` is *not* safe — it formats to the same `badgeCount2`, which reverses to `Badge count 2`, not to the authored name. The grammar excludes mixed letter-digit words precisely so that no two safe names collapse onto one key by this route. + +Names satisfying the grammar reconstruct identically from the output of any `format.keys` value. Names that do not satisfy it require `com.figma.name` to be recoverable. + +### Names already authored as keys + +*Applies only when `format.figmaKeys` is `SENTENCE` or `TITLE`.* + +Every Figma name is decided by two tests, in order: + +1. **Does it match the origin convention** — the safe key grammar for the declared `figmaKeys`? If so, format it and emit nothing. The key reverses back to the name unaided. +2. **Does it match the destination convention** — a well-formed key in the declared `format.keys`? If so, **retain it verbatim** and record `com.figma.name`. +3. **Otherwise** — it matches neither. Format it, and record `com.figma.name`. + +Only the *destination* format retains. A `PascalCase` name with a `KEBAB` destination is not a match: it is reformatted and recorded, exactly like any other divergent name. Retention exists because formatting cannot *convert* between key conventions — it splits on spaces, hyphens, and underscores, not case transitions — so a name already in the destination convention can only be damaged by re-running it through the formatter, never improved. + +Test 1 runs first so that a name satisfying both tests stays quiet. `Label` is `SENTENCE`-safe *and* a well-formed `PASCAL` key; formatting and retention produce the same key, so the origin test wins and no extension is emitted for a name that reverses correctly on its own. + +`SAFE` has no destination pattern — it emits names as authored, so no consumer reverses a `SAFE` key and test 2 does not apply. `SAFE` is still lossy for the characters it strips (`.`, `[`, `]`, `\`, quotes), so a name it changes is recorded on that basis alone. + +```yaml +# figmaKeys: SENTENCE, keys: KEBAB +Icon leading → icon-leading # matches origin: formatted, no extension +is-disabled → is-disabled # matches destination KEBAB: retained, name: is-disabled +isDisabled → isdisabled # matches neither: formatted, name: isDisabled +IsDisabled → isdisabled # PASCAL is not the destination: formatted, name: IsDisabled +URL field → url-field # matches neither: formatted, name: URL field + +# figmaKeys: SENTENCE, keys: CAMEL +isDisabled → isDisabled # now matches the destination: retained, name: isDisabled +is-disabled → isDisabled # KEBAB is not the destination: formatted, name: is-disabled +``` + +The retained case always emits `name`. `iconLeading` and `isDisabled` are both valid `CAMEL` and both invalid `SENTENCE`, so the emitted key carries no signal a consumer could use to tell a formatted key from a natively-authored one — the extension is the only thing that distinguishes them. + +```yaml +# figmaKeys: SENTENCE + +# Safe — survives every format.keys value +Icon leading # → iconLeading | icon_leading | icon-leading | IconLeading | Icon-Leading +Label +Badge count 2 # → badgeCount2 — the digit is its own word, boundary recovered +Icon 2 leading # → icon2Leading — same rule + +# Unsafe — requires $extensions.com.figma.name +Icon-leading # separator is not a space +Icon Leading # casing diverges from SENTENCE (safe under figmaKeys: TITLE) +URL field # inner capitals lost +Badge count2 # mixed letter-digit word — reverses to "Badge count 2" +2 icons # name begins with a digit +Cut & paste # symbol deleted, word boundary lost +Size (large) # parentheses deleted +Étiquette # non-ASCII letter deleted +``` + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Anatomy.ts` | Renamed `FigmaAnatomyElementExtension.originalName` → `name`, widened to record the Figma name whenever the anatomy key diverges from it (format projection or wrapper collapse) | MAJOR | +| `Props.ts` | Added optional field `name` to `FigmaPropExtension` | MINOR | +| `Config.ts` | Added optional field `format.figmaKeys` to `Config`, required field `format.figmaKeys` to `ResolvedConfig`, and `figmaKeys: 'NONE'` to `DEFAULT_CONFIG` | MINOR | +| `Config.ts` | Expanded `format.keys` documentation to reference `format.figmaKeys` and the safe key grammar | PATCH | + +**Example — anatomy extension** (`types/Anatomy.ts`): + +```yaml +# Before +FigmaAnatomyElementExtension: + originalName?: string # pre-collapse layer name only (ADR-058) + +# After +FigmaAnatomyElementExtension: + name?: string # the element's name in Figma — present when the key diverges from it +``` + +**Example — prop extension** (`types/Props.ts`): + +```yaml +# Before +FigmaPropExtension: + type?: string + source?: FigmaCodeOnlySource + +# After +FigmaPropExtension: + type?: string + source?: FigmaCodeOnlySource + name?: string # optional — present only when the prop key diverges from the Figma property name +``` + +**Example — config** (`types/Config.ts`): + +```yaml +# Before +Config.format: + output?: 'JSON' | 'YAML' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + +# After +Config.format: + output?: 'JSON' | 'YAML' + keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN' + figmaKeys?: 'NONE' | 'SENTENCE' | 'TITLE' # optional — defaults to NONE +``` + +**Example — emitted spec under `format.keys: KEBAB`, `format.figmaKeys: SENTENCE`**: + +```yaml +anatomy: + icon-leading: # safe — derived from "Icon leading", no extension emitted + type: glyph + url-field: + type: text + $extensions: + com.figma: + name: URL field +props: + is-disabled: # safe — derived from "Is disabled" + type: boolean + default: false + cut-paste: + type: boolean + default: false + $extensions: + com.figma: + name: Cut & paste +elements: + url-field: # references remain in formatted key space + text: Email address +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Renamed `AnatomyElement.$extensions['com.figma'].originalName` → `name`, with widened description | MAJOR | +| `component.schema.json` | Added property `name` to `FigmaPropExtension` | MINOR | +| `component.schema.json` | Added definitions `SafeKeySentence` and `SafeKeyTitle` with their `pattern`s and descriptions | MINOR | +| `component.schema.json` | Expanded descriptions of `Anatomy` and `Props` to reference the safe key definitions | PATCH | +| `workspace.schema.json` | Added property `figmaKeys` under `format`, enum `[NONE, SENTENCE, TITLE]`, default `NONE` | MINOR | + +**Example — new definitions** (`schema/component.schema.json`): + +```yaml +SafeKeySentence: + type: string + pattern: "^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$" + description: >- + A round-trip-safe Figma name under format.figmaKeys SENTENCE: ASCII letters + and digits, single-space word separators, sentence case, each word all + letters or all digits, never digit-initial. Names matching this pattern + reconstruct identically from the output of any format.keys value; names + that do not require $extensions.com.figma.name. + +SafeKeyTitle: + type: string + pattern: "^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$" + description: >- + As SafeKeySentence, but for format.figmaKeys TITLE — every letter word + capitalized. +``` + +```yaml +# New property under #/definitions/FigmaPropExtension/properties +name: + type: string + description: >- + The Figma component-property name, recorded when the prop key diverges from + it under format.keys. Absent when the name matches the safe key grammar. +``` + +```yaml +# New property under #/definitions/.../format/properties (workspace.schema.json) +figmaKeys: + type: string + enum: [NONE, SENTENCE, TITLE] + default: NONE + description: >- + Naming convention the Figma file uses for layer names and component + property names. Reversal target for format.keys. NONE (the default) + declares no convention — the safe key grammar is not evaluated, no + com.figma.name is emitted for format divergence, and reversal is undefined. +``` + +### Notes + +- `format.figmaKeys` describes the source, `format.keys` describes the output. They are independent; `figmaKeys: SENTENCE` with `keys: SAFE` is the identity case where keys are emitted as authored. +- `figmaKeys: NONE` (the default) preserves today's behavior, though not today's bytes — the `originalName` → `name` rename applies under it. It is the "I have not told you what my file looks like" value, not a claim that the file has no convention. +- `SafeKeySentence` and `SafeKeyTitle` are definitions, not applied constraints on `Anatomy`/`Props` property names. They describe the *Figma-side* name shape, while the keys in a document are in `format.keys` space. +- `name` remains optional on both surfaces, so a catalog whose names all satisfy the grammar emits no *format-divergence* extensions. Wrapper collapse still emits `name` on its own trigger, independent of `figmaKeys` and of the grammar. +- Reference sites are deliberately unchanged. A consumer resolving `elements.url-field` looks up `anatomy['url-field']` and reads `name` there. +- On `AnatomyElement`, `name` has two triggers — wrapper-collapse promotion and format divergence — that are not distinguished. When both apply, the pre-collapse Figma name is the correct value for both purposes. + +### Migration + +`originalName` shipped in `0.28.0`. Its consumers are: + +| Location | Role | Risk | +|----------|------|------| +| `specs-from-figma` — anatomy element construction | Writes the field | Compile error on rename — caught | +| `figma-from-specs` — collapsed-root name recovery | Reads the field | **Reads via an inline structural type, not the published type — will not fail compilation. Silently yields `undefined` if missed.** | +| `specs-plugin-2` | — | No references | +| `specs-testing` workspace specs | Generated `api.yaml` output | Regenerated, not authored | + +The read site in `figma-from-specs` is the one hazard: because it declares the extension shape inline rather than importing `FigmaAnatomyElementExtension`, the rename is invisible to `tsc` there. Implementation MUST update it explicitly rather than relying on the compiler. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: + - `FigmaAnatomyElementExtension.name` (`types/Anatomy.ts`) ↔ `#/definitions/AnatomyElement/properties/$extensions/properties/com.figma/properties/name` + - `FigmaPropExtension.name` (`types/Props.ts`) ↔ `#/definitions/FigmaPropExtension/properties/name` + - `Config['format']['figmaKeys']` and `ResolvedConfig['format']['figmaKeys']` (`types/Config.ts`) ↔ `format.figmaKeys` in `schema/workspace.schema.json` + - `SafeKeySentence` and `SafeKeyTitle` (`#/definitions`) have no TypeScript counterpart — a `pattern` constraint on a string is not expressible as a distinct TypeScript type, and introducing a branded type would be logic, not a declaration (Constitution II). This asymmetry is justified and intentional; the type-side contract remains `string`. + +--- + +## Coverage inventory — every named space in a spec + +Two fields (`AnatomyElement.$extensions['com.figma'].name` and `FigmaPropExtension.name`) are claimed to cover all Figma-derived naming in a spec. This inventory is the basis of that claim: every keyed space in the schema, what its keys are, and why it is or is not covered. + +| Space | Type | Keys are | Status | +|-------|------|----------|--------| +| `anatomy` | `Record` | Figma layer names, formatted | **Covered** — `name` on the definition | +| `props` | `Record` | Figma component-property names, formatted | **Covered** — `name` on the definition | +| `elements` | `Record` | References to anatomy keys | **Covered by reference** — resolves through `anatomy` (Decision 1) | +| `propConfigurations` | `Record` | References to prop keys | **Covered by reference** — resolves through `props` | +| `children`, `slotContent` refs, `layout` | key references | References to anatomy keys | **Covered by reference** | +| `compositions[].anatomy` / `.elements` | nested `Anatomy` / `Elements` | Figma layer names, formatted | **Covered recursively** — same `AnatomyElement` type (`Composition.ts:26-27`) | +| `slotContentExamples[].anatomy` / `.elements` | nested `Anatomy` / `Elements` | Figma layer names, formatted | **Covered recursively** — same `AnatomyElement` type (`SlotContent.ts:12-13`) | +| `props..options` values | string values | Figma variant option values, formatted | **Not covered — gap.** No per-option preservation surface | +| `subcomponents` | `Record` | Formatted from `sub.title` | **Not needed** — unformatted `title` sits alongside | +| `title` (component, composition, instance example) | `string` | Verbatim Figma node name, never formatted | **Not needed** — nothing was projected | +| `instanceExamples` | `Record` | Authored identifiers (`^[a-zA-Z0-9_-]+$`), not Figma names | **Out of scope** — carries its own `title` for the label | +| `compositions` (registry keys) | `Record` | Authored identifiers | **Out of scope** — carries its own `title` | +| `slotContentExamples` (registry keys) | `Record` | Authored registry identifiers | **Out of scope** | +| `images` | `Record` | Authored / content-derived identifiers | **Out of scope** | +| `config.format.states` | `Record` | Config-declared state names | **Out of scope** — config vocabulary, not a Figma name | + +Three consequences of this inventory are normative: + +- **Reference sites need no schema change.** Because every reference is in formatted key space and resolves to a definition, adding `name` in two places covers every use of those names throughout the document. This is the whole reason Decision 1 selected the definition site. +- **Producers MUST recurse.** `Anatomy` appears at the top level, inside every `Composition`, and inside every `SlotContent`. A producer that evaluates only the top-level `anatomy` map silently leaves composed and slot-filled elements unprotected, and because the nested type is identical, nothing in the type system catches the omission. Implementation must walk all three. +- **The out-of-scope rows are out of scope because nothing was projected**, not because the loss is tolerated. They hold authored identifiers or verbatim names, so there is no source name to reconstruct. If a producer ever derives one of these keys from a Figma name, it moves into the covered set and needs its own decision. + +This inventory is derived from the schema types and the render-direction write sites. It has not been confirmed against a full sweep of what `specs-from-figma` emits into each registry. + +--- + +## Key application surfaces in `figma-from-specs` + +Audited on `feat/figma-from-specs`. Every place the render direction either formats a key or writes a spec key back into Figma as a name, and whether `AnatomyElement.name` / `FigmaPropExtension.name` covers it. + +| Surface | Site | Direction | Covered by this ADR | +|---------|------|-----------|---------------------| +| Anatomy layer names | `Elements/Elements.ts:231` — `childNode.name = name` | Spec key written to Figma **verbatim, unformatted** | **Yes** — anatomy `name` becomes the value to use here, replacing the raw key | +| Collapsed-root leaf name | `Elements/Elements.ts:122,131` — reads `originalName` | Extension read | **Yes** — the ADR-058 site; rename to `name` (see Migration) | +| Component property names | `Props/Props.ts:48,50,53` — `addComponentProperty(name, …)` | Spec prop key written to Figma **verbatim** | **Yes** — prop `name` becomes the value to use | +| Boolean-pairing property names | `Props/Props.ts:63` — `addComponentProperty(pair.booleanPropName, …)` | Derived from a prop key | **Yes**, transitively — resolves through the same prop `name` | +| Code-only property names | `Props/CodeOnlyProps.ts:76-77,188,206` — property, container, and layer names | Derived from prop keys and `$extensions.com.figma.source` | **Partly** — the property name resolves through prop `name`, but the layer/pattern names at `:188,206` come from the code-only source block and are not a key-format surface | +| Prop-configuration property matching | `PropConfigurations/PropConfigurations.ts:103` — `matchFormatted` | Formats the *Figma* name forward and compares | **Yes** — becomes a `name` lookup with the format comparison as fallback | +| **Variant option values** | `PropConfigurations/PropConfigurations.ts:72` — `formatKey(option, fmt) === specVal` | Formats Figma enum *values* forward and compares | **No — gap.** Enum option values pass through `format.keys` too, and neither `name` field records them. See below | +| Glyph content keys | `Elements/GlyphElement.ts:32` — `formatKey(rawName, glyphKeyFormat) === contentKey` | Formats a glyph library name forward and compares | **No — out of scope.** Matches against a live glyph manifest, not a spec key; the source name is external to the component | +| Subcomponent titles | `Subcomponents/Subcomponents.ts:28,42` — `formatKey(sub.title, keyFormat)` | Formats a title into key space to resolve a manifest entry | **Not needed.** `title` is stored unformatted and stays available to reverse from | +| Component / variant frame names | `Component.ts:83,108,114`, `Variants/Variants.ts:78` | Writes `title` back as the Figma name | **Not needed** — `title` is never in key space (see note below) | + +### What this ADR deliberately leaves untouched + +The contract covers **anatomy element keys and prop keys**. Three other naming surfaces are explicitly out of scope, for two different reasons. + +**Variant option values — a real gap, not fixed here.** + +`props..options` entries are formatted by `format.keys`, and the render direction matches them back by re-formatting the live Figma enum value (`PropConfigurations.ts:72`). `Cut & paste` as an *option value* is exactly as lossy as it is as a key, and `FigmaPropExtension.name` records only the property name — there is no per-option preservation surface. Adding one means a keyed structure under the prop extension, which is a separate decision about the prop extension's shape and is not made here. + +**Component titles and subcomponent titles — already lossless, no change needed.** + +`title` is not in key space, which is a fact about the read direction: `specs-from-figma` sets `title: this.node.name` verbatim (`Component/Component.ts:283`), so the Figma component name reaches the spec unformatted and no `format.keys` value touches it. Nothing needs preserving, because nothing was ever projected. + +In the write direction, `figma-from-specs` formats a title only at the *consumption* site — `Subcomponents.ts:28,42` derives a key from `sub.title` to resolve a manifest entry — and the unformatted `title` remains alongside. Frame naming writes `title` back as-is. + +So titles already do what `com.figma.name` does for keys — they record the original name — and they do it in the schema's own field rather than an extension. No `name` extension is added for titles, and none is needed. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | When `format.figmaKeys` is not `NONE`, must evaluate each anatomy element name and prop name against the safe key grammar, apply the authored-key retention test, and emit `name` on divergence or retention; existing `originalName` write site breaks under every value including `NONE` | Rename the emitted extension key unconditionally; add the grammar and retention evaluation gated on `format.figmaKeys`; align the word splitter with the stated letter↔digit rule; recompile | +| `specs-cli` | Emits and validates specs carrying the new optional field; the new config key is accepted in workspace config | Recompile against the new schema version; surface `format.figmaKeys` in config resolution and documentation | +| `specs-plugin-2` | Same emission path as the CLI in the plugin runtime; the render direction reconstructs Figma names by formatting into `format.figmaKeys`, preferring `name` where present | Recompile; use `name` in preference to the formatted key when matching Figma nodes | + +--- + +## Semver Decision + +**Version bump**: none — lands in the in-flight `0.30.0` (`MAJOR`-class change, released as a pre-1.0 minor) + +**Justification**: Renaming `originalName` → `name` removes a published field name, which the constitution classes as `MAJOR`. The package is pre-1.0 and already releases breaking narrowing changes as minors — `0.29.0` narrowed `Styles.textAlignHorizontal` from `Style` to a string enum. `0.30.0` is unreleased and is the version this ADR lands in, so it absorbs the change and `package.json` is unchanged. All other changes are additive: `FigmaPropExtension.name` and `Config.format.figmaKeys` are optional, and the safe-key definitions are referenced only from descriptions. `ResolvedConfig.format.figmaKeys` is required, and adding a required field to a published type is `MAJOR`-class in its own right — a caller assembling a `ResolvedConfig` literal rather than spreading `DEFAULT_CONFIG` loses compilation. It is absorbed by the same pre-1.0 `0.30.0` as the rename, and `DEFAULT_CONFIG` supplies `NONE` so the spread path is unaffected. + +--- + +## Consequences + +- The schema states, for the first time, what an unformatted Figma-side name looks like — declared per workspace via `format.figmaKeys`. +- No existing catalog changes behavior. `figmaKeys` defaults to `NONE`, so the grammar check, the divergence extension, and reversal are opt-in; a catalog adopts them by naming its convention. Output is not byte-identical — the `originalName` → `name` rename applies regardless of `figmaKeys`. +- Solving the problem costs spec complexity, and the author chooses whether to pay it. Opting in adds a `$extensions` block to every name outside the safe grammar and every name already written in the destination format. On a catalog with inconsistent Figma naming, that is substantial noise in exchange for fidelity — which is why it is opt-in rather than the default. +- The default being `NONE` means most catalogs stay lossy until someone opts in. Adoption depends on the value being visible, which is what the analyze report below is for. +- Key formatting becomes a declared pair — `figmaKeys` in, `keys` out — so reversal is defined by config rather than inferred. +- The set of names that survive every `format.keys` value is defined by two mechanically checkable patterns rather than by the behavior of one producer. +- **When `figmaKeys` is declared**, specs produced with a lossy `format.keys` value carry enough information to reconstruct Figma names, making the spec → Figma direction lossless for anatomy and prop identity. Under the `NONE` default they do not, and reversal stays undefined. +- Well-formed catalogs stay quiet — no format-divergence `$extensions` appear unless a name falls outside the safe grammar, so the field doubles as a signal that a Figma name needs attention. Wrapper collapse emits `name` on its own trigger, so a grammar-conforming catalog is not necessarily extension-free. +- Two distinct Figma names can still format to the same key — `Cut & paste` and `Cut paste` both reach `cutPaste`, and a retained name can collide with a formatted one. This is pre-existing behavior and is not changed here: a keyed record holds one entry per key, so one name wins and the other's `name` is not recorded. The loss belongs to the author who named two things the same in key space, not to the system. The safe grammar is collision-free among safe names, and `analyze keys` is the place to surface the rest. +- Consumers that match Figma nodes by name MUST prefer `com.figma.name` over the formatted key when it is present; ignoring it reintroduces the divergence this ADR removes. +- Names outside the safe grammar remain fully supported — they are recorded, not rejected. Narrowing them is an authoring recommendation, not a validation gate. +- `&`, `+`, and `/` will always trigger the extension. Catalogs wanting extension-free output must avoid them in layer and property names. +- `format.figmaKeys` starts deliberately narrow at `SENTENCE | TITLE`; encountering a file with another convention is a schema change, not a config choice. +- The grammar makes naming quality **reportable**, which suggests a follow-on `specs analyze keys` report: run the safe key grammar across a catalog and surface every anatomy element, prop, and option whose Figma name falls outside it — grouped by cause (separator, casing, symbol, mixed letter-digit word, digit-initial, non-ASCII) and by the convention the name *would* be safe under. Two audiences: + - **Design-system teams** get a list of layer and property names to fix in Figma, ranked by how many components each affects. + - **Adopters of `figmaKeys`** get the evidence for which value to declare — a catalog that is 94% `SENTENCE`-safe and 3% `TITLE`-safe has an obvious answer, and the residue is the exact list of names that will emit `com.figma.name`. + The report is also the honest way to run under `figmaKeys: NONE`: no extension is emitted, but the divergence is still measurable and can be shown. +- Deriving the retention and divergence signals is producer behavior (Constitution II). The schema declares the grammar; `analyze keys` is CLI work, out of scope for this ADR. +- Two fields cover every Figma-derived name in a spec, at every nesting depth, because reference sites resolve through definitions — see the Coverage inventory for the full enumeration and the recursion requirement it places on producers. +- The contract covers anatomy and prop *identity* only, and three surfaces stay as they are: + - **Variant option values** pass through `format.keys` with no preservation field and remain lossy. Closing this needs a per-option surface on the prop extension — a separate decision. + - **Component titles** are already the verbatim Figma node name and are never formatted. + - **Subcomponent titles** are likewise verbatim; they are formatted only when read, and the unformatted title remains alongside. + Titles therefore already record the original name in a first-class field, which is why no extension is added for them. diff --git a/adr/INDEX.md b/adr/INDEX.md index aa7b832c..a8fadd88 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -40,6 +40,7 @@ | # | Title | Highlights | |---|-------|------------| +| 066 | Lossless Key Formatting — Safe Key Grammar and Figma Name Preservation | Adds opt-in `format.figmaKeys` (`NONE` default), a safe key grammar, and `com.figma.name` on anatomy and props; renames `originalName` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | Narrow from `Style` to `TextAlignHorizontal \| null` (`'START' \| 'CENTER' \| 'END' \| 'JUSTIFY'`); Figma `LEFT`/`RIGHT`/`JUSTIFIED` remapped | | 063 | Image Content — `backgroundImage` fill, an `images` registry, `ImageProp`, and `ImageBinding` | Add `backgroundImage` fills, a `Component.images` registry (Figma identity + optional `src`), `ImageProp`/`ImageBinding`, and presence-switched `processing.images` | | 060 | Subcomponent Figma Source Identity — `Subcomponent.source` | Add optional `SubcomponentSource` (`pageId`, `nodeId`, `nodeType`) to `Subcomponent`; enables reverse-direction tools to resolve `SubcomponentRef` to Figma nodes | diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index da024bc6..d5b50d1b 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `format.figmaKeys` in the generated `specs.config.yaml` template — commented out at its `NONE` default, documenting the opt-in that enables the safe key grammar and Figma name preservation (ADR-066) +- `specs analyze keys` — reports Figma layer and property names a formatted key cannot reconstruct, written to `_analysis/keys.yaml`. Organized `byComponent` as a designer's checklist — each component splitting into `props` and `anatomy`, with an empty surface omitted — then `byCause` for systemic problems and `byName` for a name repeated across the library. Requires `format.figmaKeys` to declare a convention; empty under the `NONE` default (ADR-066) + ### Changed ### Removed diff --git a/packages/cli/src/Config/ConfigTemplates.ts b/packages/cli/src/Config/ConfigTemplates.ts index 96be015e..7fea335d 100644 --- a/packages/cli/src/Config/ConfigTemplates.ts +++ b/packages/cli/src/Config/ConfigTemplates.ts @@ -62,6 +62,13 @@ config: # See: https://www.specsplugin.com/guides/key-formatting/ keys: SAFE + # Naming convention your Figma file uses for layer and property names, and the + # target a spec reverses into when rendered back to Figma: NONE, SENTENCE, or TITLE. + # NONE declares no convention — names are not checked and none are preserved. + # Declaring one records the Figma name wherever a key cannot reconstruct it. + # See: https://www.specsplugin.com/settings/figma-keys/ + # figmaKeys: NONE + # Layout representation: LAYOUT, PARENT_CHILDREN, or BOTH # See: https://www.specsplugin.com/guides/data-layout/ layout: LAYOUT diff --git a/packages/cli/src/analyzers/Keys.ts b/packages/cli/src/analyzers/Keys.ts new file mode 100644 index 00000000..b33c7fbd --- /dev/null +++ b/packages/cli/src/analyzers/Keys.ts @@ -0,0 +1,271 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import type { Transformer, TransformerContext } from '../Types/Transformer.js'; + +/** + * Reports Figma layer and property names that a formatted key cannot reconstruct + * (ADR-066), so they can be tidied at the source. + * + * Reads generated specs, and therefore sees a name only where the producer recorded it + * in `$extensions['com.figma'].name`. That happens when `format.figmaKeys` declares a + * source convention — under the `NONE` default nothing diverges and this report is + * empty, which is correct: no convention was declared, so no name departed from one. + * + * A recorded name is NOT by itself a naming problem. The field has two independent + * triggers: format divergence (ADR-066) and wrapper-collapse provenance (ADR-058), and + * the latter fires on the `root` key regardless of `figmaKeys` or of how well-formed + * the name is. `Text` on a collapsed root is perfectly safe under SENTENCE and + * round-trips unaided. So each recorded name is re-tested against the safe key grammar + * here, and only genuine failures are reported. + */ + +type Surface = 'anatomy' | 'prop'; + +type DeclaredConvention = 'SENTENCE' | 'TITLE'; + +/** + * The safe key grammar. These mirror the `SafeKeySentence` and `SafeKeyTitle` + * definitions in `component.schema.json`, which remain the contract — kept as literals + * here because the CLI bundles to a single file and reading the schema JSON at runtime + * would make the report depend on a resolvable package path. If the schema patterns + * change, change these. + */ +const SAFE_KEY_PATTERNS: Record = { + SENTENCE: /^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$/, + TITLE: /^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$/, +}; + +/** Why a Figma name falls outside the safe key grammar. First match wins. */ +type Cause = + | 'separator' + | 'symbol' + | 'non-ascii' + | 'mixed-letter-digit' + | 'digit-initial' + | 'casing' + | 'already-a-key'; + +interface NameEntry { + key: string; + figmaName: string; + cause: Cause; +} + +/** Empty surfaces are omitted rather than emitted as `[]`, to keep the checklist quiet. */ +interface ComponentEntry { + divergent: number; + props?: NameEntry[]; + anatomy?: NameEntry[]; +} + +interface CauseEntry { + cause: Cause; + occurrences: number; + names: string[]; +} + +interface NameFrequencyEntry { + figmaName: string; + occurrences: number; + components: string[]; + cause: Cause; +} + +interface KeysAggregate { + summary: { + totalComponents: number; + componentsWithDivergence: number; + totalNames: number; + divergentNames: number; + causeDistribution: Record; + }; + byComponent: Record; + byCause: CauseEntry[]; + byName: NameFrequencyEntry[]; +} + +interface Collected extends NameEntry { + component: string; + surface: Surface; +} + +export class KeysAnalyzer implements Transformer { + readonly name = 'keys'; + + private readonly _divergent: Collected[] = []; + private readonly _components = new Set(); + private _totalNames = 0; + private _outputFormat: 'JSON' | 'YAML' = 'JSON'; + /** Read from each spec's own `metadata.config`; undefined means no convention declared. */ + private _convention: DeclaredConvention | undefined; + + async run(apiYaml: Record, context: TransformerContext): Promise { + const { componentKey, outputFormat } = context; + this._outputFormat = outputFormat; + this._components.add(componentKey); + this._convention = declaredConvention(apiYaml) ?? this._convention; + + this.collect(componentKey, apiYaml); + + const subcomponents = (apiYaml.subcomponents ?? {}) as Record; + for (const [subName, subRaw] of Object.entries(subcomponents)) { + this.collect(`${componentKey}.${subName}`, subRaw as Record); + } + } + + /** + * Walks a component's anatomy and props. Compositions and slot content carry their own + * nested anatomy, so those are walked too — the producer records names at every depth. + */ + private collect(component: string, comp: Record): void { + this.collectAnatomy(component, comp.anatomy); + this.collectProps(component, comp.props); + + const slotContent = (comp.slotContentExamples ?? {}) as Record; + for (const entry of Object.values(slotContent)) { + this.collectAnatomy(component, (entry as Record)?.anatomy); + } + + const compositions = (comp.compositions ?? {}) as Record; + for (const entry of Object.values(compositions)) { + this.collectAnatomy(component, (entry as Record)?.anatomy); + } + } + + private collectAnatomy(component: string, anatomy: unknown): void { + for (const [key, raw] of Object.entries((anatomy ?? {}) as Record)) { + this._totalNames++; + this.record(component, key, 'anatomy', figmaNameOf(raw)); + } + } + + private collectProps(component: string, props: unknown): void { + for (const [key, raw] of Object.entries((props ?? {}) as Record)) { + this._totalNames++; + this.record(component, key, 'prop', figmaNameOf(raw)); + } + } + + /** + * Records a name only if it actually fails the declared grammar. A recorded name that + * passes was written for the other reason the field exists — wrapper-collapse + * provenance — and is not a naming problem: it reconstructs from its key unaided. + */ + private record(component: string, key: string, surface: Surface, figmaName: string | undefined): void { + if (!figmaName || !this._convention) return; + if (SAFE_KEY_PATTERNS[this._convention].test(figmaName)) return; + this._divergent.push({ component, key, figmaName, surface, cause: causeOf(figmaName) }); + } + + async finalize(outputDir: string, analysisDir?: string): Promise { + if (this._totalNames === 0) return; + + const outDir = analysisDir ?? path.join(outputDir, '_analysis'); + await fs.ensureDir(outDir); + + const aggregate = this.buildAggregate(); + const ext = this._outputFormat === 'JSON' ? 'json' : 'yaml'; + const content = this._outputFormat === 'JSON' + ? JSON.stringify(aggregate, null, 2) + '\n' + : yaml.stringify(aggregate, { lineWidth: 120 }); + await fs.writeFile(path.join(outDir, `keys.${ext}`), content, 'utf-8'); + } + + private buildAggregate(): KeysAggregate { + const surfaces = new Map(); + for (const entry of this._divergent) { + const bucket = surfaces.get(entry.component) + ?? surfaces.set(entry.component, { props: [], anatomy: [] }).get(entry.component)!; + const list = entry.surface === 'prop' ? bucket.props : bucket.anatomy; + list.push({ key: entry.key, figmaName: entry.figmaName, cause: entry.cause }); + } + const byComponent: Record = {}; + for (const [component, { props, anatomy }] of surfaces) { + props.sort((a, b) => a.figmaName.localeCompare(b.figmaName)); + anatomy.sort((a, b) => a.figmaName.localeCompare(b.figmaName)); + byComponent[component] = { + divergent: props.length + anatomy.length, + ...(props.length ? { props } : {}), + ...(anatomy.length ? { anatomy } : {}), + }; + } + + const causeDistribution: Record = {}; + const causeNames = new Map>(); + for (const entry of this._divergent) { + causeDistribution[entry.cause] = (causeDistribution[entry.cause] ?? 0) + 1; + (causeNames.get(entry.cause) ?? causeNames.set(entry.cause, new Set()).get(entry.cause)!).add(entry.figmaName); + } + const byCause: CauseEntry[] = [...causeNames.entries()] + .map(([cause, names]) => ({ cause, occurrences: causeDistribution[cause], names: [...names].sort() })) + .sort((a, b) => b.occurrences - a.occurrences); + + // A name wrong in twelve components is one decision, not twelve — which is what a + // per-component checklist cannot show on its own. + const frequency = new Map; occurrences: number; cause: Cause }>(); + for (const entry of this._divergent) { + const record = frequency.get(entry.figmaName) + ?? { components: new Set(), occurrences: 0, cause: entry.cause }; + record.components.add(entry.component); + record.occurrences++; + frequency.set(entry.figmaName, record); + } + const byName: NameFrequencyEntry[] = [...frequency.entries()] + .map(([figmaName, r]) => ({ + figmaName, + occurrences: r.occurrences, + components: [...r.components].sort(), + cause: r.cause, + })) + .sort((a, b) => b.occurrences - a.occurrences || a.figmaName.localeCompare(b.figmaName)); + + return { + summary: { + totalComponents: this._components.size, + componentsWithDivergence: Object.keys(byComponent).length, + totalNames: this._totalNames, + divergentNames: this._divergent.length, + causeDistribution, + }, + byComponent: Object.fromEntries(Object.entries(byComponent).sort(([a], [b]) => a.localeCompare(b))), + byCause, + byName, + }; + } +} + +function figmaNameOf(raw: unknown): string | undefined { + const extensions = (raw as Record)?.$extensions as Record | undefined; + const figma = extensions?.['com.figma'] as Record | undefined; + const name = figma?.name; + return typeof name === 'string' ? name : undefined; +} + +/** + * Classifies why a name diverged. Ordered most-specific first: a name with both a + * symbol and odd casing is reported as a symbol problem, because that is the edit to + * make. `already-a-key` is last — it is not a defect, just a name authored in the + * spec's own convention rather than as a Figma display name. + */ +function causeOf(name: string): Cause { + if (/[\-_]/.test(name) && !/\s/.test(name)) return 'already-a-key'; + if (/[^\x20-\x7E]/.test(name)) return 'non-ascii'; + if (/[^A-Za-z0-9 ]/.test(name)) return 'symbol'; + if (/\s\s|^\s|\s$|[\t\n]/.test(name)) return 'separator'; + if (/[A-Za-z][0-9]|[0-9][A-Za-z]/.test(name)) return 'mixed-letter-digit'; + if (/^[0-9]/.test(name)) return 'digit-initial'; + // Characters and word shape are all fine, so the only rule left to fail is casing. + // Reached only for names that already failed the grammar, so this is a verdict rather + // than a catch-all — a well-formed name never gets here. + return 'casing'; +} + +/** The convention the spec was generated under, from its own `metadata.config`. */ +function declaredConvention(apiYaml: Record): DeclaredConvention | undefined { + const metadata = apiYaml.metadata as Record | undefined; + const config = metadata?.config as Record | undefined; + const format = config?.format as Record | undefined; + const value = format?.figmaKeys; + return value === 'SENTENCE' || value === 'TITLE' ? value : undefined; +} diff --git a/packages/cli/src/analyzers/index.ts b/packages/cli/src/analyzers/index.ts index 66930428..a33f0024 100644 --- a/packages/cli/src/analyzers/index.ts +++ b/packages/cli/src/analyzers/index.ts @@ -1,5 +1,6 @@ import type { Transformer } from '../Types/Transformer.js'; import { DependenciesAnalyzer } from './Dependencies.js'; +import { KeysAnalyzer } from './Keys.js'; import { PropsAnalyzer } from './Props.js'; import { StylingAnalyzer } from './Styling.js'; @@ -7,6 +8,7 @@ const ALL_ANALYZERS: Transformer[] = [ new PropsAnalyzer(), new StylingAnalyzer(), new DependenciesAnalyzer(), + new KeysAnalyzer(), ]; const BY_NAME = new Map(ALL_ANALYZERS.map(a => [a.name, a])); diff --git a/packages/cli/src/commands/AnalyzeCommand.ts b/packages/cli/src/commands/AnalyzeCommand.ts index 6797e06e..f11d77b6 100644 --- a/packages/cli/src/commands/AnalyzeCommand.ts +++ b/packages/cli/src/commands/AnalyzeCommand.ts @@ -19,7 +19,7 @@ interface AnalyzeOptions { export const Analyze = new Command('analyze') .description('Run analysis passes over component specs and write aggregate reports to _analysis/') - .argument('[analyzers...]', 'Analyzer names to run (props, styling, dependencies)') + .argument('[analyzers...]', 'Analyzer names to run (props, styling, dependencies, keys)') .option('-o, --output ', 'Path to the specs directory (input)') .option('--analysis ', 'Path to write analysis output (default: /_analysis)') .option('--config ', 'Path to config file (specs.config.yaml)') diff --git a/packages/cli/tests/unit/analyzers/Keys.test.ts b/packages/cli/tests/unit/analyzers/Keys.test.ts new file mode 100644 index 00000000..75c6b309 --- /dev/null +++ b/packages/cli/tests/unit/analyzers/Keys.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import yaml from 'yaml'; +import { KeysAnalyzer } from '../../../src/analyzers/Keys.js'; + +type KeysYaml = { + summary: { + totalComponents: number; + componentsWithDivergence: number; + totalNames: number; + divergentNames: number; + causeDistribution: Record; + }; + byComponent: Record; + anatomy?: Array<{ key: string; figmaName: string; cause: string }>; + }>; + byCause: Array<{ cause: string; occurrences: number; names: string[] }>; + byName: Array<{ figmaName: string; occurrences: number; components: string[]; cause: string }>; +}; + +/** An anatomy or prop entry carrying a recorded Figma name. */ +const withName = (type: string, figmaName: string) => ({ + type, + $extensions: { 'com.figma': { name: figmaName } }, +}); + +describe('KeysAnalyzer', () => { + let outputDir: string; + let analysisDir: string; + + beforeEach(async () => { + outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'keys-test-')); + analysisDir = path.join(outputDir, '_analysis'); + }); + + afterEach(async () => { + await fs.remove(outputDir); + }); + + /** + * @param figmaKeys The convention the specs declare in `metadata.config`. Defaults to + * SENTENCE; pass undefined to model a catalog generated under the NONE default. + */ + async function runAnalyzer( + components: Record>, + figmaKeys: 'SENTENCE' | 'TITLE' | undefined = 'SENTENCE', + ) { + const a = new KeysAnalyzer(); + for (const [componentKey, apiYaml] of Object.entries(components)) { + const compDir = path.join(outputDir, componentKey); + await fs.ensureDir(compDir); + const spec = figmaKeys + ? { ...apiYaml, metadata: { config: { format: { keys: 'CAMEL', figmaKeys } } } } + : apiYaml; + await a.run(spec, { outputDir: compDir, componentKey, outputFormat: 'YAML', tokensFormat: 'TOKEN' }); + } + await a.finalize!(outputDir, analysisDir); + const file = path.join(analysisDir, 'keys.yaml'); + if (!await fs.pathExists(file)) return null; + return yaml.parse(await fs.readFile(file, 'utf-8')) as KeysYaml; + } + + it('reports nothing for a catalog generated under the NONE default', async () => { + const result = await runAnalyzer({ + dsBody: { anatomy: { root: withName('text', 'Text') } }, + }, undefined); + expect(result!.summary.divergentNames).toBe(0); + expect(result!.byComponent).toEqual({}); + }); + + it('ignores a recorded name that satisfies the grammar — wrapper-collapse provenance', async () => { + // $extensions['com.figma'].name has two triggers. ADR-058 wrapper collapse records + // the pre-collapse layer name on `root` whether or not it diverges, so a safe name + // like 'Text' or 'Icon glyph' must not be reported as a naming problem. + const result = await runAnalyzer({ + dsBody: { anatomy: { root: withName('text', 'Text') } }, + dsIcon: { anatomy: { root: withName('glyph', 'Icon glyph') } }, + dsText: { anatomy: { root: withName('text', 'Text'), urlField: withName('text', 'URL field') } }, + }); + expect(result!.summary.divergentNames).toBe(1); + expect(Object.keys(result!.byComponent)).toEqual(['dsText']); + expect(result!.byComponent.dsText.anatomy).toEqual([ + { key: 'urlField', figmaName: 'URL field', cause: 'casing' }, + ]); + }); + + it('reports nothing when no name was recorded', async () => { + const result = await runAnalyzer({ + dsButton: { anatomy: { label: { type: 'text' } }, props: { size: { type: 'string' } } }, + }); + expect(result!.summary.divergentNames).toBe(0); + expect(result!.byComponent).toEqual({}); + }); + + it('groups divergent names under their component', async () => { + const result = await runAnalyzer({ + dsAlert: { + anatomy: { root: { type: 'container' }, iconLeading: withName('glyph', 'Icon Leading') }, + props: { fullBleed: { type: 'boolean', $extensions: { 'com.figma': { name: 'Full Bleed' } } } }, + }, + }); + + expect(result!.byComponent.dsAlert.divergent).toBe(2); + expect(result!.byComponent.dsAlert.props).toEqual([ + { key: 'fullBleed', figmaName: 'Full Bleed', cause: 'casing' }, + ]); + expect(result!.byComponent.dsAlert.anatomy).toEqual([ + { key: 'iconLeading', figmaName: 'Icon Leading', cause: 'casing' }, + ]); + }); + + it('omits a surface array entirely when it is empty', async () => { + const result = await runAnalyzer({ + dsBadge: { props: { fullBleed: withName('boolean', 'Full Bleed') } }, + }); + expect(result!.byComponent.dsBadge).not.toHaveProperty('anatomy'); + expect(result!.byComponent.dsBadge.props).toHaveLength(1); + }); + + it('counts every name, not only divergent ones', async () => { + const result = await runAnalyzer({ + dsButton: { + anatomy: { root: { type: 'container' }, label: { type: 'text' }, urlField: withName('text', 'URL field') }, + props: { size: { type: 'string' } }, + }, + }); + expect(result!.summary.totalNames).toBe(4); + expect(result!.summary.divergentNames).toBe(1); + expect(result!.summary.componentsWithDivergence).toBe(1); + expect(result!.summary.totalComponents).toBe(1); + }); + + it('aggregates a repeated name across components — one decision, not many', async () => { + const props = { a11yLabel: withName('string', 'A11y label') }; + const result = await runAnalyzer({ + dsAvatar: { props }, dsBadge: { props }, dsButton: { props }, + }); + + const entry = result!.byName.find(n => n.figmaName === 'A11y label')!; + expect(entry.occurrences).toBe(3); + expect(entry.components).toEqual(['dsAvatar', 'dsBadge', 'dsButton']); + }); + + it('walks anatomy nested in slot content and compositions', async () => { + const result = await runAnalyzer({ + dsCard: { + anatomy: { root: { type: 'container' } }, + slotContentExamples: { basic: { anatomy: { urlField: withName('text', 'URL field') } } }, + compositions: { hero: { anatomy: { cutPaste: withName('container', 'Cut & paste') } } }, + }, + }); + expect(result!.byComponent.dsCard.divergent).toBe(2); + }); + + it('collects subcomponents under a dot-path key', async () => { + const result = await runAnalyzer({ + dsList: { + anatomy: { root: { type: 'container' } }, + subcomponents: { item: { anatomy: { startIcon: withName('glyph', 'Start Icon') } } }, + }, + }); + expect(result!.byComponent['dsList.item'].divergent).toBe(1); + }); + + describe('cause classification', () => { + it.each([ + ['Label ', 'separator'], + ['Cut & paste', 'symbol'], + ['Étiquette', 'non-ascii'], + ['A11y label', 'mixed-letter-digit'], + ['0000 0000', 'digit-initial'], + ['Start Icon', 'casing'], + ['x-figmacollapse', 'already-a-key'], + ])('classifies %s as %s', async (figmaName, expected) => { + const result = await runAnalyzer({ + dsX: { anatomy: { k: withName('text', figmaName as string) } }, + }); + expect(result!.byComponent.dsX.anatomy![0].cause).toBe(expected); + }); + + it('ranks causes by frequency', async () => { + const result = await runAnalyzer({ + dsA: { anatomy: { a: withName('text', 'Start Icon'), b: withName('text', 'End Icon') } }, + dsB: { anatomy: { c: withName('text', 'Cut & paste') } }, + }); + expect(result!.byCause[0]).toEqual({ cause: 'casing', occurrences: 2, names: ['End Icon', 'Start Icon'] }); + expect(result!.byCause[1].cause).toBe('symbol'); + }); + }); +}); diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 4d9c413f..259be0fb 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -9,10 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `Config.format.figmaKeys` — naming convention the Figma file uses (`NONE` | `SENTENCE` | `TITLE`, default `NONE`); the reversal target for `format.keys` (ADR-066). `NONE` declares no convention, so the safe key grammar, the `com.figma.name` divergence extension, and reversal are all opt-in +- `FigmaPropExtension.name` — Figma property name, emitted when the prop key cannot reconstruct it (ADR-066) +- `SafeKeySentence` and `SafeKeyTitle` schema definitions — the key grammar that survives every `format.keys` value (ADR-066) +- `NumberProp.$extensions` — the only prop type that could not carry platform extensions, so a number prop had nowhere to record its Figma name under ADR-066. The JSON schema already permitted `$`-prefixed keys; the TypeScript type did not. + ### Changed +- `FigmaAnatomyElementExtension.name` — renamed from `originalName`; now records the Figma layer name for key divergence as well as wrapper collapse (ADR-066) + ### Removed +### Migration + +`FigmaAnatomyElementExtension.originalName` → `FigmaAnatomyElementExtension.name`: read `$extensions['com.figma'].name` instead; the value and its collapse-provenance meaning are unchanged. Consumers that read the extension through an inline structural type will not see a compile error — update those call sites explicitly. + ## [0.29.0] - 2026-08-07 diff --git a/packages/schema/schema/component.schema.json b/packages/schema/schema/component.schema.json index da2bd23f..a157a9ea 100644 --- a/packages/schema/schema/component.schema.json +++ b/packages/schema/schema/component.schema.json @@ -124,12 +124,22 @@ }, "Anatomy": { "type": "object", - "description": "The structural elements that make up the component. Element names format depends on user settings.", + "description": "The structural elements that make up the component. Keys are formatted per format.keys. A key whose Figma name did not satisfy the safe key grammar (see SafeKeySentence / SafeKeyTitle) carries that name in $extensions['com.figma'].name.", "examples": [], "additionalProperties": { "$ref": "#/definitions/AnatomyElement" } }, + "SafeKeySentence": { + "type": "string", + "pattern": "^[A-Z][a-z]*( ([a-z]+|[0-9]+))*$", + "description": "A round-trip-safe Figma name under format.figmaKeys SENTENCE: ASCII letters and digits, single-space word separators, sentence case, each word either all letters or all digits, never beginning with a digit. Names matching this pattern reconstruct identically from the output of any format.keys value; names that do not require $extensions['com.figma'].name." + }, + "SafeKeyTitle": { + "type": "string", + "pattern": "^[A-Z][a-z]*( ([A-Z][a-z]*|[0-9]+))*$", + "description": "As SafeKeySentence, but for format.figmaKeys TITLE — every letter word capitalized." + }, "ElementType": { "type": "string", "description": "Element types derived from Figma node analysis.", @@ -213,9 +223,9 @@ "com.figma": { "type": "object", "properties": { - "originalName": { + "name": { "type": "string", - "description": "Original Figma layer name before primitive-wrapper collapse promoted this element to root (ADR-058)." + "description": "The element's name in Figma. Recorded on any of three triggers: primitive-wrapper collapse promoted this element to root (ADR-058), always and independent of format.figmaKeys; the name fell outside the safe key grammar so format.keys could not represent it losslessly (ADR-066); or the name was already written in the destination format and passed through unformatted, making reversal identity rather than re-derivation (ADR-066). The latter two apply only when format.figmaKeys is not NONE — under NONE, format divergence is not evaluated and this field is absent however the key was derived." } }, "additionalProperties": false @@ -231,7 +241,7 @@ }, "Props": { "type": "object", - "description": "", + "description": "Component properties. Keys are formatted per format.keys. A key whose Figma property name did not satisfy the safe key grammar (see SafeKeySentence / SafeKeyTitle) carries that name in $extensions['com.figma'].name.", "examples": [], "additionalProperties": { "$ref": "#/definitions/AnyProp" @@ -302,6 +312,10 @@ "source": { "$ref": "#/definitions/FigmaCodeOnlySource", "description": "Provenance metadata \u2014 present only for props extracted from a code-only container layer" + }, + "name": { + "type": "string", + "description": "The Figma component-property name (ADR-066). Recorded when the name fell outside the safe key grammar, or when it was already written in the destination format and passed through unformatted — in which case reversal is identity, not re-derivation. Both triggers require format.figmaKeys to be other than NONE; under NONE this field is absent however the key was derived." } }, "additionalProperties": true @@ -1049,6 +1063,9 @@ "type": "number" }, "description": "Sample numeric values demonstrating typical content for this prop" + }, + "$extensions": { + "$ref": "#/definitions/PropExtensions" } }, "required": [ diff --git a/packages/schema/schema/workspace.schema.json b/packages/schema/schema/workspace.schema.json index 4e0a89d9..29f6c1e2 100644 --- a/packages/schema/schema/workspace.schema.json +++ b/packages/schema/schema/workspace.schema.json @@ -217,7 +217,17 @@ "TRAIN" ], "default": "SAFE", - "description": "Key naming convention. Defaults to SAFE." + "description": "Key naming convention applied to anatomy keys, prop keys, and every reference to them. Every value other than SAFE is a lossy projection of the Figma name; names outside the safe key grammar are preserved in $extensions['com.figma'].name (ADR-066). Defaults to SAFE." + }, + "figmaKeys": { + "type": "string", + "enum": [ + "NONE", + "SENTENCE", + "TITLE" + ], + "default": "NONE", + "description": "Naming convention the Figma file uses for layer names and component property names. The reversal target for keys — a renderer reconstructs a Figma name by re-formatting the spec key into this convention. NONE declares no convention: the safe key grammar is not evaluated, no $extensions['com.figma'].name is emitted for format divergence, and reversal is undefined. Declaring SENTENCE or TITLE opts in to all three. Defaults to NONE." }, "layout": { "type": "string", diff --git a/packages/schema/tests/Anatomy.test-d.ts b/packages/schema/tests/Anatomy.test-d.ts index 6f2ed4f8..332ecebc 100644 --- a/packages/schema/tests/Anatomy.test-d.ts +++ b/packages/schema/tests/Anatomy.test-d.ts @@ -90,14 +90,29 @@ if (typeof element.type === 'string') { const _ref: string = element.type.$ref; } -// AnatomyElement.$extensions carries collapse provenance (ADR-058) +// AnatomyElement.$extensions carries the Figma name (ADR-058 collapse, ADR-066 key divergence) const collapsedRoot: AnatomyElement = { type: 'text', - $extensions: { 'com.figma': { originalName: 'Text' } }, + $extensions: { 'com.figma': { name: 'Text' } }, +}; + +// ADR-066 — a key that could not represent its Figma name losslessly +const divergentKey: AnatomyElement = { + type: 'glyph', + $extensions: { 'com.figma': { name: 'Cut & paste' } }, }; +// name is optional — safe keys emit no extension at all +const safeKey: AnatomyElement = { type: 'text' }; + const badExtension: AnatomyElement = { type: 'text', // @ts-expect-error — unknown extension members are rejected $extensions: { 'com.figma': { layerName: 'Text' } }, }; + +const renamedExtension: AnatomyElement = { + type: 'text', + // @ts-expect-error — originalName was renamed to name in 0.30.0 (ADR-066) + $extensions: { 'com.figma': { originalName: 'Text' } }, +}; diff --git a/packages/schema/tests/Config.test-d.ts b/packages/schema/tests/Config.test-d.ts index 96e97894..05ce1558 100644 --- a/packages/schema/tests/Config.test-d.ts +++ b/packages/schema/tests/Config.test-d.ts @@ -38,6 +38,7 @@ const fullConfig: Config = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'SENTENCE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', @@ -178,11 +179,24 @@ const defaultIsValidConfig: Config = DEFAULT_CONFIG; const defaultTokensValue: typeof DEFAULT_CONFIG.format.tokens = 'TOKEN'; +// ─── format.figmaKeys (ADR-066) ────────────────────────────────────────────── + +// Optional on Config — absence means SENTENCE +const noFigmaKeys: Config = { processing: {}, format: {}, include: {} }; + +const sentenceKeys: Config = { processing: {}, format: { figmaKeys: 'SENTENCE' }, include: {} }; +const titleKeys: Config = { processing: {}, format: { figmaKeys: 'TITLE' }, include: {} }; + +// @ts-expect-error — figmaKeys is deliberately narrower than format.keys +const kebabFigmaKeys: Config = { processing: {}, format: { figmaKeys: 'KEBAB' }, include: {} }; + +const defaultFigmaKeysValue: typeof DEFAULT_CONFIG.format.figmaKeys = 'SENTENCE'; + // ─── ResolvedConfig requires all defaultable fields ────────────────────────── const resolved: ResolvedConfig = { processing: { slotConstraints: false, collapsePrimitiveWrapper: false, variantDepth: 9999, details: 'LAYERED', inferNumberProps: false }, - format: { output: 'JSON', keys: 'SAFE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' }, + format: { output: 'JSON', keys: 'SAFE', figmaKeys: 'SENTENCE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX' }, include: { invalidVariants: false, invalidCombinations: true, emptyVariants: false, defaultSlotContent: false }, transformers: [], }; diff --git a/packages/schema/tests/Props.test-d.ts b/packages/schema/tests/Props.test-d.ts index b9b08108..d69c47a6 100644 --- a/packages/schema/tests/Props.test-d.ts +++ b/packages/schema/tests/Props.test-d.ts @@ -97,6 +97,21 @@ const slotWithExt: SlotProp = { type: 'slot', $extensions: { 'com.figma': { type // $extensions with empty com.figma const boolEmptyFigma: BooleanProp = { type: 'boolean', default: true, $extensions: { 'com.figma': {} } }; +// com.figma.name preserves a Figma property name the key could not represent (ADR-066) +const boolDivergentKey: BooleanProp = { + type: 'boolean', + default: false, + $extensions: { 'com.figma': { type: 'BOOLEAN', name: 'Cut & paste' } }, +}; + +// name is optional — safe keys emit no name at all +const enumSafeKey: EnumProp = { + type: 'string', + default: 'sm', + enum: ['sm', 'md'], + $extensions: { 'com.figma': { type: 'VARIANT' } }, +}; + // $extensions with empty object const boolEmptyExt: BooleanProp = { type: 'boolean', default: true, $extensions: {} }; diff --git a/packages/schema/types/Anatomy.ts b/packages/schema/types/Anatomy.ts index 9a1d8fe4..cd34e430 100644 --- a/packages/schema/types/Anatomy.ts +++ b/packages/schema/types/Anatomy.ts @@ -30,8 +30,21 @@ export type SubcomponentRef = { * @since 0.28.0 */ export interface FigmaAnatomyElementExtension { - /** Original Figma layer name before primitive-wrapper collapse promoted this element to root (ADR-058). */ - originalName?: string; + /** + * The element's name in Figma. Recorded on any of three triggers: + * - primitive-wrapper collapse promoted this element to root (ADR-058) — always, + * independent of `format.figmaKeys`; + * - the name fell outside the safe key grammar, so `format.keys` could not + * represent it losslessly (ADR-066); + * - the name was already written in the destination format and passed through + * unformatted, making reversal identity rather than re-derivation (ADR-066). + * + * The latter two apply only when `format.figmaKeys` is not NONE. Under NONE no + * source convention is declared, so format divergence is not evaluated and this + * field is absent however the key was derived. + * @since 0.30.0 — renamed from `originalName` + */ + name?: string; } /** diff --git a/packages/schema/types/Config.ts b/packages/schema/types/Config.ts index cdad0c7c..7ac55173 100644 --- a/packages/schema/types/Config.ts +++ b/packages/schema/types/Config.ts @@ -121,8 +121,27 @@ export interface Config { format: { /** Output format. Optional; defaults to JSON. */ output?: 'JSON' | 'YAML'; - /** Key naming convention. Optional; defaults to SAFE. */ + /** + * Key naming convention applied to anatomy keys, prop keys, and every reference + * to them. Every value other than SAFE is a lossy projection of the Figma name; + * names outside the safe key grammar are preserved in `$extensions['com.figma'].name` + * (ADR-066). Optional; defaults to SAFE. + */ keys?: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + /** + * Naming convention the Figma file uses for layer names and component property + * names. The reversal target for `keys` — a renderer reconstructs a Figma name by + * re-formatting the spec key into this convention. + * + * NONE declares no convention: the safe key grammar is not evaluated, no + * `$extensions['com.figma'].name` is emitted for format divergence, and reversal + * is undefined. Declaring SENTENCE or TITLE opts the catalog into all three, at + * the cost of an extension on every name outside the grammar. + * + * Optional; defaults to NONE. + * @since 0.30.0 + */ + figmaKeys?: 'NONE' | 'SENTENCE' | 'TITLE'; /** Layout representation format. Optional; defaults to LAYOUT. */ layout?: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; /** @@ -209,6 +228,8 @@ export interface ResolvedConfig { output: 'JSON' | 'YAML'; /** Key naming convention. */ keys: 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + /** Naming convention the Figma file uses — the reversal target for `keys`. NONE declares none. @since 0.30.0 */ + figmaKeys: 'NONE' | 'SENTENCE' | 'TITLE'; /** Layout representation format. */ layout: 'LAYOUT' | 'PARENT_CHILDREN' | 'BOTH'; /** Token reference serialization profile. */ @@ -241,6 +262,7 @@ export interface ResolvedConfig { * - processing.details: LAYERED reduces output size by only showing differences from default * - processing.inferNumberProps: false — opt-in feature, off by default * - format.keys: SAFE prevents corruption of special characters while maintaining readability + * - format.figmaKeys: NONE declares no source convention — the safe key grammar, the name extension, and reversal are opt-in (ADR-066) * - format.layout: LAYOUT provides tree structure with layout properties * - format.tokens: TOKEN provides platform-neutral token references with $token path and $type * - format.color: HEX matches historical v1 behaviour and maximises human readability @@ -262,6 +284,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'NONE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', diff --git a/packages/schema/types/Props.ts b/packages/schema/types/Props.ts index cbd995d7..c5226ce0 100644 --- a/packages/schema/types/Props.ts +++ b/packages/schema/types/Props.ts @@ -35,6 +35,17 @@ export interface FigmaPropExtension { type?: string; /** Provenance metadata — present only for props extracted from a code-only container layer. @since 0.14.0 */ source?: FigmaCodeOnlySource; + /** + * The Figma component-property name (ADR-066). Recorded when the name fell outside + * the safe key grammar, or when it was already written in the destination format and + * passed through unformatted — in which case reversal is identity, not re-derivation. + * + * Both triggers require `format.figmaKeys` to be other than NONE. Under NONE no + * source convention is declared, so divergence is not evaluated and this field is + * absent however the key was derived. + * @since 0.30.0 + */ + name?: string; /** Additional Figma-specific metadata passes through without type enforcement. */ [key: string]: unknown; } @@ -111,6 +122,8 @@ export interface NumberProp { nullable?: boolean; /** Sample numeric values demonstrating typical content for this prop */ examples?: number[]; + /** DTCG §5.2.3 platform-specific extensions. @since 0.30.0 */ + $extensions?: PropExtensions; } /** diff --git a/scripts/check-prerequisites.sh b/scripts/check-prerequisites.sh index 98e387c2..c85f8292 100755 --- a/scripts/check-prerequisites.sh +++ b/scripts/check-prerequisites.sh @@ -74,12 +74,59 @@ EOF esac done -# Source common functions +# Resolve repo root and branch. These were previously sourced from common.sh, which +# was removed with the rest of the spec-kit scaffolding; the helpers this script +# actually used are inlined here so it has no external dependency. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" -# Get feature paths and validate branch -eval $(get_feature_paths) +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT="$(git rev-parse --show-toplevel)" + HAS_GIT="true" +else + # Fall back to the script's parent directory (scripts/ lives at the repo root) + REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/.." && pwd)" + HAS_GIT="false" +fi + +if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + CURRENT_BRANCH="$SPECIFY_FEATURE" +elif [[ "$HAS_GIT" == "true" ]]; then + CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +else + CURRENT_BRANCH="" +fi + +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + # Accept feature branches (###-name) or ADR branches under a release (X.Y.Z/###-name) + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]+\.[0-9]+\.[0-9]+/[0-9]{3}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 0.12.0/001-feature-name" >&2 + return 1 + fi + + return 0 +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +FEATURE_DIR="$REPO_ROOT/specs/$CURRENT_BRANCH" +FEATURE_SPEC="$FEATURE_DIR/spec.md" +IMPL_PLAN="$FEATURE_DIR/plan.md" +TASKS="$FEATURE_DIR/tasks.md" +RESEARCH="$FEATURE_DIR/research.md" +DATA_MODEL="$FEATURE_DIR/data-model.md" +QUICKSTART="$FEATURE_DIR/quickstart.md" +CONTRACTS_DIR="$FEATURE_DIR/contracts" + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 # If paths-only mode, output paths and exit (support JSON + paths-only combined) diff --git a/scripts/validate-schema.sh b/scripts/validate-schema.sh index c8395866..c10a284e 100755 --- a/scripts/validate-schema.sh +++ b/scripts/validate-schema.sh @@ -7,7 +7,7 @@ # # Usage: ./validate-schema.sh [--json] [--schema-dir ] # --json Output results in JSON format -# --schema-dir Override the schema directory (default: /schema) +# --schema-dir Override the schema directory (default: /packages/schema/schema) set -e @@ -23,10 +23,16 @@ for arg in "$@"; do esac done -# Locate repo root and schema dir +# Locate repo root and schema dir. This script lives at /scripts/, so the +# repo root is one level up — it previously assumed the old spec-kit location three +# levels down, which resolved outside the repo entirely. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/../../.." && pwd)" -SCHEMA_DIR="${SCHEMA_DIR:-$REPO_ROOT/schema}" +if git -C "$SCRIPT_DIR" rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +else + REPO_ROOT="$(CDPATH="" cd "$SCRIPT_DIR/.." && pwd)" +fi +SCHEMA_DIR="${SCHEMA_DIR:-$REPO_ROOT/packages/schema/schema}" if [ ! -d "$SCHEMA_DIR" ]; then echo "ERROR: Schema directory not found: $SCHEMA_DIR" >&2 diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 97ba7614..696b0ab7 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -125,6 +125,7 @@ export default defineConfig({ { label: 'props', slug: 'cli/analyze/props' }, { label: 'styling', slug: 'cli/analyze/styling' }, { label: 'dependencies', slug: 'cli/analyze/dependencies' }, + { label: 'keys', slug: 'cli/analyze/keys' }, ], }, ], diff --git a/site/src/content/docs/cli/analyze/index.md b/site/src/content/docs/cli/analyze/index.md index 2716b176..d0b1995f 100644 --- a/site/src/content/docs/cli/analyze/index.md +++ b/site/src/content/docs/cli/analyze/index.md @@ -21,6 +21,7 @@ Analyzer names are passed as positional arguments. There is no config key — an specs analyze props specs analyze styling specs analyze dependencies +specs analyze keys specs analyze props styling dependencies specs analyze props --analysis ./reports ``` @@ -41,6 +42,7 @@ specs analyze props --analysis ./reports | [`props`](/cli/analyze/props/) | `_analysis/props.yaml` | Cross-library prop inventory — frequency, enum discordance, API surface, slots | | [`styling`](/cli/analyze/styling/) | `_analysis/styling.byComponent.json`, `_analysis/styling.byToken.json`, `_analysis/styling.unused.json` | Token usage indexed by component and by token name, plus tokens no spec references | | [`dependencies`](/cli/analyze/dependencies/) | `_analysis/dependencies.graph.json`, `_analysis/dependencies.byComponent.json` | Component dependency graph — blast radius of a change, and which props consumers configure | +| [`keys`](/cli/analyze/keys/) | `_analysis/keys.yaml` | Figma names a formatted key cannot reconstruct, as a per-component checklist. Requires `format.figmaKeys` | ## Output Directory @@ -53,6 +55,7 @@ specs/ styling.unused.json # from specs analyze styling dependencies.graph.json # from specs analyze dependencies dependencies.byComponent.json # from specs analyze dependencies + keys.yaml # from specs analyze keys ds-button/ api.yaml contract.ts diff --git a/site/src/content/docs/cli/analyze/keys.md b/site/src/content/docs/cli/analyze/keys.md new file mode 100644 index 00000000..5bf5b05b --- /dev/null +++ b/site/src/content/docs/cli/analyze/keys.md @@ -0,0 +1,152 @@ +--- +title: "keys" +description: "List the Figma layer and property names a formatted key cannot reconstruct, organized as a per-component checklist" +--- + + + +Reads every component's `api.yaml` and produces `_analysis/keys.yaml`: every Figma layer and property name that falls outside the [safe key grammar](/guides/key-formatting/), grouped by component so it reads as a checklist, then by cause and by frequency. + +## Requires a declared convention + +This analyzer reports names the producer recorded in `$extensions['com.figma'].name`, which only happens when [`format.figmaKeys`](/settings/figma-keys/) declares a source convention: + +```yaml +config: + format: + figmaKeys: SENTENCE +``` + +Under the default `NONE`, no convention is declared, no names are recorded, and this report is empty. That is correct rather than a failure — nothing has diverged from a convention you never stated. + +## Use When + +- You want a per-component list of Figma names to tidy, in the order a designer would work through them. +- You want to find a badly-formed name that repeats across the library, where one rename fixes dozens of specs. +- You want to see which kinds of naming problem dominate before deciding what to fix first. +- You want to confirm a naming cleanup actually landed, by diffing the aggregate between runs. + +## Invocation + +```bash +specs analyze keys +``` + +## Output + +Writes a single file to `_analysis/` after all components are processed. + +``` +specs/ + _analysis/ + keys.yaml # cross-library aggregate + ds-button/ + api.yaml + ds-alert/ + api.yaml +``` + +## Sections + +### summary + +Counts for the library as a whole, and the distribution of causes. + +```yaml +summary: + totalComponents: 69 + componentsWithDivergence: 52 + totalNames: 948 + divergentNames: 75 + causeDistribution: + mixed-letter-digit: 42 + casing: 28 + symbol: 2 + separator: 1 + digit-initial: 1 + already-a-key: 1 +``` + +### byComponent + +The checklist. Names are grouped under the component they belong to, because that is the unit a designer opens and edits. + +```yaml +byComponent: + dsAlert: + divergent: 1 + props: + - key: fullBleed + figmaName: Full Bleed + cause: casing + dsAvatar: + divergent: 2 + props: + - key: a11yLabel + figmaName: A11y label + cause: mixed-letter-digit + anatomy: + - key: startIcon + figmaName: Start Icon + cause: casing +``` + +Names appear under `props` (component property names) or `anatomy` (layer names). A surface with nothing to fix is omitted. + +### byCause + +The same names grouped by what is wrong with them, most common first. Useful for deciding what to fix in bulk: a library with 28 casing problems and 2 symbol problems has one systemic issue and two one-offs. + +```yaml +byCause: + - cause: casing + occurrences: 28 + names: + - Alternate Half + - Children minItems + - EGDS Bottom Sheet +``` + +### byName + +Each distinct Figma name with the components it appears in, most frequent first. This is the counterweight to the per-component checklist — a name wrong in forty components is one decision, not forty. + +```yaml +byName: + - figmaName: A11y label + occurrences: 42 + components: + - dsAvatar + - dsBadge + - dsButton + cause: mixed-letter-digit +``` + +## Causes + +| Cause | Meaning | Example | +|-------|---------|---------| +| `separator` | Leading, trailing, or repeated spaces | `Label ` | +| `symbol` | A character outside letters, digits, and single spaces | `Cut & paste` | +| `non-ascii` | A non-ASCII character | `Étiquette` | +| `mixed-letter-digit` | Letters and digits share a word, so the boundary is lost | `A11y label` | +| `digit-initial` | The name begins with a digit | `0000 0000 0000 0000` | +| `casing` | Characters are fine, but casing diverges from the declared convention | `Start Icon` under `SENTENCE` | +| `already-a-key` | Not a defect — a name authored in a key convention rather than as a display name | `x-figmacollapse` | + +A name is reported under the most specific cause that applies, since that is the edit to make. `already-a-key` is listed last because it is a deliberate authoring choice, not a problem: such names are [retained as authored](/guides/key-formatting/#names-already-written-as-keys). + +## Fixing What It Finds + +Nothing here is a validation failure. Every name it lists is fully supported — its Figma name is recorded, so the spec round-trips correctly either way. The report exists so naming can be tidied at the source, which makes the extensions disappear and the specs smaller. + +Two things worth knowing before a cleanup: + +- Renaming a layer or property in Figma changes the spec key too, which is a breaking change for anything consuming that key. +- The report cannot see names in a catalog that has never declared `figmaKeys`. Declare a convention first, generate, then analyze. + +## See Also + +- [Key Formatting guide](/guides/key-formatting/) — the safe key grammar and what happens to unsafe names +- [Figma Keys](/settings/figma-keys/) — declaring the source convention this analyzer depends on +- [Analyze overview](/cli/analyze/) — options shared by every analyzer diff --git a/site/src/content/docs/guides/key-formatting.md b/site/src/content/docs/guides/key-formatting.md index 25fa55cd..07ae5f3c 100644 --- a/site/src/content/docs/guides/key-formatting.md +++ b/site/src/content/docs/guides/key-formatting.md @@ -89,12 +89,113 @@ model: **Choose one format and use it consistently.** Mixing formats across different spec runs creates the same inconsistency problem you're trying to solve. Set the format once in your config file and leave it. -**`SAFE` is lossless; everything else is lossy.** The `SAFE` format preserves the original name exactly. All other formats discard information (casing, separators, spaces). If you need the original Figma name alongside a transformed key, `SAFE` is the only format that preserves it. +**`SAFE` is lossless; everything else is lossy.** The `SAFE` format preserves the original name exactly. All other formats discard information (casing, separators, spaces). Names that would lose information are preserved separately — see [Round-Trip Safety](#round-trip-safety) below. **Match your consuming platform.** If your specs feed a React component library, use `CAMEL`. If they feed a Python SDK, use `SNAKE`. The format should eliminate transformation work for the most common consumer, not add it. **Special characters are handled gracefully.** Keys containing special characters (slashes, dots, brackets) are cleaned during transformation. The `SAFE` format preserves them as-is; other formats normalize them into the target convention. +## Round-Trip Safety + +Formatting a key is one-way: `Icon leading`, `Icon-leading`, and `Icon_leading` all become `icon-leading` under `KEBAB`, and the formatted key alone cannot say which one it came from. That matters when a spec is rendered back into Figma, where layer and property names are the identity used to match existing nodes. + +Two settings make the round trip reliable. + +### The source convention + +`figmaKeys` declares the convention your Figma file already uses, so a formatted key has a defined name to reverse into: + +```yaml +model: + format: + figmaKeys: SENTENCE # what your Figma file uses + keys: KEBAB # what the spec emits +``` + +| Value | Shape | Example | +|-------|-------|---------| +| `NONE` (default) | No convention declared | — | +| `SENTENCE` | First word capitalized, rest lowercase | `Icon leading` | +| `TITLE` | Every word capitalized | `Icon Leading` | + +Everything in the rest of this guide is **opt-in**. Under the `NONE` default, names are formatted per `keys` and nothing else happens: no grammar check, no preserved names, no defined reversal. Declaring `SENTENCE` or `TITLE` turns all of it on, and your specs grow an extension block for each name that needs one. Declare a convention when you intend to render specs back into Figma, or when you want divergent names surfaced. + +### The safe key grammar + +*Applies when `figmaKeys` is `SENTENCE` or `TITLE`.* + +A Figma name survives every `keys` format when it satisfies all of the following: + +- ASCII letters and digits only — no `&`, `+`, `/`, `.`, parentheses, punctuation, or accented characters +- Exactly one space between words, with no leading, trailing, or repeated spaces +- Each word is either all letters or all digits — `Badge count 2` is fine, `Badge count2` is not +- The name does not begin with a digit +- Casing matches your declared `figmaKeys` + +```yaml +# figmaKeys: SENTENCE + +# Safe — reconstructs under every keys format +Icon leading +Label +Badge count 2 # the digit is its own word, so the boundary survives +Icon 2 leading + +# Unsafe — the Figma name is preserved separately +Icon-leading # separator is not a space +URL field # inner capitals are lost +Badge count2 # letters and digits share a word +2 icons # begins with a digit +Cut & paste # the symbol and its word boundary are deleted +``` + +A digit run always counts as its own word, in both directions. That is what lets `Badge count 2` become `badgeCount2` and come back intact — and why `Badge count2` cannot, since it formats to the same key but is not the same name. + +### Names already written as keys + +Figma files are rarely uniform. If yours is mostly sentence case but a few properties were named `isDisabled` or `is-disabled` for the engineers consuming them, those names fail the grammar — yet they are already exactly what you want on both sides. + +A name that is already a well-formed key **in the convention you emit** is kept exactly as authored, and recorded. With `keys: CAMEL`, a layer named `isDisabled` stays `isDisabled`, and rendering the spec back into Figma restores it rather than rewriting it to `Is disabled`: + +```yaml +props: + isDisabled: + type: boolean + $extensions: + com.figma: + name: isDisabled +``` + +Only the convention you emit is retained. A name in some *other* key convention is formatted like any divergent name — with `keys: KEBAB`, a layer named `isDisabled` or `IsDisabled` becomes `isdisabled` and carries its Figma name in the extension. Formatting splits on spaces, hyphens, and underscores rather than case, so it flattens such names rather than converting them; the extension is what keeps them recoverable. + +### What happens to unsafe names + +Unsafe names are fully supported — nothing is rejected. When a key cannot reconstruct its Figma name, that name is recorded on the definition: + +```yaml +anatomy: + icon-leading: # safe — nothing extra emitted + type: glyph + url-field: + type: text + $extensions: + com.figma: + name: URL field +``` + +References elsewhere in the spec (`elements`, `propConfigurations`) keep pointing at the formatted key; the Figma name is resolved through the definition. + +Because well-formed names emit nothing, the presence of `com.figma.name` doubles as a signal that a Figma layer or property name is worth tidying. + +### What is not covered + +The Figma names of anatomy elements and props are preserved, including those nested inside compositions and slot content. Two things are not: + +- **Variant option values.** The values inside a variant prop's `options` are formatted like keys, but no Figma name is recorded for them. +- **Titles.** Component and subcomponent titles are never formatted — they carry the Figma name verbatim already, so there is nothing to preserve. + ## See Also +- [Keys](/settings/keys/) — the output convention setting +- [Figma Keys](/settings/figma-keys/) — the source convention setting - [CLI Configuration](/settings/) — full config reference diff --git a/site/src/content/docs/schema/anatomy.md b/site/src/content/docs/schema/anatomy.md index 37bfc18e..d76b84de 100644 --- a/site/src/content/docs/schema/anatomy.md +++ b/site/src/content/docs/schema/anatomy.md @@ -16,6 +16,15 @@ type Anatomy = Record; | `type` | `ElementType \| ElementTypeRef` | Yes | What kind of element this is | | `detectedIn` | `string` | No | Frame or node name where this element was found | | `instanceOf` | `string \| SubcomponentRef` | No | Component name this element is an instance of, or a `$ref` to a subcomponent | +| `$extensions` | `AnatomyElementExtensions` | No | Vendor extensions; `com.figma` carries extraction provenance | + +### AnatomyElementExtensions + +Only the `com.figma` extension is defined. + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | The element's Figma layer name, present only when the anatomy key cannot reconstruct it — either after [wrapper collapse](/settings/collapse-primitive-wrapper/) or when the name falls outside the safe key grammar (see [Key Formatting](/guides/key-formatting/)) | ### ElementType diff --git a/site/src/content/docs/schema/config.md b/site/src/content/docs/schema/config.md index e304d5e6..44b2df08 100644 --- a/site/src/content/docs/schema/config.md +++ b/site/src/content/docs/schema/config.md @@ -11,6 +11,7 @@ Controls how specs are generated. See the [settings reference](/settings/) for d |----------|------|---------|-------------| | [`output`](/settings/output-format/) | `'JSON' \| 'YAML'` | `'JSON'` | Output file format | | [`keys`](/guides/key-formatting/) | `'SAFE' \| 'CAMEL' \| 'SNAKE' \| 'KEBAB' \| 'PASCAL' \| 'TRAIN'` | `'SAFE'` | Key casing style | +| [`figmaKeys`](/settings/figma-keys/) | `'NONE' \| 'SENTENCE' \| 'TITLE'` | `'NONE'` | Naming convention the Figma file uses — the reversal target for `keys`. `NONE` declares none, making key preservation opt-in | | [`layout`](/guides/data-layout/) | `'LAYOUT' \| 'PARENT_CHILDREN' \| 'BOTH'` | `'LAYOUT'` | Element hierarchy representation | | [`tokens`](/settings/tokens/) | `'TOKEN' \| 'TOKEN_NAME' \| 'TOKEN_FIGMA_EXTENSIONS' \| 'FIGMA_NAME' \| 'CUSTOM' \| 'FIGMA_SYNTAX_WEB' \| 'FIGMA_SYNTAX_IOS' \| 'FIGMA_SYNTAX_ANDROID'` | `'TOKEN'` | Token reference output format — `FIGMA_SYNTAX_*` emit per-platform Figma code syntax, falling back to `TOKEN` | | [`color`](/settings/color/) | `ColorFormat` | `'HEX'` | Color value output format — `HEX`, `HEXA`, `RGB`, `RGBA`, `HSLA`, `HSB`, `OKLCH`, `OKLAB`, or `OBJECT` | @@ -99,6 +100,7 @@ const DEFAULT_CONFIG: ResolvedConfig = { format: { output: 'JSON', keys: 'SAFE', + figmaKeys: 'NONE', layout: 'LAYOUT', tokens: 'TOKEN', color: 'HEX', diff --git a/site/src/content/docs/schema/props.md b/site/src/content/docs/schema/props.md index 1bc09110..109a91c9 100644 --- a/site/src/content/docs/schema/props.md +++ b/site/src/content/docs/schema/props.md @@ -115,6 +115,7 @@ The `$extensions` object holds vendor-specific metadata. Currently only the `com |----------|------|-------------| | `type` | `string` | Figma property type (e.g. `BOOLEAN`, `TEXT`, `INSTANCE_SWAP`, `VARIANT`) | | `source` | `FigmaCodeOnlySource` | Present when the prop originates from a code-only prop layer | +| `name` | `string` | The Figma property name, present only when the prop key cannot reconstruct it — see [Key Formatting](/guides/key-formatting/) | ### FigmaCodeOnlySource @@ -131,3 +132,4 @@ The `$extensions` object holds vendor-specific metadata. Currently only the `com - [ADR 056 — Rename SlotProp.minItems/maxItems → minChildren/maxChildren](https://github.com/DirectedEdges/specs/blob/main/adr/056-slot-children-constraints.md) — aligns field names with Figma native `slotSettings`; adds native `preferredValues` resolution - [ADR 029 — NumberProp](https://github.com/DirectedEdges/specs/blob/main/adr/029-number-prop.md) — adds the `NumberProp` type with opt-in inference - [ADR 063 — Image Content](https://github.com/DirectedEdges/specs/blob/main/adr/063-image-content.md) — adds the `ImageProp` type and image fills/registry +- [ADR 066 — Lossless Key Formatting](https://github.com/DirectedEdges/specs/blob/main/adr/066-lossless-key-formatting.md) — adds `name` to `FigmaPropExtension` so lossy key formats stay reversible diff --git a/site/src/content/docs/settings/collapse-primitive-wrapper.md b/site/src/content/docs/settings/collapse-primitive-wrapper.md index f38373fc..60af7efe 100644 --- a/site/src/content/docs/settings/collapse-primitive-wrapper.md +++ b/site/src/content/docs/settings/collapse-primitive-wrapper.md @@ -33,10 +33,10 @@ anatomy: root: type: text $extensions: - com.figma.originalName: Label + com.figma.name: Label ``` -The `$extensions.com.figma.originalName` field on the anatomy root carries the original Figma layer name so the source layer remains traceable. +The `$extensions.com.figma.name` field on the anatomy root carries the Figma layer name so the source layer remains traceable. ## Eligibility diff --git a/site/src/content/docs/settings/figma-keys.md b/site/src/content/docs/settings/figma-keys.md new file mode 100644 index 00000000..754090ec --- /dev/null +++ b/site/src/content/docs/settings/figma-keys.md @@ -0,0 +1,56 @@ +--- +title: "Figma Keys" +description: "Declare the naming convention your Figma file uses, so formatted keys stay reversible" +--- + +The naming convention your Figma file uses for layer names and component property names. + +Where [`keys`](/settings/keys/) controls what the spec *emits*, `figmaKeys` describes what the Figma file *contains*. Declaring it gives every formatted key a defined name to reverse back into when a spec is rendered into Figma. + +## Options + +- **Default**: `NONE` +- **Values**: + - `NONE` - No convention declared. Names are not checked, and nothing is preserved + - `SENTENCE` - First word capitalized, rest lowercase (`Icon leading`) + - `TITLE` - Every word capitalized (`Icon Leading`) + +Only the two conventions observed in real Figma files are accepted as declarations. This is deliberately narrower than `keys`. + +## Path + +`config.format.figmaKeys` + +### Example + +```yaml +config: + format: + figmaKeys: TITLE # Figma layer names are Title Case + keys: CAMEL # spec emits camelCase +``` + +## Opting in + +`NONE` is the default, and it means your specs behave exactly as they always have: names are formatted per [`keys`](/settings/keys/), nothing is checked, and no Figma names are preserved. + +Declaring `SENTENCE` or `TITLE` turns on three things at once: + +- Every layer and property name is checked against the safe key grammar. +- Any name that cannot survive formatting has its Figma name recorded in `$extensions.com.figma.name`. +- Rendering a spec back into Figma has a defined name to reverse into. + +This is a trade, not a free upgrade. You gain round-trip fidelity and pay for it in spec size — every name outside the grammar grows an extension block. On a file with inconsistent layer naming, that can be a lot of new output. Declare a convention when you intend to render specs back into Figma, or when you want the divergences surfaced. + +## Why the declaration matters + +Declaring the convention your file actually uses keeps specs quiet. A file authored in Title Case but read as sentence case treats every name as divergent, so each anatomy element and prop carries a preserved copy of its Figma name — noise that buries the names genuinely worth fixing. + +Names that fall outside the declared convention, or outside the safe character set, are still fully supported. Their Figma name is recorded in `$extensions.com.figma.name` on the definition. + +Names already written as keys are handled too. If your file is mostly sentence case but a few properties are named `isDisabled` for the engineers reading them, those are kept exactly as authored — provided they match the convention you emit — and never rewritten to `Is disabled` when the spec is rendered back. + +## See Also + +- [Keys](/settings/keys/) - the output naming convention +- [Key Formatting guide](/guides/key-formatting/) - the safe key grammar and round-trip behavior diff --git a/site/src/content/docs/settings/index.mdx b/site/src/content/docs/settings/index.mdx index d3b45acc..8ed845ac 100644 --- a/site/src/content/docs/settings/index.mdx +++ b/site/src/content/docs/settings/index.mdx @@ -25,6 +25,7 @@ The **Format** section controls how spec data is serialized once it's extracted: - [Output Format](/settings/output-format/) — Serializes specs as `YAML` or `JSON` - [Keys](/settings/keys/) — Renames keys, such as `camelCase` or `snake_case` +- [Figma Keys](/settings/figma-keys/) — Declares the convention your Figma file uses, keeping renamed keys reversible - [Tokens](/settings/tokens/) — Serializes as forms such as a name string or `{ $token, $type }` object - [Color](/settings/color/) — Formats color, such as `#FF6600` or `{ colorSpace, components }` @@ -153,6 +154,7 @@ config: format: output: YAML keys: SAFE + figmaKeys: SENTENCE # opts in to key preservation; defaults to NONE layout: LAYOUT tokens: TOKEN diff --git a/site/src/content/docs/settings/keys.md b/site/src/content/docs/settings/keys.md index a4fdac25..569a4659 100644 --- a/site/src/content/docs/settings/keys.md +++ b/site/src/content/docs/settings/keys.md @@ -41,6 +41,9 @@ config: keys: CAMEL # Transform keys to camelCase ``` +Every value other than `SAFE` is a lossy projection of the Figma name. Names that cannot be reconstructed from the formatted key are preserved in `$extensions.com.figma.name` on the definition, so the spec stays reversible into Figma. + ## See Also +- [Figma Keys](/settings/figma-keys/) - The convention your Figma file uses, and the target keys reverse into - [Key Formatting guide](/guides/key-formatting/) - Detailed formatting behavior and edge cases From a7083f65eeeca2fa4a16d4cfcdfd9e1dd9d17c3d Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:49:45 -0400 Subject: [PATCH 06/19] docs(adr): drop the stale draft row for ADR-066 066 appeared in both tables after the merge: the accepted row from #296, plus the draft row claimed in 8392fe7, which still carried the pre-acceptance title ("Original-Name Preservation"). Removes the draft row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y --- adr/INDEX.md | 1 - 1 file changed, 1 deletion(-) diff --git a/adr/INDEX.md b/adr/INDEX.md index a8fadd88..f65b2cd2 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,7 +4,6 @@ | # | Title | Highlights | |---|-------|------------| -| 066 | Lossless Key Formatting — Safe Key Grammar and Original-Name Preservation | | | 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | | | 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | | From 6b437779962df1ad0ac840d2cdd32c6a4166fc1b Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:51:17 -0400 Subject: [PATCH 07/19] docs(adr): clear stale draft rows and stop the accept flow leaving them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine ADRs sat in both INDEX tables — 035, 041, 043, 051, 055, 057, 058, 059, 064 — each with a Status of ACCEPTED in its own file and an empty-Highlights draft row left behind. Removed all nine; the index now has 21 draft and 45 accepted rows with no overlap. The accept agent already said to "move" the row, which reads as one action and was carried out as an add. Step 6 now splits it into delete, insert, and a grep that must return exactly one match, with a note that a duplicate can also appear when a draft row claimed on the release branch reconciles with an in-flight ADR branch — which is how 066 ended up listed twice under two different titles. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y --- .github/agents/Specs.adr.accept.agent.md | 18 +++++++++++++----- adr/INDEX.md | 9 --------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/agents/Specs.adr.accept.agent.md b/.github/agents/Specs.adr.accept.agent.md index b5fe6b7e..f58e09fc 100644 --- a/.github/agents/Specs.adr.accept.agent.md +++ b/.github/agents/Specs.adr.accept.agent.md @@ -45,11 +45,18 @@ You **MUST** consider the user input before proceeding (if not empty). 5. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$ADR_NAME.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. Skip if step 3 determined the ADR is already `ACCEPTED`. -6. **Update INDEX**: Read `adr/INDEX.md` and update the entry for this ADR: - - Move the row from the **Draft** table to the **Accepted** table (descending by number). - - Update the **Title** to match the final ADR heading (it may have changed during implementation). - - Add a **Highlights** summary (max 144 characters) describing the key change as accepted. - - If no entry exists in the Draft table (older ADR created before INDEX tracking), add the row directly to the Accepted table. +6. **Update INDEX**: Read `adr/INDEX.md` and move this ADR's row from **Draft** to **Accepted**. Treat this as two discrete edits, not one "move" — every stale row in this file got there because the accepted row was added and the draft row was left behind. + + 1. **Delete** the ADR's row from the **Draft** table. Skip only if it genuinely has no draft row (older ADRs created before INDEX tracking). + 2. **Insert** its row into the **Accepted** table, in descending number order, with: + - the **Title** matching the final ADR heading (it may have changed during implementation) + - a **Highlights** summary (max 144 characters) describing the key change as accepted + 3. **Verify**: the ADR number must now appear exactly once in the file. Run + `grep -c "^| |" adr/INDEX.md` and confirm it returns `1`. If it returns `2`, step 1 did not happen — delete the draft row before continuing. + + A draft row left in place is not cosmetic: the draft table is how a reader finds unfinished work, and a stale row carries the pre-acceptance title, so the same ADR appears twice under two different names. + + Re-check this after any merge or rebase. A draft row claimed on the release branch while the ADR branch was in flight will reappear when the branches reconcile, leaving a duplicate that neither side authored. 7. **Determine release branch** *(skip entirely if `EXISTING_PR` is set — use that PR's base)*: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). 1. Find active in-flight release branches with `git branch -r --list 'origin/release/*'`. @@ -73,4 +80,5 @@ You **MUST** consider the user input before proceeding (if not empty). - Status MUST only move to `ACCEPTED` after all three validation gates pass in step 4. - **Never open a second PR for a branch that already has one.** Step 2 runs before everything else for this reason. This command is re-runnable: an ADR branch may already be in review from an earlier session, and re-running must update that PR, not duplicate it. - Verify repo and PR state by querying it — never infer from the conversation or assume a branch is fresh. +- **An ADR appears in exactly one INDEX table.** Accepting means deleting the draft row as well as adding the accepted one; verify with the grep in step 6 rather than assuming the edit landed. - Use absolute paths for all file operations. diff --git a/adr/INDEX.md b/adr/INDEX.md index f65b2cd2..501596c8 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -5,18 +5,12 @@ | # | Title | Highlights | |---|-------|------------| | 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | -| 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | | | 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | | | 061 | Schema Entry Points for Concern-Split Output | | -| 059 | Border Style and Dash Pattern — `borderStyle` and `borderDashPattern` on `Styles` | | -| 058 | Wrapper Collapse Config Flag — `processing.wrapperCollapse` | | -| 057 | Fix `Metadata.generator.version` type: `number` → `string` | | | 056 | Rename `SlotProp.minItems`/`maxItems` → `minChildren`/`maxChildren` | Align with Figma-native `slotSettings` field names; `anyOf` populated from `preferredValues` when `allowPreferredValuesOnly` is true | -| 055 | Variant State Classification via `processing.states` | | | 054 | Workspace Schema File | | | 053 | Transform Command and Configuration | | | 052 | Deeply-Nested Slot Content — Path-Anchored Overrides Across Non-Slot Instance Boundaries | | -| 051 | Platform Code-Syntax Token Profiles | | | 050 | Examples Config | Add `include.defaultSlotContent` (default false); add `processing.instanceExamples` with scope, match, exclude, parentNames — instance-example output is presence-driven (no `include.instanceExamples` flag), mirroring `subcomponents` | | 049 | Nested Slot Compositions | Recursion follow-on to ADR-047: fill nested instances' slots from a parent context (reserved, draft on `042-composition-type` branch) | | 048 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding` (reserved, draft on `042-composition-type` branch) | @@ -24,10 +18,7 @@ | 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample` and `InstanceExamples`; add `Component.instanceExamples?` named record (reserved, draft on `042-composition-type` branch) | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | -| 043 | Custom Color Format Configuration | | | 042 | Composition as a First-Class Type | | -| 041 | Layout Positioning — Constraint-Based Naming | | -| 035 | Make Config Properties with Defaults Optional | | | 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | | 025 | Flowing Content into a Nested Instance's Slot | Model parent components that flow defined content into a nested child instance's slot _(branch)_ | | 024 | Component Extends Relationship | Add `extends` field to express base/derived component relationships and prop/variant inheritance _(branch)_ | From 98f677fbc8a29911038c44c22c687c41ae821c0a Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:37:45 -0400 Subject: [PATCH 08/19] docs(adr): reconcile ADR statuses with what the schema actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index listed 21 drafts, most of which shipped some time ago. Each was checked against three sources: its INDEX row, the Status in its own file, and whether the change exists in the schema. Flipped DRAFT -> ACCEPTED in the ADR file and moved the INDEX row (12), each verified present in the schema: 022 SlotProp.nullable 034 emptyVariants, variantNames removed 042 Composition + SlotContent 046 InstanceExample + instanceExamples 047 slotContentExamples 048 PropBinding in PropConfigurationValue 049 SlotContentRef 050 defaultSlotContent + instanceExamples 053 config.transformers 054 workspace.schema.json 062 textOverflow + maxLines 065 NumberProp.nullable Moved the INDEX row only, the file already reading ACCEPTED (3): 052 $nested on PropConfigurations 056 minChildren/maxChildren 061 concern-split entry points (confirmed shipped; ADR file lives on origin/061-concern-split-schemas, so its status is untouched here) Added a Superseded table and moved 025 there, closed in favour of ADR-042 composition and ADR-047 slot content. Still draft, confirmed not shipped: 020, 024, 044, 045, and 021 — the schema and types both still carry invalidVariantCombinations, so the rename that ADR describes has not happened. The accept agent now documents the Superseded table alongside Draft and Accepted, so a closed ADR gets a pointer to what replaced it rather than lingering as unfinished work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TH28uXaeosYCb8om9Jru2y --- .github/agents/Specs.adr.accept.agent.md | 2 ++ adr/022-nullable-slot-props.md | 2 +- adr/034-empty-variants.md | 2 +- adr/042-composition-type.md | 2 +- adr/046-slots-and-slot-references.md | 2 +- adr/047-component-slot-examples.md | 2 +- adr/048-component-instance-examples.md | 2 +- adr/049-prop-configurations-bindings.md | 2 +- adr/050-examples-config.md | 2 +- adr/053-transform-emit-config.md | 2 +- adr/054-workspace-schema.md | 2 +- adr/062-text-truncation.md | 2 +- adr/065-code-only-text-prop-nullable.md | 2 +- adr/INDEX.md | 37 ++++++++++++++---------- 14 files changed, 35 insertions(+), 28 deletions(-) diff --git a/.github/agents/Specs.adr.accept.agent.md b/.github/agents/Specs.adr.accept.agent.md index f58e09fc..3c510ee5 100644 --- a/.github/agents/Specs.adr.accept.agent.md +++ b/.github/agents/Specs.adr.accept.agent.md @@ -56,6 +56,8 @@ You **MUST** consider the user input before proceeding (if not empty). A draft row left in place is not cosmetic: the draft table is how a reader finds unfinished work, and a stale row carries the pre-acceptance title, so the same ADR appears twice under two different names. + The file also has a **Superseded** table, for an ADR closed in favour of a later decision rather than accepted. Move the row there instead, and name what replaced it in the "Superseded by" column — an ADR abandoned without that pointer reads as merely unfinished. + Re-check this after any merge or rebase. A draft row claimed on the release branch while the ADR branch was in flight will reappear when the branches reconcile, leaving a duplicate that neither side authored. 7. **Determine release branch** *(skip entirely if `EXISTING_PR` is set — use that PR's base)*: Release branches follow the `release/-` convention and may jointly cover multiple published packages (e.g., `release/schema-0.21.0-cli-0.16.0`). Do **not** invent a bare version-number branch (e.g., `0.21.0`). diff --git a/adr/022-nullable-slot-props.md b/adr/022-nullable-slot-props.md index cc6a55af..1e2bc73d 100644 --- a/adr/022-nullable-slot-props.md +++ b/adr/022-nullable-slot-props.md @@ -2,7 +2,7 @@ **Branch**: `022-nullable-slot-props` **Created**: 2026-03-12 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 019 to cover `SlotProp`)* diff --git a/adr/034-empty-variants.md b/adr/034-empty-variants.md index bdf45673..52500e42 100644 --- a/adr/034-empty-variants.md +++ b/adr/034-empty-variants.md @@ -2,7 +2,7 @@ **Branch**: `034-empty-variants` **Created**: 2026-03-30 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: nathanacurtis (author) **Supersedes**: *(none)* diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 5ad3a4da..9326e190 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -2,7 +2,7 @@ **Branch**: `042-composition-type` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-049)* **Extended by**: ADR-046 (Slots and Slot References), ADR-047 (Component Slot Examples), ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/046-slots-and-slot-references.md b/adr/046-slots-and-slot-references.md index 8ea432b3..7ac4411d 100644 --- a/adr/046-slots-and-slot-references.md +++ b/adr/046-slots-and-slot-references.md @@ -2,7 +2,7 @@ **Branch**: `046-slots-and-slot-references` **Created**: 2026-05-18 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) **Extended by**: ADR-047 (Component Slot Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/047-component-slot-examples.md b/adr/047-component-slot-examples.md index bbdb9fd9..16fe58ee 100644 --- a/adr/047-component-slot-examples.md +++ b/adr/047-component-slot-examples.md @@ -2,7 +2,7 @@ **Branch**: `047-component-slot-examples` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references) **Extended by**: ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/048-component-instance-examples.md b/adr/048-component-instance-examples.md index fefbe880..d0c959fe 100644 --- a/adr/048-component-instance-examples.md +++ b/adr/048-component-instance-examples.md @@ -2,7 +2,7 @@ **Branch**: `048-component-instance-examples` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples) **Extended by**: ADR-049 (Prop Configurations and Bindings) diff --git a/adr/049-prop-configurations-bindings.md b/adr/049-prop-configurations-bindings.md index be9f93af..9c45a58b 100644 --- a/adr/049-prop-configurations-bindings.md +++ b/adr/049-prop-configurations-bindings.md @@ -2,7 +2,7 @@ **Branch**: `049-prop-configurations-bindings` **Created**: 2026-04-29 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-008 — Introduce PropBinding](008-prop-binding), [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/050-examples-config.md b/adr/050-examples-config.md index 56c3ae0f..7629ca6e 100644 --- a/adr/050-examples-config.md +++ b/adr/050-examples-config.md @@ -2,7 +2,7 @@ **Branch**: `042-composition-type` **Created**: 2026-05-19 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/053-transform-emit-config.md b/adr/053-transform-emit-config.md index 94b6c4ff..78a5d2b1 100644 --- a/adr/053-transform-emit-config.md +++ b/adr/053-transform-emit-config.md @@ -2,7 +2,7 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **RFC**: [RFC 001: Component Dictionary](../rfc/001-component-dictionary/README.md) diff --git a/adr/054-workspace-schema.md b/adr/054-workspace-schema.md index fab5cb62..67890d02 100644 --- a/adr/054-workspace-schema.md +++ b/adr/054-workspace-schema.md @@ -2,7 +2,7 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **Follows**: [ADR 053](053-transform-emit-config.md) diff --git a/adr/062-text-truncation.md b/adr/062-text-truncation.md index 88af3da3..ceec1195 100644 --- a/adr/062-text-truncation.md +++ b/adr/062-text-truncation.md @@ -2,7 +2,7 @@ **Branch**: `062-text-truncation` **Created**: 2026-07-09 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/065-code-only-text-prop-nullable.md b/adr/065-code-only-text-prop-nullable.md index 88351a3c..a9bf4d01 100644 --- a/adr/065-code-only-text-prop-nullable.md +++ b/adr/065-code-only-text-prop-nullable.md @@ -2,7 +2,7 @@ **Branch**: `065-code-only-text-prop-nullable` **Created**: 2026-08-05 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR-019 and ADR-022)* diff --git a/adr/INDEX.md b/adr/INDEX.md index 501596c8..4d4309e5 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,25 +4,9 @@ | # | Title | Highlights | |---|-------|------------| -| 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | -| 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | | -| 061 | Schema Entry Points for Concern-Split Output | | -| 056 | Rename `SlotProp.minItems`/`maxItems` → `minChildren`/`maxChildren` | Align with Figma-native `slotSettings` field names; `anyOf` populated from `preferredValues` when `allowPreferredValuesOnly` is true | -| 054 | Workspace Schema File | | -| 053 | Transform Command and Configuration | | -| 052 | Deeply-Nested Slot Content — Path-Anchored Overrides Across Non-Slot Instance Boundaries | | -| 050 | Examples Config | Add `include.defaultSlotContent` (default false); add `processing.instanceExamples` with scope, match, exclude, parentNames — instance-example output is presence-driven (no `include.instanceExamples` flag), mirroring `subcomponents` | -| 049 | Nested Slot Compositions | Recursion follow-on to ADR-047: fill nested instances' slots from a parent context (reserved, draft on `042-composition-type` branch) | -| 048 | PropConfigurations PropBinding | Widen `PropConfigurations` value union to add `PropBinding` (reserved, draft on `042-composition-type` branch) | -| 047 | Slot Content — Component.slotContentExamples and SlotBinding | Add `Component.slotContentExamples: Record` + `SlotBinding` extending `PropBinding` with optional `examples?: SlotContentRef[]` (Figma authoring default at index 0); widens `Children` to `string[] \| SlotBinding` (reserved, draft on `042-composition-type` branch) | -| 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample` and `InstanceExamples`; add `Component.instanceExamples?` named record (reserved, draft on `042-composition-type` branch) | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | -| 042 | Composition as a First-Class Type | | -| 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | -| 025 | Flowing Content into a Nested Instance's Slot | Model parent components that flow defined content into a nested child instance's slot _(branch)_ | | 024 | Component Extends Relationship | Add `extends` field to express base/derived component relationships and prop/variant inheritance _(branch)_ | -| 022 | Add Nullable Support to SlotProp | Fix type-schema drift: add `nullable?: boolean` and widen `default` to `string \| null` on SlotProp | | 021 | Rename `invalidVariantCombinations` to `invalidPropConfigurations` | Shorten verbose field name and fix misleading "Variant" terminology — it describes prop configs, not variants _(branch)_ | | 020 | Classify Props by External vs Internal Ownership | Add ownership metadata to props distinguishing public API, dual-purpose, and interaction-driven properties _(branch)_ | @@ -31,15 +15,28 @@ | # | Title | Highlights | |---|-------|------------| | 066 | Lossless Key Formatting — Safe Key Grammar and Figma Name Preservation | Adds opt-in `format.figmaKeys` (`NONE` default), a safe key grammar, and `com.figma.name` on anatomy and props; renames `originalName` | +| 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | Narrow from `Style` to `TextAlignHorizontal \| null` (`'START' \| 'CENTER' \| 'END' \| 'JUSTIFY'`); Figma `LEFT`/`RIGHT`/`JUSTIFIED` remapped | | 063 | Image Content — `backgroundImage` fill, an `images` registry, `ImageProp`, and `ImageBinding` | Add `backgroundImage` fills, a `Component.images` registry (Figma identity + optional `src`), `ImageProp`/`ImageBinding`, and presence-switched `processing.images` | +| 062 | Text Overflow & Max Lines — `textOverflow` and `maxLines` on `Styles` | Add `textOverflow` (`TextOverflow \| null`) and `maxLines` to `Styles`; not token-bindable | +| 061 | Schema Entry Points for Concern-Split Output | Split output across `root`, `components`, `component`, and `styles` schema entry points | | 060 | Subcomponent Figma Source Identity — `Subcomponent.source` | Add optional `SubcomponentSource` (`pageId`, `nodeId`, `nodeType`) to `Subcomponent`; enables reverse-direction tools to resolve `SubcomponentRef` to Figma nodes | | 059 | Stroke Dash Pattern — `strokeDashPattern` on `Styles` | Add `StrokeDashPattern { dash, gap }` structural type; presence = dashed stroke, null/absent = solid; not token-bindable | | 058 | Collapsing Wrapped Primitives — `processing.collapsePrimitiveWrapper` | Add optional boolean to `Config.processing` (default false); strips plain container wrappers around a single text/glyph child and promotes the leaf to spec root | | 057 | Fix `Metadata.generator.version` type: `number` → `string` | Corrects type mismatch — field holds semver strings (e.g. `"1.10.0"`) in all producers; was incorrectly typed as `number` | +| 056 | Rename `SlotProp.minItems`/`maxItems` → `minChildren`/`maxChildren` | Align with Figma-native `slotSettings` field names; `anyOf` populated from `preferredValues` when `allowPreferredValuesOnly` is true | | 055 | Variant State Classification via `processing.states` | Add `VariantStateEntry` type; add `Config.processing.states` — classifies Figma variant props as browser-driven or consumer-controlled for CSS selector and contract output | +| 054 | Workspace Schema File | Add `workspace.schema.json` describing `specs.config.yaml` — sources, output, and the `config` block | +| 053 | Transform Command and Configuration | Add `config.transformers` and the `specs transform` command for generating code artifacts from specs | +| 052 | Deeply-Nested Slot Content — Path-Anchored Overrides Across Non-Slot Instance Boundaries | Add the reserved `$nested` key on `PropConfigurations` for path-addressed overrides across instance boundaries | | 051 | Platform Code-Syntax Token Profiles | Add `FIGMA_SYNTAX_WEB`/`_IOS`/`_ANDROID` to `Config.format.tokens`, emitting per-platform Figma code syntax with fallback to `TOKEN` | +| 050 | Examples Config | Add `include.defaultSlotContent` (default false); add `processing.instanceExamples` with scope, match, exclude, parentNames | +| 049 | Nested Slot Compositions | Fill nested instances’ slots from a parent context; `PropConfigurationValue` accepts `SlotContentRef` under slot-prop keys | +| 048 | PropConfigurations PropBinding | Widen `PropConfigurationValue` to accept `PropBinding`, so a nested prop can pass through to a parent prop | +| 047 | Slot Content — Component.slotContentExamples and SlotBinding | Add `Component.slotContentExamples` and `SlotBinding` with `examples?: SlotContentRef[]`; widen `Children` | +| 046 | Component Instance Examples — InstanceExample and Component.instanceExamples | Add `InstanceExample`/`InstanceExamples` and `Component.instanceExamples` for documented whole-component usages | | 043 | Custom Color Format Configuration | Add `Config.format.color` with 9-format enum (HEX default); rename `ColorValue` → `ColorObject`; widen color types with `string` arm | +| 042 | Composition as a First-Class Type | Add `Composition` and `SlotContent` as first-class types carrying their own anatomy, elements, and layout | | 041 | Layout Positioning — Constraint-Based Naming | Replace `x`/`y`/`layoutPositioning` with constraint-based `position`, `start`, `end`, `top`, `bottom`, center offsets | | 040 | Replace `primaryAxisAlignItems` and `counterAxisAlignItems` with `mainAxisAlignment` and `crossAxisAlignment` | Rename to platform-neutral names; add `MainAxisAlignment` and `CrossAxisAlignment` enums; not token-bindable | | 039 | Replace `layoutWrap` and `counterAxisAlignContent` with `wrap` and `wrapAlignment` | Rename to platform-neutral names; add `WrapAlignment` enum (`START \| SPACE_BETWEEN`); not token-bindable | @@ -47,6 +44,7 @@ | 037 | Consolidate Item Spacing into a Bi-Axial Model | Replace `itemSpacing` + `counterAxisSpacing` with single `itemSpacing: Style \| ItemSpacing` using `{ horizontal, vertical }` | | 036 | Remove `name` and `baseline` from `Variant` | Remove unused `name` and `baseline` optional fields from `Variant` type and schema (breaking) | | 035 | Make Config Properties with Defaults Optional | Make 5 required Config properties optional with defaults; add `ResolvedConfig` type for fully-resolved shape | +| 034 | Remove variantNames, add emptyVariants, make Config.include fields optional | Remove unused `variantNames` (breaking); add `emptyVariants` for filtering; make remaining fields optional | | 033 | Typography fontFamily/fontStyle — Remove Number, Add TokenReference | Fix font fields: remove impossible `number` branch, add `TokenReference` for variable-bound font properties | | 032 | Typography leadingTrim — Correct to String Enum | Fix `leadingTrim` from incorrect `number \| "mixed"` to correct `"NONE" \| "CAP_HEIGHT" \| "mixed"` string enum | | 031 | Subcomponent Search Scope Config | Replace `subcomponentNamePattern` with structured `processing.subcomponents` object (`scope`, `match[]`, `exclude[]`) | @@ -56,6 +54,7 @@ | 027 | Code-Only Props | Surface Figma code-only props (a11y, semantics) in `props` with `$extensions` source kind `codeOnlyProp` | | 026 | Unify Platform-Specific Properties Under `$extensions` | Standardize on DTCG `$extensions` with reverse-domain keys for all platform metadata; remove `x-platform` | | 023 | Fix Schema Compliance Gaps | Fix 58 schema violations: optional SlotProp.default, `$`-prefix patternProperties, hex in ColorStyleValue, schema URL | +| 022 | Add Nullable Support to SlotProp | Fix type-schema drift: add `nullable?: boolean` and widen `default` to `string \| null` on SlotProp | | 019 | Allow null in StringProp.default | Widen `StringProp.default` from `string` to `string \| null` so nullable props can express `null` as their default | | 018 | Conditional Visible Binding | Add `Conditional` type with `if`/`condition`/`then`/`else` for declarative visibility derived from nullable props | | 017 | Rename `icon` Element Type to `glyph` | Rename `icon` to `glyph` in ElementType, `IconProp` to `GlyphProp`, `iconNamePattern` to `glyphNamePattern` | @@ -75,3 +74,9 @@ | 003 | Gradient Support for Color Style Properties | Add `GradientValue` (LINEAR/RADIAL/ANGULAR) discriminated union and `ColorStyle` type for gradient fills | | 002 | Replace `effectStyleId` with `effects` | Remove `effectStyleId`; add grouped `effects` key with `Shadow`, `Blur`, `Effects` types | | 001 | Surface License State in Component Output | Add optional `generator.license` (`status`, `level`) to Metadata for downstream entitlement gating | + +## Superseded + +| # | Title | Superseded by | +|---|-------|---------------| +| 025 | Flowing Content into a Nested Instance's Slot | ADR-042 composition and ADR-047 slot content, which model nested fills as first-class content rather than per-element flow | From 2ed89f570b330d46cab50cb415f4a2c0f86f2bf0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:58:07 -0400 Subject: [PATCH 09/19] feat(site): publish accepted ADRs as a generated docs section (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(site): publish accepted ADRs as a generated docs section Adds site/scripts/build-adrs.mjs, which reads adr/*.md and emits one Starlight page per ACCEPTED record into site/src/content/docs/adr/ plus an index. Runs from prebuild/predev alongside build-releases.mjs, and the sidebar uses autogenerate so new ADRs need no config edit. Generated output is gitignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * feat(site): reposition ADR nav entry and restyle the index Decision Records (ADRs) is now a single sidebar link beside Releases rather than an autogenerated group, so individual records stay out of the nav. The index gains an introduction explaining what an ADR is and when to read one, and its table becomes a two-column layout — number alongside the title, with a clipped summary beneath in a muted style. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * docs(site): tighten the ADR index introduction Two sentences per paragraph, roughly 60% of the prior length. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * feat(adr): author a Summary line per record and publish it in the index Adds **Summary** to the ADR metadata block — one present-tense sentence naming the property or type as its subject — written for all 59 accepted records. /specs.adr.implement now writes it as a gated step at implementation time, when the decision has settled, and the template carries a placeholder until then. The site index reads the field directly with no fallback, so an unwritten summary surfaces as a build warning and a blank row rather than as prose lifted from Context. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * fix(site): render the ADR metadata block as a label/value grid The **Key**: value lines opening every ADR are consecutive, so markdown joined them into a single run-on paragraph. They now become a bordered definition list, one row per field, collapsing to stacked labels on narrow screens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * fix(site): trim the ADR metadata grid to Summary, Status, Deciders Drops the Branch row, folds Created into Status, and shows Supersedes only when it names an actual superseded record rather than an "(none — extends ADR-0xx)" aside. Cross-references now resolve against the published set, so a pointer to an unpublished draft renders as text instead of a dead link. Row padding tightened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * feat(site): add ADRs to the top nav and set section links to 16px Places ADRs between Schema and Docs in the header section links. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * feat(site): give ADR record pages a crumb and section eyebrow Records sit outside the sidebar tree, so they arrived with no sense of place. A PageTitle override adds a back-link to the index and an ADRs eyebrow above the title, scoped to record pages only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * style(site): tighten and mute the ADR eyebrow Reads ADR rather than ADRs, sits closer to the title, and uses the muted gray rather than the accent color. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo * style(site): hug the eyebrow to the title, tighten the top nav The h1 carries its own 1rem margin-top, which won the margin collapse against the eyebrow's container and left it floating mid-gap; an adjacent-sibling rule overrides it. Top nav links drop to 14px/600 with half the previous gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJ7cvqDEapmdCwarATNwzo --------- Co-authored-by: Claude Opus 5 --- .github/agents/Specs.adr.implement.agent.md | 27 ++- .gitignore | 1 + adr/001-metadata.license.md | 1 + adr/002-effects-shadows-blurs.md | 1 + adr/003-gradients.md | 1 + adr/004-aspect-ratio.md | 1 + adr/005-typography-composite.md | 1 + adr/006-token-references.md | 1 + adr/007-token-reference-config.md | 1 + adr/008-prop-bindings.md | 1 + adr/009-color-values.md | 1 + adr/010-sides-and-corners.md | 1 + adr/011-icon-glyph-as-content.md | 1 + adr/012-element-type-references.md | 1 + adr/013-icon-fillColor.md | 1 + adr/014-prop-examples.md | 1 + adr/015-anyprop-oneOf-discrimination.md | 1 + adr/016-element-content.md | 1 + adr/017-icon-or-glyph-element-name.md | 1 + adr/018-conditional-visible-binding.md | 1 + adr/019-nullable-prop-defaults.md | 1 + adr/022-nullable-slot-props.md | 1 + adr/023-schema-compliance-fixes.md | 1 + adr/026-platform-extensions.md | 1 + adr/027-code-only-props.md | 1 + adr/028-slot-constraints.md | 1 + adr/029-number-prop.md | 1 + adr/030-subcomponent-refs.md | 1 + adr/031-subcomponent-search-scope.md | 1 + adr/032-typography-leading-trim-enum.md | 1 + adr/033-typography-font-token-reference.md | 1 + adr/034-empty-variants.md | 1 + adr/035-optional-config-defaults.md | 1 + adr/036-remove-variant-name-baseline.md | 1 + adr/037-item-spacing.md | 1 + adr/038-layout-mode-enum.md | 1 + adr/039-wrap-alignment.md | 1 + adr/040-layout-alignment.md | 1 + adr/041-layout-positioning.md | 1 + adr/042-composition-type.md | 1 + adr/043-custom-color-format-config.md | 1 + adr/046-slots-and-slot-references.md | 1 + adr/047-component-slot-examples.md | 1 + adr/048-component-instance-examples.md | 1 + adr/049-prop-configurations-bindings.md | 1 + adr/050-examples-config.md | 1 + adr/051-platform-token-syntax.md | 1 + adr/052-slotting-nested-instances.md | 1 + adr/053-transform-emit-config.md | 1 + adr/054-workspace-schema.md | 1 + adr/055-processing-states.md | 1 + adr/056-slot-children-constraints.md | 1 + adr/057-generator-version-string.md | 1 + adr/058-wrapper-collapse.md | 1 + adr/059-border-style.md | 1 + adr/060-subcomponent-source-metadata.md | 1 + adr/062-text-truncation.md | 1 + adr/063-image-content.md | 1 + adr/064-text-align-horizontal.md | 1 + adr/065-code-only-text-prop-nullable.md | 1 + adr/066-lossless-key-formatting.md | 1 + adr/adr-template.md | 1 + site/astro.config.mjs | 2 + site/package.json | 4 +- site/scripts/build-adrs.mjs | 200 ++++++++++++++++++++ site/src/components/Header.astro | 7 +- site/src/components/PageTitle.astro | 68 +++++++ site/src/custom.css | 81 ++++++++ 68 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 site/scripts/build-adrs.mjs create mode 100644 site/src/components/PageTitle.astro diff --git a/.github/agents/Specs.adr.implement.agent.md b/.github/agents/Specs.adr.implement.agent.md index f090b355..260ec37e 100644 --- a/.github/agents/Specs.adr.implement.agent.md +++ b/.github/agents/Specs.adr.implement.agent.md @@ -62,9 +62,20 @@ You **MUST** consider the user input before proceeding (if not empty). - Create or update `tests/[type-name].test-d.ts` for each changed type using `tsd`-style assertions or `@ts-expect-error` patterns - Run: `tsc --noEmit --strict tests/*.test-d.ts` to confirm test files compile - If tests fail: halt and report - - **All gates have now passed. Steps 10–12 are REQUIRED before reporting completion. Do not skip to step 13.** - -10. **Update docs**: + - **All gates have now passed. Steps 10–13 are REQUIRED before reporting completion. Do not skip to step 14.** + +10. **Write the ADR summary**: Add or update the `**Summary**:` line in the ADR's metadata block, directly beneath `**Status**`. Write it now rather than at draft time — it must describe what was actually implemented, which often differs from the original draft. + - **One sentence, present tense, roughly 15–18 words.** The new property, type, or config option is the grammatical subject. + - Anchor the addition to the neighbouring fields it joins, not to the gap it filled. + - Never open with "The schema", and never include a past-tense problem clause ("could not", "was dropped", "had nowhere to land"). + - Keep identifiers in backticks. + - Examples of the target voice: + - A `strokeDashPattern` property adds dashes to strokes already supported with color, weight and alignment. + - A `glyph` element type, `IconProp` and `glyphNamePattern` emit icons as first-class by applying Figma conventions. + - Images are supported by `backgroundImage` style, `ImageProp` and binding in components and examples. + - **Gate**: read the ADR back and confirm the `**Summary**:` line is present and no longer a placeholder. The docs site publishes this line verbatim with no fallback — a missing summary leaves a blank row in the published index. + +11. **Update docs**: - Docs live in `site/src/content/docs/`. Schema type pages are under `site/src/content/docs/schema/` (e.g., `schema/styles.md` for `Styles`, `schema/config.md` for `Config`). Individual config option pages are under `site/src/content/docs/config/` (e.g., `config/tokens.md`, `config/keys.md`). - For each property added, removed, or renamed in the ADR: update the relevant doc page's Properties table, Values table, and "Relating properties to values" section to reflect the new state. - For new dedicated types (e.g., `LayoutMode`, `WrapAlignment`, `ItemSpacing`): add a row to the Values table describing the type and its valid values. @@ -72,7 +83,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Do not create new doc pages for types that are only used as field values on an existing documented type — document them inline in the parent type's page. - If no doc file exists for the changed type, skip this step. -11. **Update CHANGELOG.md**: +12. **Update CHANGELOG.md**: - The release branch scaffolds an `## [X.Y.Z] - Unreleased` heading with empty sections. Add entries into the existing scaffold — do **not** replace `Unreleased` with a date (the date is set at release time). If no scaffold heading exists, prepend one using `Unreleased` as the date. - **Format**: one top-level bullet per user-visible change; no sub-bullets; no bold; no code blocks; no wrapping prose paragraphs - **Entry line**: `` `Parent.field` `` — one-phrase description; aim for ≤ 12 words; omit implementation detail (class names, file paths, method names) @@ -80,12 +91,12 @@ You **MUST** consider the user input before proceeding (if not empty). - **Consolidation**: When a new type exists only to serve a property, merge into one property-first bullet — e.g. `` `Styles.mainAxisAlignment` — typed as `MainAxisAlignment` (`'START' | 'END' | 'CENTER' | 'SPACE_BETWEEN'`) or `null`; description ``. Do not list the type as a separate bullet. - **Sections**: use `### Added`, `### Changed`, `### Removed` as needed; add `### Migration` (MAJOR or rename only) - **Migration line**: `` `Parent.old` → `Parent.new` ``: one sentence; imperative; describe what to read instead and how to handle the new type - - **Gate**: After writing, verify the new entry is present in the file. If CHANGELOG.md does not contain the new version heading, halt and report — do not proceed to step 12. + - **Gate**: After writing, verify the new entry is present in the file. If CHANGELOG.md does not contain the new version heading, halt and report — do not proceed to step 13. -12. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. - - **Gate**: After writing, read `package.json` back and confirm the `"version"` field matches the ADR's `NEW` version. If it does not match, halt and report — do not proceed to step 13. +13. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. + - **Gate**: After writing, read `package.json` back and confirm the `"version"` field matches the ADR's `NEW` version. If it does not match, halt and report — do not proceed to step 14. -13. **Report**: List every file modified (with one-line description each). The list **must** include `CHANGELOG.md` and `package.json` — if either is absent from the list, halt: steps 11–12 were not completed. State that the author should review the diff and accept the ADR once satisfied. Remind the author that this ADR branch (`$BRANCH`) targets the release branch (`$RELEASE_BRANCH`), not `main`. +14. **Report**: List every file modified (with one-line description each). The list **must** include the ADR file, `CHANGELOG.md`, and `package.json` — if any is absent from the list, halt: steps 10, 12, or 13 were not completed. State that the author should review the diff and accept the ADR once satisfied. Remind the author that this ADR branch (`$BRANCH`) targets the release branch (`$RELEASE_BRANCH`), not `main`. ## Key rules diff --git a/.gitignore b/.gitignore index ec3f02e8..4b380a18 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tests/tmp/ # Generated at build time from package CHANGELOGs site/src/content/docs/overview/releases.mdx +site/src/content/docs/adr/ diff --git a/adr/001-metadata.license.md b/adr/001-metadata.license.md index eae47003..55650150 100644 --- a/adr/001-metadata.license.md +++ b/adr/001-metadata.license.md @@ -3,6 +3,7 @@ **Branch**: `001-license-check` **Created**: 2026-02-24 **Status**: ACCEPTED +**Summary**: A `license` field inside `metadata.generator` records the license state that produced a component's output. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/002-effects-shadows-blurs.md b/adr/002-effects-shadows-blurs.md index f1646ccc..6927d3a8 100644 --- a/adr/002-effects-shadows-blurs.md +++ b/adr/002-effects-shadows-blurs.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0` **Created**: 2026-02-24 **Status**: ACCEPTED +**Summary**: An `effects` property with `Shadow`, `Blur` and `Effects` types replaces `effectStyleId` to describe shadows and blurs. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/003-gradients.md b/adr/003-gradients.md index e103955e..28e7b602 100644 --- a/adr/003-gradients.md +++ b/adr/003-gradients.md @@ -3,6 +3,7 @@ **Branch**: `003-gradients` **Created**: 2026-02-25 **Status**: ACCEPTED +**Summary**: Gradient types and a `ColorStyle` alias let `backgroundColor`, `textColor` and `strokes` carry linear, radial and angular gradients. **Deciders**: Nathan Curtis **Supersedes**: *(none)* diff --git a/adr/004-aspect-ratio.md b/adr/004-aspect-ratio.md index 2ea9b63f..b6fb733f 100644 --- a/adr/004-aspect-ratio.md +++ b/adr/004-aspect-ratio.md @@ -3,6 +3,7 @@ **Branch**: `004-aspect-ratio` **Created**: 2026-02-25 **Status**: ACCEPTED +**Summary**: An `aspectRatio` property carries an `x`/`y` ratio object on styles alongside width and height. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/005-typography-composite.md b/adr/005-typography-composite.md index 4bba77f6..f775bd9d 100644 --- a/adr/005-typography-composite.md +++ b/adr/005-typography-composite.md @@ -3,6 +3,7 @@ **Branch**: `005-typography-composite` **Created**: 2026-02-26 **Status**: ACCEPTED +**Summary**: A composite `typography` property replaces fourteen flat text keys including `fontSize`, `lineHeight`, `letterSpacing` and `textStyleId`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/006-token-references.md b/adr/006-token-references.md index c390b9ec..1050ab62 100644 --- a/adr/006-token-references.md +++ b/adr/006-token-references.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0/006-token-references` **Created**: 2026-02-28 **Status**: ACCEPTED +**Summary**: A single `TokenReference` type replaces `VariableStyle` and `FigmaStyle` for variables, named styles and composite references. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/007-token-reference-config.md b/adr/007-token-reference-config.md index e0068546..e38a1659 100644 --- a/adr/007-token-reference-config.md +++ b/adr/007-token-reference-config.md @@ -3,6 +3,7 @@ **Branch**: `007-token-reference-config` **Created**: 2026-03-01 **Status**: ACCEPTED +**Summary**: A `format.tokens` option replaces `variables`, `simplifyVariables` and `simplifyStyles` with one token output format. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 006: Unified Token Reference Type)* diff --git a/adr/008-prop-bindings.md b/adr/008-prop-bindings.md index c82b6db4..6e2f1b01 100644 --- a/adr/008-prop-bindings.md +++ b/adr/008-prop-bindings.md @@ -3,6 +3,7 @@ **Branch**: `v0.11.0` **Created**: 2026-03-02 **Status**: ACCEPTED +**Summary**: A `PropBinding` type keyed `$binding` replaces `ReferenceValue` unions wherever a value is bound to a prop. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/009-color-values.md b/adr/009-color-values.md index f2637a7f..5e9b0237 100644 --- a/adr/009-color-values.md +++ b/adr/009-color-values.md @@ -2,6 +2,7 @@ **Created**: 2026-03-02 **Status**: ACCEPTED +**Summary**: A DTCG `ColorValue` object replaces the hex string in `ColorStyle`, aligning color values with token standards. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/010-sides-and-corners.md b/adr/010-sides-and-corners.md index 74b1bd5f..3b247d7f 100644 --- a/adr/010-sides-and-corners.md +++ b/adr/010-sides-and-corners.md @@ -3,6 +3,7 @@ **Branch**: `010-sides-and-corners` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: Composite `Sides` and `Corners` types replace flat padding, stroke weight and corner radius properties. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/011-icon-glyph-as-content.md b/adr/011-icon-glyph-as-content.md index 2cd24b9f..bcbff239 100644 --- a/adr/011-icon-glyph-as-content.md +++ b/adr/011-icon-glyph-as-content.md @@ -3,6 +3,7 @@ **Branch**: `011-icon-glyph-as-content` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: An `ElementType` union and `iconNamePattern` option constrain element types and detect icons by Figma naming. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/012-element-type-references.md b/adr/012-element-type-references.md index 6314e765..ac2d1ebe 100644 --- a/adr/012-element-type-references.md +++ b/adr/012-element-type-references.md @@ -3,6 +3,7 @@ **Branch**: `012-element-type-references` **Created**: 2026-03-05 **Status**: ACCEPTED +**Summary**: An `ElementTypeRef` widens `AnatomyElement.type`, letting an element type point at a shared definition. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/013-icon-fillColor.md b/adr/013-icon-fillColor.md index f181257a..bb6c871a 100644 --- a/adr/013-icon-fillColor.md +++ b/adr/013-icon-fillColor.md @@ -3,6 +3,7 @@ **Branch**: `013-icon-fillColor` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: A `fillColor` property colors icon elements alongside `backgroundColor`, `textColor` and `strokes`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/014-prop-examples.md b/adr/014-prop-examples.md index 9aacf10b..42fc0e7d 100644 --- a/adr/014-prop-examples.md +++ b/adr/014-prop-examples.md @@ -3,6 +3,7 @@ **Branch**: `014-prop-examples` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: An `examples` array on `TextProp` and `IconProp` carries sample values, and `default` becomes optional. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/015-anyprop-oneOf-discrimination.md b/adr/015-anyprop-oneOf-discrimination.md index 834b84a3..f93bc8fd 100644 --- a/adr/015-anyprop-oneOf-discrimination.md +++ b/adr/015-anyprop-oneOf-discrimination.md @@ -3,6 +3,7 @@ **Branch**: `015-anyprop-oneOf-discrimination` **Created**: 2026-03-09 **Status**: ACCEPTED +**Summary**: A single `StringProp` merges `TextProp` and `IconProp`, restoring valid `oneOf` discrimination in `AnyProp`. **Deciders**: Nathan Curtis (author), *(collaborators TBD)* **Supersedes**: ADR 017 (`017-icon-or-glyph-element-name`) — prop rename portion only. ADR 017 renamed `IconProp` → `GlyphProp`; this ADR merges both `TextProp` and `IconProp` into `StringProp`, making the intermediate `GlyphProp` rename moot. The element type rename (`icon` → `glyph`) and other non-prop changes from ADR 017 remain in effect. diff --git a/adr/016-element-content.md b/adr/016-element-content.md index fc51eee6..f7c932c8 100644 --- a/adr/016-element-content.md +++ b/adr/016-element-content.md @@ -3,6 +3,7 @@ **Branch**: `016-element-content` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `content` property replaces `text`, identifying literal or bound content on any element type. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/017-icon-or-glyph-element-name.md b/adr/017-icon-or-glyph-element-name.md index ba04ae1f..fe2d12d2 100644 --- a/adr/017-icon-or-glyph-element-name.md +++ b/adr/017-icon-or-glyph-element-name.md @@ -3,6 +3,7 @@ **Branch**: `017-icon-or-glyph-element-name` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `glyph` element type, `GlyphProp` and `glyphNamePattern` emit icons as first-class by applying Figma conventions. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/018-conditional-visible-binding.md b/adr/018-conditional-visible-binding.md index c58de6e5..89ba8e90 100644 --- a/adr/018-conditional-visible-binding.md +++ b/adr/018-conditional-visible-binding.md @@ -3,6 +3,7 @@ **Branch**: `018-conditional-visible-binding` **Created**: 2026-03-10 **Status**: ACCEPTED +**Summary**: A `Conditional` type with condition expressions lets `visible` test a prop value rather than mirror it. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/019-nullable-prop-defaults.md b/adr/019-nullable-prop-defaults.md index 6b861e01..033ddcb5 100644 --- a/adr/019-nullable-prop-defaults.md +++ b/adr/019-nullable-prop-defaults.md @@ -3,6 +3,7 @@ **Branch**: `019-nullable-prop-defaults` **Created**: 2026-03-11 **Status**: ACCEPTED +**Summary**: `StringProp.default` accepts `null`, expressing props whose absent value is meaningful. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/022-nullable-slot-props.md b/adr/022-nullable-slot-props.md index 1e2bc73d..ffd49a09 100644 --- a/adr/022-nullable-slot-props.md +++ b/adr/022-nullable-slot-props.md @@ -3,6 +3,7 @@ **Branch**: `022-nullable-slot-props` **Created**: 2026-03-12 **Status**: ACCEPTED +**Summary**: `SlotProp` gains `nullable` and a nullable `default`, matching the other prop types. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 019 to cover `SlotProp`)* diff --git a/adr/023-schema-compliance-fixes.md b/adr/023-schema-compliance-fixes.md index 6efb30df..24d302c5 100644 --- a/adr/023-schema-compliance-fixes.md +++ b/adr/023-schema-compliance-fixes.md @@ -3,6 +3,7 @@ **Branch**: `023-schema-compliance-fixes` **Created**: 2026-03-13 **Status**: ACCEPTED +**Summary**: `SlotProp.default` becomes optional and pattern properties admit `$`-prefixed keys, bringing types and schema back into compliance. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR 022 for `SlotProp.default` optionality)* diff --git a/adr/026-platform-extensions.md b/adr/026-platform-extensions.md index e72cab76..164548cc 100644 --- a/adr/026-platform-extensions.md +++ b/adr/026-platform-extensions.md @@ -3,6 +3,7 @@ **Branch**: `026-platform-extensions` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: An `$extensions` object on every prop type unifies platform-specific metadata, replacing the Figma-only `x-platform` field. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/027-code-only-props.md b/adr/027-code-only-props.md index 7a82ab2e..c8f7847a 100644 --- a/adr/027-code-only-props.md +++ b/adr/027-code-only-props.md @@ -3,6 +3,7 @@ **Branch**: `027-code-only-props` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: A `codeOnlyPropsPattern` option and `FigmaCodeOnlySource` extension emit props that exist in code but not Figma. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **Depends on**: [026-platform-extensions](./026-platform-extensions.md) (`$extensions` pattern) diff --git a/adr/028-slot-constraints.md b/adr/028-slot-constraints.md index f5fe6abf..dab96933 100644 --- a/adr/028-slot-constraints.md +++ b/adr/028-slot-constraints.md @@ -3,6 +3,7 @@ **Branch**: `028-slot-constraints` **Created**: 2026-03-16 **Status**: ACCEPTED +**Summary**: `minItems`, `maxItems` and `anyOf` on `SlotProp`, with a `slotConstraints` option, describe what a slot accepts. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/029-number-prop.md b/adr/029-number-prop.md index 662a1417..61eeb0a7 100644 --- a/adr/029-number-prop.md +++ b/adr/029-number-prop.md @@ -3,6 +3,7 @@ **Branch**: `029-number-prop` **Created**: 2026-03-17 **Status**: ACCEPTED +**Summary**: A `NumberProp` type and `inferNumberProps` option carry numeric properties alongside boolean, string, enum and slot props. **Deciders**: (author) **Supersedes**: *(none)* diff --git a/adr/030-subcomponent-refs.md b/adr/030-subcomponent-refs.md index 36965ea4..8cb9af0c 100644 --- a/adr/030-subcomponent-refs.md +++ b/adr/030-subcomponent-refs.md @@ -3,6 +3,7 @@ **Branch**: `030-subcomponent-refs` **Created**: 2026-03-23 **Status**: ACCEPTED +**Summary**: A `SubcomponentRef` lets `instanceOf` point at a subcomponent definition rather than repeat its name. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/031-subcomponent-search-scope.md b/adr/031-subcomponent-search-scope.md index f75e2b05..3db7d903 100644 --- a/adr/031-subcomponent-search-scope.md +++ b/adr/031-subcomponent-search-scope.md @@ -3,6 +3,7 @@ **Branch**: `031-subcomponent-search-scope` **Created**: 2026-03-24 **Status**: ACCEPTED +**Summary**: A `processing.subcomponents` object with `scope`, `match` and `exclude` replaces `subcomponentNamePattern` for finding subcomponents. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/032-typography-leading-trim-enum.md b/adr/032-typography-leading-trim-enum.md index a08dc475..d4a6b625 100644 --- a/adr/032-typography-leading-trim-enum.md +++ b/adr/032-typography-leading-trim-enum.md @@ -3,6 +3,7 @@ **Branch**: `032-leading-trim-enum` **Created**: 2026-03-25 **Status**: ACCEPTED +**Summary**: `Typography.leadingTrim` is a string enum, matching the values Figma actually reports. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/033-typography-font-token-reference.md b/adr/033-typography-font-token-reference.md index b2881d31..1a7aa00f 100644 --- a/adr/033-typography-font-token-reference.md +++ b/adr/033-typography-font-token-reference.md @@ -3,6 +3,7 @@ **Branch**: `033-font-token-reference` **Created**: 2026-03-25 **Status**: ACCEPTED +**Summary**: `Typography.fontFamily` and `fontStyle` accept a `TokenReference` and drop `number`, matching how fonts are tokenized. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/034-empty-variants.md b/adr/034-empty-variants.md index 52500e42..68466ad2 100644 --- a/adr/034-empty-variants.md +++ b/adr/034-empty-variants.md @@ -3,6 +3,7 @@ **Branch**: `034-empty-variants` **Created**: 2026-03-30 **Status**: ACCEPTED +**Summary**: An `emptyVariants` include option replaces `variantNames`, and the remaining `Config.include` fields become optional. **Deciders**: nathanacurtis (author) **Supersedes**: *(none)* diff --git a/adr/035-optional-config-defaults.md b/adr/035-optional-config-defaults.md index a9bd8b8f..98538093 100644 --- a/adr/035-optional-config-defaults.md +++ b/adr/035-optional-config-defaults.md @@ -3,6 +3,7 @@ **Branch**: `035-optional-config-defaults` **Created**: 2026-04-10 **Status**: ACCEPTED +**Summary**: Every `Config` property carrying a default becomes optional, so a config states only what it changes. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/036-remove-variant-name-baseline.md b/adr/036-remove-variant-name-baseline.md index 47514771..c6635536 100644 --- a/adr/036-remove-variant-name-baseline.md +++ b/adr/036-remove-variant-name-baseline.md @@ -3,6 +3,7 @@ **Branch**: `036-remove-variant-name-baseline` **Created**: 2026-04-13 **Status**: ACCEPTED +**Summary**: `Variant.name` and `Variant.baseline` are removed, leaving a variant identified by its prop configuration alone. **Deciders**: nathanacurtis (author) **Supersedes**: *(none)* diff --git a/adr/037-item-spacing.md b/adr/037-item-spacing.md index 23a0180e..f7a38e8a 100644 --- a/adr/037-item-spacing.md +++ b/adr/037-item-spacing.md @@ -3,6 +3,7 @@ **Branch**: `037-item-spacing` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: An `ItemSpacing` type gives `itemSpacing` a bi-axial shape, replacing the separate `counterAxisSpacing` property. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/038-layout-mode-enum.md b/adr/038-layout-mode-enum.md index 488d60c1..f676d3e7 100644 --- a/adr/038-layout-mode-enum.md +++ b/adr/038-layout-mode-enum.md @@ -3,6 +3,7 @@ **Branch**: `038-layout-mode-enum` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: A `LayoutMode` enum types `layoutMode` as `NONE`, `HORIZONTAL` or `VERTICAL` rather than an open string. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/039-wrap-alignment.md b/adr/039-wrap-alignment.md index 1278f342..b1a73038 100644 --- a/adr/039-wrap-alignment.md +++ b/adr/039-wrap-alignment.md @@ -3,6 +3,7 @@ **Branch**: `039-wrap-alignment` **Created**: 2026-04-20 **Status**: ACCEPTED +**Summary**: `wrap` and `wrapAlignment` with a `WrapAlignment` enum replace `layoutWrap` and `counterAxisAlignContent`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/040-layout-alignment.md b/adr/040-layout-alignment.md index 2a01bafa..8ac56735 100644 --- a/adr/040-layout-alignment.md +++ b/adr/040-layout-alignment.md @@ -3,6 +3,7 @@ **Branch**: `040-layout-alignment` **Created**: 2026-04-23 **Status**: ACCEPTED +**Summary**: `mainAxisAlignment` and `crossAxisAlignment` with named enums replace `primaryAxisAlignItems` and `counterAxisAlignItems`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/041-layout-positioning.md b/adr/041-layout-positioning.md index 469e0ee8..624d70f2 100644 --- a/adr/041-layout-positioning.md +++ b/adr/041-layout-positioning.md @@ -3,6 +3,7 @@ **Branch**: `041-layout-positioning` **Created**: 2026-04-27 **Status**: ACCEPTED +**Summary**: `Position` and `PositionOffset` types describe constraint-based placement, replacing `x`, `y` and `layoutPositioning`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/042-composition-type.md b/adr/042-composition-type.md index 9326e190..61f40061 100644 --- a/adr/042-composition-type.md +++ b/adr/042-composition-type.md @@ -3,6 +3,7 @@ **Branch**: `042-composition-type` **Created**: 2026-04-29 **Status**: ACCEPTED +**Summary**: A `Composition` type names composed content, carrying `anatomy`, `elements` and `layout` with optional title and description. **Deciders**: Nathan Curtis (author) **Supersedes**: [ADR 025 — Flowing Content into a Nested Instance's Slot](025-nested-slot-api) *(partially — retains its `Composition` shape; defers its `PropConfigurations` widening to ADR-049)* **Extended by**: ADR-046 (Slots and Slot References), ADR-047 (Component Slot Examples), ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/043-custom-color-format-config.md b/adr/043-custom-color-format-config.md index bd923fb4..e9a19ace 100644 --- a/adr/043-custom-color-format-config.md +++ b/adr/043-custom-color-format-config.md @@ -3,6 +3,7 @@ **Branch**: `043-custom-color-format-config` **Created**: 2026-05-01 **Status**: ACCEPTED +**Summary**: A `format.color` option with a `ColorFormat` type emits color as hex, RGB, HSL, OKLCH or object. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/046-slots-and-slot-references.md b/adr/046-slots-and-slot-references.md index 7ac4411d..8c54ba68 100644 --- a/adr/046-slots-and-slot-references.md +++ b/adr/046-slots-and-slot-references.md @@ -3,6 +3,7 @@ **Branch**: `046-slots-and-slot-references` **Created**: 2026-05-18 **Status**: ACCEPTED +**Summary**: A `SlotContent` triplet and `SlotContentRef` pointer let compositions fill slots by reference. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type) **Extended by**: ADR-047 (Component Slot Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/047-component-slot-examples.md b/adr/047-component-slot-examples.md index 16fe58ee..1f403845 100644 --- a/adr/047-component-slot-examples.md +++ b/adr/047-component-slot-examples.md @@ -3,6 +3,7 @@ **Branch**: `047-component-slot-examples` **Created**: 2026-04-29 **Status**: ACCEPTED +**Summary**: A `slotContentExamples` registry and `SlotBinding` carry example slot fills, referenced rather than duplicated across variants. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references) **Extended by**: ADR-048 (Component Instance Examples), ADR-049 (Prop Configurations and Bindings) diff --git a/adr/048-component-instance-examples.md b/adr/048-component-instance-examples.md index d0c959fe..942da185 100644 --- a/adr/048-component-instance-examples.md +++ b/adr/048-component-instance-examples.md @@ -3,6 +3,7 @@ **Branch**: `048-component-instance-examples` **Created**: 2026-04-29 **Status**: ACCEPTED +**Summary**: An `instanceExamples` registry carries real instances of a component, stored once and referenced by variants. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples) **Extended by**: ADR-049 (Prop Configurations and Bindings) diff --git a/adr/049-prop-configurations-bindings.md b/adr/049-prop-configurations-bindings.md index 9c45a58b..473d4daa 100644 --- a/adr/049-prop-configurations-bindings.md +++ b/adr/049-prop-configurations-bindings.md @@ -3,6 +3,7 @@ **Branch**: `049-prop-configurations-bindings` **Created**: 2026-04-29 **Status**: ACCEPTED +**Summary**: `PropConfigurations` values accept `PropBinding` and `SlotContentRef`, so a configuration can forward a prop or fill a slot. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-008 — Introduce PropBinding](008-prop-binding), [ADR-042 — Composition Structural Type](042-composition-type), [ADR-046 — Slots and Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/050-examples-config.md b/adr/050-examples-config.md index 7629ca6e..c39c34d6 100644 --- a/adr/050-examples-config.md +++ b/adr/050-examples-config.md @@ -3,6 +3,7 @@ **Branch**: `042-composition-type` **Created**: 2026-05-19 **Status**: ACCEPTED +**Summary**: A `processing.instanceExamples` block and `include.defaultSlotContent` govern which examples a spec carries. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples) diff --git a/adr/051-platform-token-syntax.md b/adr/051-platform-token-syntax.md index c569aa20..13db1705 100644 --- a/adr/051-platform-token-syntax.md +++ b/adr/051-platform-token-syntax.md @@ -3,6 +3,7 @@ **Branch**: `051-platform-token-syntax` **Created**: 2026-05-20 **Status**: ACCEPTED +**Summary**: Three `FIGMA_SYNTAX_WEB`, `FIGMA_SYNTAX_IOS` and `FIGMA_SYNTAX_ANDROID` token profiles emit platform code syntax alongside the default `TOKEN`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/052-slotting-nested-instances.md b/adr/052-slotting-nested-instances.md index c1500bfd..cfde063c 100644 --- a/adr/052-slotting-nested-instances.md +++ b/adr/052-slotting-nested-instances.md @@ -3,6 +3,7 @@ **Branch**: `examples-slots-instances` **Created**: 2026-05-22 **Status**: ACCEPTED — supersedes the deferred `Element.overrides` design (retained below as Option C, *rejected*) +**Summary**: A reserved `$nested` key in `PropConfigurations` carries path-addressed configurations that reach into nested instances. **Deciders**: Nathan Curtis (author) **Depends on**: [ADR-046 — Slots & Slot References](046-slots-and-slot-references), [ADR-047 — Component Slot Examples](047-component-slot-examples), [ADR-048 — Component Instance Examples](048-component-instance-examples), [ADR-049 — PropConfigurations Bindings](049-prop-configurations-bindings), [ADR-050 — Examples Config](050-examples-config) diff --git a/adr/053-transform-emit-config.md b/adr/053-transform-emit-config.md index 78a5d2b1..e05968db 100644 --- a/adr/053-transform-emit-config.md +++ b/adr/053-transform-emit-config.md @@ -3,6 +3,7 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 **Status**: ACCEPTED +**Summary**: A `transform` config block with `TransformEntry` drives the `specs transform` command's code artifact output. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **RFC**: [RFC 001: Component Dictionary](../rfc/001-component-dictionary/README.md) diff --git a/adr/054-workspace-schema.md b/adr/054-workspace-schema.md index 67890d02..2f4e6d1d 100644 --- a/adr/054-workspace-schema.md +++ b/adr/054-workspace-schema.md @@ -3,6 +3,7 @@ **Branch**: `053-transform-emit-config` **Created**: 2026-06-05 **Status**: ACCEPTED +**Summary**: A `workspace.schema.json` file describes `specs.config.yaml`, covering sources, output and the `config` block. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* **Follows**: [ADR 053](053-transform-emit-config.md) diff --git a/adr/055-processing-states.md b/adr/055-processing-states.md index a6612547..5870691a 100644 --- a/adr/055-processing-states.md +++ b/adr/055-processing-states.md @@ -3,6 +3,7 @@ **Branch**: `055-processing-states` **Created**: 2026-06-05 **Status**: ACCEPTED +**Summary**: A `processing.states` list of `VariantStateEntry` classifies variant props as browser-driven or consumer-controlled. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/056-slot-children-constraints.md b/adr/056-slot-children-constraints.md index fd131f55..4e2c05be 100644 --- a/adr/056-slot-children-constraints.md +++ b/adr/056-slot-children-constraints.md @@ -3,6 +3,7 @@ **Branch**: `adr/056-slot-children-constraints` **Created**: 2026-06-12 **Status**: ACCEPTED +**Summary**: `SlotProp.minChildren` and `maxChildren` replace `minItems` and `maxItems`, aligning with Figma's slot settings. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/057-generator-version-string.md b/adr/057-generator-version-string.md index b3bd8202..a084af60 100644 --- a/adr/057-generator-version-string.md +++ b/adr/057-generator-version-string.md @@ -3,6 +3,7 @@ **Branch**: `057-generator-version-string` **Created**: 2026-06-15 **Status**: ACCEPTED +**Summary**: `Metadata.generator.version` is typed `string`, matching the semver values every producer already emits. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/058-wrapper-collapse.md b/adr/058-wrapper-collapse.md index 44dc266d..2be1ab46 100644 --- a/adr/058-wrapper-collapse.md +++ b/adr/058-wrapper-collapse.md @@ -3,6 +3,7 @@ **Branch**: `058-wrapper-collapse` **Created**: 2026-06-16 **Status**: ACCEPTED +**Summary**: A `collapsePrimitiveWrapper` processing option strips plain wrappers, promoting a single text or glyph child to the spec root. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/059-border-style.md b/adr/059-border-style.md index e13b7e6b..b825a84f 100644 --- a/adr/059-border-style.md +++ b/adr/059-border-style.md @@ -3,6 +3,7 @@ **Branch**: `059-border-style` **Created**: 2026-06-26 **Status**: ACCEPTED +**Summary**: A `strokeDashPattern` property adds dashes to strokes already supported with color, weight and alignment. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/060-subcomponent-source-metadata.md b/adr/060-subcomponent-source-metadata.md index 7afea082..888ddbef 100644 --- a/adr/060-subcomponent-source-metadata.md +++ b/adr/060-subcomponent-source-metadata.md @@ -3,6 +3,7 @@ **Branch**: `060-subcomponent-source-metadata` **Created**: 2026-06-27 **Status**: ACCEPTED +**Summary**: A `Subcomponent.source` carrying `pageId`, `nodeId` and `nodeType` resolves a subcomponent back to its Figma node. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/062-text-truncation.md b/adr/062-text-truncation.md index ceec1195..ceff452d 100644 --- a/adr/062-text-truncation.md +++ b/adr/062-text-truncation.md @@ -3,6 +3,7 @@ **Branch**: `062-text-truncation` **Created**: 2026-07-09 **Status**: ACCEPTED +**Summary**: `textOverflow` and `maxLines` properties add truncation to styles already covering typography and layout. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/063-image-content.md b/adr/063-image-content.md index 4a087b11..3d962053 100644 --- a/adr/063-image-content.md +++ b/adr/063-image-content.md @@ -3,6 +3,7 @@ **Branch**: `063-image-content` **Created**: 2026-07-14 **Status**: ACCEPTED +**Summary**: Images are supported by `backgroundImage` style, `ImageProp` and binding in components and examples. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/064-text-align-horizontal.md b/adr/064-text-align-horizontal.md index db6658b6..95adb36d 100644 --- a/adr/064-text-align-horizontal.md +++ b/adr/064-text-align-horizontal.md @@ -3,6 +3,7 @@ **Branch**: `064-text-align-horizontal` **Created**: 2026-08-03 **Status**: ACCEPTED +**Summary**: A `TextAlignHorizontal` enum types `textAlignHorizontal` as `START`, `CENTER`, `END` or `JUSTIFY`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* diff --git a/adr/065-code-only-text-prop-nullable.md b/adr/065-code-only-text-prop-nullable.md index a9bf4d01..d3667ecf 100644 --- a/adr/065-code-only-text-prop-nullable.md +++ b/adr/065-code-only-text-prop-nullable.md @@ -3,6 +3,7 @@ **Branch**: `065-code-only-text-prop-nullable` **Created**: 2026-08-05 **Status**: ACCEPTED +**Summary**: `NumberProp` gains `nullable`, and JSDoc documents the default each prop type applies when `nullable` is absent. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR-019 and ADR-022)* diff --git a/adr/066-lossless-key-formatting.md b/adr/066-lossless-key-formatting.md index 67c951a3..d3e048f9 100644 --- a/adr/066-lossless-key-formatting.md +++ b/adr/066-lossless-key-formatting.md @@ -3,6 +3,7 @@ **Branch**: `066-lossless-key-formatting` **Created**: 2026-08-10 **Status**: ACCEPTED +**Summary**: A `format.figmaKeys` option and safe key grammar preserve Figma layer names losslessly through `com.figma.name`. **Deciders**: Nathan Curtis (author) **Supersedes**: *(none — extends ADR-058)* diff --git a/adr/adr-template.md b/adr/adr-template.md index 8426e020..21488c77 100644 --- a/adr/adr-template.md +++ b/adr/adr-template.md @@ -3,6 +3,7 @@ **Branch**: `[###-short-name]` **Created**: [DATE] **Status**: DRAFT +**Summary**: *(written at implementation — see `/specs.adr.implement`)* **Deciders**: [Author] (author), [name], [name] **Supersedes**: *(none, or link to prior ADR)* diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 696b0ab7..66536c61 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -20,6 +20,7 @@ export default defineConfig({ Sidebar: './src/components/Sidebar.astro', Hero: './src/components/Hero.astro', Header: './src/components/Header.astro', + PageTitle: './src/components/PageTitle.astro', Footer: './src/components/Footer.astro', }, customCss: ['./src/custom.css'], @@ -63,6 +64,7 @@ export default defineConfig({ { label: 'About Specs', slug: 'overview/aboutspecs' }, { label: 'Getting Started', slug: 'cli/getting-started' }, { label: 'Releases', slug: 'overview/releases' }, + { label: 'Decision Records (ADRs)', slug: 'adr' }, { label: 'Licensing', slug: 'overview/licensing', badge: pro }, { label: 'Specs 2 Figma Plugin', diff --git a/site/package.json b/site/package.json index 97c2169c..abab1975 100644 --- a/site/package.json +++ b/site/package.json @@ -3,8 +3,8 @@ "type": "module", "version": "0.0.1", "scripts": { - "prebuild": "node scripts/build-releases.mjs", - "predev": "node scripts/build-releases.mjs", + "prebuild": "node scripts/build-releases.mjs && node scripts/build-adrs.mjs", + "predev": "node scripts/build-releases.mjs && node scripts/build-adrs.mjs", "dev": "astro dev", "start": "astro dev", "build": "astro build", diff --git a/site/scripts/build-adrs.mjs b/site/scripts/build-adrs.mjs new file mode 100644 index 00000000..1f8dfb9f --- /dev/null +++ b/site/scripts/build-adrs.mjs @@ -0,0 +1,200 @@ +/** + * Reads ACCEPTED ADRs from adr/ and emits one Starlight page each into + * site/src/content/docs/adr/, plus an index page. Run before `astro dev` + * or `astro build`. + * + * Usage: node scripts/build-adrs.mjs + */ + +import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const repo = resolve(root, '..'); + +const srcDir = resolve(repo, 'adr'); +const outDir = resolve(root, 'src/content/docs/adr'); + +/** Quote a string for a YAML double-quoted scalar. */ +function yaml(text) { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** `# ADR: Foo Bar` or `# ADR 058: Foo Bar` → `Foo Bar` */ +function extractTitle(md) { + const m = md.match(/^#\s+(?:ADR[\s-]*\d*\s*:\s*)?(.+)$/m); + return m ? m[1].trim() : null; +} + +/** `**Status**: ACCEPTED — because` → `ACCEPTED` */ +function extractStatus(md) { + const m = md.match(/^\*\*Status\*\*:\s*(\S+)/m); + return m ? m[1].replace(/[^A-Z]/gi, '').toUpperCase() : null; +} + +/** Escape text destined for raw HTML in the generated index. */ +function escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Markdown inside a raw HTML block is passed through untouched, so text + * carrying `code spans` needs them rendered here. + */ +function renderInline(text) { + return escapeHtml(text) + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, href) => { + // Cross-references are bare ADR slugs. Link only to records that are + // actually published — drafts would 404. + const slug = href.replace(/^\.?\/?/, '').replace(/\.md$/, ''); + if (/^\d{3}-/.test(slug)) { + return published.has(slug) ? `${label}` : label; + } + return `${label}`; + }) + .replace(/\*([^*]+)\*/g, '$1'); +} + +/** Slugs of the records this build publishes, for cross-reference linking. */ +const published = new Set(); + +/** + * The `**Summary**:` line from the metadata block, authored at implementation + * time. There is deliberately no fallback — an unwritten summary shows up as a + * blank index row rather than as plausible-looking prose lifted from Context. + */ +function extractSummary(md) { + const m = md.match(/^\*\*Summary\*\*:\s*(.+)$/m); + if (!m) return null; + const text = m[1].trim(); + return /^\*\(.*\)\*$/.test(text) ? null : text; +} + +/** Drop the H1 — Starlight renders the title from frontmatter. */ +function stripTitle(md) { + return md.replace(/^#\s+.+$/m, '').replace(/^\n+/, ''); +} + +/** + * The `**Key**: value` lines opening every ADR are consecutive, so markdown + * joins them into one run-on paragraph. Render them as a label/value grid + * instead, and drop the horizontal rule that followed the block. + * + * `Branch` is dropped — it names a merged branch that no longer exists. + * `Created` folds into the Status row, and `Supersedes` appears only when it + * actually points at a prior record. + */ +function formatMetadata(md) { + return md.replace(/^((?:\*\*[A-Za-z]+\*\*:.*\n)+)(\n---\n)?/, (match, block) => { + const fields = Object.fromEntries( + [...block.matchAll(/^\*\*([A-Za-z]+)\*\*:\s*(.*)$/gm)].map(([, key, value]) => [ + key, + value.trim(), + ]), + ); + + const status = [fields.Status, fields.Created].filter(Boolean).join(' · '); + // Many records use Supersedes for an "(none — extends ADR-0xx)" aside; + // only a value that actually names a superseded record earns a row. + const raw = fields.Supersedes ?? ''; + const supersedes = /^\*?\(?\s*none\b/i.test(raw.trim()) || !raw.trim() ? null : raw; + + const rows = [ + ['Summary', fields.Summary], + ['Status', status], + ['Deciders', fields.Deciders], + ['Supersedes', supersedes], + ] + .filter(([, value]) => value) + .map( + ([label, value]) => + `
${label}
${renderInline(value)}
`, + ); + + return rows.length ? `
\n${rows.join('\n')}\n
\n` : match; + }); +} + +const files = readdirSync(srcDir) + .filter(f => /^\d{3}-.+\.md$/.test(f)) + .sort(); + +const accepted = []; + +for (const file of files) { + const md = readFileSync(resolve(srcDir, file), 'utf-8'); + if (extractStatus(md) !== 'ACCEPTED') continue; + + const title = extractTitle(md); + if (!title) { + console.warn(`⚠ ${file}: no H1 title, skipped`); + continue; + } + const slug = file.replace(/\.md$/, ''); + published.add(slug); + accepted.push({ file, md, slug, title, number: file.slice(0, 3) }); +} + +// Bodies are rendered in a second pass so cross-references can be resolved +// against the full set of published records. +const adrs = accepted.map(({ file, md, slug, title, number }) => { + const summary = extractSummary(md); + if (!summary) console.warn(`⚠ ${file}: no **Summary** in the metadata block`); + return { number, slug, title, summary, body: formatMetadata(stripTitle(md)) }; +}); + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const adr of adrs) { + const frontmatter = [ + '---', + `title: ${yaml(adr.title)}`, + adr.summary ? `description: ${yaml(adr.summary.replace(/`/g, ''))}` : null, + '---', + ] + .filter(Boolean) + .join('\n'); + + writeFileSync(resolve(outDir, `${adr.slug}.md`), `${frontmatter}\n\n${adr.body}`); +} + +const rows = [...adrs] + .reverse() + .map(a => { + const summary = a.summary + ? `\n

${renderInline(a.summary)}

` + : ''; + return ` +${a.number} +${renderInline(a.title)}${summary} +`; + }) + .join('\n'); + +const index = `--- +title: "Decision Records (ADRs)" +description: "Accepted architecture decisions shaping the Specs schema, CLI, and plugin." +tableOfContents: false +--- + +Every field in the Specs schema exists because of a decision about what a design system needs to express and how Figma exposes it. An **Architecture Decision Record** captures one such decision: the problem, the options weighed, the choice made, and the designs rejected along the way. + +Where [Schema](/schema/) and [Settings](/settings/) tell you what a field is, an ADR tells you why it looks that way — read one when a field's shape surprises you, or when you're weighing a change. Only accepted decisions appear here, newest first, each left as written, so later records may supersede earlier ones. + + + +${rows} + +
+`; + +writeFileSync(resolve(outDir, 'index.md'), index); +console.log(`✓ Built ${adrs.length} ADR pages + index in site/src/content/docs/adr/`); diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 2440d44d..8ae538d7 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -20,6 +20,7 @@ const SECTION_LINKS = [ { label: 'Plugin', href: '/plugin/', prefix: '/plugin/' }, { label: 'Commands', href: '/cli/', prefix: '/cli/' }, { label: 'Schema', href: '/schema/', prefix: '/schema/' }, + { label: 'ADRs', href: '/adr/', prefix: '/adr/' }, // Points at the Settings section home for now, per explicit instruction. // TODO: point to the future consolidated Docs section landing page once Settings+Guides are merged. { label: 'Docs', href: '/settings/', prefix: '/settings/' }, @@ -111,14 +112,14 @@ const SLACK_PATHS = [ .section-links { display: flex; align-items: center; - gap: 2.5rem; + gap: 1.25rem; margin-inline-end: 1rem; white-space: nowrap; } .section-link { - font-size: 18px; - font-weight: 700; + font-size: 14px; + font-weight: 600; color: var(--sl-color-gray-2); text-decoration: none; } diff --git a/site/src/components/PageTitle.astro b/site/src/components/PageTitle.astro new file mode 100644 index 00000000..a6d9f307 --- /dev/null +++ b/site/src/components/PageTitle.astro @@ -0,0 +1,68 @@ +--- +// Custom PageTitle override for Starlight. +// ADR record pages sit outside the sidebar tree, so they arrive with no +// surrounding context. Give them a crumb back to the index and an eyebrow +// naming the section, then defer to the default title rendering. +import Default from '@astrojs/starlight/components/PageTitle.astro'; + +const pathname = Astro.url.pathname; +// Matches a record page (/adr/059-border-style/) but not the index (/adr/). +const isAdrRecord = /^\/adr\/[^/]+\/?$/.test(pathname); +--- + +{ + isAdrRecord && ( + + ) +} + + + + diff --git a/site/src/custom.css b/site/src/custom.css index fa9cfbcc..e8357388 100644 --- a/site/src/custom.css +++ b/site/src/custom.css @@ -377,3 +377,84 @@ html[data-has-hero] .hero .tagline { max-width: 700px; line-height: 1.2; } + +/* ── ADR index ───────────────────────────────────────────────── */ + +.adr-index { + width: 100%; + border-collapse: collapse; + margin-top: 1.5rem; +} + +.adr-index td { + padding: 0.75rem 0; + border-bottom: 1px solid var(--sl-color-gray-5); + vertical-align: baseline; +} + +.adr-index .adr-number { + width: 3.5rem; + padding-right: 1rem; + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +.adr-index .adr-entry a { + font-weight: 600; + text-decoration: none; +} + +.adr-index .adr-entry a:hover { + text-decoration: underline; +} + +.adr-index .adr-summary { + margin: 0.25rem 0 0; + font-size: var(--sl-text-sm); + line-height: 1.5; + color: var(--sl-color-gray-3); +} + +/* ── ADR metadata block ──────────────────────────────────────── */ + +.adr-meta { + margin: 0 0 2rem; + padding: 0.6rem 1.25rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + background-color: var(--sl-color-gray-7, var(--sl-color-gray-6)); + font-size: var(--sl-text-sm); +} + +.adr-meta-row { + display: grid; + grid-template-columns: 7rem 1fr; + gap: 0 1rem; + padding: 0.4rem 0; +} + +.adr-meta-row + .adr-meta-row { + border-top: 1px solid var(--sl-color-gray-5); +} + +.adr-meta dt { + font-weight: 600; + color: var(--sl-color-gray-2); +} + +.adr-meta dd { + margin: 0; + color: var(--sl-color-gray-1); +} + +.adr-meta dd > :first-child { margin-top: 0; } +.adr-meta dd > :last-child { margin-bottom: 0; } + +@media (max-width: 30rem) { + .adr-meta-row { + grid-template-columns: 1fr; + gap: 0.1rem; + } +} From efb38043682a386cbfcc3eed250140d44ecd9831 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:56:24 -0400 Subject: [PATCH 10/19] docs(cli): fold the orphaned second Unreleased section into the first Two '## Unreleased' headings had accumulated on this branch, leaving the 'specs fetch' icons entry stranded in a second section below the ADR-066 entries. Keep a Changelog expects one. Released sections are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0156cLx4PqspYVvGdBaw2FjS --- packages/cli/CHANGELOG.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d5b50d1b..3e50f81f 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -11,18 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `format.figmaKeys` in the generated `specs.config.yaml` template — commented out at its `NONE` default, documenting the opt-in that enables the safe key grammar and Figma name preservation (ADR-066) - `specs analyze keys` — reports Figma layer and property names a formatted key cannot reconstruct, written to `_analysis/keys.yaml`. Organized `byComponent` as a designer's checklist — each component splitting into `props` and `anatomy`, with an empty surface omitted — then `byCause` for systemic problems and `byName` for a name repeated across the library. Requires `format.figmaKeys` to declare a convention; empty under the `NONE` default (ADR-066) +- **`specs fetch` icons** — a fourth data kind alongside `file`, `variables`, and `styles`: fetch derives the library's icon glyphs from the downloaded file payload (no `scan` required) and downloads their SVG assets with stable kebab-case slugs. ### Changed ### Removed -## Unreleased - -### Added - -- **`specs fetch` icons** — a fourth data kind alongside `file`, `variables`, and `styles`: fetch derives the library's icon glyphs from the downloaded file payload (no `scan` required) and downloads their SVG assets with stable kebab-case slugs. - ## [0.26.0] - 2026-08-07 Two new `specs analyze` reports help you understand a design system at scale. `specs analyze dependencies` maps how components compose one another — instances, slot constraints, and example compositions — so you can answer "what's the blast radius of changing this component?" and "which props does anyone actually use?" `specs analyze styling` now also flags tokens nobody references, surfacing dead variables and styles you can safely retire. From e8629e1f16caad305164a2102cd64d05393f665e Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:09:10 -0400 Subject: [PATCH 11/19] docs(adr): claim ADR number 069 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yjZYakqWEbzkZnLrqugdX --- adr/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/adr/INDEX.md b/adr/INDEX.md index 4d4309e5..492282da 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 069 | Rename `clipContent` to `clipsContent` | | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | | 024 | Component Extends Relationship | Add `extends` field to express base/derived component relationships and prop/variant inheritance _(branch)_ | From e1396583e4d991c313534b1281cdae474951a0ae Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:44:07 -0400 Subject: [PATCH 12/19] Rename clipContent to clipsContent (ADR-069) (#327) * docs(adr): draft ADR 069 renaming clipContent to clipsContent Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yjZYakqWEbzkZnLrqugdX * docs(adr): justify clip construct and name on cross-platform evidence Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yjZYakqWEbzkZnLrqugdX * feat(schema)!: rename clipContent to clipsContent Renames the Styles field, the StyleKey union member, and the styles.schema.json property to clipsContent, the key the data carries. The old name matched nothing, so the schema property, the union member, and the CLI overflow mapping were all inert. Closes ADR-069. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yjZYakqWEbzkZnLrqugdX --------- Co-authored-by: Claude Opus 5 --- adr/069-clips-content.md | 237 ++++++++++++++++++ adr/INDEX.md | 1 + packages/cli/CHANGELOG.md | 2 + packages/cli/src/transforms/Css.mapping.md | 4 +- packages/cli/src/transforms/css/styleToCSS.ts | 6 +- packages/schema/CHANGELOG.md | 3 + packages/schema/schema/styles.schema.json | 2 +- packages/schema/tests/Styles.test-d.ts | 18 ++ packages/schema/types/Styles.ts | 5 +- site/src/content/docs/schema/styles.md | 2 +- .../{clip-content.md => clips-content.md} | 6 +- .../settings/collapse-primitive-wrapper.md | 2 +- 12 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 adr/069-clips-content.md rename site/src/content/docs/schema/styles/{clip-content.md => clips-content.md} (80%) diff --git a/adr/069-clips-content.md b/adr/069-clips-content.md new file mode 100644 index 00000000..c2950a8c --- /dev/null +++ b/adr/069-clips-content.md @@ -0,0 +1,237 @@ +# ADR: Rename `clipContent` to `clipsContent` + +**Branch**: `069-clips-content` +**Created**: 2026-08-14 +**Status**: ACCEPTED +**Summary**: A `clipsContent` boolean style replaces `clipContent`, joining `visible` and `locked` as element state consumers map to `overflow`. +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`Styles` declares a boolean style named `clipContent`: + +```yaml +# types/Styles.ts — current +Styles: + clipContent: Style +``` + +The name matches nothing. The property describes whether an element cuts off content that overflows its box, and the key the data actually carries is `clipsContent`. Because the declared key and the carried key differ by one character, every consumer of the declared key has been inert since it shipped: + +- The `styles.schema.json` property `clipContent` never validates against emitted spec output. +- The `StyleKey` union member `'clipContent'` never selects a real style. +- The CSS mapping rule for `clipContent → overflow` never fires, so `overflow: hidden` / `overflow: visible` is never emitted. + +The defect is a typo. But correcting a typo still means publishing a field name, and a published field name is a contract with every consumer — so this ADR treats both halves as open questions rather than assuming the answer: + +1. **The construct** — is an on/off boolean the right way to model clipping at all, or should this be an enum? +2. **The name** — given the construct, what should the field be called? + +The risk to guard against is that `clipsContent` happens to be Figma's spelling. Constitution VI is explicit that Figma is the data source, not the naming authority. Arriving at Figma's spelling is acceptable; arriving at it *because* it is Figma's spelling is not. Each decision below is therefore settled on code-platform evidence and on terms the schema already uses, and each records what would have changed the answer. + +--- + +## Cross-platform survey + +Both decisions draw on the same evidence, gathered once here. + +| Platform | API | Construct | Name shape | +|----------|-----|-----------|------------| +| UIKit | `UIView.clipsToBounds: Bool` | Boolean | third-person verb + boundary | +| SwiftUI | `.clipped()`, `.clipShape(_:)` | Modifier presence (on/off) | past participle | +| Android View | `ViewGroup.clipChildren`, `View.clipToOutline` | Boolean | verb + object / verb + boundary | +| Jetpack Compose | `Modifier.clip(shape)`, `Modifier.clipToBounds()` | Modifier presence (on/off) | verb + boundary | +| CSS | `overflow: visible \| hidden \| clip \| scroll \| auto` | Enum | overflow noun | +| React Native | `overflow: 'visible' \| 'hidden' \| 'scroll'` | Enum (CSS subset) | overflow noun | +| Flutter | `clipBehavior: Clip` (`none`, `hardEdge`, `antiAlias`, `antiAliasWithSaveLayer`) | Enum | verb + behavior noun | +| Figma | `clipsContent: boolean` | Boolean | third-person verb + object | + +--- + +## Decision Drivers + +- **Construct neutrality**: the shape must be the one code platforms agree on, and must not carry values this schema has no way to populate. +- **Name neutrality**: every naming tiebreak must resolve on code-platform evidence or on vocabulary the schema already uses — never on "Figma spells it this way". +- **Falsifiability**: each decision states what evidence would have produced a different answer, so a reviewer can check the reasoning rather than trust it. +- **Type ↔ schema symmetry**: the type, the schema property, and the `StyleKey` union must move together (Constitution IV). +- **Breaking-change discipline**: renaming a named field within an exported type is breaking and MUST be versioned accordingly (Constitution III). + +--- + +## Decision 1 — Construct: boolean or enum? + +### Option A: Keep the boolean (`Style` / `BooleanStyleValue`) *(Selected)* + +Model clipping as an on/off style, unchanged from today. + +**Pros**: +- **Constitution VI rule 1 applies — 2+ code platforms agree, independent of Figma.** Four of the seven code platforms surveyed model clipping as on/off: UIKit and Android View as literal booleans, SwiftUI and Compose as modifier presence. Figma's agreement is a fifth data point, not the basis. +- **The three enums degenerate to a boolean in this domain.** Their extra values fall into two buckets, neither of which this schema can express: + - *Scrolling* — CSS/React Native `scroll` and `auto` describe a scrolling affordance, a runtime interaction behavior. The schema has no scroll concept anywhere in `Styles`, and a static design surface produces no scroll state to extract. + - *Rasterization quality* — Flutter's `hardEdge` / `antiAlias` / `antiAliasWithSaveLayer` describe how the clip is rendered, a render-time performance trade-off with no design-surface counterpart. + + Remove what the schema cannot populate and CSS `overflow` collapses to `visible` vs `hidden`, and Flutter's `Clip` collapses to `none` vs everything-else. Both are booleans wearing an enum's clothes. +- **Widening at the consumer boundary is lossless; narrowing is not.** A boolean projects cleanly onto every enum — the existing CSS mapping already does exactly this (`true → overflow: hidden`, `false → overflow: visible`). An enum in the schema would instead force platforms with a boolean to narrow, and would advertise values no extractor can ever produce. +- Reuses the existing `BooleanStyleValue` schema definition, so the value stays token-bindable like other boolean styles (`visible`, `locked`, `wrap`). + +**Cons / Trade-offs**: +- If the schema ever models scrolling containers, clipping and scrollability will need to be reconciled — a boolean cannot express `overflow: scroll`. That is a genuine future cost, but it is a *new capability* deserving its own ADR, not a reason to pre-build an enum whose extra values would sit permanently unpopulated. + +**What would have changed this**: if a majority of code platforms modeled clipping with a value set that survives the design-surface filter — three or more distinct, extractable clipping modes rather than on/off — the enum would win. + +--- + +### Option B: CSS-shaped `overflow` enum *(Rejected)* + +Replace the boolean with `overflow: 'VISIBLE' | 'HIDDEN' | 'CLIP'`. + +**Rejected because**: it violates the construct-neutrality driver from the opposite direction — it adopts the *web's* model as the neutral one. CSS and React Native are one platform family, not two independent votes, so this is closer to a single-platform preference than the rule-1 consensus Option A has. It also imports `scroll`/`auto` semantics the schema cannot populate, and collides with the existing `textOverflow` style (`CLIP` | `ELLIPSIS`) — two unrelated `overflow`-named properties with a shared `CLIP` value that means different things. + +--- + +### Option C: Flutter-shaped `clipBehavior` enum *(Rejected)* + +Adopt a named enum modeled on Flutter's `Clip`. + +**Rejected because**: its distinctions are rasterization quality, which is a rendering implementation concern rather than a design decision, and nothing on a design surface determines which value to emit. It is a single-platform model with no second platform agreeing. + +--- + +## Decision 2 — Name: what should the boolean be called? + +Given a boolean, the survey offers three naming shapes. Two axes are in play — what the name points at (the clipped object vs. the clipping boundary), and the verb form. + +### Option A: `clipsContent` *(Selected)* + +**Pros**: +- **Object-naming beats boundary-naming on schema-internal grounds.** The platforms split evenly on this axis — Android `clipChildren` and Figma `clipsContent` name the clipped object; UIKit `clipsToBounds` and Compose `clipToBounds` name the boundary. The tiebreak is not Figma's vote, it is that **`Styles` has no `bounds` concept to point at.** The schema models extent as `width`/`height`/`min*`/`max*` with no bounds object, so a `*ToBounds` name would reference a term the schema never defines. `content` is already the schema's word for what an element contains. +- **`content` generalizes where `children` does not.** Android's `clipChildren` names child views specifically, but clipping applies equally to overflowing text and paint on elements with no children. `content` covers all three; `children` would be wrong for a clipped `TEXT` element. +- **The verb form is settled by internal consistency, not by Figma.** Booleans in `Styles` read as state predicates describing the element — `visible`, `locked`, `wrap` — not as commands. `clipsContent` reads as a predicate ("this element clips content"); the current `clipContent` reads as an imperative instruction, which is why it looks out of place beside its neighbors. UIKit's `clipsToBounds` independently confirms the third-person form for a boolean clip flag. + +**Cons / Trade-offs**: +- The result is spelled the same as Figma's property. Every tiebreak above was decided on code-platform or in-schema evidence, and the coincidence is what it is — but the reasoning, not the match, is what should be reviewed. + +**What would have changed this**: if `Styles` modeled a bounds or frame object, `clipsToBounds` would carry two code platforms (UIKit, Compose) plus an in-schema referent and would win. It does not, so it cannot. + +--- + +### Option B: `clipsToBounds` *(Rejected)* + +**Rejected because**: it names a referent the schema does not define. `Styles` has no bounds object, so the name would point at a concept a consumer cannot look up — trading a Figma-shaped name for a UIKit-shaped one without improving neutrality. + +--- + +### Option C: Keep `clipContent`, translate in the transformer *(Rejected)* + +Leave the schema name alone and rename `clipsContent` → `clipContent` on the way out. + +**Rejected because**: Constitution VI's rationale for making the transformer translate is to spare consumers a *deliberate* naming decision. This name was not decided, it was mistyped. Paying a permanent translation step to preserve a typo inverts the rule. It also keeps the imperative verb form that reads wrong beside `visible` and `locked`. + +--- + +## Decision + +Model clipping as a boolean, and rename the field, the schema property, and the `StyleKey` member to `clipsContent`. + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Styles.ts` | Renamed field `clipContent` → `clipsContent` on `Styles` | MAJOR | +| `Styles.ts` | Renamed `StyleKey` union member `'clipContent'` → `'clipsContent'` | MAJOR | + +**Example — new shape** (`types/Styles.ts`): +```yaml +# Before +Styles: + effects: TokenReference | Effects + clipContent: Style + cornerRadius: Style | Corners + +# After +Styles: + effects: TokenReference | Effects + clipsContent: Style + cornerRadius: Style | Corners +``` + +```yaml +# StyleKey — before +StyleKey: + - effects + - clipContent + - cornerRadius + +# StyleKey — after +StyleKey: + - effects + - clipsContent + - cornerRadius +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `styles.schema.json` | Renamed property `clipContent` → `clipsContent` | MAJOR | + +**Example — new shape** (`schema/styles.schema.json`): +```yaml +# Before — under #/definitions/Styles/properties +clipContent: + $ref: "#/definitions/BooleanStyleValue" + description: "Clip content" + +# After +clipsContent: + $ref: "#/definitions/BooleanStyleValue" + description: "Whether the element clips content that overflows its box" +``` + +### Notes + +- The construct is unchanged: the type stays `Style`, the schema `$ref` stays `BooleanStyleValue`, and the property stays optional. Only the key changes. Decision 1 records that the boolean was re-examined and re-affirmed rather than inherited by default. +- No deprecation alias is introduced. An alias would keep alive a name that has never resolved to a value, and would require consumers to handle two keys for one concept. +- The description is rewritten from `"Clip content"` — an imperative fragment restating the old field name — to a statement of what the boolean means. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes. +- **Parity check**: `Styles.clipsContent` ↔ `#/definitions/Styles/properties/clipsContent`; the `StyleKey` union member `'clipsContent'` names the same key. All three rename in one change; no drift is introduced. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-cli` | The CSS mapping keyed on the old name stops being dead code once the key matches | Update the key used by the style-to-CSS mapping and its mapping documentation; recompile | +| `specs-from-figma` | Emitted output already uses `clipsContent`; it now validates against the schema | Recompile against the new types; confirm no code references the old key | +| `specs-plugin-2` | Consumes the same style key surface | Recompile; update any reference to the old key | +| Docs site | A styles reference page is published under the old name | Rename the page and update references to the new key | + +--- + +## Semver Decision + +**Target version**: `0.30.0` — the version of the active release branch (`release/schema-0.30.0+cli-0.27.0`). This ADR ships within that release and proposes no bump of its own. + +**Change class**: `MAJOR`-class (breaking). Renaming a named field within an exported type and renaming a schema property are both breaking changes under Constitution III and the Versioning rule ("`MAJOR` for any breaking change to a type signature, field name, field presence, or schema structure"). It MUST be called out as breaking in `CHANGELOG.md` under the release's entry. + +**Naming governance citation**: Constitution VI **rule 1** — 2+ code platforms agree. For the construct, UIKit, Android View, SwiftUI, and Compose agree on on/off. For the object-vs-boundary naming axis the code platforms tie, and the tiebreak is drawn from the schema's own vocabulary rather than from Figma. Rule 3 (defer to Figma) is **not** invoked anywhere in this ADR. + +--- + +## Consequences + +- `clipsContent` resolves against real data for the first time, so the style appears in spec output and the schema validates it. +- Consumers that map this style — including the CSS `overflow` mapping — begin producing output where they previously produced nothing. +- The boolean construct is now on the record as examined and justified, so a future proposal to widen it to an enum has a documented bar to clear: it must name extractable clipping modes beyond on/off. +- Scrolling remains unrepresentable in `Styles`. If the schema ever gains scroll semantics, the relationship between clipping and scrollability needs its own ADR. +- The name `clipContent` is gone with no alias; any consumer referencing it fails at compile time rather than silently matching nothing. +- Published spec documents produced before this change carry no `clipContent` key to migrate, since the key was never emitted. diff --git a/adr/INDEX.md b/adr/INDEX.md index 492282da..56e657a8 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -15,6 +15,7 @@ | # | Title | Highlights | |---|-------|------------| +| 069 | Rename `clipContent` to `clipsContent` | Renames the clip flag to the key the data carries, so container clipping and CSS `overflow` resolve for the first time | | 066 | Lossless Key Formatting — Safe Key Grammar and Figma Name Preservation | Adds opt-in `format.figmaKeys` (`NONE` default), a safe key grammar, and `com.figma.name` on anatomy and props; renames `originalName` | | 065 | Document the `nullable` Default and Add `NumberProp.nullable` | Absent `nullable` means true for `StringProp`/`NumberProp`/`SlotProp`/`ImageProp`, false for `EnumProp`; adds `NumberProp.nullable` | | 064 | Tighten `textAlignHorizontal` to a Logical-Direction String Enum | Narrow from `Style` to `TextAlignHorizontal \| null` (`'START' \| 'CENTER' \| 'END' \| 'JUSTIFY'`); Figma `LEFT`/`RIGHT`/`JUSTIFIED` remapped | diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 3e50f81f..21b0d47d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- CSS `overflow` output — keyed on the renamed `Styles.clipsContent`, so `overflow: hidden` / `overflow: visible` is emitted for the first time (ADR-069) + ### Removed diff --git a/packages/cli/src/transforms/Css.mapping.md b/packages/cli/src/transforms/Css.mapping.md index 280c37f3..5ecae94f 100644 --- a/packages/cli/src/transforms/Css.mapping.md +++ b/packages/cli/src/transforms/Css.mapping.md @@ -144,8 +144,8 @@ if present, else `currentColor`. Gradients deferred to phase 2. | Spec key | CSS property | Notes | |----------|-------------|-------| | `visible: false` | `display: none` | Only emitted when explicitly false. | -| `clipContent: true` | `overflow: hidden` | | -| `clipContent: false` | `overflow: visible` | | +| `clipsContent: true` | `overflow: hidden` | | +| `clipsContent: false` | `overflow: visible` | | ### Transform diff --git a/packages/cli/src/transforms/css/styleToCSS.ts b/packages/cli/src/transforms/css/styleToCSS.ts index 89df73d7..b648bff1 100644 --- a/packages/cli/src/transforms/css/styleToCSS.ts +++ b/packages/cli/src/transforms/css/styleToCSS.ts @@ -265,9 +265,9 @@ export function styleToCSS(styles: Record, tokensFormat = 'TOKE // ── Overflow ───────────────────────────────────────────────────────────────── - if ('clipContent' in styles && styles.clipContent !== undefined) { - if (styles.clipContent === true) decls.push('overflow: hidden'); - else if (styles.clipContent === false) decls.push('overflow: visible'); + if ('clipsContent' in styles && styles.clipsContent !== undefined) { + if (styles.clipsContent === true) decls.push('overflow: hidden'); + else if (styles.clipsContent === false) decls.push('overflow: visible'); } // ── Transform ──────────────────────────────────────────────────────────────── diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 259be0fb..4b5356ee 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -17,11 +17,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `FigmaAnatomyElementExtension.name` — renamed from `originalName`; now records the Figma layer name for key divergence as well as wrapper collapse (ADR-066) +- `Styles.clipsContent` — renamed from `clipContent`; boolean clip flag, type and token-bindability unchanged (ADR-069) ### Removed ### Migration +`Styles.clipContent` → `Styles.clipsContent`: read `clipsContent` instead; the value shape is unchanged. No stored spec carries the old key — it never matched emitted output. + `FigmaAnatomyElementExtension.originalName` → `FigmaAnatomyElementExtension.name`: read `$extensions['com.figma'].name` instead; the value and its collapse-provenance meaning are unchanged. Consumers that read the extension through an inline structural type will not see a compile error — update those call sites explicitly. diff --git a/packages/schema/schema/styles.schema.json b/packages/schema/schema/styles.schema.json index e240a103..ca794a4b 100644 --- a/packages/schema/schema/styles.schema.json +++ b/packages/schema/schema/styles.schema.json @@ -18,7 +18,7 @@ "fillColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Glyph fill color. Present on GLYPH element type only. Represented in Figma as fills." }, "textColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Text fill color. Present on TEXT element type only. Represented in Figma as fills." }, "effects": { "$ref": "#/definitions/EffectsStyleValue", "description": "Effect output. TokenReference when the node references a named effects style; Effects when effects are defined inline. Replaces effectStyleId." }, - "clipContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Clip content" }, + "clipsContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Whether the element clips content that overflows its box" }, "cornerRadius": { "$ref": "#/definitions/CornersStyleValue", "description": "Corner radius. Scalar when uniform; Corners object when per-corner values differ." }, "width": { "$ref": "#/definitions/NumberStyleValue", "description": "Width in pixels" }, "height": { "$ref": "#/definitions/NumberStyleValue", "description": "Height in pixels" }, diff --git a/packages/schema/tests/Styles.test-d.ts b/packages/schema/tests/Styles.test-d.ts index 28435539..2dbd2cd4 100644 --- a/packages/schema/tests/Styles.test-d.ts +++ b/packages/schema/tests/Styles.test-d.ts @@ -585,6 +585,24 @@ const withWrapToken: Styles = { wrap: { $token: 'Layout.Wrap', $type: 'boolean' } satisfies TokenReference, }; +// ─── Styles.clipsContent (boolean Style) ──────────────────────────────────── + +// Boolean values +const withClipsContentTrue: Styles = { clipsContent: true }; +const withClipsContentFalse: Styles = { clipsContent: false }; + +// null is valid (Style includes null) +const withClipsContentNull: Styles = { clipsContent: null }; + +// TokenReference is valid (Style includes TokenReference) +const withClipsContentToken: Styles = { + clipsContent: { $token: 'Layout.ClipsContent', $type: 'boolean' } satisfies TokenReference, +}; + +// The pre-ADR-069 name is no longer a Styles key +// @ts-expect-error: clipContent was renamed to clipsContent +const withOldClipName: Styles = { clipContent: true }; + // ─── Styles.wrapAlignment (WrapAlignment | null) ──────────────────────────── // Valid enum values on Styles diff --git a/packages/schema/types/Styles.ts b/packages/schema/types/Styles.ts index 7c60e4ec..633ad8ed 100644 --- a/packages/schema/types/Styles.ts +++ b/packages/schema/types/Styles.ts @@ -15,7 +15,8 @@ export type Styles = Partial<{ /** Glyph fill color. Present on GLYPH element type only. Represented in Figma as fills. @since 0.13.0 */ fillColor: ColorStyle; effects: TokenReference | Effects; - clipContent: Style; + /** Whether the element clips content that overflows its box. @since 0.30.0 */ + clipsContent: Style; /** Corner radius. Scalar when uniform; `Corners` object when per-corner values differ. @since 1.0.0 */ cornerRadius: Style | Corners; width: Style; @@ -343,7 +344,7 @@ export type StyleKey = | 'backgroundImage' | 'fillColor' | 'effects' - | 'clipContent' + | 'clipsContent' | 'cornerRadius' | 'width' | 'height' diff --git a/site/src/content/docs/schema/styles.md b/site/src/content/docs/schema/styles.md index 4d345c7f..6c55e57d 100644 --- a/site/src/content/docs/schema/styles.md +++ b/site/src/content/docs/schema/styles.md @@ -15,7 +15,7 @@ The `Styles` object holds visual properties for an element. Every property is op | [`bottom`](/schema/styles/bottom/) | `PositionOffset` | `y` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | | [`centerHorizontalOffset`](/schema/styles/center-horizontal-offset/) | `PositionOffset` | `x` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | | [`centerVerticalOffset`](/schema/styles/center-vertical-offset/) | `PositionOffset` | `y` and constraints [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | -| [`clipContent`](/schema/styles/clip-content/) | `Style` | — | +| [`clipsContent`](/schema/styles/clips-content/) | `Style` | — | | [`cornerRadius`](/schema/styles/corner-radius/) | `Style`
`Corners` | — | | [`cornerSmoothing`](/schema/styles/corner-smoothing/) | `Style` | — | | [`crossAxisAlignment`](/schema/styles/cross-axis-alignment/) | `CrossAxisAlignment` | `counterAxisAlignItems` [ADR 040](https://github.com/DirectedEdges/specs/blob/main/adr/040-layout-alignment.md) | diff --git a/site/src/content/docs/schema/styles/clip-content.md b/site/src/content/docs/schema/styles/clips-content.md similarity index 80% rename from site/src/content/docs/schema/styles/clip-content.md rename to site/src/content/docs/schema/styles/clips-content.md index 0f4dd1c6..607d660e 100644 --- a/site/src/content/docs/schema/styles/clip-content.md +++ b/site/src/content/docs/schema/styles/clips-content.md @@ -1,9 +1,9 @@ --- -title: "clipContent" -description: "Whether a container clips overflowing children" +title: "clipsContent" +description: "Whether a container clips overflowing content" --- -Whether the container clips content that overflows its bounds. +Whether the container clips content that overflows its box. #### Type(s) diff --git a/site/src/content/docs/settings/collapse-primitive-wrapper.md b/site/src/content/docs/settings/collapse-primitive-wrapper.md index 60af7efe..18063694 100644 --- a/site/src/content/docs/settings/collapse-primitive-wrapper.md +++ b/site/src/content/docs/settings/collapse-primitive-wrapper.md @@ -47,7 +47,7 @@ A component qualifies for collapse when **all** of the following are true: - That child's type is `text` or `glyph`. - The child has no children of its own. - The root carries no slot binding on its children. -- The root carries none of the following styles after default/zero values are stripped: `clipContent`, `cornerRadius`, `strokes`, `strokeAlign`, `strokeWeight`, `itemSpacing`, `padding`, `effects`, `backgroundColor`, `cornerSmoothing`. +- The root carries none of the following styles after default/zero values are stripped: `clipsContent`, `cornerRadius`, `strokes`, `strokeAlign`, `strokeWeight`, `itemSpacing`, `padding`, `effects`, `backgroundColor`, `cornerSmoothing`. Collapse is **all-or-nothing**: if any variant in the component set fails the eligibility check, no collapse occurs for any variant. From 149f960d46a23187d3d5be913368f4528c198c6f Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:40:52 -0400 Subject: [PATCH 13/19] =?UTF-8?q?render:=20spec=20=E2=86=92=20Figma,=20plu?= =?UTF-8?q?s=20the=20bridge=20and=20cache=20it=20needs=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: start specs-schema v0.29.0 development * chore: start specs-cli v0.26.0 development * feat(cli): add styling.unused report to specs analyze styling (#181) * chore: start @directededges/specs-schema v0.28.0 development * chore: start @directededges/specs-cli v0.24.0 development * feat(cli): add styling.unused report to specs analyze styling The styling analyzer now compares the tokens referenced by generated specs against the full token universe from fetched foundations data and writes _analysis/styling.unused.{json|yaml} listing variables, color styles, text styles, and effect styles no spec uses, plus a per-category total/used/unused summary. - AnalyzeCommand resolves {alias}.variables.json / {alias}.styles.json from config.sources + dataDirectory and loads them via loadFoundations - Transformer.finalize gains an optional AnalyzerFoundations parameter (structural type, no dependency on specs-from-figma) - Variables matched by collection-prefixed name mirroring FigmaVariableReference.tokenPath(); styles by name; FILL/TEXT/EFFECT categorized, GRID excluded; used-name matching is cross-category - Report is skipped when no foundations data files exist - Docs: analyze/styling page restructured around the three outputs with collection logic described; byToken example corrected to the real category-grouped shape Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 * docs(adr): claim ADR-064 in INDEX Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EtBHLa8cCNVRfeaB4PmeuZ * ADR-064: Tighten textAlignHorizontal to a logical-direction string enum (#226) * docs(adr): draft ADR-064 — tighten textAlignHorizontal to logical-direction enum Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EtBHLa8cCNVRfeaB4PmeuZ * docs(adr): claim ADR-064 in INDEX Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EtBHLa8cCNVRfeaB4PmeuZ * feat(schema): ADR-064 — tighten textAlignHorizontal to logical-direction enum Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EtBHLa8cCNVRfeaB4PmeuZ --------- Co-authored-by: Claude Fable 5 * feat(cli): dependencies analyzer — component dependency graph with blast radius and prop usage (#227) * feat(cli): add dependencies analyzer — component dependency graph with blast radius and prop usage Adds `specs analyze dependencies`, emitting two aggregates to _analysis/: - dependencies.graph.{json,yaml} — nodes, typed edges (instance/slot/example), roots, leaves, cycles, and external references - dependencies.byComponent.{json,yaml} — per-component direct/transitive dependents and dependencies with min depth, potential (slot/example) relations, and per-prop usage values with consumers Blast-radius closure runs over placed-instance edges only; slot anyOf and slotContentExamples edges are advisory kinds. Unmatched instanceOf names become external nodes; the (unresolved instance) sentinel is ignored. Supports split-concerns layouts (variants.yaml, examples.yaml) and $nested deep prop configurations resolved through element targets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * docs(site): document the dependencies analyzer Adds cli/analyze/dependencies with a graph-theory primer, edge-kind reference, output examples, a Mermaid visualization recipe, and an LLM change-impact report workflow. Registers the page in the sidebar and cross-links it from the analyze overview, props, and styling pages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * refactor(cli): rename potentialDependents/Dependencies to contractDependents/Dependencies Slot anyOf and slotContentExamples edges are proven facts of the spec — what distinguishes them from instance edges is the class of change they transmit (contract and documentation coupling vs. rendered output), not certainty. 'contract' names that accurately; 'potential' undersold it. Docs now note that for contract-level changes (delete, rename), contractDependents reads as part of the blast radius. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * feat(skills): add specs-cli.analyze-dependencies change-impact skill Adds a skill that takes component name(s) and an optional intended-change description, regenerates `specs analyze dependencies` fresh, reads the relevant byComponent entries, and replies with a change-impact report or blast-radius profile — replacing the manual paste workflow. Slims the docs page's LLM section to the report template, dropping the step choreography. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * feat(cli): per-consumer prop-usage breakdown in dependencies analyzer propUsage entries gain a consumers block: for each consumer, how many configuration sites set the prop in its default configuration, its variants, and its slotContentExamples, plus per-value site counts. Makes aggregate counts like configuredBy: 12 legible — e.g. egdsPagingButton configures state at 2 default sites and 4 variant sites. The values roll-up (value → consumers) is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * fix(skills): enumerate every consumer in prop-utilization blocks The Prop Utilization section now mandates one bullet per consumer with every consumer enumerated — no partial enumeration closed by summarizing prose, no lists crammed into table cells. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF --------- Co-authored-by: Claude Fable 5 * docs(changelog): add specs analyze dependencies to 0.26.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mhj8MVFxebismMD3Gxt8cF * feat(cli): add specs write command (demo stub for figma-from-specs writer) Thin wrapper that shells out to specs-from-figma's src-figma-writer/bridge/write.js. Only works against a local specs-from-figma checkout (writer isn't packaged yet) — see project-011 notes for the real specs-cli integration this previews. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit e500e40c718304a3d8129b882b0563142a3c1629) * rename(cli): specs write -> specs render 'write' collided conceptually with generate (which also writes files to disk); 'render' is specific to producing visual output in Figma, matching what this command actually does. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit cc5a0e784ee26e6b62e9ecfb1d4b0e87339446c5) * docs: document specs render, rename write->render terminology in CLI/docs Adds the render command reference and a Render to Figma guide covering bridge setup, prerequisites, and expected behavior. Also renames RenderCommand.ts's default manifest filename convention (write-manifest.md -> render-manifest.md) and its option/error strings from "write" to "render" wording, and sweeps cli/index.md, workflows.md, generate.md, and scan.md for the same terminology and for "CLI Bridge" (the plugin's renamed bridge toggle. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015AyCpYvqgaDeDZT7GBWMr9 EOF ) (cherry picked from commit 55a44c61e3a96df7846191101644eb00ac4ee46b) * feat(cli): add specs bridge command, migrate bridge server from specs-from-figma The bridge server previously lived in specs-from-figma's src-figma-writer/, which never ships in a published npm install (only dist/ does) — so `specs render` only worked against a local file-linked checkout. It has no dependency on the writer engine (pure Node.js file I/O + HTTP/WS relay), so it's moved here as packages/cli/src/bridge/ and exposed as `specs bridge start|stop|status`, daemonized with a pidfile under ~/.specs/. This makes render/bridge genuinely installable, not just a local dev-checkout demo. RenderCommand.ts no longer spawns a child script to reach the bridge — it posts directly via the shared bridge/client.ts, collapsing a subprocess hop that only existed because the bridge client used to live in a different package. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015AyCpYvqgaDeDZT7GBWMr9 (cherry picked from commit 717b4fd8d7d4979d7c45ff3c9f86a496ce94f58c) * Bridge updates (cherry picked from commit 1c4aa83addd97c3cd3ff2428912e984a31a3674f) * feat(cli): render autodetects split-concerns folders and json/yaml specs RenderCommand now reads and merges spec input itself via a new SpecLoader, supporting both a single combined spec file and a split-concerns component folder (api/variants/examples), auto-detecting json vs yaml by extension. The bridge's HTTP contract gains an optional pre-parsed spec field so the bridge relays render payloads instead of reading/parsing them itself. Closes DirectedEdges/specs#216 (cherry picked from commit b1b17b4343b759779f22343e5a9be3e32429b450) * fix: rename bridge protocol write to render (#217) WriteResult->RenderResult, sendWrite()->sendRender(), the writeComponent/ writeComponent-result WebSocket messages -> renderComponent/ renderComponent-result, the POST /write HTTP control endpoint -> POST /render, and the --write debug flag -> --render. Finishes the write->render terminology rename for the bridge, deferred earlier while the bridge server migration from specs-from-figma into specs-cli was still in flight. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 95d4987003899ce8e74ae5d467d708f399e7a445) * feat(cli): specs generate --from-selection via the CLI bridge (#220) Adds a third source mode alongside REST-file and manifest: generate a spec directly from whatever's selected in a connected Figma file, no REST fetch needed. Errors clearly when 2+ plugins are connected with no --file target, when nothing is selected, or when the selection isn't a component/component set/frame (reusing ConnectionRegistry's existing ambiguity handling). Output resolution/writing is shared with file/manifest mode via an extracted writeGeneratedOutput() helper, so --output/--split-components/--split-concerns behave identically. Bridge side: new generateFromSelection/-result WebSocket messages and a POST /generate HTTP route on the specs-cli bridge server, plus a postGenerateFromSelection client function. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 07dbe626f6316fe7b10d2f09ea244cd4897ecd3d) * refactor: rename generate --from-selection to --from-bridge (#220) --from-selection named today's implementation (reads figma.currentPage.selection) rather than the durable characteristic (this mode talks to a live, connected Figma file over the CLI bridge). --from-bridge matches the existing "CLI Bridge" vocabulary already used by specs bridge start/stop/status and the plugin's bridge toggle, and stays accurate if this mode later supports targeting something other than the current selection. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 0f1a6dae9a2025552d4a9e9570ab065c1adc470e) * feat(cli): interactive file picker when multiple plugins are connected (#220) Adds pickConnection.ts: when --file is omitted on render or generate --from-bridge and 2+ Figma files are connected, prompts to choose one from a numbered list (by file name) in an interactive terminal instead of failing immediately. Non-interactive callers (scripts, CI) are unaffected -- resolveFileKey() returns undefined when stdin/stdout aren't a TTY, so the existing ambiguity error from ConnectionRegistry still surfaces rather than hanging on input. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 35f87ce31c513578218a5d521a31ea3ff586a2ad) * feat(cli): resolve published component key for cross-file glyphs (#220) buildGlyphManifest now cross-references each glyph's node id against the fetched file's top-level components map (library.file.json, already on disk from specs fetch -- no new REST call) to attach the published component's stable key alongside the existing same-file id. Matches the GlyphIdManifest widening in specs-from-figma and specs-plugin-2. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 8d4462b8217277069310fc83f2c7590b9aa2ac0f) * feat(cli): render --overwrite flag (#220) Threads a new overwrite boolean through the bridge HTTP request body, the renderComponent WebSocket message, and drainManifest/sendRender, matching the render engine's new opt-in overwrite semantics (a title collision on the target page is now a hard error by default, not a silent delete). Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 17f1caefde848e55d7a240892fd1eeb0b8336e8e) * feat(cli): render --watch flag for live re-render on change Watches the target spec path (recursive, 300ms debounce) and re-renders in-process with overwrite:true on every change, without exiting on a failed render. Refactors the single-render path into a shared renderSpecPath() used by both the one-shot and watch flows. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 758552d889f2eeefad0f2a1ca712bd2283c91d94) * feat(cli)!: render returns success only — round trip via generate --from-bridge Removes the render spec round-trip: --no-return-spec flag, returnSpec protocol field, and specData on render responses (bridge client, server, command). Render's contract is success/failure + nodeId; reading the produced component's spec is an explicit second call, specs generate --from-bridge (#220). generateFromSelection's specData is untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018D8F4U7o3pF7QeEuKWZwAx (cherry picked from commit 32f773baa57e1c846bc8bc7ddb505826da571777) * feat(cli): bridge instance manifest carries published component keys buildManifest now cross-references each spec's source nodeId against the workspace file data's components/componentSets maps (same source the glyph manifest reads) so instance entries ship { id, key? } and the writer can import published components into files that don't contain them. Also fixes a sendRender call-arity bug in drainManifest that silently dropped the instance-id accumulator and --overwrite for manifest renders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R6Vv5cN61YHWNM3v3mxHC2 * render running on a directory * updated docs * feat(bridge): variables manifest carries publish state Only published variables live in the team library that the writer's importVariableByKeyAsync reads, so a variable hidden from publishing cannot be imported by key. The manifest now pairs each key with that state, letting the writer name the reason a variable is unreachable instead of reporting a generic resolution failure, and the render log reports how many entries are affected. buildVariablesManifest also had its own type: it was declared as Manifest (Record) while assigning bare strings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwHwfXm7w4XxavmXwZAV13 * feat(render): report incomplete renders and add --strict A render that could not resolve an instance element dropped it, warned to the console, and still printed the success line with exit 0 — so a component missing content reported success. The warnings already reached the CLI; they just had no effect on the outcome. - Separate content-dropping warnings from cosmetic ones (font fallbacks, skipped style keys); only the former mean content is missing - Print an INCOMPLETE line with the count before the success line - --strict turns an incomplete render into a failure, for scripted runs - Batch renders tally incomplete components separately from failures Default stays non-fatal: genuinely external dependencies (another library's components) never resolve, so failing by default would make every render of such a library permanently red for an expected condition. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01999RsTtdECymy6SBSfNhFJ * Variable resolution updates * Index variables by token name for render Build the bridge variables manifest as token name -> {key, id, published}, so render can resolve a variable from the name a spec carries. The id comes from the fetched data rather than the spec, and serves rendering into the file the variables were fetched from, where no library import is available. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QCTfQQ7dJTYqy2g6Yqvnqp * fix(cli): report bare bridge error messages, document bridge mode (#220) The bridge server stringified caught errors with String(err), which renders an Error as "Error: message". Callers add their own "Error: " prefix, so ambiguity and connection failures printed a doubled prefix. Applies to POST /generate and both POST /render handlers. Also drops the stale --from-selection changelog entry (the flag shipped as --from-bridge) and adds a current-selection workflow to the CLI workflows guide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ELnRyPVrSVkXwqpPxH4Ci4 * feat(cli): remove render-from-manifest, keep folder batch Render manifests were a second batch mechanism alongside directory batching, with their own file format, parser, and bridge code path. One batch surface is enough for the first release. - Drop `-m, --manifest` and the manifest branch from RenderCommand - Zero-arg `specs render` now resolves straight to outputDirectory - Remove parseRenderManifest/drainManifest from the bridge server, and manifestPath plus the batch-summary response fields from the client - Drop sendRender's instanceIdOverrides param, only ever populated by drainManifest Cross-component instance resolution during a batch went with it: the accumulator that let a later spec reference an earlier render existed only on the manifest path. Folder batches resolve instances through buildManifest's scan of specs/ + file.json. Docs: update render.md, workflows.md, and the render-to-figma guide. Also drop the documented --no-return-spec flag and the "round-trip check by default" claim — neither exists in the code; the round-trip read is a separate `generate --from-bridge` call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GKZGM4X9EYRNeaKL5P1aZd * docs(cli): document render's --file, --page, --overwrite, --watch, --strict render.md listed only --config while the command defines six flags, so the two with the most surprising behavior — --overwrite (deletes a same-titled component) and --watch (implies --overwrite) — were undocumented entirely. Each section describes the behavior as implemented, including the multi-connection prompt and its non-interactive fallback, the batch confirmation prompt, watch's debounce and continue-on-failure, and how --strict interacts with a directory sweep. Also broadens the exit-code 2 row to cover a missing spec path and --watch without one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GKZGM4X9EYRNeaKL5P1aZd * feat(render): report render phase timings, raise the bridge render timeout `specs render --timing` prints where a render's time went: the bridge's manifest builds and payload size, then the plugin's write phases, each with its share of wall clock and how many times it ran. Concurrent phases can sum above the total, which the count column makes readable. The render result now carries the writer's timings through the bridge, and the success line reports elapsed seconds. The bridge's render timeout was 60s, shorter than a large component set takes, so a render the plugin had actually completed came back as a timeout with its result discarded. Raised to 300s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * feat(cache): resolve renders through cached lookup tables Render resolves a spec's component, style, token, and glyph names against fetched Figma data. That data arrives as whole API responses — a file payload for a large library runs to hundreds of megabytes, of which a render needs a few hundred entries. Those entries were re-derived per render, parsing the payload three times before any Figma work began: 6.5s of every render, whatever the component. `specs cache` extracts them once into four merged YAML files under {dataDirectory}/cache/ — components, styles, variables, icons — covering every fetched source. `fetch` and `applyCustomTokens` build them as their final step, so the normal workflow never runs the command by hand, and `render --refresh-cache` covers the rest. Each entry records the source it came from: node ids are file-scoped, so an entry's origin is what tells render whether an id is usable, and it lets one library be rebuilt without re-reading the others. Each file records what it was built from, so a re-fetch, a custom-token run, or an edited glyph pattern is detected. Render validates and fails naming the stale concern rather than rebuilding — rebuilding is the cost the cache removes, and rendering against data that has moved on binds specs to the wrong variables. Render no longer reads scan output. Glyphs resolve through icons.yaml, built by matching the configured pattern against the fetched file, so a manifest that is generated and then authored is no longer something a render depends on. Measured on a workspace with a 159MB file payload: bridge lookup work 6.5s to 0.84s, a 2-variant component 7.4s to 2.6s, a 4-variant component 7.9s to 3.3s. Round-trips for three components are unchanged against baselines captured before the change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * fix(render): place instances of components the workspace has no spec for A spec names an instance's component with a formatted key, and that transform collapses "/" along with spaces and case: a component named "Link/On overlay/M/False/Rest/Start" becomes one key with no inverse. Components like these — the older one-per-combination convention rather than variants of a set — have no spec in the workspace, so the instance manifest had no entry and render skipped the instance, producing a component missing content. No inverse is needed. The library's own component names are known, so the match is made by applying the same transform forward to every candidate and comparing in formatted space, which is what the reader already does elsewhere. The components cache records each component's raw Figma name beside its published key, stored raw and formatted at manifest-build time so a change to format.keys needs no rebuild. The bridge collects the instanceOf names a spec actually references and matches the unresolved ones against those names, adding only what the spec asks for rather than the library's whole component list. Spec-derived entries still win: a workspace component is the more specific answer, and its node id is local to the target file. Render resolves the rest through its existing published-key import. formatKey is duplicated here rather than imported — it is internal to specs-from-figma — with tests pinning it to that implementation's outputs. Six components that previously reported unresolved instances now render with none, and 27 instanceOf names in the test catalogue point at components with no spec folder, so the reach is wider than the cases that prompted this. Known limitation: two differently-named components can format to the same key, and the lookup takes the first match. Closes #283 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * fix(render): resolve instance-swap names against the library, not just instanceOf The bridge offers a spec's component names to the manifest's library fallback, but collected only `instanceOf` values. A prop configuration names a component too when the property behind it is an instance swap, so a swap targeting a component the workspace has no spec for resolved to nothing — the last unresolved reference in the reference catalogue. Prop configuration values are now offered up as well, including those in `$nested` configs. Every string value goes in, because a spec records a property's value rather than its Figma type, so nothing in it says which values are component names: one that matches no library component finds nothing, and one that matches is only used by a swap that asked for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * feat(render): one line per component, spinning while Figma works A render printed the full spec path before starting and a second line after finishing, so a batch scrolled past as two lines per component with the same long path prefix on every other one. It now spins in place — `Rendering: dsButton` with a live elapsed count — and the outcome overwrites it: `✓ Rendered: dsButton (3.5s, nodeId: …)`. The shape matches what fetch already sets for per-item work, and drops "in Figma", which no other command says of itself. The spinner moves out of FetchCommand into a shared utility rather than being copied. Outside a TTY it prints its text once and still reports elapsed time, so logs and CI stay free of escape codes, and it stops in a `finally` so a failed render cannot leave it spinning. Writer warnings are withheld for now behind a single constant. They are dominated by known, tracked defects and by degradations a user cannot act on, so a wall of them per render buries the outcome. Failures and the count of unresolved instance elements still print — a broken render cannot pass as a clean one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(cli): document the cache command and expand the render guide Adds cache to the command index and sidebar, and revises the render, bridge and render-to-figma pages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * fix(bridge): send the resolved config to the plugin `generate --from-bridge` resolved a config and never sent it. The plugin therefore built the spec under its own panel settings, and the CLI's `--config` governed only where the output was written — so the same node in the same file produced different specs for different people, and a render round trip compared a baseline made under one config against a read made under another. Every difference that produced looked like render fidelity loss. The config now travels with the generate request. Render sends it too, as a fallback for a spec carrying no `metadata.config` of its own; a spec that has one still wins, since it records the conventions render must reverse. Verified against the eg library: the same card read twice, once under the workspace config and once under a config declaring KEBAB keys, emits `cornerRadius` and `corner-radius` respectively. Closes #279 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): say render, not writer, for the spec to Figma path Comments, a flag name (`SHOW_WRITER_WARNINGS` → `SHOW_RENDER_WARNINGS`), the `--timing` help text, and one line of the render guide still called the spec→Figma path the writer. Matches the same rename in figma-from-specs. `src/Writers/` is untouched: those write files to disk, which is a different thing that happens to share a word. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(changelog): lead with the commands, not their revisions `render`, `bridge`, and `cache` have never shipped, yet the Unreleased section described them only through changes: a flag removed, a prompt added, a return value dropped. Every entry read as a delta against a command the reader has never had, and `bridge` and `cache` went unmentioned entirely. Rewritten as four entries — render, bridge, cache, and generate --from-bridge — each saying what the command does with its flags folded in, under a lead stating plainly that specs can now go back into Figma. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * fix(cli): honor --component in manifest mode, and count only dropped instances `--component` applied only in file mode. Asking for one component in manifest mode silently generated the whole catalogue — 41 seconds and 66 components where one was wanted, with nothing to say the flag had been ignored. It now filters, matching on the Figma name, the id, or the formatted key the output is written under, since that is the name a caller has in front of them. An unmatched name errors with near-misses instead of generating everything. `render`'s INCOMPLETE count matched any warning containing "no manifest entry for", which `VariableResolver` also emits — about a token. Unresolved variables were reported as instance elements that had not rendered, which describes a component missing content when only a binding was lost. The predicate now requires the instance-element message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): name the places a round trip will not match Four things a rendered component does differently from the file its spec came from, each with why rather than just what: property order in the panel, a locked aspect ratio holding one bound dimension rather than two, a filling root having no width to reproduce, and boolean variant values rendering lowercase. None of them change what a component is — every property, value and binding survives — and the section says so up front, since the natural reading of a "limitation" is that something was lost. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * feat(generate): --remove deletes the node once its spec has been read A round-trip sweep over a catalogue renders every component and reads each one back, leaving all of them on the page. `--remove` lets the sweep put the file back as it found it: the node is deleted after its spec is in hand, so a failed deletion cannot cost the read. Removal rides on the same bridge request rather than a command of its own — there is no `specs remove`, and nothing here is reachable except through `generate --from-bridge`. Subcomponents are not removed: render creates them as their own components on the page, and only the returned node is deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): say why property order is not chased, not just that it differs The entry read as though the source had a definite order render was failing to honour. It does not: outside variants, Figma records property order as creation order — the sequence a designer happened to add them in, draggable afterwards, exposed only as key order. Nothing in the file says a property belongs at a position, so there is no target to match. Variant order is the exception and now says why it matters: it determines how variants layer and the order they enumerate in. It is also the one ordering Figma states twice, in the definitions and in each variant's name, and the two agree in all 112 component sets of the library measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * feat(fetch): --only narrows by data kind, not just by source Re-downloading icons meant re-fetching everything, including a 165MB file payload the icons are derived from and that had not changed. `--only icons` now fetches just those; `--only variables,styles` skips the file; a source alias behaves as before, and the two axes combine (`--only library,icons`). Two things a caller cannot see are now said out loud rather than guessed at: a name matching neither an alias nor a kind is an error listing both sets, where before a typo beside a valid alias silently widened the fetch; and a kind no selected source is configured for is an error rather than a run that does nothing. Also reverts a SHOW_RENDER_WARNINGS debug flag left enabled in RenderCommand while diagnosing a round trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): note the legacy slot content bindings Figma withdrew Not a limitation, so it sits outside that section: the spec records what the layer shows, and render reproduces it faithfully. What is worth saying is why a warning appears on older components, that the fix belongs in the Figma file rather than here, and that such a component may describe fewer variants than it used to — those variants existed only by virtue of a capability Figma has withdrawn. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): one Fidelity section, stating the bar this feature is held to Known limitations, the legacy-bindings note, and the open defects were three separate places a reader had to assemble for themselves. They are one section now, and it opens with the standard rather than leaving it implied: a rendered component read back is byte-identical to the spec that produced it, except where a listed limitation explains the difference or an open defect accounts for it — and a difference neither explains is a bug nobody has written down. That makes the tool's status visible and, more usefully, gives a comparison somewhere to land: every differing line is attributable or it is news. Headings and the defect table describe behaviour rather than the component that happens to trigger it, so nothing here goes stale when a fixture does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): cut Fidelity to the bar and two lists The section had grown an essay per limitation. A reader wants to know the standard and what currently falls outside it, not the reasoning behind each one — that lives in the issues. One sentence for the bar, a bullet each for the five limitations and the five open defects, linked where a link helps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): move Fidelity to the command page It states what a render guarantees and where it currently falls short, which is reference material about the command rather than part of the walkthrough. Sits after Examples, before Exit Codes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): refresh Fidelity for the fixed defects Slot content layering, FILL width drift and boolean variant inversion are all fixed, so they leave the open list; what replaces them is the API losses found while verifying the first of those. Adds a limitation the list was missing: a render reproduces the component asset, so examples drawn from instances placed elsewhere in the file have nothing to come from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): drop the code-only prop collision from the open defects It arises only from VARIANT-based code-only props, which are out of scope for launch, so it does not belong in the list the launch bar is measured against. The issue stays open in the backlog. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): record masking as a limitation, close out the API losses Every difference gathered under #349 turned out to be authoring noise in the source file rather than a fault in the tools — a code-only container parked deep in the tree, a property bound only into slot content, a property bound to the code-only layer itself. One exception: masked shapes are neither read nor rendered, which is a real boundary and now says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): no known open defects, and name the authoring category The last defect on the list is out of scope for the initial launch, so the section says so plainly rather than sitting empty. Adds the third category round-trip testing kept producing: differences a component's own authoring causes, which no release fixes and which would otherwise be rediscovered as defects on every sweep. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo * docs(render): restore the open defects the page had dropped Release scope was allowed to edit a page that documents behaviour. A defect being out of scope for a release does not stop a user meeting it, so the list records what a comparison will show regardless of what any release schedules — scoping stays in the issues, where it can change without rewriting the docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNWUpswwFPCB5mBgRHeeWo --------- Co-authored-by: Claude Fable 5 --- package-lock.json | 35 +- packages/cli/CHANGELOG.md | 36 +- packages/cli/package.json | 11 +- packages/cli/src/Cache/Cache.ts | 383 ++++++++++ packages/cli/src/Render/SpecLoader.ts | 147 ++++ packages/cli/src/bridge/client.ts | 103 +++ packages/cli/src/bridge/config.ts | 32 + packages/cli/src/bridge/connections.ts | 66 ++ packages/cli/src/bridge/pickConnection.ts | 77 ++ packages/cli/src/bridge/pidfile.ts | 51 ++ packages/cli/src/bridge/requestTracker.ts | 45 ++ packages/cli/src/bridge/server.ts | 680 ++++++++++++++++++ .../src/commands/ApplyCustomTokensCommand.ts | 14 + packages/cli/src/commands/BridgeCommand.ts | 124 ++++ packages/cli/src/commands/CacheCommand.ts | 66 ++ packages/cli/src/commands/FetchCommand.ts | 155 ++-- packages/cli/src/commands/GenerateCommand.ts | 398 ++++++---- packages/cli/src/commands/RenderCommand.ts | 345 +++++++++ packages/cli/src/index.ts | 10 +- packages/cli/src/utilities/formatKey.ts | 76 ++ packages/cli/src/utilities/glyphComponents.ts | 45 ++ packages/cli/src/utilities/spinner.ts | 62 ++ packages/cli/src/utilities/variablesIndex.ts | 76 ++ packages/cli/tests/unit/bridge/client.test.ts | 39 + .../cli/tests/unit/bridge/connections.test.ts | 97 +++ .../tests/unit/bridge/pickConnection.test.ts | 80 +++ .../cli/tests/unit/bridge/pidfile.test.ts | 131 ++++ .../tests/unit/bridge/requestTracker.test.ts | 90 +++ packages/cli/tests/unit/cache/Cache.test.ts | 262 +++++++ .../unit/commands/GenerateCommand.test.ts | 11 + .../cli/tests/unit/render/SpecLoader.test.ts | 205 ++++++ .../tests/unit/utilities/formatKey.test.ts | 73 ++ .../unit/utilities/variablesIndex.test.ts | 87 +++ site/astro.config.mjs | 4 + .../docs/cli/commands/apply-custom-tokens.md | 6 + site/src/content/docs/cli/commands/bridge.md | 98 +++ site/src/content/docs/cli/commands/cache.md | 102 +++ site/src/content/docs/cli/commands/fetch.md | 12 + .../src/content/docs/cli/commands/generate.md | 302 ++++---- site/src/content/docs/cli/commands/render.md | 274 +++++++ site/src/content/docs/cli/commands/scan.md | 1 + site/src/content/docs/cli/index.md | 7 +- site/src/content/docs/cli/workflows.md | 56 ++ .../content/docs/guides/render-to-figma.md | 278 +++++++ 44 files changed, 4846 insertions(+), 406 deletions(-) create mode 100644 packages/cli/src/Cache/Cache.ts create mode 100644 packages/cli/src/Render/SpecLoader.ts create mode 100644 packages/cli/src/bridge/client.ts create mode 100644 packages/cli/src/bridge/config.ts create mode 100644 packages/cli/src/bridge/connections.ts create mode 100644 packages/cli/src/bridge/pickConnection.ts create mode 100644 packages/cli/src/bridge/pidfile.ts create mode 100644 packages/cli/src/bridge/requestTracker.ts create mode 100644 packages/cli/src/bridge/server.ts create mode 100644 packages/cli/src/commands/BridgeCommand.ts create mode 100644 packages/cli/src/commands/CacheCommand.ts create mode 100644 packages/cli/src/commands/RenderCommand.ts create mode 100644 packages/cli/src/utilities/formatKey.ts create mode 100644 packages/cli/src/utilities/glyphComponents.ts create mode 100644 packages/cli/src/utilities/spinner.ts create mode 100644 packages/cli/src/utilities/variablesIndex.ts create mode 100644 packages/cli/tests/unit/bridge/client.test.ts create mode 100644 packages/cli/tests/unit/bridge/connections.test.ts create mode 100644 packages/cli/tests/unit/bridge/pickConnection.test.ts create mode 100644 packages/cli/tests/unit/bridge/pidfile.test.ts create mode 100644 packages/cli/tests/unit/bridge/requestTracker.test.ts create mode 100644 packages/cli/tests/unit/cache/Cache.test.ts create mode 100644 packages/cli/tests/unit/render/SpecLoader.test.ts create mode 100644 packages/cli/tests/unit/utilities/formatKey.test.ts create mode 100644 packages/cli/tests/unit/utilities/variablesIndex.test.ts create mode 100644 site/src/content/docs/cli/commands/bridge.md create mode 100644 site/src/content/docs/cli/commands/cache.md create mode 100644 site/src/content/docs/cli/commands/render.md create mode 100644 site/src/content/docs/guides/render-to-figma.md diff --git a/package-lock.json b/package-lock.json index db097163..269bfd6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "version": "0.29.0", "license": "PolyForm-Internal-Use-1.0.0", "dependencies": { - "@directededges/specs-schema": "^0.29.0", + "@directededges/specs-schema": "file:../../../specs/packages/schema", "fs-extra": "^11.3.3", "yaml": "^2.8.0" }, @@ -901,6 +901,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "dev": true, @@ -1617,6 +1627,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.3", "license": "ISC", @@ -1640,6 +1671,7 @@ "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2", + "ws": "^8.21.1", "yaml": "^2.3.4" }, "bin": { @@ -1648,6 +1680,7 @@ "devDependencies": { "@types/fs-extra": "^11.0.4", "@types/node": "^20.10.5", + "@types/ws": "^8.18.1", "esbuild": "^0.20.0", "typescript": "^5.3.3", "vitest": "^3.0.0" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 21b0d47d..92d7f5f2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,8 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +Specs can now go back into Figma. `specs render` takes a spec you already have and builds +the component it describes in a connected Figma file — the reverse of `specs generate`, and +the other half of a round trip you can run and re-run as a spec changes. + ### Added +- **`specs render` — build a component in Figma from a spec.** Point it at a spec file, a + component folder, or a directory of them; with no argument it renders everything in your + configured `outputDirectory`. Variants, styles, props, slots, subcomponents, and icons are + all reconstructed, with tokens bound to the file's own variables and styles. + - `--watch` re-renders on every save, so you can edit a spec and watch the component change. + - `--overwrite` replaces a component of the same name. Without it, a name collision is an + error rather than a silent deletion. + - `--page ` renders onto a specific page instead of whichever one is open — worth using + in any script, since the open page can move underneath you. + - `--strict` fails the run when an element cannot be resolved, instead of producing a + component with content missing. + - `--timing` reports where the time went, phase by phase. +- **`specs bridge` — the connection render works over.** `start`, `stop`, and `status` for a + local server the Figma plugin connects to. Figma cannot be reached from outside, so the + plugin opens the connection and the CLI talks through it. Enable the CLI Bridge in the + plugin, and `specs bridge status` will name the file it is connected to. Several Figma + files can be connected at once; `--file ` picks one, and in an interactive + terminal you are offered a numbered list instead of an error. +- **`specs cache` — the lookup tables render needs.** Every component, icon, style, and + variable name in your fetched data, resolved to what Figma needs to place it. Built from + `specs fetch` output, refreshed with `--force`, or rebuilt in place with + `specs render --refresh-cache`. A stale cache fails the render rather than rendering + something subtly wrong. +- **`specs generate --from-bridge` — read a spec from what's selected in Figma.** A third + source for `generate` alongside a fetched file and a manifest, with no REST fetch and no + Figma token: select a component in a connected file and generate its spec directly. Your + config governs the result, exactly as it does for a fetched generate, and the run leaves + the Figma file untouched. Output follows the same `--output` / `--split-components` / + `--split-concerns` resolution as every other source. +- **`specs fetch --only ` now narrows by data kind as well as by source.** `--only icons` re-downloads just the icon SVGs, deriving them from the file payload already on disk rather than pulling the whole file again; `--only variables,styles` skips the file entirely. A source alias still works as before, and the two combine (`--only library,icons`). A name that matches neither is an error listing both the configured aliases and the available kinds, rather than being quietly ignored. - `format.figmaKeys` in the generated `specs.config.yaml` template — commented out at its `NONE` default, documenting the opt-in that enables the safe key grammar and Figma name preservation (ADR-066) - `specs analyze keys` — reports Figma layer and property names a formatted key cannot reconstruct, written to `_analysis/keys.yaml`. Organized `byComponent` as a designer's checklist — each component splitting into `props` and `anatomy`, with an empty surface omitted — then `byCause` for systemic problems and `byName` for a name repeated across the library. Requires `format.figmaKeys` to declare a convention; empty under the `NONE` default (ADR-066) - **`specs fetch` icons** — a fourth data kind alongside `file`, `variables`, and `styles`: fetch derives the library's icon glyphs from the downloaded file payload (no `scan` required) and downloads their SVG assets with stable kebab-case slugs. @@ -17,8 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSS `overflow` output — keyed on the renamed `Styles.clipsContent`, so `overflow: hidden` / `overflow: visible` is emitted for the first time (ADR-069) -### Removed - ## [0.26.0] - 2026-08-07 diff --git a/packages/cli/package.json b/packages/cli/package.json index c16d667d..0f090f4f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,8 @@ "docs" ], "scripts": { - "build": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --banner:js='#!/usr/bin/env node' && chmod +x dist/specs.js", + "build": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --banner:js='#!/usr/bin/env node' && chmod +x dist/specs.js && npm run build:bridge-server", + "build:bridge-server": "esbuild src/bridge/server.ts --bundle --platform=node --target=node18 --format=esm --packages=external --outfile=dist/bridge-server.js && chmod +x dist/bridge-server.js", "watch": "esbuild src/bin/specs.ts --bundle --platform=node --target=node18 --format=esm --packages=external --define:__SPECS_CLI_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/specs.js --watch", "test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests" }, @@ -24,12 +25,14 @@ "@directededges/specs-from-figma": "^0.28.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", - "yaml": "^2.3.4", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "ws": "^8.21.1", + "yaml": "^2.3.4" }, "devDependencies": { - "@types/node": "^20.10.5", "@types/fs-extra": "^11.0.4", + "@types/node": "^20.10.5", + "@types/ws": "^8.18.1", "esbuild": "^0.20.0", "typescript": "^5.3.3", "vitest": "^3.0.0" diff --git a/packages/cli/src/Cache/Cache.ts b/packages/cli/src/Cache/Cache.ts new file mode 100644 index 00000000..8d098814 --- /dev/null +++ b/packages/cli/src/Cache/Cache.ts @@ -0,0 +1,383 @@ +/** + * Render lookup caches. + * + * Render resolves a spec's references — component ids, style names, token names, glyph + * names — against data fetched from Figma. That data arrives as whole API responses: a + * file payload can be hundreds of megabytes, of which render needs a few hundred small + * entries. Re-deriving those entries per render meant parsing the whole payload every + * time, which cost seconds before any Figma work began. + * + * These caches are those entries, extracted once and written to `/cache/` as + * four merged YAML files — one per concern, covering every fetched library: + * + * | file | maps | built from | + * |-------------------|-----------------------------------|------------------------| + * | `components.yaml` | node id → published key + name | `.file.json` | + * | `styles.yaml` | style name → key + type | `.file.json` | + * | `variables.yaml` | token name → key, id, published | `.variables.json` | + * | `icons.yaml` | glyph name → node id + key | `.file.json` | + * + * Merged rather than per-library, because render wants one lookup, not N. Each entry + * records the alias it came from: node ids are file-scoped, so knowing an entry's origin + * is what lets render decide whether an id is usable at all — and it lets one library be + * rebuilt without re-parsing the rest. + * + * Every file carries a `sources` block naming the payload each alias was built from, with + * its size and mtime. That block is the staleness check: a `stat` per source, and any + * mismatch means the cache no longer describes the data on disk. Render validates and + * fails; `specs cache` rebuilds. + * + * @packageDocumentation + */ + +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { parse, stringify } from 'yaml'; +import { buildVariablesIndex } from '../utilities/variablesIndex.js'; +import { collectGlyphComponents } from '../utilities/glyphComponents.js'; + +/** The four caches, by concern. Order is display order for reporting. */ +export const CACHE_CONCERNS = ['components', 'styles', 'variables', 'icons'] as const; +export type CacheConcern = (typeof CACHE_CONCERNS)[number]; + +/** What one alias's slice of a cache was built from — the staleness check's input. */ +export interface CacheSource { + /** Payload file name, relative to the data directory. */ + from: string; + bytes: number; + mtime: string; + /** icons.yaml only: the pattern glyph names were extracted with. A config edit to the + * pattern changes what the entries mean, with no change to any fetched file. */ + glyphNamePattern?: string; +} + +export interface CacheFile { + /** Alias → the payload that alias's entries came from. */ + sources: Record; + entries: Record; +} + +/** + * Component/component-set node id → its published key and raw Figma name. Keyless nodes are + * omitted: without a key there is nothing to record beyond the id the caller already holds. + * + * The name is what lets the bridge resolve an `instanceOf` value naming a component the + * workspace has no spec for. A spec records `instanceOf` as a formatted key, and that + * transform is lossy — "DS Link/On overlay/M" and "DS Link On Overlay M" format + * identically — so the match is made by formatting these names the same way rather than + * by inverting the key. Stored raw, and formatted at manifest-build time, so a change to + * `format.keys` in config needs no cache rebuild. + */ +export interface ComponentsEntry { key: string; name: string; file: string } +export interface StylesEntry { key: string; type: 'FILL' | 'TEXT' | 'EFFECT'; file: string } +export interface VariablesEntry { key: string; id: string; published: boolean; file: string } +export interface IconsEntry { id: string; key?: string; file: string } + +export type AnyCacheEntry = ComponentsEntry | StylesEntry | VariablesEntry | IconsEntry; + +/** One alias's contribution to all four caches. */ +interface AliasSlice { + components: { source: CacheSource | null; entries: Record }; + styles: { source: CacheSource | null; entries: Record }; + variables: { source: CacheSource | null; entries: Record }; + icons: { source: CacheSource | null; entries: Record }; +} + +export interface CacheOptions { + dataDir: string; + /** Aliases declared in config, in declaration order. */ + aliases: string[]; + glyphNamePattern?: string; + /** Rebuild every alias, whether or not its provenance still matches. */ + force?: boolean; +} + +export interface CacheReport { + /** Aliases whose entries were re-derived. */ + rebuilt: string[]; + /** Aliases whose cached provenance still matched the payload on disk. */ + current: string[]; + /** Aliases declared in config with no fetched payload — skipped, not an error here. */ + unfetched: string[]; + /** Entry counts per concern, after the rebuild. */ + counts: Record; +} + +// ── Paths ───────────────────────────────────────────────────────────────────── + +export function cacheDir(dataDir: string): string { + return join(dataDir, 'cache'); +} + +export function cachePath(dataDir: string, concern: CacheConcern): string { + return join(cacheDir(dataDir), `${concern}.yaml`); +} + +// ── Read / write ────────────────────────────────────────────────────────────── + +export function readCacheFile(dataDir: string, concern: CacheConcern): CacheFile | null { + const path = cachePath(dataDir, concern); + if (!existsSync(path)) return null; + try { + const parsed = parse(readFileSync(path, 'utf8')) as CacheFile | null; + if (!parsed || typeof parsed !== 'object') return null; + return { sources: parsed.sources ?? {}, entries: parsed.entries ?? {} }; + } catch { + return null; // unreadable is indistinguishable from absent, and both mean "rebuild" + } +} + +function writeCacheFile(dataDir: string, concern: CacheConcern, data: CacheFile): void { + mkdirSync(cacheDir(dataDir), { recursive: true }); + const header = + `# Generated by \`specs cache\` — derived from fetched Figma data, safe to delete.\n` + + `# Rebuild with \`specs cache\`. Do not edit by hand.\n`; + writeFileSync(cachePath(dataDir, concern), header + stringify(data, { lineWidth: 0 }), 'utf8'); +} + +// ── Provenance ──────────────────────────────────────────────────────────────── + +function sourceOf(dataDir: string, fileName: string, glyphNamePattern?: string): CacheSource | null { + const path = join(dataDir, fileName); + if (!existsSync(path)) return null; + const stat = statSync(path); + return { + from: fileName, + bytes: stat.size, + mtime: stat.mtime.toISOString(), + ...(glyphNamePattern ? { glyphNamePattern } : {}), + }; +} + +/** True when a recorded source still describes the file on disk. A payload that has been + * re-fetched, or a glyph pattern that has been edited in config, fails this. */ +function matches(recorded: CacheSource | undefined, current: CacheSource | null): boolean { + if (!recorded || !current) return false; + return recorded.from === current.from + && recorded.bytes === current.bytes + && recorded.mtime === current.mtime + && recorded.glyphNamePattern === current.glyphNamePattern; +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +function readJson(path: string): Record | null { + try { + return JSON.parse(readFileSync(path, 'utf8')) as Record; + } catch { + return null; + } +} + +/** + * Derive one alias's entries for all four concerns. The file payload is parsed once and + * feeds three of them; the variables payload is separate and much smaller. + */ +function buildAliasSlice(alias: string, dataDir: string, glyphNamePattern?: string): AliasSlice { + const empty: AliasSlice = { + components: { source: null, entries: {} }, + styles: { source: null, entries: {} }, + variables: { source: null, entries: {} }, + icons: { source: null, entries: {} }, + }; + + const fileName = `${alias}.file.json`; + const fileSource = sourceOf(dataDir, fileName); + if (fileSource) { + const data = readJson(join(dataDir, fileName)); + if (data) { + const refs = { + ...((data.components as Record | undefined) ?? {}), + ...((data.componentSets as Record | undefined) ?? {}), + }; + for (const [id, meta] of Object.entries(refs)) { + if (meta?.key) empty.components.entries[id] = { key: meta.key, name: meta.name ?? '', file: alias }; + } + empty.components.source = fileSource; + + const styles = (data.styles as Record | undefined) ?? {}; + for (const meta of Object.values(styles)) { + const type = meta.styleType; + if ((type === 'EFFECT' || type === 'TEXT' || type === 'FILL') && meta.name && meta.key) { + empty.styles.entries[meta.name] = { key: meta.key, type, file: alias }; + } + } + empty.styles.source = fileSource; + + // An unset pattern means this workspace has no glyph convention — the cache is + // written empty rather than skipped, so "no glyphs" stays distinguishable from + // "never built". + empty.icons.source = sourceOf(dataDir, fileName, glyphNamePattern); + if (glyphNamePattern) { + for (const glyph of collectGlyphComponents(data.document, glyphNamePattern)) { + if (empty.icons.entries[glyph.name]) continue; // first occurrence wins, as scan does + const key = refs[glyph.id]?.key; + empty.icons.entries[glyph.name] = key ? { id: glyph.id, key, file: alias } : { id: glyph.id, file: alias }; + } + } + } + } + + const variablesName = `${alias}.variables.json`; + const variablesSource = sourceOf(dataDir, variablesName); + if (variablesSource) { + const data = readJson(join(dataDir, variablesName)); + if (data) { + const index = buildVariablesIndex((data.meta ? data : { meta: data }) as Parameters[0]); + for (const [name, entry] of Object.entries(index)) { + empty.variables.entries[name] = { ...entry, file: alias }; + } + empty.variables.source = variablesSource; + } + } + + return empty; +} + +// ── Refresh ─────────────────────────────────────────────────────────────────── + +/** + * Bring the caches in line with the fetched data on disk. + * + * An alias whose recorded provenance still matches is left alone — its entries are copied + * forward untouched — so refreshing after fetching one library parses that library only. + * An alias with no fetched payload is skipped rather than reported as an error: not having + * fetched something yet is a normal state for this command, and only render is in a + * position to call it a problem. + */ +export function refreshCache(options: CacheOptions): CacheReport { + const { dataDir, aliases, glyphNamePattern, force } = options; + + const existing = { + components: readCacheFile(dataDir, 'components'), + styles: readCacheFile(dataDir, 'styles'), + variables: readCacheFile(dataDir, 'variables'), + icons: readCacheFile(dataDir, 'icons'), + }; + + const next: { [K in CacheConcern]: CacheFile } = { + components: { sources: {}, entries: {} }, + styles: { sources: {}, entries: {} }, + variables: { sources: {}, entries: {} }, + icons: { sources: {}, entries: {} }, + }; + + const report: CacheReport = { + rebuilt: [], + current: [], + unfetched: [], + counts: { components: 0, styles: 0, variables: 0, icons: 0 }, + }; + + for (const alias of aliases) { + const hasFile = existsSync(join(dataDir, `${alias}.file.json`)); + const hasVariables = existsSync(join(dataDir, `${alias}.variables.json`)); + if (!hasFile && !hasVariables) { + report.unfetched.push(alias); + continue; + } + + const currentSources = { + components: sourceOf(dataDir, `${alias}.file.json`), + styles: sourceOf(dataDir, `${alias}.file.json`), + variables: sourceOf(dataDir, `${alias}.variables.json`), + icons: sourceOf(dataDir, `${alias}.file.json`, glyphNamePattern), + }; + + const stale = force || CACHE_CONCERNS.some(concern => { + const current = currentSources[concern]; + if (!current) return false; // that payload isn't fetched — nothing to be stale about + return !matches(existing[concern]?.sources[alias], current); + }); + + if (!stale) { + for (const concern of CACHE_CONCERNS) { + const from = existing[concern]; + if (!from?.sources[alias]) continue; + next[concern].sources[alias] = from.sources[alias]; + for (const [key, entry] of Object.entries(from.entries)) { + if ((entry as { file?: string }).file === alias) next[concern].entries[key] = entry; + } + } + report.current.push(alias); + continue; + } + + const slice = buildAliasSlice(alias, dataDir, glyphNamePattern); + for (const concern of CACHE_CONCERNS) { + const built = slice[concern]; + if (!built.source) continue; + next[concern].sources[alias] = built.source; + Object.assign(next[concern].entries, built.entries); + } + report.rebuilt.push(alias); + } + + for (const concern of CACHE_CONCERNS) { + writeCacheFile(dataDir, concern, next[concern]); + report.counts[concern] = Object.keys(next[concern].entries).length; + } + + return report; +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +export interface CacheProblem { + concern: CacheConcern; + alias: string; + reason: 'missing' | 'stale'; +} + +/** + * Check the caches against the payloads on disk, without writing anything. Every declared + * alias must be present and current in every cache whose payload it has fetched — render + * calls this and refuses to run on any problem, because a stale lookup binds a spec to the + * wrong variable or drops an instance, which surfaces far from its cause. + */ +export function validateCache(options: Omit): CacheProblem[] { + const { dataDir, aliases, glyphNamePattern } = options; + const problems: CacheProblem[] = []; + + const files = { + components: readCacheFile(dataDir, 'components'), + styles: readCacheFile(dataDir, 'styles'), + variables: readCacheFile(dataDir, 'variables'), + icons: readCacheFile(dataDir, 'icons'), + }; + + for (const alias of aliases) { + const currentSources = { + components: sourceOf(dataDir, `${alias}.file.json`), + styles: sourceOf(dataDir, `${alias}.file.json`), + variables: sourceOf(dataDir, `${alias}.variables.json`), + icons: sourceOf(dataDir, `${alias}.file.json`, glyphNamePattern), + }; + + for (const concern of CACHE_CONCERNS) { + const current = currentSources[concern]; + if (!current) continue; // payload not fetched for this alias — nothing to validate + const file = files[concern]; + if (!file || !file.sources[alias]) { + problems.push({ concern, alias, reason: 'missing' }); + continue; + } + if (!matches(file.sources[alias], current)) { + problems.push({ concern, alias, reason: 'stale' }); + } + } + } + + return problems; +} + +/** One line per problem, plus the command that fixes them. */ +export function describeProblems(problems: CacheProblem[]): string { + const lines = problems.map(p => ` - ${p.concern}.yaml: "${p.alias}" is ${p.reason}`); + return [ + `Render cache is not usable:`, + ...lines, + ``, + `Run \`specs cache\` to rebuild it.`, + ].join('\n'); +} diff --git a/packages/cli/src/Render/SpecLoader.ts b/packages/cli/src/Render/SpecLoader.ts new file mode 100644 index 00000000..e5e3177f --- /dev/null +++ b/packages/cli/src/Render/SpecLoader.ts @@ -0,0 +1,147 @@ +/** + * Loads a render input from disk, resolving two supported shapes: + * - a single spec file (.yaml/.yml/.json) — combined component data, as + * produced by `generate` without --split-concerns + * - a split-concerns component folder — api.(yaml|json) + variants.(yaml|json) + * + optional examples.(yaml|json), as produced by + * `generate --split-components --split-concerns` + * - a parent directory of those — see `findComponentFolders`, which collects + * every component folder beneath it for a batch render + * + * Format (JSON vs YAML) is detected per-file by extension. The result is + * always a single merged, in-memory component spec — the bridge/plugin never + * sees the on-disk shape. + */ + +import fs from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +export interface LoadedSpec { + spec: Record; + /** Absolute path used for workspace (specsDir/dataDir) derivation. */ + resolvePath: string; +} + +const CONCERN_EXTENSIONS = ['.yaml', '.yml', '.json']; + +function parseByExtension(filePath: string, raw: string): Record { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.json') return JSON.parse(raw); + if (ext === '.yaml' || ext === '.yml') return parseYaml(raw); + throw new Error(`Unsupported spec file extension "${ext}" (expected .yaml, .yml, or .json): ${filePath}`); +} + +function readSpecFile(filePath: string): Record { + const raw = fs.readFileSync(filePath, 'utf8'); + return parseByExtension(filePath, raw); +} + +function findConcernFile(dir: string, name: string): string | null { + for (const ext of CONCERN_EXTENSIONS) { + const candidate = path.join(dir, `${name}${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Recursively recombines api/variants/examples concern data (as split by + * `splitComponentByConcern` in the generate pipeline) back into one component + * object. Each concern's `metadata` is identical at the source, so last-write + * wins is safe. `subcomponents` are merged per-key rather than overwritten, + * since each concern only carries its own slice of subcomponent fields. + */ +function mergeConcerns( + api: Record, + variants: Record, + examples?: Record +): Record { + const merged: Record = { ...api, ...variants, ...(examples ?? {}) }; + + const subKeys = new Set([ + ...Object.keys(api.subcomponents ?? {}), + ...Object.keys(variants.subcomponents ?? {}), + ...Object.keys(examples?.subcomponents ?? {}), + ]); + + if (subKeys.size > 0) { + merged.subcomponents = {}; + for (const key of subKeys) { + merged.subcomponents[key] = mergeConcerns( + api.subcomponents?.[key] ?? {}, + variants.subcomponents?.[key] ?? {}, + examples?.subcomponents?.[key] + ); + } + } else { + delete merged.subcomponents; + } + + return merged; +} + +function loadSplitConcernsFolder(dir: string): Record { + const apiPath = findConcernFile(dir, 'api'); + const variantsPath = findConcernFile(dir, 'variants'); + const examplesPath = findConcernFile(dir, 'examples'); + + if (!apiPath || !variantsPath) { + throw new Error( + `Expected api.(yaml|json) and variants.(yaml|json) in split-concerns component folder: ${dir}` + ); + } + + const api = readSpecFile(apiPath); + const variants = readSpecFile(variantsPath); + const examples = examplesPath ? readSpecFile(examplesPath) : undefined; + + return mergeConcerns(api, variants, examples); +} + +/** A directory is a component folder when it carries both required concerns. */ +export function isComponentFolder(dir: string): boolean { + return Boolean(findConcernFile(dir, 'api') && findConcernFile(dir, 'variants')); +} + +/** + * How far below a parent directory component folders are looked for. 1 covers + * the flat `specs/deButton/` layout; 2 also covers one level of grouping, + * `specs/forms/deInput/`. Deeper nesting is intentionally not scanned — a batch + * render should stay predictable about what it will touch. + */ +const MAX_SCAN_DEPTH = 2; + +/** + * Collect every component folder at or beneath `dir`, sorted by path for a + * deterministic render order. If `dir` is itself a component folder it is the + * only result — scanning never descends into a component. + */ +export function findComponentFolders(dir: string, maxDepth = MAX_SCAN_DEPTH): string[] { + if (isComponentFolder(dir)) return [dir]; + if (maxDepth < 1) return []; + + const found: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + found.push(...findComponentFolders(path.join(dir, entry.name), maxDepth - 1)); + } + return found.sort(); +} + +/** + * Load a render input (single spec file or split-concerns component folder) + * into a single in-memory spec object, ready to send to the bridge. + */ +export function loadSpec(rawPath: string): LoadedSpec { + const absPath = path.resolve(rawPath); + + if (!fs.existsSync(absPath)) { + throw new Error(`Spec path not found: ${absPath}`); + } + + const isDirectory = fs.statSync(absPath).isDirectory(); + const spec = isDirectory ? loadSplitConcernsFolder(absPath) : readSpecFile(absPath); + + return { spec, resolvePath: absPath }; +} diff --git a/packages/cli/src/bridge/client.ts b/packages/cli/src/bridge/client.ts new file mode 100644 index 00000000..24804b69 --- /dev/null +++ b/packages/cli/src/bridge/client.ts @@ -0,0 +1,103 @@ +// Thin HTTP client for talking to a running bridge server (see bridge/server.ts). + +import type { ResolvedConfig } from '@directededges/specs-schema'; +import { HTTP_PORT } from './config.js'; + +export interface BridgeConnection { + fileKey: string; + fileName?: string; + connected: boolean; +} + +export interface BridgeStatus { + connections: BridgeConnection[]; +} + +export async function getBridgeStatus(timeoutMs = 1500): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`http://localhost:${HTTP_PORT}/status`, { signal: controller.signal }); + return (await res.json()) as BridgeStatus; + } finally { + clearTimeout(timer); + } +} + +export interface RenderRequestBody { + specPath?: string; + /** Pre-parsed, pre-merged component spec. When present, the bridge relays + * it as-is instead of reading/parsing specPath itself. specPath is still + * required alongside it to derive the workspace (specsDir/dataDir). */ + spec?: Record; + pageId?: string; + fileKey?: string; + /** Delete a same-titled page node before rendering, instead of erroring on the collision. */ + overwrite?: boolean; + /** + * The workspace config, as a fallback for a spec that carries no `metadata.config` — + * a hand-authored one, say. The spec's own config still wins: it records how the spec + * was produced, which is what render has to reverse. + */ + config?: ResolvedConfig; +} + +// Render reports success/failure only — the round-trip spec read is a second, +// explicit call (`specs generate --from-bridge`), not a render side effect. +export interface RenderResponse { + success: boolean; + nodeId?: string; + /** Non-fatal render degradations reported by the plugin (font fallbacks, skipped keys). */ + warnings?: string[]; + error?: unknown; + /** Phase durations inside the plugin render. */ + timings?: { total: number; phases: Array<{ label: string; ms: number; count: number }> }; + /** Phase durations on the bridge — manifest builds and the plugin round-trip. */ + bridgeTimings?: Array<{ label: string; ms: number }>; + payloadKB?: number; +} + +export async function postRender(body: RenderRequestBody): Promise { + const res = await fetch(`http://localhost:${HTTP_PORT}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as RenderResponse; +} + +export interface GenerateFromSelectionRequestBody { + fileKey?: string; + /** Generate from this node instead of the current selection (the plugin selects it first). */ + nodeId?: string; + /** + * The config to process the node under. Without it the plugin builds the spec under its + * own UI settings, so the same node in the same file yields different specs depending on + * how someone last left the panel — and a round trip compares a baseline made under one + * config against a read made under another. Applies to this request only; it must never + * be stored as the plugin's settings. + */ + config?: ResolvedConfig; + /** + * Testing utility: delete the node once its spec has been read. A catalogue sweep that + * renders and reads every component otherwise leaves all of them on the page. + */ + remove?: boolean; +} + +export interface GenerateFromSelectionResponse { + success: boolean; + nodeId?: string; + name?: string; + specData?: unknown; + error?: unknown; +} + +export async function postGenerateFromSelection(body: GenerateFromSelectionRequestBody = {}): Promise { + const res = await fetch(`http://localhost:${HTTP_PORT}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as GenerateFromSelectionResponse; +} diff --git a/packages/cli/src/bridge/config.ts b/packages/cli/src/bridge/config.ts new file mode 100644 index 00000000..ac96f4ea --- /dev/null +++ b/packages/cli/src/bridge/config.ts @@ -0,0 +1,32 @@ +// Bridge configuration constants and workspace resolution utilities. + +import { existsSync, statSync } from 'fs'; +import { resolve, dirname } from 'path'; + +export const WS_PORT = 9001; +export const HTTP_PORT = 9002; + +// Default render target page — the "Specs 2 Testing" render page. +// Override per-request via body.pageId or CLI --pageId. +export const DEFAULT_PAGE_ID = '1462-365'; + +/** + * Walk up from `fromPath` to find the workspace home directory: + * the nearest ancestor that contains both a `specs/` and `data/` subdirectory. + * + * Returns the workspace home path, or null if no such directory is found. + */ +export function resolveWorkspaceDir(fromPath: string): string | null { + let dir = existsSync(fromPath) && statSync(fromPath).isDirectory() + ? fromPath + : dirname(fromPath); + + while (true) { + if (existsSync(resolve(dir, 'specs')) && existsSync(resolve(dir, 'data'))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) return null; // filesystem root + dir = parent; + } +} diff --git a/packages/cli/src/bridge/connections.ts b/packages/cli/src/bridge/connections.ts new file mode 100644 index 00000000..c81d4981 --- /dev/null +++ b/packages/cli/src/bridge/connections.ts @@ -0,0 +1,66 @@ +// Tracks connected plugin sockets, keyed by Figma file key. Extracted from +// server.ts so the connection-resolution logic (ambiguous-file detection, +// stale-close handling) can be unit tested without binding real network ports. + +export interface ConnectionLike { + readyState: number; +} + +export interface Connection { + ws: TSocket; + fileKey: string; + fileName?: string; +} + +export class ConnectionRegistry { + private connections = new Map>(); + + register(fileKey: string, ws: TSocket, fileName?: string): void { + this.connections.set(fileKey, { ws, fileKey, fileName }); + } + + unregister(fileKey: string): void { + this.connections.delete(fileKey); + } + + get size(): number { + return this.connections.size; + } + + /** + * Resolve which connection a request targets. + * - Explicit fileKey: must match a connected plugin. + * - No fileKey, exactly one connection: use it. + * - No fileKey, zero or 2+ connections: throw — never guess. + */ + resolve(fileKey?: string | null): Connection { + if (fileKey) { + const conn = this.connections.get(fileKey); + if (!conn) { + throw new Error(`No plugin connected for file "${fileKey}".`); + } + return conn; + } + + if (this.connections.size === 0) { + throw new Error('No plugin connected. Enable the CLI Bridge in Specs 2 first.'); + } + + if (this.connections.size > 1) { + const list = [...this.connections.values()] + .map((c) => c.fileName ? `${c.fileKey} (${c.fileName})` : c.fileKey) + .join(', '); + throw new Error(`Multiple plugins connected — specify --file . Connected: ${list}`); + } + + return [...this.connections.values()][0]; + } + + list(): Array<{ fileKey: string; fileName?: string; connected: boolean }> { + return [...this.connections.values()].map((c) => ({ + fileKey: c.fileKey, + fileName: c.fileName, + connected: c.ws.readyState === 1, + })); + } +} diff --git a/packages/cli/src/bridge/pickConnection.ts b/packages/cli/src/bridge/pickConnection.ts new file mode 100644 index 00000000..1f6bca8c --- /dev/null +++ b/packages/cli/src/bridge/pickConnection.ts @@ -0,0 +1,77 @@ +// Resolves which connected Figma file a bridge request should target when the +// caller didn't pass --file explicitly. With 2+ connections and an interactive +// terminal, prompts the user to pick one instead of failing immediately — +// non-interactive callers (scripts, CI) keep today's fail-loud behavior so +// they never hang waiting on stdin. + +import { createInterface } from 'readline'; +import { getBridgeStatus, type BridgeConnection } from './client.js'; + +export function isAmbiguous(connections: BridgeConnection[]): boolean { + return connections.length >= 2; +} + +/** Converts a 1-based answer string to a 0-based index, or null if out of range/unparseable. */ +export function parseSelection(answer: string, count: number): number | null { + const idx = parseInt(answer.trim(), 10) - 1; + return Number.isInteger(idx) && idx >= 0 && idx < count ? idx : null; +} + +function promptForConnection(connections: BridgeConnection[]): Promise { + return new Promise((resolve) => { + console.log('\nMultiple Figma files are connected:'); + connections.forEach((c, i) => { + console.log(` ${i + 1}) ${c.fileName ?? c.fileKey} (${c.fileKey})`); + }); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`Select a file [1-${connections.length}]: `, (answer) => { + rl.close(); + const idx = parseSelection(answer, connections.length); + if (idx === null) { + console.error('Invalid selection.'); + resolve(undefined); + return; + } + resolve(connections[idx].fileKey); + }); + }); +} + +export interface ResolveFileKeyOptions { + /** Override interactivity detection (for tests). Defaults to real stdin/stdout TTY state. */ + isTTY?: boolean; + /** Override the status fetch (for tests). Defaults to getBridgeStatus(). */ + getStatus?: typeof getBridgeStatus; + /** Override the prompt (for tests). Defaults to a readline-based picker. */ + prompt?: (connections: BridgeConnection[]) => Promise; +} + +/** + * Returns the fileKey to target for a bridge request. + * - An explicit fileKey passes through unchanged. + * - Non-interactive (no TTY): returns undefined — the bridge server's own + * ambiguity/no-connection error surfaces from the actual request. + * - Interactive with fewer than 2 connections: returns undefined — resolve() + * on the server picks the sole connection, or reports "none connected". + * - Interactive with 2+ connections: prompts the user to choose. + */ +export async function resolveFileKey(explicit: string | undefined, opts: ResolveFileKeyOptions = {}): Promise { + if (explicit) return explicit; + + const isTTY = opts.isTTY ?? (!!process.stdin.isTTY && !!process.stdout.isTTY); + if (!isTTY) return undefined; + + const getStatus = opts.getStatus ?? getBridgeStatus; + let status; + try { + status = await getStatus(); + } catch { + return undefined; + } + + if (!isAmbiguous(status.connections)) return undefined; + + const prompt = opts.prompt ?? promptForConnection; + return prompt(status.connections); +} diff --git a/packages/cli/src/bridge/pidfile.ts b/packages/cli/src/bridge/pidfile.ts new file mode 100644 index 00000000..81590410 --- /dev/null +++ b/packages/cli/src/bridge/pidfile.ts @@ -0,0 +1,51 @@ +// Pidfile management for the daemonized bridge server process. + +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const SPECS_HOME = join(homedir(), '.specs'); +export const BRIDGE_PID_FILE = join(SPECS_HOME, 'bridge.pid'); +export const BRIDGE_LOG_FILE = join(SPECS_HOME, 'bridge.log'); + +export function ensureSpecsHome(): void { + if (!existsSync(SPECS_HOME)) mkdirSync(SPECS_HOME, { recursive: true }); +} + +export function readPid(): number | null { + if (!existsSync(BRIDGE_PID_FILE)) return null; + const raw = readFileSync(BRIDGE_PID_FILE, 'utf8').trim(); + const pid = Number(raw); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + +export function writePid(pid: number): void { + ensureSpecsHome(); + writeFileSync(BRIDGE_PID_FILE, String(pid), 'utf8'); +} + +export function clearPidFile(): void { + if (existsSync(BRIDGE_PID_FILE)) unlinkSync(BRIDGE_PID_FILE); +} + +/** True if a process with this pid is currently alive. */ +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Reads the pidfile and checks liveness. Cleans up a stale pidfile + * (recorded pid no longer running) automatically. + */ +export function getRunningPid(): number | null { + const pid = readPid(); + if (pid === null) return null; + if (isAlive(pid)) return pid; + clearPidFile(); + return null; +} diff --git a/packages/cli/src/bridge/requestTracker.ts b/packages/cli/src/bridge/requestTracker.ts new file mode 100644 index 00000000..9bd738e4 --- /dev/null +++ b/packages/cli/src/bridge/requestTracker.ts @@ -0,0 +1,45 @@ +// Correlation-ID based request/response matching, so responses route to the +// right caller even with multiple connections and requests in flight. +// Extracted from server.ts for unit testability. + +import { randomUUID } from 'crypto'; + +export class RequestTracker { + private pending = new Map void; reject: (e: unknown) => void }>(); + + /** + * Register a new pending request. Returns its requestId (to send alongside + * the outgoing message) and a promise that resolves/rejects when `resolve()` + * is called with that ID, or rejects on timeout if it never is. + */ + create(timeoutMs: number, timeoutMessage: string): { requestId: string; promise: Promise } { + const requestId = randomUUID(); + const promise = new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + setTimeout(() => { + if (this.pending.has(requestId)) { + this.pending.delete(requestId); + reject(new Error(timeoutMessage)); + } + }, timeoutMs); + }); + return { requestId, promise }; + } + + /** Resolve a pending request by ID. Returns false if the ID is unknown (already resolved, timed out, or never registered). */ + resolve(requestId: string, value: T): boolean { + const pending = this.pending.get(requestId); + if (!pending) return false; + this.pending.delete(requestId); + pending.resolve(value); + return true; + } + + has(requestId: string): boolean { + return this.pending.has(requestId); + } + + get size(): number { + return this.pending.size; + } +} diff --git a/packages/cli/src/bridge/server.ts b/packages/cli/src/bridge/server.ts new file mode 100644 index 00000000..b427c1b9 --- /dev/null +++ b/packages/cli/src/bridge/server.ts @@ -0,0 +1,680 @@ +#!/usr/bin/env node +// Bridge server — persistent WebSocket server for the Specs 2 CLI bridge. +// Stays running until Ctrl+C (or SIGTERM from `specs bridge stop`). Accepts +// multiple renderComponent/generateFromSelection commands per session, and +// multiple simultaneously connected Figma files (each with its own plugin +// connection). +// +// Ports: +// 9001 — WebSocket, for plugin connections (ui.html) — one per open Figma file +// 9002 — HTTP, control endpoint for the CLI (`specs render` / `specs generate`) or scripts +// +// HTTP API: +// POST http://localhost:9002/render +// Body: { "specPath": "/abs/path/to/spec.yaml", "pageId": "1462-365", "fileKey": "..." } +// fileKey is optional when exactly one plugin is connected; required (and +// validated) when more than one is connected. +// Response: { "success": true, "nodeId": "..." } (round-trip spec read is +// an explicit second call — POST /generate — not a render side effect) +// POST http://localhost:9002/generate +// Body: { "fileKey": "..." } (fileKey optional under the same single-connection rule) +// Generates a spec from the plugin's current Figma selection — no REST fetch needed. +// Response: { "success": true, "nodeId": "...", "name": "...", "specData": {...} } +// GET http://localhost:9002/status +// Response: { "connections": [{ "fileKey": "...", "fileName": "...", "connected": true }] } +// +// CLI (one-shot render, then stays running): +// node bridge-server.js [--workspace /path/to/workspace] --render path/to/spec.yaml [--pageId 1462-365] +// +// Workspace resolution (in order of precedence): +// 1. --workspace CLI flag +// 2. WORKSPACE_DIR env var +// 3. Derived per-request from the specPath (walks up to find specs/ + data/ sibling dirs) +// 4. SPECS_DIR / DATA_DIR env vars as explicit overrides for non-standard layouts +// +// Multi-connection protocol: +// Each plugin connection sends { type: 'hello', fileKey, fileName? } immediately +// on connect. The server tracks connections in a Map keyed by fileKey — this +// replaces a single "activeSocket" variable, which had a real bug: with only +// one tracked socket, an older connection's close event would null out a +// newer, still-live connection's reference. Every request (getPageId, +// renderComponent) carries a generated requestId; the plugin must echo it +// back on the matching result message, so responses route to the right +// caller even with multiple connections and requests in flight. + +import type { ResolvedConfig } from '@directededges/specs-schema'; +import { WebSocketServer, type WebSocket } from 'ws'; +import { createServer } from 'http'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { resolve as pathResolve, isAbsolute, basename } from 'path'; +import { parse } from 'yaml'; +import { WS_PORT, HTTP_PORT, DEFAULT_PAGE_ID, resolveWorkspaceDir } from './config.js'; +import { ConnectionRegistry, type Connection } from './connections.js'; +import { RequestTracker } from './requestTracker.js'; +import { countUnpublished, type VariablesIndex } from '../utilities/variablesIndex.js'; +import { formatKey } from '../utilities/formatKey.js'; +import { + readCacheFile, validateCache, describeProblems, + type ComponentsEntry, type StylesEntry, type VariablesEntry, type IconsEntry, +} from '../Cache/Cache.js'; + +/** id = same-file node id (fast path); key = published cross-file key (fallback import). */ +type ComponentEntry = { id: string; key?: string }; +type Manifest = Record; +type GlyphManifest = Record; +/** Token name → the handles that name resolves to. A spec references a variable by name only, + * so this is what gives the name meaning on the render side. */ +type VariablesManifest = VariablesIndex; + +interface RenderResult { + success: boolean; + nodeId?: string; + error?: string; + /** Phase durations reported by the plugin render (see figma-from-specs Timings.ts). */ + timings?: { total: number; phases: Array<{ label: string; ms: number; count: number }> }; + /** Phase durations measured here, before and around the plugin round-trip. */ + bridgeTimings?: Array<{ label: string; ms: number }>; + /** Size of the render payload sent over the socket, in KB. */ + payloadKB?: number; +} + +interface GenerateResult { + success: boolean; + nodeId?: string; + name?: string; + specData?: unknown; + error?: string; +} + +// ── Startup workspace (optional) ────────────────────────────────────────────── +// If set, all requests use this workspace. If not set, workspace is derived per-request. + +const workspaceIdx = process.argv.indexOf('--workspace'); +const startupWorkspaceDir: string | null = + process.env.WORKSPACE_DIR ?? + (workspaceIdx !== -1 ? process.argv[workspaceIdx + 1] : null); + +if (startupWorkspaceDir) { + console.log(` Workspace : ${startupWorkspaceDir}`); +} else { + console.log(` Workspace : (derived per-request from spec path)`); +} + +// SPECS_DIR / DATA_DIR env vars override workspace derivation for non-standard layouts. +const envSpecsDir = process.env.SPECS_DIR ?? null; +const envDataDir = process.env.DATA_DIR ?? null; + +/** + * Resolve the specs and data directories for a given spec or manifest path. + * Precedence: explicit env overrides → startup workspace → per-request derivation. + */ +function resolveDirs(fromPath: string): { specsDir: string; dataDir: string; aliases: string[]; glyphNamePattern?: string } { + const workspaceDir = startupWorkspaceDir ?? resolveWorkspaceDir(fromPath); + if (!workspaceDir && (!envSpecsDir || !envDataDir)) { + throw new Error( + `Cannot determine workspace for "${fromPath}". ` + + `Pass --workspace , set WORKSPACE_DIR, or ensure the path is inside a directory containing specs/ and data/.` + ); + } + const specsDir = envSpecsDir ?? pathResolve(workspaceDir as string, 'specs'); + const dataDir = envDataDir ?? pathResolve(workspaceDir as string, 'data'); + // Data files are named {sourceAlias}.manifest.md, {sourceAlias}.file.json, etc. + // The source alias comes from the first key under `sources` in specs.config.yaml. + const { aliases, glyphNamePattern } = resolveSources(workspaceDir as string); + return { specsDir, dataDir, aliases, glyphNamePattern }; +} + +console.log(`\nSpecs 2 — CLI bridge`); +console.log(` WebSocket : ws://localhost:${WS_PORT} (plugin)`); +console.log(` HTTP : http://localhost:${HTTP_PORT}/render (control)`); +console.log(` Enable the CLI Bridge in the Specs 2 plugin to connect.`); +console.log(` Ctrl+C to stop.\n`); + +// ── WebSocket server (plugin connections) ───────────────────────────────────── + +const wss = new WebSocketServer({ port: WS_PORT }); + +// Keyed by Figma file key. One entry per connected plugin instance. +const registry = new ConnectionRegistry(); + +// Pending requests keyed by a generated requestId, so responses route to the +// right caller regardless of which connection they came from. +const requests = new RequestTracker(); + +wss.on('connection', (ws: WebSocket) => { + // This connection's fileKey isn't known until its 'hello' message arrives. + let thisFileKey: string | null = null; + console.log('Plugin socket opened, awaiting hello…'); + + ws.on('message', (data: Buffer) => { + let msg: Record; + try { msg = JSON.parse(data.toString()); } catch { return; } + + if (msg.type === 'hello') { + thisFileKey = msg.fileKey as string; + const fileName = msg.fileName as string | undefined; + registry.register(thisFileKey, ws, fileName); + console.log(`Plugin connected: ${thisFileKey}${fileName ? ` (${fileName})` : ''}`); + return; + } + + if (msg.type === 'pageId-result' || msg.type === 'renderComponent-result' || msg.type === 'generateFromSelection-result' || msg.type === 'removeNode-result') { + const requestId = msg.requestId as string | undefined; + if (!requestId) return; // no way to route this response + + if (msg.type === 'pageId-result') { + requests.resolve(requestId, msg.pageId as string); + return; + } + + if (msg.type === 'generateFromSelection-result') { + if (msg.success) { + const nodeId = msg.nodeId as string; + const name = msg.name as string | undefined; + console.log(`✓ Generated from selection. nodeId: ${nodeId}`); + requests.resolve(requestId, { success: true, nodeId, name, specData: msg.specData ?? null }); + } else { + console.error(`✗ Generate from selection failed: ${msg.error}`); + requests.resolve(requestId, { success: false, error: msg.error as string }); + } + return; + } + + // renderComponent-result + if (msg.success) { + const nodeId = msg.nodeId as string; + const warnings = Array.isArray(msg.warnings) && msg.warnings.length > 0 ? (msg.warnings as string[]) : undefined; + console.log(`✓ Rendered in Figma. nodeId: ${nodeId}`); + for (const w of warnings ?? []) console.warn(` ⚠ ${w}`); + const timings = msg.timings as RenderResult['timings']; + requests.resolve(requestId, { success: true, nodeId, ...(warnings ? { warnings } : {}), ...(timings ? { timings } : {}) }); + } else { + console.error(`✗ Render failed: ${msg.error}`); + requests.resolve(requestId, { success: false, error: msg.error as string }); + } + } + }); + + ws.on('close', () => { + if (thisFileKey) { + registry.unregister(thisFileKey); + console.log(`Plugin disconnected: ${thisFileKey}`); + } else { + console.log('Plugin socket closed before hello.'); + } + }); + + ws.on('error', (e: Error) => console.error('Socket error:', e.message)); +}); + +wss.on('error', (e: NodeJS.ErrnoException) => { + console.error(`WebSocket server error: ${e.message}`); + if (e.code === 'EADDRINUSE') console.error(`Port ${WS_PORT} already in use.`); + process.exit(1); +}); + +// ── HTTP control server ─────────────────────────────────────────────────────── + +/** Callers prefix what they print with "Error: ", so report the bare message. */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +const http = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ connections: registry.list() })); + return; + } + + if (req.method === 'POST' && req.url === '/generate') { + let genBody = ''; + req.on('data', (chunk) => { genBody += chunk; }); + req.on('end', () => { + let params: { fileKey?: string; nodeId?: string; config?: ResolvedConfig; remove?: boolean }; + try { params = genBody ? JSON.parse(genBody) : {}; } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Invalid JSON body.' })); + return; + } + + sendGenerateFromSelection(params.fileKey, params.nodeId, params.config) + // The spec is in hand before the node goes, so a failed removal cannot cost the read. + .then(async (result) => { + if (params.remove && result.success && result.nodeId) { + try { await sendRemoveNode(result.nodeId, params.fileKey); } catch { /* reported by the caller's next read */ } + } + return result; + }) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: errorMessage(err) })); + }); + }); + return; + } + + if (req.method !== 'POST' || req.url !== '/render') { + res.writeHead(405); + res.end(JSON.stringify({ error: 'Only POST /render, POST /generate, or GET /status is supported.' })); + return; + } + + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let params: { specPath?: string; spec?: Record; pageId?: string | null; fileKey?: string; overwrite?: boolean; config?: ResolvedConfig }; + try { params = JSON.parse(body); } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Invalid JSON body.' })); + return; + } + + const { specPath: specArg, spec: preParsedSpec, pageId = null, fileKey, overwrite, config } = params; + + if (!specArg) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'specPath is required.' })); + return; + } + + // Single spec path: must be absolute (workspace derivation requires a real path) + if (!specArg || !isAbsolute(specArg)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'specPath must be absolute when no startup workspace is configured.' })); + return; + } + + sendRender(specArg, pageId, fileKey, preParsedSpec, overwrite, config) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch((err) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: errorMessage(err) })); + }); + }); +}); + +http.listen(HTTP_PORT); +http.on('error', (e: NodeJS.ErrnoException) => { + console.error(`HTTP server error: ${e.message}`); + if (e.code === 'EADDRINUSE') console.error(`Port ${HTTP_PORT} already in use.`); + process.exit(1); +}); + +// ── Shared utilities ────────────────────────────────────────────────────────── + +// ── Manifest builders ───────────────────────────────────────────────────────── +// +// Every library-side lookup a render needs is read from the caches under +// {dataDir}/cache/ (see src/Cache/Cache.ts). They are built by `specs fetch` and +// `specs cache` from the fetched payloads, so no payload is parsed here — a file +// payload can be hundreds of megabytes and used to be parsed three times per render. +// Only the spec-side half of the instance manifest is derived at render time, since +// it reflects the workspace's specs rather than the library. + +/** + * Build a flat manifest of spec key → component entry by scanning specsDir. + * Also layers in subcomponent ref aliases from the current spec. Published + * cross-file keys come from the components cache, which records them per node id + * across every fetched library. + */ +function buildManifest(spec: Record, specsDir: string, dataDir: string, keyFormat?: string): Manifest { + const manifest: Manifest = {}; + + const cache = readCacheFile(dataDir, 'components'); + const entryFor = (id: string): ComponentEntry => { + const key = cache?.entries[id]?.key; + return key ? { id, key } : { id }; + }; + + const specFiles = collectSpecFiles(specsDir); + // Deduplicate by key: first nodeId found wins (variants.yaml before api.yaml, etc.) + for (const { key, path } of specFiles) { + if (manifest[key]) continue; // already have a nodeId for this key + try { + const s = parse(readFileSync(path, 'utf8')) as { metadata?: { source?: { nodeId?: string } } }; + const nodeId = s.metadata?.source?.nodeId; + if (nodeId) manifest[key] = entryFor(nodeId); + } catch { + // Skip unreadable or non-spec files silently + } + } + + // Layer 2: alias subcomponent ref keys from the current spec. + const specTyped = spec as { + subcomponents?: Record; + components?: Record }>; + }; + const subcomponents = specTyped.subcomponents ?? Object.values(specTyped.components ?? {})[0]?.subcomponents; + if (subcomponents) { + for (const [refKey, sub] of Object.entries(subcomponents)) { + if (sub.source?.nodeId) { + manifest[refKey] = entryFor(sub.source.nodeId); + } else if (sub.title) { + const titleKey = toCamelCase(sub.title); + if (manifest[titleKey]) manifest[refKey] = manifest[titleKey]; + } + } + } + + // Layer 3: the components the workspace has no spec for. + // + // A spec names an instance's component with a formatted key, and that transform is lossy + // — "DS Link/On overlay/M/False/Rest/Start" formats to "dsLinkOnOverlayMFalseRestStart" + // with the separators gone — so the name cannot be reconstructed from the key. It can be + // recognised, though: format the library's own component names the same way and compare. + // These resolve by published key on the render side, which is what makes a component from + // another file placeable at all. + // + // Only names this spec actually references are added, so the payload does not grow by the + // library's entire component list. Spec-derived entries always win — a workspace component + // is the more specific answer, and its node id is local to the file being rendered into. + const referenced = collectComponentNames(spec); + if (referenced.size > 0 && cache) { + const byFormattedName = new Map(); + for (const [id, entry] of Object.entries(cache.entries)) { + if (!entry.name) continue; + const formatted = formatKey(entry.name, keyFormat); + // First occurrence wins. Distinct components can format to the same key (the transform + // is lossy), and nothing in the payload says which one a spec meant. + if (!byFormattedName.has(formatted)) byFormattedName.set(formatted, { id, key: entry.key }); + } + let added = 0; + for (const name of referenced) { + if (manifest[name]) continue; + const found = byFormattedName.get(name); + if (found) { manifest[name] = found; added++; } + } + if (added > 0) console.log(` Manifest: +${added} from library components with no spec`); + } + + console.log(` Manifest: ${Object.keys(manifest).length} entries`); + return manifest; +} + +/** + * Every component name a spec references, at any depth — variants, examples, slot content, + * subcomponents. `$ref` forms are skipped: those already resolve within the spec. + * + * Two surfaces name components. `instanceOf` names the component an element instantiates. + * A `propConfigurations` value names one too when the property behind it is an instance + * swap — an icon on an avatar, say — and the spec gives no clue which of its values those + * are, since it records the property's value, not its Figma type. So every string value is + * offered up: a name that matches no library component simply finds nothing, and one that + * does is only ever used by a swap that asked for it. + */ +function collectComponentNames(node: unknown, acc = new Set()): Set { + if (!node || typeof node !== 'object') return acc; + if (Array.isArray(node)) { for (const item of node) collectComponentNames(item, acc); return acc; } + for (const [key, value] of Object.entries(node as Record)) { + if (key === 'instanceOf' && typeof value === 'string') acc.add(value); + else if (key === 'propConfigurations' && value && typeof value === 'object') { + for (const configured of Object.values(value as Record)) { + if (typeof configured === 'string') acc.add(configured); + } + collectComponentNames(value, acc); // $nested configs carry their own values + } + else collectComponentNames(value, acc); + } + return acc; +} + +/** + * Glyph name → component entry, straight from the icons cache. The cache is built by + * matching the configured glyphNamePattern against the fetched file, so render depends + * on no scan output: a scan manifest is generated and then authored, which makes it a + * poor thing to resolve against. + */ +function buildGlyphManifest(dataDir: string): GlyphManifest { + const cache = readCacheFile(dataDir, 'icons'); + const result: GlyphManifest = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + result[name] = entry.key ? { id: entry.id, key: entry.key } : { id: entry.id }; + } + console.log(` Glyph manifest: ${Object.keys(result).length} glyphs`); + return result; +} + +/** Style name → published key, for the style types a spec can reference. */ +function buildStylesManifest(dataDir: string): Record { + const cache = readCacheFile(dataDir, 'styles'); + const result: Record = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + result[name] = entry.key; + } + console.log(` Styles manifest: ${Object.keys(result).length} entries`); + return result; +} + +/** Token name → the handles it resolves to. */ +function buildVariablesManifest(dataDir: string): VariablesManifest { + const cache = readCacheFile(dataDir, 'variables'); + const index: VariablesManifest = {}; + for (const [name, entry] of Object.entries(cache?.entries ?? {})) { + index[name] = { key: entry.key, id: entry.id, published: entry.published }; + } + + const names = Object.keys(index).length; + if (names === 0) { + console.warn(` Variables manifest: no named variables cached — variable binding disabled`); + return index; + } + + const unpublished = countUnpublished(index); + const suffix = unpublished > 0 ? ` (${unpublished} not importable from the library — id fallback only)` : ''; + console.log(` Variables manifest: ${names} token names${suffix}`); + return index; +} + +/** + * Collect all spec YAML files from a specs directory. + * Handles both flat layouts (specsDir/foo.yaml) and subdirectory layouts + * (specsDir/foo/variants.yaml, specsDir/foo/api.yaml, etc.). + * Returns an array of { key, path } where key is the camelCase component name. + */ +function collectSpecFiles(specsDir: string): Array<{ key: string; path: string }> { + let entries: string[]; + try { + entries = readdirSync(specsDir); + } catch (e) { + throw new Error(`Cannot read specs directory "${specsDir}": ${(e as Error).message}`); + } + + const files: Array<{ key: string; path: string }> = []; + for (const entry of entries) { + const entryPath = pathResolve(specsDir, entry); + let stat; + try { stat = statSync(entryPath); } catch { continue; } + + if (stat.isFile() && entry.endsWith('.yaml')) { + // Flat layout: specsDir/foo.yaml → key "foo" + files.push({ key: basename(entry, '.yaml'), path: entryPath }); + } else if (stat.isDirectory()) { + // Subdirectory layout: specsDir/foo/variants.yaml → key "foo" + // Prefer variants.yaml; also collect api.yaml and others for nodeId scanning. + let subEntries: string[]; + try { subEntries = readdirSync(entryPath); } catch { continue; } + for (const sub of subEntries) { + if (sub.endsWith('.yaml')) { + files.push({ key: entry, path: pathResolve(entryPath, sub) }); + } + } + } + } + + return files; +} + + +function toCamelCase(str: string): string { + return str + .replace(/[^a-zA-Z0-9 ]/g, '') + .split(' ') + .filter(Boolean) + .map((w, i) => i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1).toLowerCase()) + .join(''); +} + +// ── Workspace config resolution ─────────────────────────────────────────────── + +/** + * Read specs.config.yaml from the workspace root and return the first source alias. + * Data files are named {alias}.manifest.md, {alias}.file.json, etc. — matching what + * `specs scan` produces (line: baseName = path.basename(alias + '.file.json', '.file.json')). + * Falls back to the workspace directory name if the config is absent or has no sources. + */ +/** + * Every source alias declared in the workspace config, in declaration order, plus the + * glyph naming pattern. Render resolves against all of them: a component can instance a + * component, bind a token, or place a glyph from any library the workspace declares. + */ +function resolveSources(workspaceDir: string): { aliases: string[]; glyphNamePattern?: string } { + const configPath = pathResolve(workspaceDir, 'specs.config.yaml'); + try { + const config = parse(readFileSync(configPath, 'utf8')) as { + sources?: Record; + config?: { processing?: { glyphNamePattern?: string } }; + }; + const sources = config?.sources; + const aliases = sources && typeof sources === 'object' ? Object.keys(sources) : []; + return { aliases, glyphNamePattern: config?.config?.processing?.glyphNamePattern }; + } catch { + // No config or unreadable — no aliases, which render reports as an unusable cache + return { aliases: [] }; + } +} + +// ── Shared render logic ─────────────────────────────────────────────────────── + +/** + * Ask a specific connection's plugin for the current Figma page ID. + */ +function getCurrentPageId(conn: Connection): Promise { + const { requestId, promise } = requests.create(5000, 'Timed out waiting for pageId-result.'); + conn.ws.send(JSON.stringify({ type: 'getPageId', requestId })); + return promise as Promise; +} + +/** + * Ask a specific connection's plugin to generate a spec from its current Figma + * selection, or from an explicit nodeId (the plugin selects that node first). + */ +/** Testing utility: delete a rendered node so a catalogue sweep leaves the page as it found it. */ +async function sendRemoveNode(nodeId: string, fileKey?: string): Promise<{ success: boolean; error?: string }> { + const conn = registry.resolve(fileKey); + const { requestId, promise } = requests.create(30000, 'Timed out waiting for removeNode-result.'); + conn.ws.send(JSON.stringify({ type: 'removeNode', requestId, nodeId })); + return promise as Promise<{ success: boolean; error?: string }>; +} + +async function sendGenerateFromSelection(fileKey?: string, nodeId?: string, config?: ResolvedConfig): Promise { + const conn = registry.resolve(fileKey); + const { requestId, promise } = requests.create(60000, 'Timed out waiting for generateFromSelection-result.'); + // `config` travels with the request and governs how the plugin builds this one spec. + // The plugin must not adopt it as its own settings. + conn.ws.send(JSON.stringify({ type: 'generateFromSelection', requestId, nodeId, config })); + return promise as Promise; +} + +/** + * Send a single renderComponent message over the WebSocket and wait for the result. + */ +async function sendRender(specPath: string, rawPageId: string | null, fileKey?: string, preParsedSpec?: Record, overwrite?: boolean, config?: ResolvedConfig): Promise { + const conn = registry.resolve(fileKey); + + let spec: Record; + if (preParsedSpec) { + spec = preParsedSpec; + } else { + try { + spec = parse(readFileSync(specPath, 'utf8')); + } catch (e) { + throw new Error(`Error reading spec: ${(e as Error).message}`); + } + } + + const dirs = resolveDirs(specPath); + const { specsDir, dataDir, aliases, glyphNamePattern } = dirs; + + // Render resolves against the caches only. A missing or stale cache is fatal rather + // than rebuilt here: rebuilding parses every fetched payload, which is exactly the + // per-render cost the caches exist to remove — and silently rendering against data + // that no longer matches what was fetched binds specs to the wrong variables. + const problems = validateCache({ dataDir, aliases, glyphNamePattern }); + if (problems.length > 0) throw new Error(describeProblems(problems)); + + const bridgeTimings: Array<{ label: string; ms: number }> = []; + const timed = (label: string, fn: () => T): T => { + const start = Date.now(); + try { return fn(); } finally { bridgeTimings.push({ label, ms: Date.now() - start }); } + }; + + const keyFormat = (spec as { metadata?: { config?: { format?: { keys?: string } } } })?.metadata?.config?.format?.keys + ?? (spec as { components?: Record }) + ?.components?.[Object.keys((spec as { components?: Record }).components ?? {})[0]] + ?.metadata?.config?.format?.keys; + const manifest = timed('instance manifest', () => buildManifest(spec, specsDir, dataDir, keyFormat)); + const glyphIdManifest = timed('glyph manifest', () => buildGlyphManifest(dataDir)); + const stylesManifest = timed('styles manifest', () => buildStylesManifest(dataDir)); + const variablesManifest = timed('variables manifest', () => buildVariablesManifest(dataDir)); + + // Resolve page ID: use provided value, or ask this connection's plugin for its current page. + let pageId: string; + if (rawPageId) { + pageId = rawPageId.replace(/-/g, ':'); + } else { + pageId = await getCurrentPageId(conn); + console.log(` Page ID: ${pageId} (from plugin current page, ${conn.fileKey})`); + } + + const specTyped = spec as { components?: Record }; + const componentName = Object.values(specTyped.components ?? {})[0]?.title + ?? Object.keys(specTyped.components ?? {})[0] + ?? specPath; + + console.log(`Rendering: ${componentName} → page ${pageId} (${conn.fileKey})`); + + // A large component set against a big library can take minutes today; a timeout + // shorter than the render discards a result the plugin actually produced. + const { requestId, promise } = requests.create(300000, 'Timed out waiting for renderComponent-result.'); + const payload = JSON.stringify({ type: 'renderComponent', requestId, spec, pageId, instanceIdManifest: manifest, glyphIdManifest, stylesManifest, variablesManifest, overwrite, config }); + + const sentAt = Date.now(); + conn.ws.send(payload); + const result = await (promise as Promise); + bridgeTimings.push({ label: 'plugin round-trip', ms: Date.now() - sentAt }); + return { ...result, bridgeTimings, payloadKB: Math.round(payload.length / 1024) }; +} + +// ── CLI: --render flag ──────────────────────────────────────────────────────── +// +// node bridge-server.js --render /abs/path/to/spec.yaml [--pageId 1462-365] +// Debug shortcut — only works when exactly one plugin is connected. + +const renderIdx = process.argv.indexOf('--render'); +if (renderIdx !== -1) { + const specArg = process.argv[renderIdx + 1]; + const pageIdIdx = process.argv.indexOf('--pageId'); + const rawPageId = pageIdIdx !== -1 ? process.argv[pageIdIdx + 1] : DEFAULT_PAGE_ID; + + if (!specArg) { console.error('--render requires a spec path'); process.exit(1); } + if (!isAbsolute(specArg)) { console.error('--render path must be absolute'); process.exit(1); } + + const tryRender = (): void => { + if (registry.size > 0) { + sendRender(specArg, rawPageId) + .catch((e) => console.error((e as Error).message)); + } else { + console.log('Waiting for plugin to connect…'); + setTimeout(tryRender, 1000); + } + }; + setTimeout(tryRender, 500); +} diff --git a/packages/cli/src/commands/ApplyCustomTokensCommand.ts b/packages/cli/src/commands/ApplyCustomTokensCommand.ts index c485dfd2..cc9b1e8c 100644 --- a/packages/cli/src/commands/ApplyCustomTokensCommand.ts +++ b/packages/cli/src/commands/ApplyCustomTokensCommand.ts @@ -12,6 +12,8 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; import yaml from 'yaml'; +import { refreshCache } from '../Cache/Cache.js'; +import { reportCache } from './CacheCommand.js'; const ERROR_CODES = { SUCCESS: 0, @@ -24,6 +26,7 @@ type MinimalConfig = { dataDirectory?: string; sourceDirectory?: string; // deprecated alias sources?: Record; + config?: { processing?: { glyphNamePattern?: string } }; }; function findConfigFile(cwd: string): string | null { @@ -285,6 +288,17 @@ export const ApplyCustomTokens = new Command('applyCustomTokens') console.log(` ${unmatchedIds.size} mapping entry ID(s) did not match any variable or style`); } + // The variables payload was just rewritten in place, so the render cache built from + // it now describes pre-custom-token data. Rebuild before anyone renders against it. + if (config.dataDirectory) { + const report = refreshCache({ + dataDir: path.resolve(configDir, config.dataDirectory), + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + }); + reportCache(report); + } + process.exit(ERROR_CODES.SUCCESS); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/BridgeCommand.ts b/packages/cli/src/commands/BridgeCommand.ts new file mode 100644 index 00000000..ce748649 --- /dev/null +++ b/packages/cli/src/commands/BridgeCommand.ts @@ -0,0 +1,124 @@ +/** + * Bridge Command + * + * Manages the local CLI bridge — a background process that relays + * `specs render` requests to a connected Specs 2 Figma plugin over + * WebSocket. See bridge/server.ts for the actual server implementation. + */ + +import { Command } from 'commander'; +import { spawn } from 'child_process'; +import { openSync, closeSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { + ensureSpecsHome, + getRunningPid, + isAlive, + writePid, + clearPidFile, + BRIDGE_PID_FILE, + BRIDGE_LOG_FILE, +} from '../bridge/pidfile.js'; +import { getBridgeStatus } from '../bridge/client.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, +}; + +function resolveBridgeServerScript(): string { + // dist/specs.js and dist/bridge-server.js are sibling build outputs. + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, 'bridge-server.js'); +} + +export const Bridge = new Command('bridge') + .description('Manage the local CLI bridge used by `specs render` to reach a running Figma plugin'); + +Bridge.command('start') + .description('Start the bridge server in the background') + .option('--workspace ', 'Pin a workspace directory instead of deriving it per-request') + .action(async (options: { workspace?: string }) => { + const runningPid = getRunningPid(); + if (runningPid !== null) { + console.log(`Bridge already running (pid ${runningPid}).`); + console.log('Run `specs bridge status` to check plugin connection.'); + process.exit(ERROR_CODES.SUCCESS); + } + + ensureSpecsHome(); + const serverScript = resolveBridgeServerScript(); + const args = options.workspace ? ['--workspace', options.workspace] : []; + + const logFd = openSync(BRIDGE_LOG_FILE, 'a'); + const child = spawn('node', [serverScript, ...args], { + detached: true, + stdio: ['ignore', logFd, logFd], + }); + closeSync(logFd); + + if (!child.pid) { + console.error('Error: failed to start the bridge server.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + child.unref(); + writePid(child.pid); + + console.log(`Bridge started (pid ${child.pid}).`); + console.log(' WebSocket : ws://localhost:9001 (plugin)'); + console.log(' HTTP : http://localhost:9002/render (control)'); + console.log(` Logs : ${BRIDGE_LOG_FILE}`); + console.log('Enable the CLI Bridge in the Specs 2 plugin to connect.'); + }); + +Bridge.command('stop') + .description('Stop the background bridge server') + .action(async () => { + const pid = getRunningPid(); + if (pid === null) { + console.log('Bridge is not running.'); + process.exit(ERROR_CODES.SUCCESS); + } + + process.kill(pid, 'SIGTERM'); + + const deadline = Date.now() + 3000; + while (Date.now() < deadline && isAlive(pid)) { + await new Promise((r) => setTimeout(r, 100)); + } + + if (isAlive(pid)) { + process.kill(pid, 'SIGKILL'); + } + + clearPidFile(); + console.log('Bridge stopped.'); + }); + +Bridge.command('status') + .description('Check whether the bridge server (and a connected plugin) are running') + .action(async () => { + const pid = getRunningPid(); + if (pid === null) { + console.log('Bridge is not running.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + try { + const { connections } = await getBridgeStatus(); + if (connections.length === 0) { + console.log(`Bridge running (pid ${pid}). No plugin connected.`); + } else { + console.log(`Bridge running (pid ${pid}). ${connections.length} plugin${connections.length === 1 ? '' : 's'} connected:`); + for (const c of connections) { + console.log(` ${c.fileKey}${c.fileName ? ` (${c.fileName})` : ''} — ${c.connected ? 'connected' : 'disconnected'}`); + } + } + process.exit(ERROR_CODES.SUCCESS); + } catch { + console.log(`Bridge running (pid ${pid}), but the control port isn't responding.`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); diff --git a/packages/cli/src/commands/CacheCommand.ts b/packages/cli/src/commands/CacheCommand.ts new file mode 100644 index 00000000..1ce77972 --- /dev/null +++ b/packages/cli/src/commands/CacheCommand.ts @@ -0,0 +1,66 @@ +/** + * Cache Command + * + * Builds the render lookup caches under `{dataDirectory}/cache/` from the fetched + * Figma payloads. `fetch` and `apply-custom-tokens` run this themselves, so it is + * needed by hand only when the caches are missing or something outside those + * commands changed the data. See src/Cache/Cache.ts for what the files contain. + */ + +import { Command } from 'commander'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; +import { refreshCache, type CacheReport } from '../Cache/Cache.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, + INVALID_ARGS: 2, +}; + +/** Shared by this command and every command that refreshes the cache as a final step. */ +export function reportCache(report: CacheReport): void { + const { rebuilt, current, unfetched, counts } = report; + if (rebuilt.length > 0) console.log(` Cache rebuilt: ${rebuilt.join(', ')}`); + if (current.length > 0) console.log(` Cache current: ${current.join(', ')}`); + // Not an error here — only render is in a position to insist a source be fetched. + if (unfetched.length > 0) console.log(` Not fetched, skipped: ${unfetched.join(', ')}`); + console.log( + ` Entries: ${counts.components} components, ${counts.styles} styles, ` + + `${counts.variables} variables, ${counts.icons} icons` + ); +} + +export const Cache = new Command('cache') + .description('Build the render lookup caches from fetched Figma data') + .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--force', 'Rebuild every source, even those whose cached data still matches') + .action((options: { config?: string; force?: boolean }) => { + try { + const config = new ConfigLoader().load(options.config); + + if (!config.dataDirectory) { + console.error('Error: dataDirectory is not set in specs.config.yaml.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const aliases = Object.keys(config.sources ?? {}); + if (aliases.length === 0) { + console.error('Error: no sources are configured in specs.config.yaml.'); + console.error('Tip: the cache is built per source — add one under `sources`, then run `specs fetch`.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + const report = refreshCache({ + dataDir: config.dataDirectory, + aliases, + glyphNamePattern: config.config?.processing?.glyphNamePattern, + force: options.force, + }); + + reportCache(report); + console.log('✓ Cache written.'); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); diff --git a/packages/cli/src/commands/FetchCommand.ts b/packages/cli/src/commands/FetchCommand.ts index c33be79a..871c1614 100644 --- a/packages/cli/src/commands/FetchCommand.ts +++ b/packages/cli/src/commands/FetchCommand.ts @@ -12,6 +12,10 @@ import fs from 'fs-extra'; import path from 'path'; import yaml from 'yaml'; import readline from 'readline'; +import { collectGlyphComponents } from '../utilities/glyphComponents.js'; +import { startSpinner, clearInlineStatus, renderInlineStatus, isInteractive, formatElapsed } from '../utilities/spinner.js'; +import { refreshCache } from '../Cache/Cache.js'; +import { reportCache } from './CacheCommand.js'; const ERROR_CODES = { SUCCESS: 0, @@ -33,41 +37,7 @@ type MinimalConfig = { config?: { processing?: { glyphNamePattern?: string } }; }; -/** - * Walk the file document for COMPONENT nodes whose name matches the - * glyphNamePattern ("DS Icon asset / {i}" — {i} captures the icon name). - * Duplicate slugs keep the first occurrence and suffix later ones with the - * node id so nothing is silently dropped. - */ -export function collectGlyphComponents(document: unknown, pattern: string): Array<{ id: string; name: string; slug: string }> { - const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{i\\\}/g, '(.+)'); - const regex = new RegExp(`^${escaped}$`); - const found: Array<{ id: string; name: string; slug: string }> = []; - const walk = (node: unknown): void => { - if (!node || typeof node !== 'object') return; - const n = node as { id?: string; name?: string; type?: string; children?: unknown[] }; - if (n.type === 'COMPONENT' && typeof n.name === 'string' && typeof n.id === 'string') { - const match = n.name.match(regex); - if (match) found.push({ id: n.id, name: match[1] ?? n.name, slug: '' }); - } - for (const child of n.children ?? []) walk(child); - }; - walk(document); - - const seen = new Set(); - for (const glyph of found) { - // Kebabize camelCase too, matching the scaffold's glyphUrl slugging. - const base = glyph.name - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - glyph.slug = seen.has(base) ? `${base}-${glyph.id.replace(':', '-')}` : base; - seen.add(base); - } - return found; -} +export { collectGlyphComponents }; async function streamToString(stream: ReadableStream | null): Promise { if (!stream) return ''; @@ -115,6 +85,12 @@ function loadConfig(configPath?: string): { configPath: string | null; config: M }; } +const FETCH_KINDS: readonly FetchKind[] = ['file', 'variables', 'styles', 'icons']; + +function isFetchKind(value: string): value is FetchKind { + return (FETCH_KINDS as readonly string[]).includes(value); +} + function splitOnly(value?: string): string[] { if (!value) return []; return value @@ -243,53 +219,6 @@ export function formatAuthError(status: number, alias: string, kind: string, con ].join('\n'); } -function isInteractive(): boolean { - return Boolean(process.stdout.isTTY); -} - -function renderInlineStatus(text: string): void { - if (!isInteractive()) { - console.log(text); - return; - } - - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - process.stdout.write(text); -} - -function clearInlineStatus(): void { - if (!isInteractive()) return; - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); -} - -function formatElapsed(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remaining = seconds % 60; - return `${minutes}m ${remaining}s`; -} - -function startSpinner(text: string): () => string { - const start = Date.now(); - if (!isInteractive()) { - console.log(text); - return () => formatElapsed(Date.now() - start); - } - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let i = 0; - const id = setInterval(() => { - const elapsed = formatElapsed(Date.now() - start); - renderInlineStatus(`${frames[i++ % frames.length]} ${text} (${elapsed})`); - }, 80); - return () => { - clearInterval(id); - clearInlineStatus(); - return formatElapsed(Date.now() - start); - }; -} export interface FetchOptions { config?: string; @@ -305,7 +234,7 @@ export const Fetch = new Command('fetch') .option('--config ', 'Path to config file (specs.config.yaml)') .option('--data-dir ', 'Override data directory (default: config dataDirectory or ./data)') .option('--outDir ', 'Deprecated: use --data-dir') - .option('--only ', 'Fetch only the given file alias(es) from sources.files') + .option('--only ', 'Fetch only these — a file alias from sources, a data kind (file, variables, styles, icons), or both') .option('--no-geometry', 'Omit geometry data (fillGeometry, strokeGeometry, size, relativeTransform) from file payloads') .option('--verbose', 'Enable detailed logging', false) .action(async (options: FetchOptions) => { @@ -338,14 +267,48 @@ export const Fetch = new Command('fetch') process.exit(ERROR_CODES.INVALID_ARGS); } - const onlyAliases = splitOnly(options.only); - const selected = onlyAliases.length > 0 ? fileEntries.filter(f => onlyAliases.includes(f.alias)) : fileEntries; + // `--only` narrows two independent axes: which source files to fetch, and which kinds + // of data to fetch for them. A value naming a data kind narrows the kind; anything + // else is read as a file alias. Both can be given together (`--only library,icons`). + const onlyValues = splitOnly(options.only); + const onlyKinds = onlyValues.filter(isFetchKind); + const onlyAliases = onlyValues.filter(v => !isFetchKind(v)); + + // An alias sharing a data kind's name would be silently unreachable, so say so + // rather than guess which the caller meant. + const shadowed = fileEntries.map(f => f.alias).filter(isFetchKind); + if (shadowed.length > 0 && onlyKinds.some(k => shadowed.includes(k))) { + console.error(`Error: --only "${onlyKinds.filter(k => shadowed.includes(k)).join(', ')}" is both a data kind and a source alias.`); + console.error('Rename the source alias, or drop --only and let config decide.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } - if (onlyAliases.length > 0 && selected.length === 0) { - console.error(`Error: --only did not match any configured aliases: ${onlyAliases.join(', ')}`); + // Every name has to mean something. A typo alongside a valid alias would otherwise + // fetch more than was asked for and say nothing — the opposite of what --only is for. + const unmatched = onlyAliases.filter(a => !fileEntries.some(f => f.alias === a)); + if (unmatched.length > 0) { + console.error(`Error: --only ${unmatched.join(', ')} — not a source alias or a data kind.`); + console.error(`Aliases: ${fileEntries.map(f => f.alias).join(', ') || '(none)'}`); + console.error(`Kinds: ${FETCH_KINDS.join(', ')}`); process.exit(ERROR_CODES.INVALID_ARGS); } + const selected = onlyAliases.length > 0 ? fileEntries.filter(f => onlyAliases.includes(f.alias)) : fileEntries; + + // A kind the caller asked for that no selected source is configured to fetch would + // otherwise do nothing at all and say nothing about why. + if (onlyKinds.length > 0) { + const available = new Set(selected.flatMap(f => f.fetch)); + const unavailable = onlyKinds.filter(k => !available.has(k)); + if (unavailable.length === onlyKinds.length) { + console.error(`Error: --only ${onlyKinds.join(', ')} — no selected source is configured to fetch ${unavailable.length === 1 ? 'it' : 'them'}.`); + console.error(`Configured data for ${selected.map(f => `${f.alias}: [${f.fetch.join(', ')}]`).join('; ')}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + const wants = (kind: FetchKind): boolean => onlyKinds.length === 0 || onlyKinds.includes(kind); + await fs.ensureDir(outDir); if (options.verbose) { @@ -355,7 +318,7 @@ export const Fetch = new Command('fetch') } for (const entry of selected) { - for (const kind of entry.fetch.filter(k => k !== 'icons')) { + for (const kind of entry.fetch.filter(k => k !== 'icons' && wants(k))) { const url = kind === 'file' ? `https://api.figma.com/v1/files/${entry.key}${options.geometry ? '?geometry=paths' : ''}` @@ -430,7 +393,7 @@ export const Fetch = new Command('fetch') // Icons run after the other kinds: glyph components are derived from // the saved file payload, so `file` must be present (fetched this run // or a previous one) before icons can resolve. - if (entry.fetch.includes('icons')) { + if (entry.fetch.includes('icons') && wants('icons')) { const pattern = config.config?.processing?.glyphNamePattern; if (!pattern) { console.error(`Error: sources.${entry.alias}.data includes "icons" but config.processing.glyphNamePattern is not set`); @@ -496,6 +459,20 @@ export const Fetch = new Command('fetch') } clearInlineStatus(); + + // Refresh the render caches from everything now on disk — the sources fetched this + // run, plus any fetched previously. A source with no payload yet is skipped: not + // having fetched it is a normal state, and only render treats it as an error. + if (config.dataDirectory) { + const dataDir = path.resolve(configDir, config.dataDirectory); + const report = refreshCache({ + dataDir, + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + }); + reportCache(report); + } + console.log('✓ Fetch complete'); process.exit(ERROR_CODES.SUCCESS); } catch (error) { diff --git a/packages/cli/src/commands/GenerateCommand.ts b/packages/cli/src/commands/GenerateCommand.ts index 443ceec9..2c21a480 100644 --- a/packages/cli/src/commands/GenerateCommand.ts +++ b/packages/cli/src/commands/GenerateCommand.ts @@ -4,8 +4,10 @@ * Auto-detects source type: * - JSON file → file mode (single component with -c) * - Markdown manifest → manifest mode (multiple components from checkboxes) + * - --from-bridge → bridge mode (current Figma selection via CLI bridge) * - * Both modes use Components.fromRestApi() batch API. + * File/manifest modes use Components.fromRestApi() batch API. Bridge mode + * gets an already-generated spec from the plugin over the bridge — no REST fetch. */ import { Command } from 'commander'; @@ -15,6 +17,7 @@ import yaml from 'yaml'; import { Components } from '@directededges/specs-from-figma'; import type { ProgressEvent, RestLicenseInput } from '@directededges/specs-from-figma'; import { ConfigLoader } from '../Config/ConfigLoader.js'; +import type { CLIConfig } from '../Types/CLIConfig.js'; import { loadFoundations } from '../utilities/loadFoundations.js'; import { ManifestParser } from '../utilities/ManifestParser.js'; import { ManifestParserV2 } from '../utilities/ManifestParserV2.js'; @@ -27,6 +30,9 @@ import { CombinedFileWriter } from '../Writers/CombinedFileWriter.js'; import type { FileWriter, WriteResult } from '../Writers/FileWriter.js'; import type { OutputFormat } from '../Types/OutputConfig.js'; import { ImageFillsResolver, IMAGES_DIR_NAME } from '../utilities/ImageFillsResolver.js'; +import { postGenerateFromSelection } from '../bridge/client.js'; +import { formatKey } from '../utilities/formatKey.js'; +import { resolveFileKey } from '../bridge/pickConnection.js'; declare const __SPECS_CLI_VERSION__: string; @@ -67,6 +73,10 @@ interface GenerateOptions { splitConcerns?: boolean; useSubfolders?: boolean; getImages?: boolean; + fromBridge?: boolean; + file?: string; + node?: string; + remove?: boolean; } /** @@ -82,6 +92,166 @@ function resolveFileSourceAlias(sources: NonNullable }>, + errors: Array<{ component: string; error: string }>, + isManifest: boolean, + options: GenerateOptions, + config: CLIConfig, + modelConfig: CLIConfig['config'] +): Promise { + // ------------------------------------------------------------------- + // File mode stdout (no -o) + // ------------------------------------------------------------------- + if (!isManifest && !options.output && !config.outputDirectory) { + if (options.getImages) { + console.error('Error: --get-images requires an output directory (set outputDirectory in config or pass -o) so image files have somewhere to be written'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const componentData = processedComponents[0].spec; + const outputFormat = options.format + ? options.format.toLowerCase() + : modelConfig.format.output.toLowerCase(); + + const formattedOutput = outputFormat === 'yaml' + ? yaml.stringify(componentData) + : JSON.stringify(componentData, null, 2); + + console.log(formattedOutput); + process.exit(ERROR_CODES.SUCCESS); + return; + } + + // ------------------------------------------------------------------- + // File output via manifest + writer + // ------------------------------------------------------------------- + const resolvedFormat: OutputFormat = options.format + ? options.format.toLowerCase() as OutputFormat + : modelConfig.format.output.toLowerCase() as OutputFormat; + + const outputConfig = { + ...config.output, + splitComponents: options.splitComponents ?? config.output?.splitComponents ?? false, + splitConcerns: options.splitConcerns ?? config.output?.splitConcerns ?? false, + useSubfolders: options.useSubfolders ?? config.output?.useSubfolders ?? false, + defaultFormat: resolvedFormat + }; + + let outputPath: string; + if (options.output) { + outputPath = path.resolve(options.output); + } else if (config.outputDirectory) { + outputPath = path.resolve(config.outputDirectory); + } else { + // Should not reach here — handled above for file mode stdout + process.exit(ERROR_CODES.INVALID_ARGS); + return; + } + + // When in single-file mode and outputPath is an existing directory, + // append a default filename so we don't try to open a directory as a file + const isSingleFileMode = !outputConfig.splitComponents && !outputConfig.splitConcerns; + if (isSingleFileMode && fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) { + outputPath = path.join(outputPath, `library.${resolvedFormat}`); + } + + const baseDir = outputConfig.splitComponents || outputConfig.splitConcerns + ? outputPath + : path.dirname(outputPath); + + const outputFileName = (!outputConfig.splitComponents && !outputConfig.splitConcerns) + ? path.basename(outputPath) + : undefined; + + // ------------------------------------------------------------------- + // Image resolution (ADR-063, --get-images): add src to unresolved + // registry entries — files written under {baseDir}/_images/, referenced + // relative to the spec file that points at them. Runs before the + // manifest so writers serialize the resolved registry values. + // ------------------------------------------------------------------- + if (options.getImages) { + const hashes = ImageFillsResolver.collectUnresolvedHashes(processedComponents); + if (hashes.size === 0) { + console.log(modelConfig.processing.images + ? 'Note: --get-images found no unresolved image placeholders' + : 'Note: --get-images has no effect — processing.images is not configured'); + } else { + // Reuse hash-named files already present in _images/ — only the + // remainder needs the token, the API call, and downloads. + const files = await ImageFillsResolver.findExisting(hashes, baseDir); + const missing = new Set([...hashes].filter(hash => !files.has(hash))); + + if (missing.size > 0) { + const token = process.env.FIGMA_TOKEN; + if (!token) { + console.error('Error: --get-images requires the FIGMA_TOKEN environment variable (same token as `specs fetch`)'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const fileSourceAlias = resolveFileSourceAlias(config.sources); + const fileKey = fileSourceAlias ? config.sources?.[fileSourceAlias]?.key : undefined; + if (!fileKey) { + console.error('Error: --get-images requires a configured source file key (sources..key in specs.config.yaml)'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + console.log(`Requesting image download URLs from Figma (${missing.size} image(s))...`); + const urls = await ImageFillsResolver.fetchImageUrls(fileKey, token); + process.stdout.write(`Images downloading (0/${missing.size})`); + const downloaded = await ImageFillsResolver.downloadAndWrite(missing, urls, baseDir, (completed, total) => { + process.stdout.write(`\rImages downloading (${completed}/${total})`); + if (completed === total) process.stdout.write('\n'); + }); + for (const [hash, filename] of downloaded) files.set(hash, filename); + } + + // Spec files sit one level below baseDir when components get their own + // folders (subfolders, or the component+concern combined layout). + const inComponentFolders = !!outputConfig.splitComponents && (!!outputConfig.useSubfolders || !!outputConfig.splitConcerns); + const relativePrefix = inComponentFolders ? `../${IMAGES_DIR_NAME}/` : `${IMAGES_DIR_NAME}/`; + const resolvedCount = ImageFillsResolver.applyResolvedSources(processedComponents, files, relativePrefix); + const reused = hashes.size - missing.size; + console.log(`✓ Resolved ${resolvedCount} image reference(s) into ${files.size} file(s) under ${IMAGES_DIR_NAME}/ (${reused} reused, ${missing.size} downloaded)`); + } + } + + const manifest = new FileManifest(processedComponents, outputConfig, baseDir, outputFileName); + + // Select appropriate writer + let writer: FileWriter; + if (outputConfig.splitConcerns && !outputConfig.splitComponents) { + writer = new ConcernFileWriter(); + } else if (outputConfig.splitComponents && !outputConfig.splitConcerns) { + writer = new ComponentFileWriter(outputConfig.useSubfolders); + } else if (!outputConfig.splitComponents && !outputConfig.splitConcerns) { + writer = new SingleFileWriter(); + } else { + writer = new CombinedFileWriter(); + } + + const writeResult: WriteResult = await writer.write(manifest); + + if (writeResult.warnings.length > 0) { + const isOverwriteWarning = (warning: string) => warning.includes('Overwriting existing file'); + const overwriteCount = writeResult.warnings.filter(isOverwriteWarning).length; + if (overwriteCount > 0) { + console.log('Warning: Overwrote existing file(s)'); + } + writeResult.warnings.filter(warning => !isOverwriteWarning(warning)).forEach(warning => console.log(warning)); + } + + if (writeResult.errors.length > 0) { + writeResult.errors.forEach(error => console.error(`Error: ${error}`)); + process.exit(ERROR_CODES.FILE_ERROR); + } + + process.exit(errors.length > 0 ? ERROR_CODES.GENERAL_ERROR : ERROR_CODES.SUCCESS); +} + export const Generate = new Command('generate') .description('Generate component specifications from Figma data or manifest') .argument('[source]', 'Path to Figma JSON file or markdown manifest (default: {dataDirectory}/{alias}.manifest.md from config)') @@ -97,6 +267,10 @@ export const Generate = new Command('generate') .option('--split-concerns', 'Separate API, variants, and examples into different files') .option('--use-subfolders', 'Organize component files in subdirectories (requires --split-components)') .option('--get-images', 'Resolve unresolved registry images into files under _images/ (requires processing.images in config and FIGMA_TOKEN)') + .option('--from-bridge', 'Generate from the current selection in a connected Figma file via the CLI bridge (no REST fetch)') + .option('--file ', 'Target a specific connected Figma file with --from-bridge (prompts to choose if more than one is connected in an interactive terminal; required otherwise)') + .option('--node ', 'With --from-bridge: generate from this node id instead of the current selection') + .option('--remove', 'With --from-bridge: delete the node once its spec has been read (round-trip testing — leaves the Figma page as it was found)') .option('--verbose', 'Enable detailed logging', false) .action(async (source: string | undefined, options: GenerateOptions) => { try { @@ -109,6 +283,51 @@ export const Generate = new Command('generate') console.log(`[CLI] Using config from: ${options.config}`); } + // --------------------------------------------------------------- + // BRIDGE MODE (--from-bridge): bypass REST fetch entirely — + // the plugin has already generated the spec from the current + // selection; just relay it through the same output writers. + // --------------------------------------------------------------- + if (options.fromBridge) { + if (source) { + console.error('Error: --from-bridge does not take a source argument (it reads the current Figma selection).'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + let result; + try { + const fileKey = await resolveFileKey(options.file); + // The config shapes the spec the plugin builds, not merely where it is written. + result = await postGenerateFromSelection({ fileKey, nodeId: options.node, config: modelConfig, remove: options.remove }); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + console.error('Error: bridge is not running.'); + console.error(' Start it with: specs bridge start'); + } else { + console.error(`Error: ${err.message}`); + } + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + if (!result.success) { + const msg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); + console.error(`Error: ${msg}`); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + if (!result.specData) { + console.error('Error: Bridge returned success but no spec data.'); + process.exit(ERROR_CODES.GENERAL_ERROR); + } + + console.log(`✓ Generated from selection: ${result.name ?? result.nodeId}`); + + const processedComponents = [{ name: result.name ?? String(result.nodeId), spec: result.specData as Record }]; + await writeGeneratedOutput(processedComponents, [], false, options, config, modelConfig); + return; + } + // Use dataDirectory for loading data files (flag > config > default) const sourceDir = options.dataDir ? path.resolve(options.dataDir) @@ -223,8 +442,34 @@ export const Generate = new Command('generate') } libraryJson = await fs.readJSON(sourceFile); - componentIds = selectedComponents.map(c => c.id); - componentNames = new Map(selectedComponents.map(c => [c.id, c.name])); + + // `--component` used to apply only in file mode, so asking for one component here + // silently generated the whole catalogue — a slow surprise, and one that looks like + // the flag worked. Match on the Figma name, the id, or the formatted key the output + // is written under, since that is the name a caller has in front of them. + let chosen = selectedComponents; + if (options.component) { + const wanted = options.component; + chosen = selectedComponents.filter(c => + c.id === wanted || c.name === wanted || formatKey(c.name, modelConfig.format.keys) === wanted); + if (chosen.length === 0) { + console.error(`Error: no component named "${wanted}" in the manifest.`); + const near = selectedComponents + .map(c => formatKey(c.name, modelConfig.format.keys)) + .filter(k => k.toLowerCase().includes(wanted.toLowerCase())) + .slice(0, 5); + if (near.length > 0) { + console.error('Did you mean:'); + for (const k of near) console.error(` ${k}`); + } else { + console.error(`Tip: ${selectedComponents.length} components are available — omit --component to generate all of them.`); + } + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + componentIds = chosen.map(c => c.id); + componentNames = new Map(chosen.map(c => [c.id, c.name])); if (options.verbose) { console.log(`[CLI] File loaded: ${libraryJson.name || path.basename(sourceFile)}`); @@ -417,152 +662,7 @@ export const Generate = new Command('generate') process.exit(ERROR_CODES.GENERAL_ERROR); } - // --------------------------------------------------------------- - // File mode stdout (no -o) - // --------------------------------------------------------------- - if (!isManifest && !options.output && !config.outputDirectory) { - if (options.getImages) { - console.error('Error: --get-images requires an output directory (set outputDirectory in config or pass -o) so image files have somewhere to be written'); - process.exit(ERROR_CODES.INVALID_ARGS); - } - const componentData = processedComponents[0].spec; - const outputFormat = options.format - ? options.format.toLowerCase() - : modelConfig.format.output.toLowerCase(); - - const formattedOutput = outputFormat === 'yaml' - ? yaml.stringify(componentData) - : JSON.stringify(componentData, null, 2); - - console.log(formattedOutput); - process.exit(ERROR_CODES.SUCCESS); - return; - } - - // --------------------------------------------------------------- - // File output via manifest + writer - // --------------------------------------------------------------- - const resolvedFormat: OutputFormat = options.format - ? options.format.toLowerCase() as OutputFormat - : modelConfig.format.output.toLowerCase() as OutputFormat; - - const outputConfig = { - ...config.output, - splitComponents: options.splitComponents ?? config.output?.splitComponents ?? false, - splitConcerns: options.splitConcerns ?? config.output?.splitConcerns ?? false, - useSubfolders: options.useSubfolders ?? config.output?.useSubfolders ?? false, - defaultFormat: resolvedFormat - }; - - let outputPath: string; - if (options.output) { - outputPath = path.resolve(options.output); - } else if (config.outputDirectory) { - outputPath = path.resolve(config.outputDirectory); - } else { - // Should not reach here — handled above for file mode stdout - process.exit(ERROR_CODES.INVALID_ARGS); - return; - } - - // When in single-file mode and outputPath is an existing directory, - // append a default filename so we don't try to open a directory as a file - const isSingleFileMode = !outputConfig.splitComponents && !outputConfig.splitConcerns; - if (isSingleFileMode && fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) { - outputPath = path.join(outputPath, `library.${resolvedFormat}`); - } - - const baseDir = outputConfig.splitComponents || outputConfig.splitConcerns - ? outputPath - : path.dirname(outputPath); - - const outputFileName = (!outputConfig.splitComponents && !outputConfig.splitConcerns) - ? path.basename(outputPath) - : undefined; - - // --------------------------------------------------------------- - // Image resolution (ADR-063, --get-images): add src to unresolved - // registry entries — files written under {baseDir}/_images/, referenced - // relative to the spec file that points at them. Runs before the - // manifest so writers serialize the resolved registry values. - // --------------------------------------------------------------- - if (options.getImages) { - const hashes = ImageFillsResolver.collectUnresolvedHashes(processedComponents); - if (hashes.size === 0) { - console.log(modelConfig.processing.images - ? 'Note: --get-images found no unresolved image placeholders' - : 'Note: --get-images has no effect — processing.images is not configured'); - } else { - // Reuse hash-named files already present in _images/ — only the - // remainder needs the token, the API call, and downloads. - const files = await ImageFillsResolver.findExisting(hashes, baseDir); - const missing = new Set([...hashes].filter(hash => !files.has(hash))); - - if (missing.size > 0) { - const token = process.env.FIGMA_TOKEN; - if (!token) { - console.error('Error: --get-images requires the FIGMA_TOKEN environment variable (same token as `specs fetch`)'); - process.exit(ERROR_CODES.INVALID_ARGS); - } - const fileSourceAlias = resolveFileSourceAlias(config.sources); - const fileKey = fileSourceAlias ? config.sources?.[fileSourceAlias]?.key : undefined; - if (!fileKey) { - console.error('Error: --get-images requires a configured source file key (sources..key in specs.config.yaml)'); - process.exit(ERROR_CODES.INVALID_ARGS); - } - - console.log(`Requesting image download URLs from Figma (${missing.size} image(s))...`); - const urls = await ImageFillsResolver.fetchImageUrls(fileKey, token); - process.stdout.write(`Images downloading (0/${missing.size})`); - const downloaded = await ImageFillsResolver.downloadAndWrite(missing, urls, baseDir, (completed, total) => { - process.stdout.write(`\rImages downloading (${completed}/${total})`); - if (completed === total) process.stdout.write('\n'); - }); - for (const [hash, filename] of downloaded) files.set(hash, filename); - } - - // Spec files sit one level below baseDir when components get their own - // folders (subfolders, or the component+concern combined layout). - const inComponentFolders = !!outputConfig.splitComponents && (!!outputConfig.useSubfolders || !!outputConfig.splitConcerns); - const relativePrefix = inComponentFolders ? `../${IMAGES_DIR_NAME}/` : `${IMAGES_DIR_NAME}/`; - const resolvedCount = ImageFillsResolver.applyResolvedSources(processedComponents, files, relativePrefix); - const reused = hashes.size - missing.size; - console.log(`✓ Resolved ${resolvedCount} image reference(s) into ${files.size} file(s) under ${IMAGES_DIR_NAME}/ (${reused} reused, ${missing.size} downloaded)`); - } - } - - const manifest = new FileManifest(processedComponents, outputConfig, baseDir, outputFileName); - - // Select appropriate writer - let writer: FileWriter; - if (outputConfig.splitConcerns && !outputConfig.splitComponents) { - writer = new ConcernFileWriter(); - } else if (outputConfig.splitComponents && !outputConfig.splitConcerns) { - writer = new ComponentFileWriter(outputConfig.useSubfolders); - } else if (!outputConfig.splitComponents && !outputConfig.splitConcerns) { - writer = new SingleFileWriter(); - } else { - writer = new CombinedFileWriter(); - } - - const writeResult: WriteResult = await writer.write(manifest); - - if (writeResult.warnings.length > 0) { - const isOverwriteWarning = (warning: string) => warning.includes('Overwriting existing file'); - const overwriteCount = writeResult.warnings.filter(isOverwriteWarning).length; - if (overwriteCount > 0) { - console.log('Warning: Overwrote existing file(s)'); - } - writeResult.warnings.filter(warning => !isOverwriteWarning(warning)).forEach(warning => console.log(warning)); - } - - if (writeResult.errors.length > 0) { - writeResult.errors.forEach(error => console.error(`Error: ${error}`)); - process.exit(ERROR_CODES.FILE_ERROR); - } - - - process.exit(errors.length > 0 ? ERROR_CODES.GENERAL_ERROR : ERROR_CODES.SUCCESS); + await writeGeneratedOutput(processedComponents, errors, isManifest, options, config, modelConfig); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/RenderCommand.ts b/packages/cli/src/commands/RenderCommand.ts new file mode 100644 index 00000000..3eb39770 --- /dev/null +++ b/packages/cli/src/commands/RenderCommand.ts @@ -0,0 +1,345 @@ +/** + * Render Command + * + * Sends a spec to the local CLI bridge, which relays it to a connected + * Specs 2 Figma plugin to create or update the matching component live in + * Figma. See `specs bridge` to start/stop the bridge. + */ + +import type { ResolvedConfig } from '@directededges/specs-schema'; +import { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'path'; +import { createInterface } from 'readline'; +import { ConfigLoader } from '../Config/ConfigLoader.js'; +import { postRender, type RenderResponse } from '../bridge/client.js'; +import { resolveFileKey } from '../bridge/pickConnection.js'; +import { findComponentFolders, isComponentFolder, loadSpec } from '../Render/SpecLoader.js'; +import { startSpinner } from '../utilities/spinner.js'; +import { refreshCache } from '../Cache/Cache.js'; +import { reportCache } from './CacheCommand.js'; + +const ERROR_CODES = { + SUCCESS: 0, + GENERAL_ERROR: 1, + INVALID_ARGS: 2, +}; + +export const Render = new Command('render') + .description('Render a spec into Figma via the local CLI bridge') + .argument('[specPath]', 'Path to a spec YAML file, a component folder, or a directory of component folders (default: {outputDirectory} from config)') + .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--file ', 'Target a specific connected Figma file (prompts to choose if more than one is connected in an interactive terminal; required otherwise)') + .option('--page ', 'Render onto this page id instead of the plugin\'s current page (recommended for scripted runs — immune to page drift)') + .option('--overwrite', 'Delete any existing page component with the same title before rendering (without this, a title collision is an error)') + .option('--watch', 'Watch the spec path and re-render on every change (implies --overwrite)') + .option('--strict', 'Fail the render when an instance element cannot be resolved, instead of rendering a component with missing content') + .option('--timing', 'Print a phase-by-phase timing report for the render (bridge manifests, then plugin render phases)') + .option('--refresh-cache', 'Rebuild the render lookup caches from fetched data before rendering (see `specs cache`)') + .action(async (specPath: string | undefined, options: { config?: string; file?: string; page?: string; overwrite?: boolean; watch?: boolean; strict?: boolean; timing?: boolean; refreshCache?: boolean; verbose?: boolean }) => { + // Ahead of everything else: a stale cache is a hard failure on the bridge, and this + // is the flag that fixes it without a separate command. + if (options.refreshCache) { + const config = new ConfigLoader().load(options.config); + if (!config.dataDirectory) { + console.error('Error: --refresh-cache needs dataDirectory set in specs.config.yaml.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + reportCache(refreshCache({ + dataDir: config.dataDirectory, + aliases: Object.keys(config.sources ?? {}), + glyphNamePattern: config.config?.processing?.glyphNamePattern, + })); + } + if (options.watch) { + if (!specPath) { + console.error('Error: --watch requires a spec path.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + await watchAndRender(specPath, options); + return; + } + + // Zero-arg resolution: the configured outputDirectory, as a batch of + // component folders. + if (!specPath) { + const configLoader = new ConfigLoader(); + const config = configLoader.load(options.config); + + if (config.outputDirectory && fs.existsSync(path.resolve(config.outputDirectory))) { + specPath = path.resolve(config.outputDirectory); + console.log(`Using output directory: ${path.relative(process.cwd(), specPath) || '.'}`); + } else { + console.error('Error: provide a spec path.'); + console.error('Tip: no outputDirectory to fall back to — set one in specs.config.yaml.'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + } + + try { + const absSpecPath = path.resolve(specPath); + if (!fs.existsSync(absSpecPath)) { + console.error(`Error: Spec path not found: ${absSpecPath}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + // A spec records the config it was produced under, and render reverses that + // record — so the spec's own `metadata.config` governs. This is the fallback for + // a spec carrying none, such as a hand-authored one. A workspace without a config + // file is fine: the spec is then the only source there is. + let workspaceConfig: ResolvedConfig | undefined; + try { + workspaceConfig = new ConfigLoader().load(options.config).config; + } catch { + workspaceConfig = undefined; + } + const withConfig = { ...options, workspaceConfig }; + + const isBatchDir = fs.statSync(absSpecPath).isDirectory() && !isComponentFolder(absSpecPath); + if (isBatchDir) { + await renderBatchDirectory(absSpecPath, withConfig); + } else { + await renderSpecPath(absSpecPath, withConfig); + } + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.cause && (err.cause as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + console.error('Error: bridge is not running.'); + console.error(' Start it with: specs bridge start'); + } else { + console.error(`Error: ${err.message}`); + } + process.exit(ERROR_CODES.GENERAL_ERROR); + } + }); + +// Shared by the one-shot render path and each watch-triggered re-render. +// Throws on failure; caller decides whether that's fatal (one-shot) or just +// logged and retried on the next change (watch). +async function renderSpecPath( + specPath: string, + options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConfig?: ResolvedConfig } +): Promise { + const { spec, resolvePath } = loadSpec(specPath); + // The component, not the path it came from: the full path is noise on every line of a + // batch, and the name is what identifies the render in progress. `resolvePath` is a + // component folder or a spec file, so the extension comes off either way. + const name = path.basename(resolvePath, path.extname(resolvePath)); + const fileKey = await resolveFileKey(options.file); + + // The spinner holds one line while Figma works, and is erased when it stops — the + // outcome prints over it rather than under it, so a batch reads as one line per + // component instead of two. + const stopSpinner = startSpinner(`Rendering: ${name}`); + const startedAt = Date.now(); + let result: RenderResponse; + try { + result = await postRender({ specPath: resolvePath, spec, fileKey, pageId: options.page, overwrite: options.overwrite, config: options.workspaceConfig }); + } finally { + stopSpinner(); + } + const elapsed = Date.now() - startedAt; + + if (!result.success) { + const msg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); + throw new Error(`Render failed: ${msg}`); + } + + // Success is the whole contract — reading the produced component's spec is + // an explicit second call (`specs generate --from-bridge`), not a side effect. + // Render warnings are withheld for now. They are dominated by known, tracked defects + // (see the sub-issues of #281) and by degradations a user cannot act on, so printing a + // wall of them per render buries the outcome rather than informing it. The INCOMPLETE + // count below still reports the one case that means content is actually missing. + const SHOW_RENDER_WARNINGS = false; + if (SHOW_RENDER_WARNINGS) for (const w of result.warnings ?? []) console.warn(` ⚠ ${w}`); + + // A render that could not place an instance produced a component missing + // content, so "✓ Rendered" on its own overstates what happened. Say it plainly + // — a per-warning line scrolls past in a batch, a count does not. + const dropped = countDroppedInstances(result.warnings); + if (dropped > 0) { + console.warn( + ` ⚠ INCOMPLETE: ${dropped} instance element(s) could not be resolved and were not rendered. ` + + 'The component exists in Figma but is missing content.' + ); + if (options.strict) { + throw new Error( + `Render incomplete: ${dropped} instance element(s) not rendered (--strict). ` + + 'Drop --strict to accept an incomplete render.' + ); + } + } + + // Matches the shape fetch sets for per-item work: `✓ Verb: subject (detail)`. + console.log(`✓ Rendered: ${name} (${(elapsed / 1000).toFixed(1)}s, nodeId: ${result.nodeId})`); + if (options.timing) printTimingReport(result, elapsed); + return dropped; +} + +/** + * Warnings that mean content is missing from the rendered component, as opposed to + * cosmetic degradations (font fallbacks, skipped style keys) which leave it complete. + * Matched on render's own message text — see `Elements.createChild`. + */ +/** + * Attribute a render's wall-clock time to bridge phases (manifest builds, which + * parse the fetched file data) and plugin render phases. Plugin phases that run + * concurrently — one row per variant, say — sum above the render's own total; + * the count column is what makes that readable. + */ +function printTimingReport(result: RenderResponse, elapsed: number): void { + const row = (label: string, ms: number, count?: number): void => { + const share = elapsed > 0 ? `${Math.round((ms / elapsed) * 100)}%`.padStart(4) : ' -'; + const times = count !== undefined && count > 1 ? ` ×${count}` : ''; + console.log(` ${label.padEnd(24)} ${String(ms).padStart(6)}ms ${share}${times}`); + }; + + console.log('\n Timing'); + const bridge = [...(result.bridgeTimings ?? [])].sort((a, b) => b.ms - a.ms); + if (bridge.length > 0) { + console.log(' Bridge:'); + for (const t of bridge) row(t.label, t.ms); + } + if (result.payloadKB !== undefined) { + console.log(` ${'payload'.padEnd(24)} ${String(result.payloadKB).padStart(6)}KB`); + } + const phases = [...(result.timings?.phases ?? [])].sort((a, b) => b.ms - a.ms); + if (phases.length > 0) { + console.log(` Plugin (${result.timings?.total}ms total):`); + for (const p of phases) row(p.label, p.ms, p.count); + } + console.log(` ${'TOTAL'.padEnd(24)} ${String(elapsed).padStart(6)}ms`); +} + +function countDroppedInstances(warnings?: string[]): number { + // Matched on the message an unresolved *instance element* produces. `VariableResolver` + // says "no manifest entry for" too, about a token — counting those reports missing + // content where only a binding was lost, which reads as a far more broken render. + return (warnings ?? []).filter((w) => w.includes('instance element') && w.includes('no manifest entry for')).length; +} + +function confirm(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${question} [y/N]: `, (answer) => { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +/** + * Render every component folder found beneath a directory, sequentially and in + * path order. One failure doesn't abort the run; the exit code reflects the total. + */ +async function renderBatchDirectory( + absDir: string, + options: { file?: string; page?: string; overwrite?: boolean; strict?: boolean; timing?: boolean; workspaceConfig?: ResolvedConfig }, + // In watch mode a batch is re-run on every change: don't re-confirm, and + // don't exit the process on a failure the next save might fix. + { watch = false }: { watch?: boolean } = {} +): Promise { + const folders = findComponentFolders(absDir); + + if (folders.length === 0) { + const msg = `no component folders found in ${absDir}\nTip: a component folder holds api.(yaml|json) and variants.(yaml|json), at most two levels deep.`; + if (watch) throw new Error(msg); + console.error(`Error: ${msg}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + + console.log(`Found ${folders.length} component${folders.length === 1 ? '' : 's'} in ${path.relative(process.cwd(), absDir) || '.'}:`); + for (const folder of folders) console.log(` - ${path.relative(absDir, folder)}`); + + // --overwrite deletes each existing same-titled component before re-rendering, + // so a multi-component sweep is worth confirming when someone is there to ask. + if (!watch && options.overwrite && folders.length > 1 && process.stdin.isTTY && process.stdout.isTTY) { + const ok = await confirm(`\nOverwrite ${folders.length} components in the connected Figma file?`); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + // Resolve the target file once, so an ambiguous bridge doesn't prompt per component. + const resolved = { ...options, file: (await resolveFileKey(options.file)) ?? options.file }; + + let written = 0; + const incomplete: string[] = []; + const failures: string[] = []; + + for (const folder of folders) { + try { + const dropped = await renderSpecPath(folder, resolved); + written++; + if (dropped > 0) incomplete.push(`${path.relative(absDir, folder)} (${dropped})`); + } catch (e) { + const name = path.relative(absDir, folder); + failures.push(name); + console.error(` ✗ ${name}: ${(e as Error).message}`); + } + } + + console.log(`\nDone: ${written} rendered in Figma, ${failures.length} failed.`); + // Incomplete renders succeeded, so they are not failures — but a sweep that + // silently produced components missing content is exactly what this reports. + if (incomplete.length > 0) { + console.warn(`⚠ ${incomplete.length} rendered with missing content: ${incomplete.join(', ')}`); + } + if (failures.length > 0 && !watch) process.exit(ERROR_CODES.GENERAL_ERROR); +} + +const WATCH_DEBOUNCE_MS = 300; + +async function watchAndRender( + specPath: string, + options: { file?: string; workspaceConfig?: ResolvedConfig } +): Promise { + const absSpecPath = path.resolve(specPath); + if (!fs.existsSync(absSpecPath)) { + console.error(`Error: Spec path not found: ${absSpecPath}`); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const isDir = fs.statSync(absSpecPath).isDirectory(); + const watchTarget = isDir ? absSpecPath : path.dirname(absSpecPath); + const isBatchDir = isDir && !isComponentFolder(absSpecPath); + + let rendering = false; + let pending = false; + let debounceTimer: NodeJS.Timeout | undefined; + + const runRender = async () => { + if (rendering) { + pending = true; + return; + } + rendering = true; + try { + if (isBatchDir) { + await renderBatchDirectory(absSpecPath, { ...options, overwrite: true }, { watch: true }); + } else { + await renderSpecPath(absSpecPath, { ...options, overwrite: true }); + } + } catch (e) { + console.error(`✗ ${(e as Error).message}`); + } finally { + rendering = false; + if (pending) { + pending = false; + void runRender(); + } + } + }; + + const scheduleRender = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(runRender, WATCH_DEBOUNCE_MS); + }; + + console.log(`Watching ${path.relative(process.cwd(), watchTarget) || '.'} for changes...`); + fs.watch(watchTarget, { recursive: true }, scheduleRender); + + await runRender(); + await new Promise(() => {}); // keep the process alive until Ctrl+C +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f5d09b6c..c55c66fb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,17 +24,20 @@ import { Command } from 'commander'; import { Generate } from './commands/GenerateCommand.js'; import { Scan } from './commands/ScanCommand.js'; import { Fetch } from './commands/FetchCommand.js'; +import { Cache } from './commands/CacheCommand.js'; import { Init } from './commands/InitCommand.js'; import { Analyze } from './commands/AnalyzeCommand.js'; import { ApplyCustomTokens } from './commands/ApplyCustomTokensCommand.js'; import { Transform } from './commands/TransformCommand.js'; +import { Render } from './commands/RenderCommand.js'; +import { Bridge } from './commands/BridgeCommand.js'; declare const __SPECS_CLI_VERSION__; // Backward compatibility: export Scan also as Audit export const Audit = Scan; -export { Generate, Scan, Fetch, Init, ApplyCustomTokens, Transform, Analyze }; +export { Generate, Scan, Fetch, Init, ApplyCustomTokens, Transform, Analyze, Render, Bridge }; export const commands = { Init, @@ -44,6 +47,8 @@ export const commands = { ApplyCustomTokens, Transform, Analyze, + Render, + Bridge, }; export function createProgram(): Command { @@ -58,9 +63,12 @@ export function createProgram(): Command { program.addCommand(Generate); program.addCommand(Scan); program.addCommand(Fetch); + program.addCommand(Cache); program.addCommand(ApplyCustomTokens); program.addCommand(Transform); program.addCommand(Analyze); + program.addCommand(Render); + program.addCommand(Bridge); // Deprecated alias: 'audit' → 'scan' const auditAlias = new Command('audit') diff --git a/packages/cli/src/utilities/formatKey.ts b/packages/cli/src/utilities/formatKey.ts new file mode 100644 index 00000000..578ba61c --- /dev/null +++ b/packages/cli/src/utilities/formatKey.ts @@ -0,0 +1,76 @@ +/** + * Spec key formatting, ported from `Utilities.formatKey` in specs-from-figma. + * + * The bridge needs to recognise a spec's `instanceOf` value — which is a formatted key — + * among the library's component names, which are raw Figma names. Since the transform is + * lossy (`"DS Link/On overlay/M"` and `"DS Link On Overlay M"` format identically), + * there is no inverse; the only way to match is to apply the same transform forward to + * every candidate name and compare in formatted space. + * + * This is a deliberate duplicate rather than an import: `formatKey` is internal to + * specs-from-figma and not part of its public surface. `tests/unit/utilities/formatKey.test.ts` + * pins the behaviour against cases taken from that implementation — the two must stay in + * lockstep, and drift shows up as a lookup that silently finds nothing. + */ + +export type FormatKeys = 'SAFE' | 'CAMEL' | 'SNAKE' | 'KEBAB' | 'PASCAL' | 'TRAIN'; + +const UNSAFE_CHARS_REGEX = /["\\\n\r\t\b\f.[\]]/g; +const WORD_SEPARATOR_REGEX = /[\s\-_]+/g; +const NON_ALPHANUMERIC_REGEX = /[^a-zA-Z0-9]+/g; + +/** + * Convert a raw Figma name to the spec key form named by `format`. + * + * @param str - the raw name + * @param format - the workspace's `format.keys` setting; SAFE when unset, matching the source + */ +export function formatKey(str: string, format: FormatKeys | string = 'SAFE'): string { + // SAFE keeps the original string minus unsafe characters; every other format works from + // words, so separators (including "/") become boundaries rather than being deleted. + const cleanedUnsafeRemoved = str.replace(UNSAFE_CHARS_REGEX, ''); + + const cleanedForSplit = str + .replace(UNSAFE_CHARS_REGEX, ' ') + .replace(NON_ALPHANUMERIC_REGEX, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const words = cleanedForSplit + .split(WORD_SEPARATOR_REGEX) + .filter(word => word.length > 0) + .map(word => word.replace(NON_ALPHANUMERIC_REGEX, '')) + .filter(word => word.length > 0); + + if (words.length === 0) return ''; + + switch (format?.toUpperCase()) { + case 'SAFE': + return cleanedUnsafeRemoved; + + case 'CAMEL': + // A single word only has its first character lowered, so an already-camelCase name + // ("effectStyle") survives intact. + if (words.length === 1) { + const w = words[0]; + return w.charAt(0).toLowerCase() + w.slice(1); + } + return words[0].toLowerCase() + + words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(''); + + case 'SNAKE': + return words.map(w => w.toLowerCase()).join('_'); + + case 'KEBAB': + return words.map(w => w.toLowerCase()).join('-'); + + case 'PASCAL': + return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(''); + + case 'TRAIN': + return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('-'); + + default: + return cleanedUnsafeRemoved; + } +} diff --git a/packages/cli/src/utilities/glyphComponents.ts b/packages/cli/src/utilities/glyphComponents.ts new file mode 100644 index 00000000..6bce7713 --- /dev/null +++ b/packages/cli/src/utilities/glyphComponents.ts @@ -0,0 +1,45 @@ +/** + * Glyph component discovery from a fetched file document. + * + * Shared by `fetch` (which downloads an SVG per glyph) and the cache builder + * (which records each glyph's node id and published key), so both read the + * same names out of the same pattern. + * + * @packageDocumentation + */ + +/** + * Walk the file document for COMPONENT nodes whose name matches the + * glyphNamePattern ("DS Icon asset / {i}" — {i} captures the icon name). + * Duplicate slugs keep the first occurrence and suffix later ones with the + * node id so nothing is silently dropped. + */ +export function collectGlyphComponents(document: unknown, pattern: string): Array<{ id: string; name: string; slug: string }> { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\{i\\\}/g, '(.+)'); + const regex = new RegExp(`^${escaped}$`); + const found: Array<{ id: string; name: string; slug: string }> = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const n = node as { id?: string; name?: string; type?: string; children?: unknown[] }; + if (n.type === 'COMPONENT' && typeof n.name === 'string' && typeof n.id === 'string') { + const match = n.name.match(regex); + if (match) found.push({ id: n.id, name: match[1] ?? n.name, slug: '' }); + } + for (const child of n.children ?? []) walk(child); + }; + walk(document); + + const seen = new Set(); + for (const glyph of found) { + // Kebabize camelCase too, matching the scaffold's glyphUrl slugging. + const base = glyph.name + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .toLowerCase(); + glyph.slug = seen.has(base) ? `${base}-${glyph.id.replace(':', '-')}` : base; + seen.add(base); + } + return found; +} diff --git a/packages/cli/src/utilities/spinner.ts b/packages/cli/src/utilities/spinner.ts new file mode 100644 index 00000000..6b2ac68c --- /dev/null +++ b/packages/cli/src/utilities/spinner.ts @@ -0,0 +1,62 @@ +/** + * Inline progress for commands that wait on something slow — a download, a render. + * + * The spinner occupies one line and is erased when it stops, so the caller prints the + * outcome over it rather than under it: one line per unit of work, start to finish. + * + * Outside a TTY (a pipe, CI, a log file) there is no cursor to move, so the text prints + * once and the elapsed time is still returned. Nothing writes escape codes into a file. + * + * @packageDocumentation + */ + +import readline from 'readline'; + +export function isInteractive(): boolean { + return Boolean(process.stdout.isTTY); +} + +export function renderInlineStatus(text: string): void { + if (!isInteractive()) { + console.log(text); + return; + } + + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(text); +} + +export function clearInlineStatus(): void { + if (!isInteractive()) return; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); +} + +export function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return `${minutes}m ${remaining}s`; +} + +/** Start spinning, and return a stop function that erases the line and reports elapsed time. */ +export function startSpinner(text: string): () => string { + const start = Date.now(); + if (!isInteractive()) { + console.log(text); + return () => formatElapsed(Date.now() - start); + } + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + const id = setInterval(() => { + const elapsed = formatElapsed(Date.now() - start); + renderInlineStatus(`${frames[i++ % frames.length]} ${text} (${elapsed})`); + }, 80); + return () => { + clearInterval(id); + clearInlineStatus(); + return formatElapsed(Date.now() - start); + }; +} diff --git a/packages/cli/src/utilities/variablesIndex.ts b/packages/cli/src/utilities/variablesIndex.ts new file mode 100644 index 00000000..8cf603fc --- /dev/null +++ b/packages/cli/src/utilities/variablesIndex.ts @@ -0,0 +1,76 @@ +/** + * Variables index construction from a fetched variables JSON file. + * + * A spec references a variable by name and nothing else. This index is what gives that name + * meaning: it maps each token name onto the handles needed to reach the actual variable — the + * library key, and the id it has in the file it was fetched from. The fetched + * `/v1/files/:key/variables/local` response is the only place a variable's name, key and id + * appear together, which is why the mapping is built here rather than recorded in the spec. + * + * @packageDocumentation + */ + +/** The handles a token name resolves to. `key` imports the variable from the team library and + * is authoritative for any file. `id` identifies it within the file it was fetched from, and + * is the fallback when a library import is unavailable. `published` is false for a variable + * hidden from publishing, which cannot be imported by key at all. */ +export interface VariableEntry { + key: string; + id: string; + published: boolean; +} + +/** Token name (`collectionName/variableName`) → the handles that name resolves to. */ +export type VariablesIndex = Record; + +interface VariablesJson { + meta?: { + variables?: Record; + variableCollections?: Record; + }; +} + +/** + * Build the token-name index from parsed variables JSON. + * + * A variable with no name is omitted: the name is the only thing a spec can reference it by, + * so an unnamed variable is unreachable regardless of what else is known about it. A variable + * with no key is kept — its id still resolves when rendering into the file it came from. + * + * @param data - Parsed contents of a `*.variables.json` file + * @returns Index keyed by token name + */ +export function buildVariablesIndex(data: VariablesJson | null | undefined): VariablesIndex { + const vars = data?.meta?.variables; + const cols = data?.meta?.variableCollections; + if (!vars || !cols) return {}; + + const colNames: Record = {}; + for (const [id, col] of Object.entries(cols)) { + if (col.name) colNames[id] = col.name; + } + + const index: VariablesIndex = {}; + for (const [id, varDef] of Object.entries(vars)) { + if (!varDef.name) continue; + const colName = (varDef.variableCollectionId ? colNames[varDef.variableCollectionId] : undefined) ?? ''; + const tokenName = colName ? `${colName}/${varDef.name}` : varDef.name; + index[tokenName] = { + key: varDef.key ?? '', + id, + published: varDef.key != null && varDef.hiddenFromPublishing !== true, + }; + } + + return index; +} + +/** Count of names that cannot be imported from the library, for operator-facing logging. */ +export function countUnpublished(index: VariablesIndex): number { + return Object.values(index).filter(e => !e.published).length; +} diff --git a/packages/cli/tests/unit/bridge/client.test.ts b/packages/cli/tests/unit/bridge/client.test.ts new file mode 100644 index 00000000..4458a7b2 --- /dev/null +++ b/packages/cli/tests/unit/bridge/client.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { postGenerateFromSelection } from '../../../src/bridge/client.js'; + +describe('postGenerateFromSelection', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts to /generate with an empty body when no fileKey is given', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: true, nodeId: '1:2', name: 'Alert', specData: { title: 'Alert' } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await postGenerateFromSelection(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/generate$/), + expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) + ); + expect(result).toEqual({ success: true, nodeId: '1:2', name: 'Alert', specData: { title: 'Alert' } }); + }); + + it('includes fileKey in the request body when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ success: false, error: 'No plugin connected for file "fileA".' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await postGenerateFromSelection({ fileKey: 'fileA' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/generate$/), + expect.objectContaining({ body: JSON.stringify({ fileKey: 'fileA' }) }) + ); + expect(result.success).toBe(false); + expect(result.error).toContain('fileA'); + }); +}); diff --git a/packages/cli/tests/unit/bridge/connections.test.ts b/packages/cli/tests/unit/bridge/connections.test.ts new file mode 100644 index 00000000..9a319cb6 --- /dev/null +++ b/packages/cli/tests/unit/bridge/connections.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { ConnectionRegistry } from '../../../src/bridge/connections.js'; + +function fakeSocket(readyState = 1) { + return { readyState }; +} + +describe('ConnectionRegistry', () => { + it('resolve() throws when nothing is connected', () => { + const registry = new ConnectionRegistry(); + expect(() => registry.resolve()).toThrow('No plugin connected. Enable the CLI Bridge in Specs 2 first.'); + }); + + it('resolve() returns the sole connection when exactly one is registered', () => { + const registry = new ConnectionRegistry(); + const ws = fakeSocket(); + registry.register('fileA', ws, 'Design System'); + + const conn = registry.resolve(); + expect(conn.fileKey).toBe('fileA'); + expect(conn.fileName).toBe('Design System'); + expect(conn.ws).toBe(ws); + }); + + it('resolve() throws an ambiguity error listing all connections when 2+ are registered and no fileKey given', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket(), 'Design System'); + registry.register('fileB', fakeSocket(), 'Prototype'); + + expect(() => registry.resolve()).toThrow( + 'Multiple plugins connected — specify --file . Connected: fileA (Design System), fileB (Prototype)' + ); + }); + + it('resolve(fileKey) returns the matching connection even when multiple are registered', () => { + const registry = new ConnectionRegistry(); + const wsA = fakeSocket(); + const wsB = fakeSocket(); + registry.register('fileA', wsA, 'Design System'); + registry.register('fileB', wsB, 'Prototype'); + + expect(registry.resolve('fileB').ws).toBe(wsB); + }); + + it('resolve(fileKey) throws when the requested fileKey is not connected', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + + expect(() => registry.resolve('nonexistent')).toThrow('No plugin connected for file "nonexistent".'); + }); + + it('unregister() removes only the specified connection, not others', () => { + // Regression test for the original single-activeSocket bug: an older + // connection's close handler used to null out a newer connection's + // reference. Confirms unregister is scoped to its own fileKey. + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + registry.register('fileB', fakeSocket()); + + registry.unregister('fileA'); + + expect(registry.size).toBe(1); + expect(() => registry.resolve('fileA')).toThrow(); + expect(registry.resolve('fileB').fileKey).toBe('fileB'); + }); + + it('unregister() of an unknown fileKey is a no-op', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket()); + + expect(() => registry.unregister('nonexistent')).not.toThrow(); + expect(registry.size).toBe(1); + }); + + it('register() with an already-used fileKey overwrites the prior entry (reconnect)', () => { + const registry = new ConnectionRegistry(); + const wsOld = fakeSocket(); + const wsNew = fakeSocket(); + registry.register('fileA', wsOld); + registry.register('fileA', wsNew); + + expect(registry.size).toBe(1); + expect(registry.resolve('fileA').ws).toBe(wsNew); + }); + + it('list() reports connected state per readyState and includes fileName when present', () => { + const registry = new ConnectionRegistry(); + registry.register('fileA', fakeSocket(1), 'Design System'); + registry.register('fileB', fakeSocket(3)); // 3 = CLOSED + + const list = registry.list().sort((a, b) => a.fileKey.localeCompare(b.fileKey)); + expect(list).toEqual([ + { fileKey: 'fileA', fileName: 'Design System', connected: true }, + { fileKey: 'fileB', fileName: undefined, connected: false }, + ]); + }); +}); diff --git a/packages/cli/tests/unit/bridge/pickConnection.test.ts b/packages/cli/tests/unit/bridge/pickConnection.test.ts new file mode 100644 index 00000000..2d123aea --- /dev/null +++ b/packages/cli/tests/unit/bridge/pickConnection.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import { isAmbiguous, parseSelection, resolveFileKey } from '../../../src/bridge/pickConnection.js'; +import type { BridgeConnection } from '../../../src/bridge/client.js'; + +const twoConnections: BridgeConnection[] = [ + { fileKey: 'fileA', fileName: 'Design System', connected: true }, + { fileKey: 'fileB', fileName: 'Prototype', connected: true }, +]; + +describe('isAmbiguous', () => { + it('is false for 0 or 1 connections', () => { + expect(isAmbiguous([])).toBe(false); + expect(isAmbiguous([twoConnections[0]])).toBe(false); + }); + + it('is true for 2+ connections', () => { + expect(isAmbiguous(twoConnections)).toBe(true); + }); +}); + +describe('parseSelection', () => { + it('converts a valid 1-based answer to a 0-based index', () => { + expect(parseSelection('1', 2)).toBe(0); + expect(parseSelection('2', 2)).toBe(1); + }); + + it('returns null for out-of-range answers', () => { + expect(parseSelection('0', 2)).toBeNull(); + expect(parseSelection('3', 2)).toBeNull(); + }); + + it('returns null for unparseable answers', () => { + expect(parseSelection('abc', 2)).toBeNull(); + expect(parseSelection('', 2)).toBeNull(); + }); + + it('trims whitespace before parsing', () => { + expect(parseSelection(' 2 ', 2)).toBe(1); + }); +}); + +describe('resolveFileKey', () => { + it('returns the explicit fileKey unchanged without checking status', async () => { + const getStatus = vi.fn(); + const result = await resolveFileKey('explicitKey', { getStatus }); + expect(result).toBe('explicitKey'); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it('returns undefined when not interactive (no TTY), without prompting', async () => { + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: false, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('returns undefined when interactive but fewer than 2 connections', async () => { + const getStatus = vi.fn().mockResolvedValue({ connections: [twoConnections[0]] }); + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('prompts and returns the chosen fileKey when interactive with 2+ connections', async () => { + const getStatus = vi.fn().mockResolvedValue({ connections: twoConnections }); + const prompt = vi.fn().mockResolvedValue('fileB'); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBe('fileB'); + expect(prompt).toHaveBeenCalledWith(twoConnections); + }); + + it('returns undefined when the status fetch fails (bridge not running)', async () => { + const getStatus = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + const prompt = vi.fn(); + const result = await resolveFileKey(undefined, { isTTY: true, getStatus, prompt }); + expect(result).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tests/unit/bridge/pidfile.test.ts b/packages/cli/tests/unit/bridge/pidfile.test.ts new file mode 100644 index 00000000..37d308ec --- /dev/null +++ b/packages/cli/tests/unit/bridge/pidfile.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('os', () => ({ + homedir: () => '/fake/home', +})); + +const fsMock = { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +}; + +vi.mock('fs', () => fsMock); + +const { ensureSpecsHome, readPid, writePid, clearPidFile, isAlive, getRunningPid, BRIDGE_PID_FILE, BRIDGE_LOG_FILE } = + await import('../../../src/bridge/pidfile.js'); + +describe('pidfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('computes paths under the (mocked) home directory, not a hardcoded location', () => { + expect(BRIDGE_PID_FILE).toBe('/fake/home/.specs/bridge.pid'); + expect(BRIDGE_LOG_FILE).toBe('/fake/home/.specs/bridge.log'); + }); + + describe('ensureSpecsHome', () => { + it('creates the directory when it does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + ensureSpecsHome(); + expect(fsMock.mkdirSync).toHaveBeenCalledWith('/fake/home/.specs', { recursive: true }); + }); + + it('does not create the directory when it already exists', () => { + fsMock.existsSync.mockReturnValue(true); + ensureSpecsHome(); + expect(fsMock.mkdirSync).not.toHaveBeenCalled(); + }); + }); + + describe('readPid', () => { + it('returns null when the pidfile does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + expect(readPid()).toBeNull(); + }); + + it('returns the parsed pid when the file contains a valid integer', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('12345\n'); + expect(readPid()).toBe(12345); + }); + + it('returns null when the file contents are not a valid positive integer', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('not-a-pid'); + expect(readPid()).toBeNull(); + }); + + it('returns null for a negative or zero pid', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('-5'); + expect(readPid()).toBeNull(); + }); + }); + + describe('writePid / clearPidFile', () => { + it('writePid ensures the directory exists and writes the pid as a string', () => { + fsMock.existsSync.mockReturnValue(false); + writePid(999); + expect(fsMock.mkdirSync).toHaveBeenCalled(); + expect(fsMock.writeFileSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid', '999', 'utf8'); + }); + + it('clearPidFile removes the file when it exists', () => { + fsMock.existsSync.mockReturnValue(true); + clearPidFile(); + expect(fsMock.unlinkSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid'); + }); + + it('clearPidFile is a no-op when the file does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + clearPidFile(); + expect(fsMock.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('isAlive', () => { + it('returns true when process.kill(pid, 0) does not throw', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never); + expect(isAlive(123)).toBe(true); + killSpy.mockRestore(); + }); + + it('returns false when process.kill(pid, 0) throws (process not running)', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); + expect(isAlive(123)).toBe(false); + killSpy.mockRestore(); + }); + }); + + describe('getRunningPid', () => { + it('returns null when there is no pidfile', () => { + fsMock.existsSync.mockReturnValue(false); + expect(getRunningPid()).toBeNull(); + }); + + it('returns the pid when the pidfile exists and the process is alive', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('456'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never); + + expect(getRunningPid()).toBe(456); + + killSpy.mockRestore(); + }); + + it('cleans up a stale pidfile and returns null when the recorded pid is not alive', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue('456'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); + + expect(getRunningPid()).toBeNull(); + expect(fsMock.unlinkSync).toHaveBeenCalledWith('/fake/home/.specs/bridge.pid'); + + killSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/tests/unit/bridge/requestTracker.test.ts b/packages/cli/tests/unit/bridge/requestTracker.test.ts new file mode 100644 index 00000000..511b3aea --- /dev/null +++ b/packages/cli/tests/unit/bridge/requestTracker.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RequestTracker } from '../../../src/bridge/requestTracker.js'; + +describe('RequestTracker', () => { + it('create() returns a unique requestId per call', () => { + const tracker = new RequestTracker(); + const a = tracker.create(1000, 'timeout'); + const b = tracker.create(1000, 'timeout'); + expect(a.requestId).not.toBe(b.requestId); + }); + + it('resolve() resolves the matching promise with the given value', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + const resolved = tracker.resolve(requestId, 'hello'); + + expect(resolved).toBe(true); + await expect(promise).resolves.toBe('hello'); + }); + + it('resolve() with an unknown requestId returns false and does not throw', () => { + const tracker = new RequestTracker(); + expect(tracker.resolve('unknown-id', 'value')).toBe(false); + }); + + it('resolve() only fires once per requestId — a second call is a no-op', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + tracker.resolve(requestId, 'first'); + const secondCallResult = tracker.resolve(requestId, 'second'); + + expect(secondCallResult).toBe(false); + await expect(promise).resolves.toBe('first'); + }); + + it('two concurrent requests resolve independently without cross-talk', async () => { + // This is the concrete regression the correlation-ID design replaced a + // single global pending-slot to fix: two in-flight requests (e.g. to two + // different connected Figma files) must not resolve each other. + const tracker = new RequestTracker(); + const first = tracker.create(1000, 'timeout'); + const second = tracker.create(1000, 'timeout'); + + tracker.resolve(second.requestId, 'second-result'); + tracker.resolve(first.requestId, 'first-result'); + + await expect(first.promise).resolves.toBe('first-result'); + await expect(second.promise).resolves.toBe('second-result'); + }); + + describe('timeouts', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('rejects with the given message if never resolved before the timeout', async () => { + const tracker = new RequestTracker(); + const { promise } = tracker.create(1000, 'Timed out waiting for result.'); + + const assertion = expect(promise).rejects.toThrow('Timed out waiting for result.'); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + }); + + it('does not reject if resolved before the timeout fires', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'Timed out.'); + + tracker.resolve(requestId, 'in time'); + await vi.advanceTimersByTimeAsync(1000); + + await expect(promise).resolves.toBe('in time'); + }); + + it('has() and size reflect pending requests and clear after resolve or timeout', async () => { + const tracker = new RequestTracker(); + const { requestId, promise } = tracker.create(1000, 'timeout'); + + expect(tracker.has(requestId)).toBe(true); + expect(tracker.size).toBe(1); + + tracker.resolve(requestId, 'done'); + await promise; + + expect(tracker.has(requestId)).toBe(false); + expect(tracker.size).toBe(0); + }); + }); +}); diff --git a/packages/cli/tests/unit/cache/Cache.test.ts b/packages/cli/tests/unit/cache/Cache.test.ts new file mode 100644 index 00000000..f5ed8f78 --- /dev/null +++ b/packages/cli/tests/unit/cache/Cache.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, utimesSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { parse } from 'yaml'; +import { + refreshCache, validateCache, readCacheFile, cachePath, describeProblems, + type ComponentsEntry, type StylesEntry, type VariablesEntry, type IconsEntry, +} from '../../../src/Cache/Cache.js'; + +const PATTERN = 'Icon / {i}'; + +function filePayload(overrides: Record = {}): string { + return JSON.stringify({ + components: { + '1:1': { key: 'componentkey1', name: 'Library Button/Primary/M' }, + '1:2': { name: 'Unpublished' }, // no published key — omitted from the cache + '9:9': { key: 'glyphkey', name: 'Icon / star' }, + }, + componentSets: { + '2:1': { key: 'setkey1', name: 'Library Card' }, + }, + styles: { + s1: { styleType: 'FILL', name: 'Color/Primary', key: 'fillkey' }, + s2: { styleType: 'TEXT', name: 'Type/Body', key: 'textkey' }, + s3: { styleType: 'GRID', name: 'Grid/Ignored', key: 'gridkey' }, + s4: { styleType: 'EFFECT', name: 'Shadow/Low', key: 'effectkey' }, + }, + document: { + id: '0:0', + children: [ + { id: '9:9', type: 'COMPONENT', name: 'Icon / star' }, + { id: '9:8', type: 'COMPONENT', name: 'Not an icon' }, + ], + }, + ...overrides, + }); +} + +function variablesPayload(): string { + return JSON.stringify({ + meta: { + variableCollections: { c1: { name: 'Color' } }, + variables: { + v1: { name: 'Primary', key: 'varkey1', variableCollectionId: 'c1', hiddenFromPublishing: false }, + v2: { name: 'Secret', key: 'varkey2', variableCollectionId: 'c1', hiddenFromPublishing: true }, + }, + }, + }); +} + +describe('Cache', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'specs-cache-')); + writeFileSync(join(dataDir, 'library.file.json'), filePayload()); + writeFileSync(join(dataDir, 'library.variables.json'), variablesPayload()); + }); + + afterEach(() => rmSync(dataDir, { recursive: true, force: true })); + + const build = (aliases = ['library'], force = false) => + refreshCache({ dataDir, aliases, glyphNamePattern: PATTERN, force }); + + describe('building', () => { + it('writes all four caches', () => { + const report = build(); + expect(report.rebuilt).toEqual(['library']); + expect(report.counts).toEqual({ components: 3, styles: 3, variables: 2, icons: 1 }); + }); + + it('records only components that carry a published key', () => { + build(); + const cache = readCacheFile(dataDir, 'components')!; + expect(Object.keys(cache.entries).sort()).toEqual(['1:1', '2:1', '9:9']); + expect(cache.entries['1:1']).toEqual({ key: 'componentkey1', name: 'Library Button/Primary/M', file: 'library' }); + }); + + it('keeps only the style types a spec can reference', () => { + build(); + const cache = readCacheFile(dataDir, 'styles')!; + expect(Object.keys(cache.entries).sort()).toEqual(['Color/Primary', 'Shadow/Low', 'Type/Body']); + expect(cache.entries['Color/Primary']).toEqual({ key: 'fillkey', type: 'FILL', file: 'library' }); + }); + + it('carries the published flag through to variables', () => { + build(); + const cache = readCacheFile(dataDir, 'variables')!; + expect(cache.entries['Color/Primary'].published).toBe(true); + expect(cache.entries['Color/Secret'].published).toBe(false); + }); + + it('extracts glyph names with the pattern and cross-references their keys', () => { + build(); + const cache = readCacheFile(dataDir, 'icons')!; + expect(cache.entries).toEqual({ star: { id: '9:9', key: 'glyphkey', file: 'library' } }); + }); + + it('writes an empty icons cache when nothing matches, so "none" differs from "never built"', () => { + refreshCache({ dataDir, aliases: ['library'], glyphNamePattern: 'Nothing / {i}' }); + const cache = readCacheFile(dataDir, 'icons')!; + expect(cache.entries).toEqual({}); + expect(cache.sources.library).toBeDefined(); + }); + + it('records the raw Figma name, which is what an instanceOf lookup matches against', () => { + build(); + const cache = readCacheFile(dataDir, 'components')!; + // Stored raw and formatted at lookup time, so changing format.keys needs no rebuild. + expect(cache.entries['2:1'].name).toBe('Library Card'); + expect(cache.entries['9:9'].name).toBe('Icon / star'); + }); + + it('tags every entry with the alias it came from', () => { + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey', name: 'Brand Thing' } }, + styles: {}, + document: {}, + })); + build(['library', 'brand']); + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['1:1'].file).toBe('library'); + expect(cache.entries['5:5'].file).toBe('brand'); + expect(Object.keys(cache.sources).sort()).toEqual(['brand', 'library']); + }); + + it('skips an alias that has not been fetched', () => { + const report = build(['library', 'missing']); + expect(report.unfetched).toEqual(['missing']); + expect(report.rebuilt).toEqual(['library']); + }); + }); + + describe('incremental rebuilds', () => { + it('leaves an alias alone when its payload has not changed', () => { + build(); + const report = build(); + expect(report.current).toEqual(['library']); + expect(report.rebuilt).toEqual([]); + expect(report.counts.components).toBe(3); + }); + + it('rebuilds every alias under --force', () => { + build(); + expect(build(['library'], true).rebuilt).toEqual(['library']); + }); + + it('rebuilds only the alias whose payload changed, keeping the other alias intact', () => { + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey', name: 'Brand Thing' } }, styles: {}, document: {}, + })); + build(['library', 'brand']); + + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ + components: { '5:5': { key: 'brandkey2', name: 'Brand Thing' }, '6:6': { key: 'newkey', name: 'New Thing' } }, styles: {}, document: {}, + })); + const report = build(['library', 'brand']); + + expect(report.rebuilt).toEqual(['brand']); + expect(report.current).toEqual(['library']); + + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['5:5'].key).toBe('brandkey2'); + expect(cache.entries['6:6']).toBeDefined(); + expect(cache.entries['1:1'].key).toBe('componentkey1'); // untouched alias survived + }); + + it('drops entries an alias no longer has', () => { + build(); + writeFileSync(join(dataDir, 'library.file.json'), JSON.stringify({ + components: { '1:1': { key: 'componentkey1', name: 'Library Button/Primary/M' } }, styles: {}, document: {}, + })); + build(); + const cache = readCacheFile(dataDir, 'components')!; + expect(cache.entries['2:1']).toBeUndefined(); + }); + }); + + describe('validation', () => { + const validate = (aliases = ['library'], pattern: string | undefined = PATTERN) => + validateCache({ dataDir, aliases, glyphNamePattern: pattern }); + + it('passes on a freshly built cache', () => { + build(); + expect(validate()).toEqual([]); + }); + + it('reports every concern as missing when nothing is built', () => { + expect(validate().map(p => p.concern).sort()).toEqual(['components', 'icons', 'styles', 'variables']); + expect(validate().every(p => p.reason === 'missing')).toBe(true); + }); + + it('reports the concern whose payload changed as stale', () => { + build(); + writeFileSync(join(dataDir, 'library.variables.json'), JSON.stringify({ meta: { variables: {}, variableCollections: {} } })); + expect(validate()).toEqual([{ concern: 'variables', alias: 'library', reason: 'stale' }]); + }); + + it('treats a same-size rewrite as stale, since mtime moved', () => { + build(); + const path = join(dataDir, 'library.file.json'); + const content = readFileSync(path, 'utf8'); + writeFileSync(path, content); + const future = new Date(Date.now() + 10_000); + utimesSync(path, future, future); + const stale = validate().filter(p => p.reason === 'stale').map(p => p.concern).sort(); + expect(stale).toEqual(['components', 'icons', 'styles']); + }); + + it('invalidates icons when the glyph pattern changes, with no file change at all', () => { + build(); + expect(validate(['library'], 'Different / {i}')).toEqual([ + { concern: 'icons', alias: 'library', reason: 'stale' }, + ]); + }); + + it('reports a newly declared but unbuilt alias', () => { + build(); + writeFileSync(join(dataDir, 'brand.file.json'), JSON.stringify({ components: {}, styles: {}, document: {} })); + const problems = validateCache({ dataDir, aliases: ['library', 'brand'], glyphNamePattern: PATTERN }); + expect(problems.every(p => p.alias === 'brand' && p.reason === 'missing')).toBe(true); + }); + + it('names the command that fixes the problem', () => { + const message = describeProblems([{ concern: 'variables', alias: 'library', reason: 'stale' }]); + expect(message).toContain('variables.yaml: "library" is stale'); + expect(message).toContain('specs cache'); + }); + }); + + describe('file format', () => { + it('writes a do-not-edit header above valid YAML', () => { + build(); + const raw = readFileSync(cachePath(dataDir, 'styles'), 'utf8'); + expect(raw.startsWith('# Generated by `specs cache`')).toBe(true); + expect(parse(raw).entries['Color/Primary'].key).toBe('fillkey'); + }); + + it('records the payload each alias was built from', () => { + build(); + const cache = readCacheFile(dataDir, 'styles')!; + expect(cache.sources.library.from).toBe('library.file.json'); + expect(cache.sources.library.bytes).toBeGreaterThan(0); + expect(cache.sources.library.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('records the glyph pattern only on the icons cache', () => { + build(); + expect(readCacheFile(dataDir, 'icons')!.sources.library.glyphNamePattern).toBe(PATTERN); + expect(readCacheFile(dataDir, 'styles')!.sources.library.glyphNamePattern).toBeUndefined(); + }); + + it('treats an unreadable cache as absent rather than throwing', () => { + build(); + writeFileSync(cachePath(dataDir, 'components'), ': not : valid : yaml :\n - ['); + expect(readCacheFile(dataDir, 'components')).toBeNull(); + const problems = validateCache({ dataDir, aliases: ['library'], glyphNamePattern: PATTERN }); + expect(problems.some(p => p.concern === 'components' && p.reason === 'missing')).toBe(true); + }); + }); +}); diff --git a/packages/cli/tests/unit/commands/GenerateCommand.test.ts b/packages/cli/tests/unit/commands/GenerateCommand.test.ts index 79ca4cad..b482f31e 100644 --- a/packages/cli/tests/unit/commands/GenerateCommand.test.ts +++ b/packages/cli/tests/unit/commands/GenerateCommand.test.ts @@ -45,9 +45,20 @@ describe('GenerateCommand', () => { expect(options).toContain('--split-components'); expect(options).toContain('--split-concerns'); expect(options).toContain('--use-subfolders'); + expect(options).toContain('--from-bridge'); + expect(options).toContain('--file'); expect(options).toContain('--verbose'); }); + it('--from-bridge and --file are not mandatory (opt-in source mode)', () => { + const fromBridge = Generate.options.find(o => o.long === '--from-bridge'); + const file = Generate.options.find(o => o.long === '--file'); + expect(fromBridge).toBeDefined(); + expect(fromBridge!.mandatory).toBeFalsy(); + expect(file).toBeDefined(); + expect(file!.mandatory).toBeFalsy(); + }); + it('has short aliases for key options', () => { const shorts = Generate.options.map(o => o.short).filter(Boolean); diff --git a/packages/cli/tests/unit/render/SpecLoader.test.ts b/packages/cli/tests/unit/render/SpecLoader.test.ts new file mode 100644 index 00000000..7aba0cc8 --- /dev/null +++ b/packages/cli/tests/unit/render/SpecLoader.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { stringify } from 'yaml'; +import { findComponentFolders, loadSpec } from '../../../src/Render/SpecLoader'; +import { splitComponentByConcern } from '../../../src/Writers/DataTransformers'; + +const tmpDirs: string[] = []; + +function makeTmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'specloader-test-')); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const COMPONENT = { + title: 'DE Button', + anatomy: { root: { type: 'container' } }, + props: { appearance: { type: 'string', default: 'Filled' } }, + default: { appearance: 'Filled' }, + variants: [{ appearance: 'Filled' }, { appearance: 'Outline' }], + slotContentExamples: { label: 'Click me' }, + metadata: { generatedAt: '2026-01-01T00:00:00.000Z' }, +}; + +describe('loadSpec — single file', () => { + it('parses a combined .yaml spec', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.yaml'); + fs.writeFileSync(file, stringify(COMPONENT), 'utf8'); + + const { spec, resolvePath } = loadSpec(file); + + expect(resolvePath).toBe(path.resolve(file)); + expect(spec.title).toBe('DE Button'); + expect(spec.variants).toEqual(COMPONENT.variants); + }); + + it('parses a combined .json spec', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.json'); + fs.writeFileSync(file, JSON.stringify(COMPONENT), 'utf8'); + + const { spec } = loadSpec(file); + + expect(spec.title).toBe('DE Button'); + expect(spec.props).toEqual(COMPONENT.props); + }); + + it('throws a clear error for an unsupported extension', () => { + const dir = makeTmpDir(); + const file = path.join(dir, 'deButton.txt'); + fs.writeFileSync(file, 'title: X', 'utf8'); + + expect(() => loadSpec(file)).toThrow(/Unsupported spec file extension/); + }); + + it('throws when the path does not exist', () => { + expect(() => loadSpec('/nonexistent/path/to/spec.yaml')).toThrow(/not found/); + }); +}); + +describe('loadSpec — split-concerns folder', () => { + it('merges api.yaml + variants.yaml + examples.yaml back into one component', () => { + const dir = makeTmpDir(); + const { api, variants, examples } = splitComponentByConcern(COMPONENT); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + fs.writeFileSync(path.join(dir, 'examples.yaml'), stringify(examples), 'utf8'); + + const { spec, resolvePath } = loadSpec(dir); + + expect(resolvePath).toBe(path.resolve(dir)); + expect(spec.title).toBe(COMPONENT.title); + expect(spec.anatomy).toEqual(COMPONENT.anatomy); + expect(spec.props).toEqual(COMPONENT.props); + expect(spec.default).toEqual(COMPONENT.default); + expect(spec.variants).toEqual(COMPONENT.variants); + expect(spec.slotContentExamples).toEqual(COMPONENT.slotContentExamples); + }); + + it('supports mixed extensions across concern files', () => { + const dir = makeTmpDir(); + const { api, variants } = splitComponentByConcern(COMPONENT); + + fs.writeFileSync(path.join(dir, 'api.json'), JSON.stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + + const { spec } = loadSpec(dir); + + expect(spec.title).toBe(COMPONENT.title); + expect(spec.variants).toEqual(COMPONENT.variants); + expect(spec.slotContentExamples).toBeUndefined(); + }); + + it('works without an examples file (optional concern)', () => { + const dir = makeTmpDir(); + const noExamples = { ...COMPONENT, slotContentExamples: undefined }; + const { api, variants } = splitComponentByConcern(noExamples); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + + const { spec } = loadSpec(dir); + + expect(spec.title).toBe(COMPONENT.title); + expect(spec.slotContentExamples).toBeUndefined(); + }); + + it('throws when the folder is missing a required concern file', () => { + const dir = makeTmpDir(); + const { api } = splitComponentByConcern(COMPONENT); + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + + expect(() => loadSpec(dir)).toThrow(/Expected api\.\(yaml\|json\) and variants\.\(yaml\|json\)/); + }); + + it('merges subcomponents recursively', () => { + const dir = makeTmpDir(); + const withSub = { + ...COMPONENT, + subcomponents: { + icon: { + title: 'Icon', + anatomy: {}, + props: { size: { type: 'number' } }, + default: { size: 16 }, + variants: [{ size: 16 }], + metadata: {}, + }, + }, + }; + const { api, variants, examples } = splitComponentByConcern(withSub); + + fs.writeFileSync(path.join(dir, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(dir, 'variants.yaml'), stringify(variants), 'utf8'); + fs.writeFileSync(path.join(dir, 'examples.yaml'), stringify(examples), 'utf8'); + + const { spec } = loadSpec(dir); + const sub = (spec.subcomponents as any).icon; + + expect(sub.title).toBe('Icon'); + expect(sub.props).toEqual({ size: { type: 'number' } }); + expect(sub.default).toEqual({ size: 16 }); + }); +}); + +describe('findComponentFolders', () => { + function makeComponent(dir: string, name: string): string { + const folder = path.join(dir, name); + fs.mkdirSync(folder, { recursive: true }); + const { api, variants } = splitComponentByConcern(COMPONENT); + fs.writeFileSync(path.join(folder, 'api.yaml'), stringify(api), 'utf8'); + fs.writeFileSync(path.join(folder, 'variants.yaml'), stringify(variants), 'utf8'); + return folder; + } + + it('returns the directory itself when it is a component folder', () => { + const dir = makeTmpDir(); + const folder = makeComponent(dir, 'deButton'); + + expect(findComponentFolders(folder)).toEqual([folder]); + }); + + it('finds component folders one and two levels down, in path order', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + const input = makeComponent(dir, path.join('forms', 'deInput')); + + expect(findComponentFolders(dir)).toEqual([button, input].sort()); + }); + + it('does not descend past two levels', () => { + const dir = makeTmpDir(); + makeComponent(dir, path.join('a', 'b', 'deTooBuried')); + + expect(findComponentFolders(dir)).toEqual([]); + }); + + it('does not descend into a component folder', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + makeComponent(button, 'nested'); + + expect(findComponentFolders(dir)).toEqual([button]); + }); + + it('ignores dot-directories and folders missing a required concern', () => { + const dir = makeTmpDir(); + const button = makeComponent(dir, 'deButton'); + makeComponent(dir, '.cache'); + fs.mkdirSync(path.join(dir, 'deHalf')); + fs.writeFileSync(path.join(dir, 'deHalf', 'api.yaml'), stringify({}), 'utf8'); + + expect(findComponentFolders(dir)).toEqual([button]); + }); +}); diff --git a/packages/cli/tests/unit/utilities/formatKey.test.ts b/packages/cli/tests/unit/utilities/formatKey.test.ts new file mode 100644 index 00000000..5d8ef3d2 --- /dev/null +++ b/packages/cli/tests/unit/utilities/formatKey.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { formatKey } from '../../../src/utilities/formatKey.js'; + +/** + * `formatKey` is a deliberate duplicate of `Utilities.formatKey` in specs-from-figma, which + * the CLI cannot import. These cases pin the behaviour: if the two drift, the bridge's + * component lookups silently stop matching rather than failing, so the drift has to be + * caught here. + */ +describe('formatKey', () => { + describe('CAMEL', () => { + it('lowercases the first word and TitleCases the rest', () => { + expect(formatKey('Action 1 appearance', 'CAMEL')).toBe('action1Appearance'); + expect(formatKey('Header overlaid', 'CAMEL')).toBe('headerOverlaid'); + }); + + it('treats "/" as a word separator, which is why the transform has no inverse', () => { + expect(formatKey('DS Link/On overlay/M/False/Rest/Start', 'CAMEL')).toBe('dsLinkOnOverlayMFalseRestStart'); + expect(formatKey('DS Toolbar/Android/S/Close/True/Base/Tertiary', 'CAMEL')).toBe('dsToolbarAndroidSCloseTrueBaseTertiary'); + }); + + it('collapses distinct names onto the same key — the reason matching is done forward', () => { + expect(formatKey('DS Link/On overlay/M', 'CAMEL')).toBe(formatKey('DS Link On Overlay M', 'CAMEL')); + expect(formatKey('DS Link-On_overlay M', 'CAMEL')).toBe(formatKey('DS Link/On/overlay/M', 'CAMEL')); + }); + + it('lowercases only the first character of a single word, preserving camelCase input', () => { + expect(formatKey('effectStyle', 'CAMEL')).toBe('effectStyle'); + expect(formatKey('Button', 'CAMEL')).toBe('button'); + }); + + it('lowercases the tail of each subsequent word', () => { + expect(formatKey('DS CARD header', 'CAMEL')).toBe('dsCardHeader'); + }); + }); + + describe('other formats', () => { + const name = 'DS Card /_ / Header'; + + it('SAFE strips unsafe characters and keeps everything else', () => { + expect(formatKey('Card "A" [1].b', 'SAFE')).toBe('Card A 1b'); + expect(formatKey(name, 'SAFE')).toBe(name); + }); + + it('SNAKE, KEBAB, PASCAL and TRAIN work from the same word split', () => { + expect(formatKey('Header overlaid', 'SNAKE')).toBe('header_overlaid'); + expect(formatKey('Header overlaid', 'KEBAB')).toBe('header-overlaid'); + expect(formatKey('Header overlaid', 'PASCAL')).toBe('HeaderOverlaid'); + expect(formatKey('Header overlaid', 'TRAIN')).toBe('Header-Overlaid'); + }); + + it('defaults to SAFE when the format is unset or unknown', () => { + expect(formatKey('Header overlaid')).toBe('Header overlaid'); + expect(formatKey('Header overlaid', 'NONSENSE')).toBe('Header overlaid'); + }); + + it('accepts lowercase format names', () => { + expect(formatKey('Header overlaid', 'camel')).toBe('headerOverlaid'); + }); + }); + + describe('edge cases', () => { + it('returns an empty string when nothing survives the split', () => { + expect(formatKey('///', 'CAMEL')).toBe(''); + expect(formatKey(' ', 'CAMEL')).toBe(''); + expect(formatKey('', 'CAMEL')).toBe(''); + }); + + it('drops non-alphanumeric characters inside words', () => { + expect(formatKey('Size=M, State=Rest', 'CAMEL')).toBe('sizeMStateRest'); + }); + }); +}); diff --git a/packages/cli/tests/unit/utilities/variablesIndex.test.ts b/packages/cli/tests/unit/utilities/variablesIndex.test.ts new file mode 100644 index 00000000..1388f683 --- /dev/null +++ b/packages/cli/tests/unit/utilities/variablesIndex.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { buildVariablesIndex, countUnpublished } from '../../../src/utilities/variablesIndex.js'; + +const VARIABLES_JSON = { + meta: { + variableCollections: { + 'VariableCollectionId:1:1': { name: 'Spacing' }, + 'VariableCollectionId:2:2': { name: 'Color' }, + }, + variables: { + 'VariableID:1:23': { + name: 'md', + key: 'key-spacing-md', + variableCollectionId: 'VariableCollectionId:1:1', + }, + 'VariableID:2:34': { + name: 'brand/primary', + key: 'key-color-brand-primary', + variableCollectionId: 'VariableCollectionId:2:2', + hiddenFromPublishing: true, + }, + }, + }, +}; + +describe('buildVariablesIndex', () => { + it('maps a token name to the key and the id it was fetched with', () => { + const index = buildVariablesIndex(VARIABLES_JSON); + + expect(index['Spacing/md']).toEqual({ + key: 'key-spacing-md', + id: 'VariableID:1:23', + published: true, + }); + }); + + it('marks a variable hidden from publishing as unpublished, keeping its id', () => { + const index = buildVariablesIndex(VARIABLES_JSON); + + expect(index['Color/brand/primary']).toEqual({ + key: 'key-color-brand-primary', + id: 'VariableID:2:34', + published: false, + }); + expect(countUnpublished(index)).toBe(1); + }); + + it('omits an unnamed variable — the name is the only thing a spec can reference', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': { name: 'Spacing' } }, + variables: { 'VariableID:3:45': { key: 'key-nameless', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(Object.keys(index)).toHaveLength(0); + }); + + it('keeps a keyless variable as unpublished, so its id can still resolve in its own file', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': { name: 'Spacing' } }, + variables: { 'VariableID:4:56': { name: 'lg', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(index['Spacing/lg']).toEqual({ key: '', id: 'VariableID:4:56', published: false }); + }); + + it('falls back to a bare name when the collection cannot be named', () => { + const index = buildVariablesIndex({ + meta: { + variableCollections: { 'VariableCollectionId:1:1': {} }, + variables: { 'VariableID:5:67': { name: 'orphan', key: 'key-orphan', variableCollectionId: 'VariableCollectionId:1:1' } }, + }, + }); + + expect(index['orphan']).toMatchObject({ key: 'key-orphan', published: true }); + }); + + it('returns an empty index for missing, empty, or malformed input', () => { + expect(buildVariablesIndex(null)).toEqual({}); + expect(buildVariablesIndex(undefined)).toEqual({}); + expect(buildVariablesIndex({})).toEqual({}); + expect(buildVariablesIndex({ meta: { variables: {} } })).toEqual({}); + }); +}); diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 66536c61..9e1bdc74 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -106,6 +106,9 @@ export default defineConfig({ { label: 'applyCustomTokens', slug: 'cli/commands/apply-custom-tokens' }, { label: 'generate', slug: 'cli/commands/generate' }, { label: 'transform', slug: 'cli/commands/transform' }, + { label: 'bridge', slug: 'cli/commands/bridge', badge: experimental }, + { label: 'cache', slug: 'cli/commands/cache', badge: experimental }, + { label: 'render', slug: 'cli/commands/render', badge: experimental }, { label: 'Transforms', collapsed: true, @@ -235,6 +238,7 @@ export default defineConfig({ { label: 'Key Formatting', slug: 'guides/key-formatting' }, { label: 'Layout Positioning', slug: 'guides/layout-positioning' }, { label: 'Number Inference', slug: 'guides/number-inference' }, + { label: 'Render to Figma', slug: 'guides/render-to-figma', badge: experimental }, { label: 'Slot Constraints', slug: 'guides/slot-constraints', badge: pro }, { label: 'Subcomponents', slug: 'guides/subcomponent-scoping', badge: pro }, { label: 'Variant Depth', slug: 'guides/variant-depth' }, diff --git a/site/src/content/docs/cli/commands/apply-custom-tokens.md b/site/src/content/docs/cli/commands/apply-custom-tokens.md index 33d62c00..f3518ee3 100644 --- a/site/src/content/docs/cli/commands/apply-custom-tokens.md +++ b/site/src/content/docs/cli/commands/apply-custom-tokens.md @@ -185,6 +185,12 @@ specs applyCustomTokens data/token-mappings.json \ -s data/library.styles.json ``` +## The Render Cache + +Because this command rewrites your fetched variables data in place, the lookup tables [`render`](/cli/commands/render/) uses would otherwise describe your data as it was before the custom tokens were applied. To prevent that, `applyCustomTokens` rebuilds the [cache](/cli/commands/cache/) as its final step, and reports what it rebuilt. + +If the command is interrupted before that step, the cache is left stale — `render` detects this and stops, telling you to run `specs cache`. + ## Behavior Details - **Idempotent**: Running the command multiple times with the same mapping produces identical output. Existing `$custom` values are overwritten. diff --git a/site/src/content/docs/cli/commands/bridge.md b/site/src/content/docs/cli/commands/bridge.md new file mode 100644 index 00000000..acfe7d4d --- /dev/null +++ b/site/src/content/docs/cli/commands/bridge.md @@ -0,0 +1,98 @@ +--- +title: "bridge" +--- + + + +Starts, stops, and checks the local CLI bridge — the background process that relays requests between the CLI and a connected Specs 2 Figma plugin. [`render`](/cli/commands/render/) sends specs into the live file through it, and [`generate --from-bridge`](/cli/commands/generate/#bridge-mode) reads specs back out of the current selection. + +## Usage + +```bash +specs bridge [options] +``` + +## Subcommands + +### `start` + +Starts the bridge server in the background. Prints the process ID, the ports it's listening on, and where its logs go. Safe to run again while already running — reports the existing process instead of starting a second one. + +```bash +specs bridge start +``` + +#### `--workspace ` + +Pin a workspace directory for the whole bridge session, instead of deriving it per-request from each spec's path. Useful when every render in a session targets the same workspace. + +```bash +specs bridge start --workspace ~/design-system +``` + +### `stop` + +Stops the background bridge server. Sends a graceful shutdown signal, then forces termination if it doesn't exit within a few seconds. Safe to run when nothing is running. + +```bash +specs bridge stop +``` + +### `status` + +Reports whether the bridge server is running and, if so, whether a Figma plugin is currently connected to it. + +```bash +specs bridge status +``` + +Each connected plugin is listed by the file key it's connected from, with the file's name when the plugin reported one: + +``` +Bridge running (pid 12345). 2 plugins connected: + abc123XYZ (Design System Library) — connected + def456UVW (Brand Marketing) — connected +``` + +``` +Bridge running (pid 12345). No plugin connected. +``` + +Those file keys are what [`render --file`](/cli/commands/render/#--file) and [`generate --from-bridge --file`](/cli/commands/generate/#--file-filekey) take, so `bridge status` is how you find the value to pin in a scripted run. + +Exits non-zero when the bridge isn't running, so it's safe to use in a conditional (`if specs bridge status; then ...`). It also exits non-zero when the process is up but its control port isn't answering: + +``` +Bridge running (pid 12345), but the control port isn't responding. +``` + +## What It Does + +The bridge server listens on two local ports: a WebSocket for the Specs 2 plugin (`ws://localhost:9001`) and an HTTP control endpoint for the CLI (`http://localhost:9002`). `specs render` posts a spec (or a render manifest) to the HTTP endpoint; the bridge relays it to whichever plugin is connected over the WebSocket, which builds the component live in Figma and reports back. + +Traffic runs both directions. `specs generate --from-bridge` posts to the same endpoint asking the plugin to generate a spec from its current selection; the plugin runs generation against the live document and returns the finished spec, which the CLI writes out. Reading a rendered component's spec is always this explicit second call — never a side effect of `render`. + +Starting the bridge doesn't connect it to anything by itself — you still need the Specs 2 plugin open in Figma with its **CLI Bridge** toggle enabled. See the [Render to Figma guide](/guides/render-to-figma/) for the full setup. + +## Logs and Process Files + +`bridge start` writes a pid file and a log file under `~/.specs/`: + +``` +~/.specs/ + bridge.pid # process ID of the running bridge server + bridge.log # stdout/stderr from the bridge server +``` + +Tail the log while debugging a render: + +```bash +tail -f ~/.specs/bridge.log +``` + +--- + +**See Also:** + +- [Render to Figma](/guides/render-to-figma/) — bridge architecture, setup, and prerequisites +- [`render`](/cli/commands/render/) — sends specs to the bridge diff --git a/site/src/content/docs/cli/commands/cache.md b/site/src/content/docs/cli/commands/cache.md new file mode 100644 index 00000000..015f58cc --- /dev/null +++ b/site/src/content/docs/cli/commands/cache.md @@ -0,0 +1,102 @@ +--- +title: "cache" +--- + + + +Builds the lookup tables `render` uses to resolve a spec's references against your fetched Figma data. + +You rarely run this by hand. [`fetch`](/cli/commands/fetch/) and [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) build the cache themselves as their last step, which covers the normal workflow. Reach for `specs cache` when the cache is missing, when something outside those commands changed the files in your data directory, or when `render` tells you to. + +## Usage + +```bash +specs cache [options] +``` + +## Why it exists + +A spec references the library by name: an instance element names a component, a style key names a token, a glyph element names an icon. None of those names mean anything on their own — they have to be resolved against the data `fetch` downloaded. + +That data arrives as whole Figma API responses. A file payload for a large library can be hundreds of megabytes, and `render` needs only a few hundred small entries out of it. Deriving those entries per render meant re-reading the entire payload every time, which cost seconds before any Figma work started. The cache is those entries, extracted once. + +## What it writes + +Four files under `{dataDirectory}/cache/`, each covering every source you've fetched: + +| File | Maps | Built from | +|------|------|------------| +| `components.yaml` | component node id → published key and name | `{alias}.file.json` | +| `styles.yaml` | style name → key and type | `{alias}.file.json` | +| `variables.yaml` | token name → key, id, published flag | `{alias}.variables.json` | +| `icons.yaml` | glyph name → node id and key | `{alias}.file.json` | + +They're generated files. Deleting them is safe — the next `specs cache` rebuilds them — and they should not be edited by hand. + +Each entry records which source it came from, because node ids are file-scoped: knowing an entry's origin is what lets `render` tell whether an id is usable in the file it's rendering into, and it lets one library be rebuilt without re-reading the others. + +`components.yaml` also records each component's raw Figma name. A spec refers to a component by a formatted key, and that transform is lossy — `DS Link/On overlay/M` and `DS Link On Overlay M` produce the same key — so the name cannot be recovered from the key. Recording it lets `render` place an instance of a component the workspace has no spec for: the library's names are formatted the same way and matched against the spec's key, then the component is imported by its published key. Names are stored raw and formatted at render time, so changing `format.keys` needs no rebuild. + +Every file also records what it was built from — the payload's name, size, and modification time, plus the glyph naming pattern for `icons.yaml`. That's how staleness is detected. + +## Staleness + +Before each render, the bridge checks the recorded source details against the files on disk. A cache that no longer matches is a hard failure: + +``` +Error: Render failed: Render cache is not usable: + - variables.yaml: "library" is stale + +Run `specs cache` to rebuild it. +``` + +`render` refuses rather than rebuilding, for two reasons: rebuilding is exactly the per-render cost the cache removes, and rendering against data that no longer matches what was fetched binds a spec to the wrong variables or drops content — a failure that surfaces far from its cause. + +The cache goes stale when a payload is re-fetched, when `applyCustomTokens` rewrites your variables, or when `config.processing.glyphNamePattern` changes — a pattern edit changes what the icon entries mean without any file changing. All three are detected. + +## Options + +### `--config ` + +Use a specific config file instead of the default `specs.config.yaml`. + +### `--force` + +Rebuild every source, even those whose recorded details still match the files on disk. + +By default a source whose payload hasn't changed is left alone and its entries are carried forward, so rebuilding after fetching one library re-reads that library only. `--force` is the escape hatch for a cache you suspect is wrong for a reason the staleness check can't see. + +```bash +specs cache --force +``` + +## Sources that aren't fetched + +The cache is built for every source declared under `sources` in your config. A source you haven't fetched yet is skipped and reported, not treated as an error: + +``` + Cache rebuilt: library + Not fetched, skipped: brand + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + +`render` is stricter: it needs every declared source present and current, and fails naming the ones that aren't. + +## Examples + +```bash +# Rebuild whatever is out of date +specs cache + +# Rebuild everything from scratch +specs cache --force + +# Rebuild as part of a render, without a separate command +specs render specs/deButton/ --refresh-cache +``` + +## Related + +- [`fetch`](/cli/commands/fetch/) — downloads the payloads the cache is built from, and builds it +- [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) — rewrites variables data, then rebuilds the cache +- [`render`](/cli/commands/render/) — reads the cache; fails when it's missing or stale diff --git a/site/src/content/docs/cli/commands/fetch.md b/site/src/content/docs/cli/commands/fetch.md index 7b62226d..1d8830a6 100644 --- a/site/src/content/docs/cli/commands/fetch.md +++ b/site/src/content/docs/cli/commands/fetch.md @@ -48,6 +48,18 @@ specs fetch --no-geometry --verbose ### `--verbose` Show request URLs and write locations. +## The Render Cache + +After downloading, `fetch` builds the lookup tables [`render`](/cli/commands/render/) resolves specs against, under `{dataDirectory}/cache/`. This is why a normal workflow never needs to run [`specs cache`](/cli/commands/cache/) by hand. + +It covers every source in your config that has been fetched — the ones downloaded this run, plus any downloaded previously — and rebuilds only the ones whose payloads actually changed, so refreshing one library doesn't re-read the rest. A source you haven't fetched yet is skipped and reported: + +``` + Cache rebuilt: library + Not fetched, skipped: brand + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + ## Examples ```bash diff --git a/site/src/content/docs/cli/commands/generate.md b/site/src/content/docs/cli/commands/generate.md index eb7afc0f..45469ea9 100644 --- a/site/src/content/docs/cli/commands/generate.md +++ b/site/src/content/docs/cli/commands/generate.md @@ -1,165 +1,190 @@ --- title: "generate" --- -Generate component specifications from Figma data. Accepts either a markdown manifest (for multiple components) or a JSON file with a component flag (for a single component). The source argument is optional — without it, `generate` uses the default manifest location derived from your config. - -## Usage +Generate component specifications from Figma. There are three ways to run it, distinguished by where the Figma data comes from: a **manifest** listing many components, a **JSON file** plus one named component, or the **live Figma file** itself over the CLI bridge. ```bash -# Zero-config: uses default manifest from config (recommended) -specs generate +specs generate # manifest mode — many components +specs generate -c # single component mode +specs generate --from-bridge # bridge mode — live selection +``` -# From an explicit manifest (multiple components) -specs generate [options] +| Mode | Source | Produces | Use it when | +|------|--------|----------|-------------| +| [Manifest](#manifest-mode) | `.md` manifest + downloaded JSON | Every selected component | Generating or regenerating the library | +| [Single component](#single-component-mode) | Downloaded JSON + `-c` | One component | Setting up or iterating on one spec | +| [Bridge](#bridge-mode) | The connected Figma file, live | The current selection | Checking work in progress, or a `render` round-trip | -# From a JSON file (single component) -specs generate -c [options] -``` +Manifest and single component mode both read a Figma REST API snapshot that `specs fetch` downloaded, and run the transformer inside the CLI. Bridge mode reads nothing from disk — the plugin generates against the live document and the CLI only writes the result out. -## Source Modes +## Manifest Mode -### Manifest Mode (recommended) +Pass a markdown manifest created by [`scan`](/cli/commands/scan/) to generate specs for all selected components in one pass. The manifest points at the source JSON file and tracks which components are included (`[x]`). This is faster than running `generate` per component, because the file is loaded and indexed once. -Pass a markdown manifest created by `scan` to generate specs for all selected components in one pass. This is faster than running `generate` multiple times, because the file is loaded and indexed once. +The full workflow: ```bash -# Uses the default manifest from config: {dataDirectory}/{alias}.manifest.md +# 1. Download the Figma data +specs fetch + +# 2. Build a manifest of everything in the file +specs scan + +# 3. Curate — edit data/library.manifest.md and check [x] the components you want + +# 4. Generate specs generate +``` -# Or pass an explicit manifest path +With no arguments, step 4 uses the default manifest (`{dataDirectory}/{alias}.manifest.md`) and writes to `config.outputDirectory`. Pass either explicitly to override: + +```bash specs generate components.md -o specs/library.yaml ``` -The manifest references the source file and tracks which components to include. See [Scan Command](/cli/commands/scan/) for creating manifests. +Manifest mode requires an output destination — `-o` or `config.outputDirectory` — since it can produce many files. Control the file layout with [`--split-components`](#--split-components), [`--split-concerns`](#--split-concerns), and [`--use-subfolders`](#--use-subfolders): -### File Mode +```bash +specs generate -o specs/ --split-components +``` -Pass a Figma JSON file directly and specify a single component by name or node ID. Useful when setting up a new component or quickly iterating on one spec. +``` +✓ Loaded manifest: 150 components (42 selected) +⏳ Processing 42 components... -```bash -specs generate data/library.file.json -c "DS Button" -o specs/button.yaml +[1/42] DS Accordion... ✓ +[2/42] DS Alert... ✓ +... +[42/42] DS Toggle... ✓ + +✓ Generated specs + - 42 components successful ``` -## Arguments +Components that fail are reported individually and the rest still generate; the run exits non-zero if any failed. -### `[source]` (optional) -Path to a markdown manifest or Figma REST API JSON file. +## Single Component Mode -- **Not provided**: defaults to `{dataDirectory}/{alias}.manifest.md`, where `dataDirectory` comes from `specs.config.yaml` and `alias` is `library` if configured with `data: [file]`, otherwise the first source alias with `data: [file]`. This matches the default output of `specs scan`. -- **Manifest path (`.md`)**: manifest mode (multiple components) -- **Figma JSON (`.json`)**: file mode (requires `-c`) +Pass a Figma JSON file directly and name one component with `-c`, by name or node ID. Useful when setting up a new component, or iterating quickly on one spec without touching the rest of the library. ```bash -# Default: data/library.manifest.md (from config) -specs generate +specs fetch +specs generate data/library.file.json -c "DS Button" -o specs/button.yaml +``` -# Explicit manifest -specs generate components.md -o specs/all.yaml +The component is resolved against the JSON file's components and component sets. Node IDs work equally well, and are the reliable choice when a name contains special characters or is duplicated: -# JSON file -specs generate data/library.file.json -c "Button" -o specs/button.yaml +```bash +specs generate data/library.file.json -c "1234:5678" -o specs/button.yaml ``` -## Options - -### `-c, --component ` -Component name or Figma node ID. Required in file mode, ignored in manifest mode. +Without `-o` (and with no `config.outputDirectory`), the spec goes to stdout — handy for piping: ```bash -# By name -specs generate data/library.file.json -c "DS Button" - -# By node ID (useful for names with special characters) -specs generate data/library.file.json -c "1234:5678" +specs generate data/library.file.json -c "DS Button" -f yaml | yq '.dsButton.anatomy' ``` -### `-l, --license ` -License key for premium features. +Variables and styles are resolved from your configured sources; without config, `generate` falls back to `foundations/variables.json` and `foundations/styles.json` next to the JSON file. Override either with [`-v`](#-v---variables-path) / [`-s`](#-s---styles-path). -When a valid Pro license is provided, generated specs include additional detail such as design token references, variable bindings, and visibility bindings. Without a license (or with an invalid key), specs are generated at the free tier — full structure and variants, but with raw values instead of token references. +## Bridge Mode -**Resolution priority**: `--license` flag > `SPECS_LICENSE_KEY` env > `ANOVA_LICENSE_KEY` env +Generate from whatever is selected right now in a connected Figma file — no `specs fetch`, no downloaded JSON. Requires the [bridge](/cli/commands/bridge/) running and the Specs 2 plugin open in Figma with its **CLI Bridge** toggle enabled. ```bash -# Via flag -specs generate components.md -o specs/all.yaml -l "your-license-key" +specs bridge start +specs generate --from-bridge -o specs/button.yaml +``` -# Via environment variable (recommended) -export SPECS_LICENSE_KEY="your-license-key" -specs generate components.md -o specs/all.yaml +``` +✓ Generated from selection: DS Button +✓ Saved to specs/button.yaml ``` -See [Getting Started — License](/cli/getting-started.md/#step-3-set-your-license-key-optional) for setup details. +Because the plugin does the generating, bridge mode behaves differently from the other two in ways worth knowing: -### `-o, --output ` -Output file or directory path. Accepts both file paths and directory paths. +- **The plugin's settings and license govern the spec.** The config that shaped the output — `Config` keys, formatting, tier-gated detail — is the plugin's, not your `specs.config.yaml`. `-l/--license` has no effect; the plugin uses the license stored in its own UI. +- **Your CLI config still controls where and how the spec is written.** `outputDirectory`, `format.output`, and the output flags all apply as usual. +- **The output is as current as the file.** Unsaved and just-edited work is included, so this reflects the document rather than the last fetch. +- **`-c`, `-v`, `-s`, and `--data-dir` are ignored**, and passing a `source` argument is an error. + +The selection must be a component, component set, or frame. To target something other than the selection — for a repeatable script, where relying on a manual selection is fragile — pass [`--node`](#--node-id). When several Figma files are connected to one bridge, pick one with [`--file`](#--file-filekey). -- **File path**: Writes all output to a single file (e.g., `-o specs/library.yaml`) -- **Directory path**: Writes output files into the directory (e.g., `-o specs/`) -- **Not provided (manifest mode)**: Falls back to `config.outputDirectory` (default `./specs`) -- **Not provided (file mode)**: Outputs to stdout +Since bridge mode reads the live document, it closes the loop on [`render`](/cli/commands/render/) — render a spec into Figma, then read the result back and compare: ```bash -# Single file -specs generate components.md -o specs/library.yaml +specs bridge start +specs render specs/dsButton.yaml +specs generate --from-bridge -o specs/roundtrip/dsButton.yaml +diff specs/dsButton.yaml specs/roundtrip/dsButton.yaml +``` -# Directory for split output -specs generate components.md -o specs/ --split-components +Common failures: -# Manifest mode without -o: uses config.outputDirectory -specs generate components.md +- `Error: bridge is not running.` — start it with `specs bridge start`. +- `Nothing selected in Figma...` — select a component, component set, or frame in the connected file. +- `Selection must be a component, component set, or frame — got INSTANCE.` — select the source component rather than an instance of it. -# Output to stdout (file mode) -specs generate data/library.file.json -c "Button" | yq . -``` +## Arguments + +### `[source]` +Path to a markdown manifest or a Figma REST API JSON file. The mode is detected from its content. + +- **Not provided**: defaults to `{dataDirectory}/{alias}.manifest.md`, where `dataDirectory` comes from `specs.config.yaml` and `alias` is `library` if configured with `data: [file]`, otherwise the first source alias with `data: [file]`. This matches the default output of `specs scan`. +- **Markdown manifest**: manifest mode. +- **Figma JSON**: single component mode — requires `-c`. +- **Bridge mode**: takes no source argument; passing one is an error. + +## Options + +### `-c, --component ` +Component name or Figma node ID. Required in single component mode; ignored in manifest and bridge mode. + +### `-o, --output ` +Output file or directory path. + +- **File path**: writes all output to a single file (e.g. `-o specs/library.yaml`). +- **Directory path**: writes output files into the directory (e.g. `-o specs/`). +- **Not provided**: falls back to `config.outputDirectory` (default `./specs`). Required in manifest mode if that isn't configured; single component and bridge mode write to stdout instead. ### `-f, --format ` -Output format: `yaml` or `json`. +Output format: `yaml` or `json`. Defaults to `config.format.output` (or JSON with no config); the flag takes precedence. -- **Default**: Uses `config.format.output` from config (or `JSON` if no config) -- **Override**: CLI flag takes precedence over config +### `-l, --license ` +License key for premium features. -```bash -specs generate components.md --format yaml -o specs/library.yaml -``` +With a valid Pro license, generated specs include additional detail such as design token references, variable bindings, and visibility bindings. Without one (or with an invalid key), specs are generated at the free tier — full structure and variants, but raw values instead of token references. -### `--data-dir ` -Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `dataDirectory` from config, or `./data` if not configured. +**Resolution priority**: `--license` flag > `SPECS_LICENSE_KEY` env > `ANOVA_LICENSE_KEY` env ```bash -specs generate components.md --data-dir ./custom-data -o specs/library.yaml +export SPECS_LICENSE_KEY="your-license-key" +specs generate ``` +No effect in bridge mode, where the plugin's own license applies. See [Getting Started — License](/cli/getting-started.md/#step-3-set-your-license-key-optional) for setup. + +### `--data-dir ` +Override the data directory used for resolving input files and auxiliary data (variables, styles). Defaults to `dataDirectory` from config, or `./data`. + ### `-v, --variables ` External variables JSON file. - **Default** (no flag): loads all `${alias}.variables.json` for aliases in config whose `data` includes `variables`. -- **Fallback** (no sources configured): tries `foundations/variables.json` next to the ``. -- **Override**: CLI flag replaces that list for this run. - -```bash -specs generate data/library.file.json -c "Button" --variables data/library.variables.json -``` +- **Fallback** (no sources configured): tries `foundations/variables.json` next to the source JSON file. +- **Override**: the flag replaces that list for this run. ### `-s, --styles ` External styles JSON file. - **Default** (no flag): loads all `${alias}.styles.json` for aliases in config whose `data` includes `styles`. -- **Fallback** (no sources configured): tries `foundations/styles.json` next to the ``. -- **Override**: CLI flag replaces that list for this run. - -```bash -specs generate data/library.file.json -c "Button" --styles data/library.styles.json -``` +- **Fallback** (no sources configured): tries `foundations/styles.json` next to the source JSON file. +- **Override**: the flag replaces that list for this run. ### `--split-components` -Create a separate file per component (manifest mode only). - -- **Default**: `false` (single file with all components) -- **Output**: Individual files named `componentName.yaml` +Create a separate file per component, instead of one file containing all of them. ```bash -specs generate components.md -o specs/ --split-components +specs generate -o specs/ --split-components ``` ``` @@ -170,15 +195,10 @@ specs/ ``` ### `--split-concerns` -Separate API specification, variant configuration, and examples. - -- **Default**: `false` (complete component data in each file) -- **Output**: Up to three files: `api.yaml` (anatomy, props), `variants.yaml` (default, variants), and `examples.yaml` (slotContentExamples, instanceExamples) -- `examples.yaml` is written only when at least one component has example data; components without examples are omitted from it. -- Example output (`slotContentExamples`, `instanceExamples`) is a [Pro feature](/settings/default-slot-content/) — on the free tier it is omitted, so `examples.yaml` is not produced. +Separate API specification, variant configuration, and examples into up to three files: `api.yaml` (anatomy, props), `variants.yaml` (default, variants), and `examples.yaml` (`slotContentExamples`, `instanceExamples`). ```bash -specs generate components.md -o specs/ --split-concerns +specs generate -o specs/ --split-concerns ``` ``` @@ -188,10 +208,12 @@ specs/ └── examples.yaml ``` -When combined with `--split-components`, each component gets its own directory with concern files (`examples.yaml` appears only for components that have examples): +`examples.yaml` is written only when at least one component has example data, and components without examples are omitted from it. Example output is a [Pro feature](/settings/default-slot-content/) — on the free tier it's omitted entirely, so no `examples.yaml` is produced. + +Combined with `--split-components`, each component gets its own directory of concern files: ```bash -specs generate components.md -o specs/ --split-components --split-concerns +specs generate -o specs/ --split-components --split-concerns ``` ``` @@ -212,7 +234,7 @@ specs/ Organize component files in subdirectories (requires `--split-components`). Wraps each component file in its own folder. ```bash -specs generate components.md -o specs/ --split-components --use-subfolders +specs generate -o specs/ --split-components --use-subfolders ``` ``` @@ -229,7 +251,7 @@ specs/ Resolve unresolved registry images into real image files. Requires a [`processing.images`](/settings/images/) block in config, a configured source file key, and the `FIGMA_TOKEN` environment variable (the same token `specs fetch` uses). ```bash -specs generate components.md -o specs/ --split-components --get-images +specs generate -o specs/ --split-components --get-images ``` Generation alone (the *detect* phase) records each image fill as an unresolved registry entry — the Figma identity in `$extensions['com.figma'].imageHash`, no `src` — structurally complete, but with no pixels. With `--get-images`, the CLI calls Figma's Get Image Fills endpoint, downloads each distinct image once, writes it as `_images/.` inside the output directory (format detected from the bytes — png, jpg, gif, or webp), and **adds** `src` to each entry — a path relative to the spec file that references it. The Figma identity survives for reverse-direction tooling: @@ -260,74 +282,36 @@ specs/ `$image` pointers (in `backgroundImage` fills and `ImageBinding` examples) are unaffected — resolution touches one registry entry per image, never the references. Files are named by Figma's content hash, so an image shared by many components is downloaded and stored once, and re-runs are idempotent. Figma's download URLs are temporary and are never persisted. With `--use-subfolders` (or the combined component + concern layout), `src` becomes `../_images/...` so it still resolves relative to each spec file. -### `--config ` -Path to configuration file. - -```bash -specs generate components.md --config configs/mobile.yaml -o specs/mobile.yaml -``` - -### `--verbose` -Enable detailed logging with progress indicator. - -```bash -specs generate components.md --verbose -o specs/library.yaml -``` - -**Manifest mode output:** -``` -✓ Loaded manifest: 150 components (42 selected) -⏳ Processing 42 components... - -[1/42] DS Accordion... ✓ -[2/42] DS Alert... ✓ -... -[42/42] DS Toggle... ✓ - -✓ Generated specs - - 42 components successful -✓ Saved to specs/library.yaml -``` - -## Examples - -### Manifest workflow - -```bash -# 1. Fetch data -specs fetch - -# 2. Create manifest (auto-resolves configured source; writes to data/library.manifest.md by default) -specs scan - -# 3. Curate (edit data/library.manifest.md to select [x] components) +### `--from-bridge` +Generate from the current selection in a connected Figma file via the [CLI bridge](/cli/commands/bridge/), instead of from a manifest or downloaded JSON. See [Bridge Mode](#bridge-mode). -# 4. Generate specs (uses default manifest + outputDirectory from config) -specs generate -``` +### `--file ` +Target a specific connected Figma file (bridge mode only). More than one file can be connected to a single bridge at once. -### Single component +- **One file connected**: not needed. +- **Several connected, interactive terminal**: omitting it prints a numbered picker and prompts you to choose. +- **Several connected, non-interactive (scripts, CI)**: required — the run fails rather than hanging on a prompt. -```bash -specs generate data/library.file.json -c "DS Button" -o specs/button.yaml -``` +### `--node ` +Generate from a specific node ID instead of the current selection (bridge mode only). The plugin selects the node first, switching pages if the node lives on another one, then restores the page you were on. -### Per-component files +### `--config ` +Path to a configuration file, when it isn't the `specs.config.yaml` in the working directory. ```bash -specs generate components.md -o specs/ --split-components +specs generate --config configs/mobile.yaml -o specs/mobile.yaml ``` -### With license key - -```bash -export SPECS_LICENSE_KEY="your-license-key" -specs generate -``` +### `--verbose` +Enable detailed logging — resolved config path, source and mode detection, foundations loaded, and per-component progress. --- **See Also:** - [Scan Command](/cli/commands/scan/) - Create component manifest +- [Fetch Command](/cli/commands/fetch/) - Download Figma data for manifest and single component mode +- [Render Command](/cli/commands/render/) - Send a generated spec back into Figma +- [Bridge Command](/cli/commands/bridge/) - Start the local bridge used by `--from-bridge` +- [Render to Figma](/guides/render-to-figma/) - Bridge architecture, setup, and prerequisites - [Configuration Reference](/settings/) - Format and config options - [Getting Started](/cli/getting-started/) - Installation and license setup diff --git a/site/src/content/docs/cli/commands/render.md b/site/src/content/docs/cli/commands/render.md new file mode 100644 index 00000000..7b116018 --- /dev/null +++ b/site/src/content/docs/cli/commands/render.md @@ -0,0 +1,274 @@ +--- +title: "render" +--- + + + +Sends a spec (or a batch of specs) to a running CLI bridge, which renders the matching component live in a Figma file — creating or updating it. + +This is the reverse of the rest of the CLI: instead of reading Figma and producing a spec, `render` reads a spec and renders it in Figma. It requires the bridge to be running (`specs bridge start`) with a connected Figma plugin — see the [Render to Figma guide](/guides/render-to-figma/) for full setup before running this command for the first time. + +It also requires a current [cache](/cli/commands/cache/) — the lookup tables that give a spec's component, token, style, and icon names meaning. `fetch` builds it, so a normal workflow already has one. When it's missing or no longer matches your fetched data, `render` stops and names what to rebuild rather than rendering against data that has moved on. + +## How a Render Works + +Each render is a single round trip through four steps: + +1. **Load the spec.** A spec file is read directly; a component folder is assembled from its `api.*`, `variants.*`, and optional `examples.*` files. +2. **Resolve the target file.** From `--file`, or from the bridge's connections — prompting when more than one plugin is connected and the terminal is interactive. In a directory batch this happens once, up front, so a sweep never prompts per component. +3. **Post to the bridge.** The bridge validates the [cache](/cli/commands/cache/) against your fetched data, builds the lookup manifests the spec's names resolve through, and relays the payload to the connected plugin. +4. **The plugin writes.** The component is built inside the live Figma document — frames, styles, variants, instances — and the resulting node id comes back. + +`render` reports that outcome and stops there. It performs no round-trip check of its own: reading back what actually landed is an explicit [`generate --from-bridge`](/cli/commands/generate/#bridge-mode) call. The bridge holds one render slot, so concurrent calls queue rather than overlap. + +## Usage + +```bash +specs render [specPath] [options] +``` + +## Arguments + +### `[specPath]` + +What to render. Three shapes are accepted: + +- **A spec file** (`.yaml`, `.yml`, `.json`) — one component. +- **A component folder** — a directory holding `api.*` and `variants.*` (plus optional `examples.*`), as produced by `generate --split-components --split-concerns`. Renders that one component. +- **A directory of component folders** — renders every component beneath it, sequentially, in path order. + +```bash +specs render specs/deButton.yaml # one spec file +specs render specs/deButton/ # one component folder +specs render specs/ # every component in the directory +specs render specs/forms/ # every component in one group +``` + +Batch scanning looks at most two levels deep, so both `specs/deButton/` and `specs/forms/deInput/` are found. It never descends into a component folder. + +Optional — when omitted, `render` uses the configured `outputDirectory` as a batch. + +A directory batch renders in path order. Render order isn't configurable, so when one component's spec references another, render them individually in the order you need. + +## Options + +### `--config ` + +Use a specific config file instead of the default `specs.config.yaml`. + +### `--file ` + +Target a specific connected Figma file. Only matters when the bridge has more than one plugin connected: + +- **Passed explicitly** — that file is used, no prompt. +- **Omitted, interactive terminal, 2+ connections** — `render` lists the connected files and asks which to use. +- **Omitted, non-interactive (scripts, CI)** — no prompt; the bridge reports the ambiguity as an error rather than hanging on stdin. + +Scripted runs against a multi-file bridge should always pass `--file`. + +```bash +specs render specs/deButton.yaml --file abc123XYZ +``` + +In a directory batch the file is resolved once, up front, so a sweep never prompts per component. + +### `--page ` + +Render onto a specific page instead of whatever page the plugin currently has open. Recommended for scripted runs — without it, the target is the user's current page, which can drift mid-run if someone navigates in Figma. + +```bash +specs render specs/deButton.yaml --page 12:345 +``` + +### `--overwrite` + +Delete any existing page component with the same title before rendering. Without it, a title collision is an error. + +This is destructive — the existing component is removed, not merged into. When a directory batch would overwrite more than one component and the terminal is interactive, `render` asks for confirmation first; non-interactive runs proceed without asking. + +```bash +specs render specs/deButton.yaml --overwrite +``` + +### `--watch` + +Watch the spec path and re-render on every change. Implies `--overwrite`, since each re-render replaces the component produced by the last one. + +Requires an explicit spec path — it can't be combined with zero-arg config resolution. It accepts any of the three `[specPath]` shapes; for a single spec file the watch is set on its containing directory, so sibling edits also trigger it. Changes are debounced (300ms), and a render in flight defers the next one rather than overlapping. Runs until interrupted with Ctrl+C. + +In watch mode a failed render is logged and the watch continues, rather than exiting — the next save may fix it. + +```bash +specs render specs/deButton/ --watch +``` + +### `--refresh-cache` + +Rebuild the [cache](/cli/commands/cache/) from your fetched data before rendering. Off by default, since `fetch` already builds it and rebuilding on every render would reintroduce the cost the cache exists to remove. + +Use it when a render has just failed on a stale cache and you'd rather not run a separate command. + +```bash +specs render specs/deButton/ --refresh-cache +``` + +### `--timing` + +Print a phase-by-phase timing report after the render: the bridge's lookup work and payload size, then each phase of the write inside Figma, with each phase's share of total time and how many times it ran. + +Phases that run concurrently — one row per variant, for instance — can sum to more than the total; the count column is what makes that readable. + +```bash +specs render specs/deButton/ --timing +``` + +### `--strict` + +Fail the render when an instance element can't be resolved, instead of rendering a component with missing content. + +By default an unresolvable instance is a warning: the component is still created in Figma, minus that content, and `render` reports how many elements were dropped. `--strict` turns that into a failure — useful in CI, where a silently incomplete component is worse than a red build. + +```bash +specs render specs/deButton.yaml --strict +``` + +In a directory batch, `--strict` fails the individual component; the sweep continues and the exit code reflects the total. + +## Output + +While the plugin works, a spinner holds a single line (`⠹ Rendering: deButton (3s)`); the outcome then prints over it, so a batch reads as one line per component. In a non-interactive terminal the status is printed as a plain line instead. + +### A successful render + +``` +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +``` + +The node id is the component that was created or updated — open it directly at `figma.com/file/.../?node-id=1234-5678`. + +### An incomplete render + +``` + ⚠ INCOMPLETE: 2 instance element(s) could not be resolved and were not rendered. The component exists in Figma but is missing content. +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) +``` + +An instance element that can't be resolved is content the component was supposed to contain, so this is called out as a count rather than left to a per-element warning that scrolls past in a batch. It's still a success — the component exists — unless you pass [`--strict`](#--strict). + +The usual cause is a cache that doesn't know the referenced component: run [`specs fetch`](/cli/commands/fetch/) to refresh it. + +### A batch + +``` +Found 6 components in specs: + - deButton + - deCard + ... +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) + ✗ deModal: Render failed: no page component named "DS Modal" +... + +Done: 5 rendered in Figma, 1 failed. +⚠ 1 rendered with missing content: deCard (2) +``` + +A failure doesn't abort the sweep — the remaining components still render, and the exit code reflects the total. Incomplete renders are reported separately from failures, because they succeeded. + +## Examples + +```bash +# Render one component +specs render specs/deButton.yaml + +# Render every component in the output directory +specs render specs/ + +# Same, resolved from config (outputDirectory) +specs render + +# Re-render on every save while iterating on a spec +specs render specs/deButton/ --watch + +# Replace an existing component instead of erroring on the title collision +specs render specs/deButton.yaml --overwrite + +# Rebuild the lookup cache first, then render +specs render specs/deButton/ --refresh-cache + +# See where a slow render spends its time +specs render specs/deButton/ --timing + +# Scripted run: pin the file and page, fail on incomplete renders +specs render specs/ --file abc123XYZ --page 12:345 --strict +``` + +## Fidelity + +A component rendered from its spec and read back should be byte-identical to the spec that +produced it, except where a limitation below explains the difference or an open defect +accounts for it — anything else is a bug nobody has written down yet. + +### Limitations + +Permanent, and imposed by Figma rather than by this tool. None of them lose a property, a +value or a binding; each is about how something is expressed. + +- **Property order in the panel.** Figma orders properties by creation, with no way to + reorder afterwards, so slots lead and code-only props trail. Variant order *is* preserved. +- **A locked aspect ratio holds one bound dimension, not two.** Declaring a ratio plus both + dimensions is over-specified, and Figma derives the second from the first. +- **A filling root has no width to reproduce.** Derived from the ratio where one exists, + otherwise a fixed fallback ([#341](https://github.com/DirectedEdges/specs/issues/341)). +- **Boolean variant values render lowercase.** `True` becomes `true`; identical behaviour, + different casing. +- **Instances placed elsewhere in the file are not recreated.** A render reproduces the + component asset, so the examples drawn from its instances have no source to come from. +- **A mask is neither read nor rendered.** Shapes masked by another layer — corner shaping, + for instance — are read as the shapes they are, and a render reproduces them unmasked. +- **Bindings from slot content to the component that holds it are ignored.** Figma withdrew + that capability; older files keep theirs, and each affected component reports it once. + +### Open defects + +Real differences between a component and a render of its own spec, whatever a given release +schedules. Expect these in a comparison until they are fixed. + +- A slot constraint naming a component absent from the fetched data is dropped — + [#325](https://github.com/DirectedEdges/specs/issues/325) +- A code-only prop whose name collides with a native property is dropped — + [#344](https://github.com/DirectedEdges/specs/issues/344) +- A code-only prop backed by a variant picker loses its provenance and its Figma type — + [#350](https://github.com/DirectedEdges/specs/issues/350) + +### Differences the source file causes + +A component only round-trips faithfully if it follows the authoring conventions. These are +not tool defects and no release fixes them; the component is edited instead. + +- A `Code only props` container placed anywhere but as a direct child of the component root + (ADR-027) is read as ordinary anatomy. +- A layer carrying two property references at once, or a code-only layer whose own + visibility is bound, loses the prop that is not read. +- A property bound only into slot content cannot be reproduced — Figma withdrew that + capability, so those bindings are relics. +- A property with no layer bound to it exists in the panel but has no binding site to + rebuild from, so it does not survive. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | General error — bridge unreachable (run `specs bridge start`), render failed, or the plugin isn't connected | +| `2` | Invalid arguments — spec path not found, `--watch` without a spec path, or no spec path given and no `outputDirectory` to fall back to | + +--- + +**See Also:** + +- [`bridge`](/cli/commands/bridge/) — start/stop/check the bridge `render` talks to +- [Render to Figma](/guides/render-to-figma/) — bridge architecture, setup, and prerequisites +- [`cache`](/cli/commands/cache/) — the lookup tables `render` resolves component, style, token, and glyph names against +- [`fetch`](/cli/commands/fetch/) — downloads the library data and builds that cache +- [`generate`](/cli/commands/generate/) — produces the specs that `render` consumes diff --git a/site/src/content/docs/cli/commands/scan.md b/site/src/content/docs/cli/commands/scan.md index 8a8eb509..1253fe49 100644 --- a/site/src/content/docs/cli/commands/scan.md +++ b/site/src/content/docs/cli/commands/scan.md @@ -240,5 +240,6 @@ Manifests produced by older versions of `scan` (checkbox-list format like `- [x] **See Also:** - [Generate Command](/cli/commands/generate/) - Generate specs from manifest or single component +- [Render Command](/cli/commands/render/) - Uses scan data to bind glyphs, styles, and variables when rendering in Figma - [glyphNamePattern](/settings/glyph-name-pattern/) - Pattern syntax that drives Glyphs-section partitioning - [Configuration Reference](/settings/) - Format and config options diff --git a/site/src/content/docs/cli/index.md b/site/src/content/docs/cli/index.md index c156daac..dd50d095 100644 --- a/site/src/content/docs/cli/index.md +++ b/site/src/content/docs/cli/index.md @@ -11,8 +11,12 @@ The Specs command-line interface (CLI) generates design system specifications fr | [`init`](/cli/commands/init/) | Initialize config file with defaults | `specs.config.yaml` | | [`fetch`](/cli/commands/fetch/) | Download raw REST payloads from Figma | JSON files in `dataDirectory` | | [`scan`](/cli/commands/scan/) | List all components in file | Markdown manifest | -| [`generate`](/cli/commands/generate/) | Generate specs from a manifest or single component | YAML/JSON spec file(s) | | [`applyCustomTokens`](/cli/commands/apply-custom-tokens/) | Inject `$custom` objects into fetched data | Modified variables/styles JSON | +| [`generate`](/cli/commands/generate/) | Generate specs from a manifest or single component | YAML/JSON spec file(s) | +| [`transform`](/cli/commands/transform/) *(experimental)* | Run transformers over generated specs | Code artifacts per transformer | +| [`bridge`](/cli/commands/bridge/) *(experimental)* | Start/stop/check the local bridge `render` talks to | Background process | +| [`cache`](/cli/commands/cache/) *(experimental)* | Build the lookup tables `render` resolves specs against | YAML files in `dataDirectory/cache` | +| [`render`](/cli/commands/render/) *(experimental)* | Send a spec to the CLI bridge to render it live in Figma | Live Figma component | ### Global Options @@ -94,6 +98,7 @@ data/ - `file` — any Figma plan with REST API access - `variables` / `styles` — Figma restricts these REST endpoints to organizations on an **Enterprise** plan, regardless of your Specs license - **License key** (optional) via `SPECS_LICENSE_KEY` for Pro features +- **A running CLI bridge and open Figma session** (for `render` only) — see the [Render to Figma guide](/guides/render-to-figma/) See [Getting Started](/cli/getting-started/) for installation instructions. diff --git a/site/src/content/docs/cli/workflows.md b/site/src/content/docs/cli/workflows.md index 659f4f27..3e87aa38 100644 --- a/site/src/content/docs/cli/workflows.md +++ b/site/src/content/docs/cli/workflows.md @@ -9,6 +9,8 @@ Three ways to generate specs, depending on how many components you need: - [**Many Components**](#many-components) — curate a manifest and generate in batch - [**CI/CD Integration**](#cicd-integration) — automate generation in a pipeline +There's also a reverse direction — turning a spec back into a live Figma component with [`render`](#render-to-figma). Unlike the workflows above, it's inherently interactive (it needs an open Figma session) and isn't a fit for CI/CD. + ## Single Component Generate a spec for one component: @@ -172,6 +174,59 @@ echo "Sync complete!" --- +## Render to Figma + +Turn a spec back into a live Figma component. This needs a running CLI bridge and an open Figma session — see the [Render to Figma guide](/guides/render-to-figma/) for full setup. + +```bash +# One-time per session: start the bridge, then in Figma enable +# the CLI Bridge in the Specs 2 plugin and navigate to the target page. +specs bridge start +``` + +```bash +# Render one component +specs render specs/deButton.yaml + +# Or render every component in a directory +specs render specs/ +``` + +--- + +## Generate from the Current Selection + +When the component you want is already open in Figma, skip the fetch entirely and generate from the live selection over the same bridge. See [Bridge Mode](/cli/commands/generate/#bridge-mode) for the full behavior. + +```bash +# Bridge running, Specs 2 plugin open with CLI Bridge enabled, +# and the component selected in Figma. +specs generate --from-bridge -o specs/dsButton.yaml +``` + +The spec reflects the document as it stands right now, including unsaved edits — useful while iterating on a component rather than after a fetch. + +```bash +# Pin the node instead of relying on a manual selection +specs generate --from-bridge --node 5507:123 -o specs/dsButton.yaml + +# Pick a file when several are connected to one bridge +specs bridge status +specs generate --from-bridge --file abc123XYZ -o specs/dsButton.yaml +``` + +With 2+ files connected and no `--file`, an interactive terminal prompts you to choose; scripts and CI fail with the ambiguity error instead of hanging. + +Pair it with `render` to round-trip a spec and diff the result: + +```bash +specs render specs/dsButton.yaml +specs generate --from-bridge -o specs/roundtrip/dsButton.yaml +diff specs/dsButton.yaml specs/roundtrip/dsButton.yaml +``` + +--- + ## Tips ### Pipe to Other Tools @@ -247,3 +302,4 @@ git commit -m "manifest: keep DS Tooltip NEW unchecked — pending API redesign" - [CLI Overview](/cli/) - Commands, Free vs Pro, output format - [Settings](/settings/) - Config file reference +- [Render to Figma](/guides/render-to-figma/) - CLI bridge setup and usage diff --git a/site/src/content/docs/guides/render-to-figma.md b/site/src/content/docs/guides/render-to-figma.md new file mode 100644 index 00000000..933af97b --- /dev/null +++ b/site/src/content/docs/guides/render-to-figma.md @@ -0,0 +1,278 @@ +--- +title: "Render to Figma" +description: "The full command sequence behind a successful render — config, fetch, cache, bridge, plugin, render, and verification" +--- + +Every other command in Specs reads Figma and produces a spec. [`render`](/cli/commands/render/) runs the pipeline in reverse: it takes a spec and creates or updates the matching component in a real, open Figma file. + +`render` itself is a thin client. Almost everything that determines whether a render succeeds happens *before* you type it — the data you fetched, the lookup cache built from it, the bridge process, and the plugin connection. This guide walks the whole chain in order, with what each stage contributes, how to verify it before moving on, and what it looks like when it's wrong. + +## The Chain + +``` +specs init config: sources, dataDirectory, outputDirectory + ↓ +specs fetch Figma payloads → data/{alias}.file.json, .variables.json + ↓ (and builds the cache as its last step) +specs cache lookup tables → data/cache/*.yaml + ↓ +specs bridge start background server on localhost + ↓ +Figma plugin Specs 2 open, CLI Bridge toggled on → connects to the bridge + ↓ +specs render spec → bridge → plugin → component in Figma + ↓ +specs generate --from-bridge: read back what actually landed + --from-bridge +``` + +The first three stages are about *meaning* — a spec names components, styles, tokens, and glyphs, and those names are resolved against your fetched library. The next two are about *reach* — a Node process cannot touch a Figma document, so the bridge relays to a plugin that can. The last is how you check the result, and it is always an explicit second call, never a side effect of `render`. + +## Stage 1 — Config + +A render workspace is an ordinary Specs workspace. If you don't have one: + +```bash +specs init +``` + +Then fill in `sources` with your Figma file keys. Three config values matter to the render path: + +| Setting | Why `render` cares | +|---|---| +| `sources` | Each declared source must be fetched and cached. `render` fails naming any that aren't. | +| `dataDirectory` | Where fetched payloads and `cache/` live. `--refresh-cache` requires it. | +| `outputDirectory` | What `specs render` with no path argument renders as a batch. | + +See [sources config](/settings/data-sources/) for the full shape. + +## Stage 2 — Fetch the Library + +```bash +export FIGMA_TOKEN=figd_... +specs fetch +``` + +This downloads the raw Figma payloads *and* builds the render cache as its final step, which is why a normal workflow never runs `specs cache` by hand. + +Re-run `fetch` whenever the library moves underneath you — components added or renamed, styles or variables changed. A spec binds by name, so rendering against data that predates a rename silently binds to the wrong thing or drops content. + +Verify the last lines of the output name your sources and non-zero entry counts: + +``` + Cache rebuilt: library + Entries: 3567 components, 97 styles, 1458 variables, 469 icons +``` + +See [`fetch`](/cli/commands/fetch/) for flags, and note that `variables` and `styles` data requires a Figma Enterprise plan — `file` and `icons` work on any plan. + +## Stage 3 — The Cache + +The [cache](/cli/commands/cache/) is the set of lookup tables that give a spec's names meaning. Four files under `{dataDirectory}/cache/`: + +| File | Resolves | What breaks without it | +|---|---|---| +| `components.yaml` | instance elements → published component keys | Instances can't be placed; content is missing | +| `styles.yaml` | style names → style keys | Styles aren't applied | +| `variables.yaml` | token names → variable keys | Token bindings fall back to raw values | +| `icons.yaml` | glyph names → icon node ids | Glyph elements can't be placed | + +You run [`specs cache`](/cli/commands/cache/) by hand only when the cache is missing, when something outside `fetch` changed your data directory, or when `render` tells you to: + +```bash +specs cache # rebuild whatever is out of date +specs cache --force # rebuild every source from scratch +``` + +The bridge checks the cache against your data files before **every** render, and a mismatch is a hard failure rather than an automatic rebuild: + +``` +Error: Render failed: Render cache is not usable: + - variables.yaml: "library" is stale + +Run `specs cache` to rebuild it. +``` + +Rebuilding on every render would reintroduce exactly the cost the cache exists to remove, and rendering against data that has moved on produces a failure that surfaces far from its cause. When you'd rather not run a separate command, [`render --refresh-cache`](/cli/commands/render/#--refresh-cache) rebuilds first and then renders. + +Note that `render` reads nothing produced by [`scan`](/cli/commands/scan/). A scan manifest is generated and then hand-authored, which makes it a poor thing to resolve against; `scan` serves `generate`, not `render`. + +## Stage 4 — The Bridge + +Making a spec real in Figma means placing frames, applying styles, wiring variants, and building instances inside a running Figma session — work that can only happen inside the plugin runtime, not in a Node process. The bridge is what connects the two. + +```bash +specs bridge start +``` + +``` +Bridge started (pid 12345). + WebSocket : ws://localhost:9001 (plugin) + HTTP : http://localhost:9002/render (control) + Logs : /Users/you/.specs/bridge.log +Enable the CLI Bridge in the Specs 2 plugin to connect. +``` + +It runs in the background until you stop it, so this is once per session, not once per render. `specs render` never starts it for you — if it isn't running, the command fails immediately rather than hanging: + +``` +Error: bridge is not running. + Start it with: specs bridge start +``` + +Pin a workspace for the whole session with `specs bridge start --workspace ~/design-system` when every render targets the same one; otherwise it's derived per request from each spec's path. + +## Stage 5 — Connect the Plugin + +Starting the bridge connects it to nothing by itself. In Figma: + +1. Open the **target file** — the one you want components rendered into. +2. Run the **Specs 2** plugin. +3. Toggle **CLI Bridge** on. If the plugin was already open before you started the server, toggle it off and back on. +4. Navigate to the **page** you want rendered onto, unless you plan to pass `--page`. + +Verify from the terminal: + +```bash +specs bridge status +``` + +``` +Bridge running (pid 12345). 1 plugin connected: + abc123XYZ (Design System Library) — connected +``` + +`bridge status` exits non-zero when the bridge isn't running, so it's safe in a conditional (`if specs bridge status; then ...`). + +**More than one file can be connected at once.** When two or more plugins are connected, `render` asks which file to use in an interactive terminal, and reports the ambiguity as an error in a non-interactive one rather than hanging on stdin. Scripted runs should always pass `--file ` — the key is the first column of `bridge status`. + +## Stage 6 — Render + +### One component + +```bash +specs render specs/deButton.yaml # a spec file +specs render specs/deButton/ # a component folder (api.* + variants.*) +``` + +A spinner holds the line while Figma works, then the outcome replaces it: + +``` +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +``` + +The node id is the component that was created or updated — open it directly at `figma.com/file/.../?node-id=1234-5678`. + +### A batch + +Point `render` at a directory of component folders, or omit the path entirely to use `outputDirectory`: + +```bash +specs render specs/ # every component beneath specs/ +specs render specs/forms/ # one group +specs render # resolved from config +``` + +``` +Found 6 components in specs: + - deButton + - deCard + ... +✓ Rendered: deButton (2.4s, nodeId: 1234:5678) +✓ Rendered: deCard (3.1s, nodeId: 1234:5690) +... + +Done: 6 rendered in Figma, 0 failed. +``` + +Batch scanning looks at most two levels deep and never descends into a component folder. Components render **one at a time, in path order** — order isn't configurable, so when one component's spec references another, render them individually in the order you need. A single failure doesn't abort the sweep; the exit code reflects the total. + +### Reading the outcome + +| Output | Means | +|---|---| +| `✓ Rendered: name (…)` | The component was created or updated | +| `⚠ INCOMPLETE: n instance element(s) …` | The component exists but is **missing content** — an instance couldn't be resolved | +| `✗ name: Render failed: …` | Nothing was produced for that component | +| `⚠ n rendered with missing content: …` | Batch summary of the incomplete ones | + +An incomplete render is a success, not a failure — the component is in Figma, minus what couldn't be placed. Missing instances almost always mean the cache doesn't know the referenced component: re-run `specs fetch`. Pass [`--strict`](/cli/commands/render/#--strict) to turn incompleteness into a failure, which is what you want in CI where a silently partial component is worse than a red build. + +## Stage 7 — Verify What Landed + +`render` reports success and a node id — nothing about fidelity. To read back what actually exists in Figma, select the rendered component and run generation through the same bridge: + +```bash +specs generate --from-bridge -o specs/roundtrip/deButton.yaml +diff specs/deButton.yaml specs/roundtrip/deButton.yaml +``` + +That round trip is the real check. Because render is under active development, treat every render as something to look at in Figma too, rather than a guaranteed match to the source spec. + +## Stage 8 — Iterate + +While working on one spec, let the watcher re-render on every save: + +```bash +specs render specs/deButton/ --watch +``` + +`--watch` implies `--overwrite`, since each pass replaces the component the last one produced. It requires an explicit path, debounces changes (300ms), defers rather than overlaps when a render is in flight, and keeps going after a failed render — the next save may fix it. Ctrl+C to stop. + +Without `--watch`, a title collision is an error; [`--overwrite`](/cli/commands/render/#--overwrite) deletes the existing same-titled component first. That's destructive, so an interactive batch of more than one component asks for confirmation. + +When a render is slow, [`--timing`](/cli/commands/render/#--timing) attributes the wall clock to bridge lookup work and each plugin write phase. + +## Stage 9 — Scripted Runs + +Everything ambiguous in an interactive session must be pinned in a script: + +```bash +specs render specs/ --file abc123XYZ --page 12:345 --strict +``` + +- `--file` — never rely on the prompt; a non-interactive run errors instead of asking. +- `--page` — without it the target is whatever page the user has open, which can drift mid-run if someone navigates in Figma. +- `--strict` — fail on incomplete renders instead of shipping partial components. + +Note that a render session still needs a human-driven Figma app open with the plugin connected, so `render` is not a fit for headless CI. + +## Stage 10 — Tear Down + +```bash +specs bridge stop +``` + +Graceful first, forced if it doesn't exit within a few seconds. Safe to run when nothing is running. + +## Troubleshooting + +| Symptom | Stage | Fix | +|---|---|---| +| `Error: bridge is not running.` | 4 | `specs bridge start`, then retry | +| `Bridge running … No plugin connected.` | 5 | Enable CLI Bridge in the Specs 2 plugin; toggle off/on if it was already open | +| Prompted to choose a file, or an ambiguity error | 5 | Two plugins are connected — pass `--file ` from `bridge status` | +| Nothing happens / times out | 5 | `specs bridge status` to confirm the connection; check you're on the intended page; `tail -f ~/.specs/bridge.log` | +| `Render cache is not usable` | 3 | `specs cache`, or render with `--refresh-cache` | +| `Not fetched, skipped: ` then a render failure | 2 | `render` needs every declared source fetched — run `specs fetch` | +| `⚠ INCOMPLETE: n instance element(s)` | 2–3 | The cache can't resolve a referenced component — `specs fetch` to refresh it | +| Missing glyphs, styles, or variable bindings | 2–3 | `specs fetch`, then render again | +| Rendered onto the wrong page | 5 | Pass `--page `; without it the target is the plugin's current page | +| `Error: provide a spec path.` | 6 | No path given and no `outputDirectory` in config to fall back to | +| `no component folders found` | 6 | A component folder holds `api.(yaml\|json)` and `variants.(yaml\|json)`, at most two levels deep | + +## Limits + +- **One render at a time.** The bridge holds a single render slot; concurrent calls queue. +- **No automatic round trip.** Reading a rendered component back is always an explicit `generate --from-bridge`. +- **Render order isn't configurable.** Batches go in path order. +- **This is a young feature.** Expect rough edges on more elaborate components. + +## Further Reading + +- [`render` command reference](/cli/commands/render/) — every flag, argument shape, and exit code +- [`bridge` command reference](/cli/commands/bridge/) — start/stop/status, logs, pid file +- [`cache`](/cli/commands/cache/) — what the lookup tables hold, and when they go stale +- [`fetch`](/cli/commands/fetch/) — downloads the library data and builds the cache +- [`generate`](/cli/commands/generate/) — produces the specs that `render` consumes, and reads them back with `--from-bridge` +- [sources config](/settings/data-sources/) — how workspace aliases and data files are resolved From 499939859f23ed17ddaf542d68fa0a45776a4037 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:32:32 -0400 Subject: [PATCH 14/19] docs(render): drop the source-file-caused differences section Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BegEfZ8hV7SGXvAehL37Aa --- site/src/content/docs/cli/commands/render.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/site/src/content/docs/cli/commands/render.md b/site/src/content/docs/cli/commands/render.md index 7b116018..12dbda5a 100644 --- a/site/src/content/docs/cli/commands/render.md +++ b/site/src/content/docs/cli/commands/render.md @@ -241,20 +241,6 @@ schedules. Expect these in a comparison until they are fixed. - A code-only prop backed by a variant picker loses its provenance and its Figma type — [#350](https://github.com/DirectedEdges/specs/issues/350) -### Differences the source file causes - -A component only round-trips faithfully if it follows the authoring conventions. These are -not tool defects and no release fixes them; the component is edited instead. - -- A `Code only props` container placed anywhere but as a direct child of the component root - (ADR-027) is read as ordinary anatomy. -- A layer carrying two property references at once, or a code-only layer whose own - visibility is bound, loses the prop that is not read. -- A property bound only into slot content cannot be reproduced — Figma withdrew that - capability, so those bindings are relics. -- A property with no layer bound to it exists in the panel but has no binding site to - rebuild from, so it does not survive. - ## Exit Codes | Code | Meaning | From 125fc1a67c063c764dbd43652d213aebfcc505f2 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:32:33 -0400 Subject: [PATCH 15/19] chore: restore local file: refs for development Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BegEfZ8hV7SGXvAehL37Aa --- package-lock.json | 21 ++------------------- packages/cli/package.json | 4 ++-- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 269bfd6d..df52242e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1666,8 +1666,8 @@ "version": "0.27.0", "license": "MIT", "dependencies": { - "@directededges/specs-from-figma": "^0.28.0", - "@directededges/specs-schema": "^0.29.0", + "@directededges/specs-from-figma": "file:../../../specs-from-figma/packages/specs-from-figma", + "@directededges/specs-schema": "file:../../../specs/packages/schema", "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2", @@ -1686,23 +1686,6 @@ "vitest": "^3.0.0" } }, - "packages/cli/node_modules/@directededges/specs-from-figma": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@directededges/specs-from-figma/-/specs-from-figma-0.28.0.tgz", - "integrity": "sha512-2Wn+YiOXBgNA+gWDy2h7kY8g7wlj0qg4e2G0S2zN91yrsvCIZn6S5zrInP1Z3RPHGXhBMlzUxEn/BZSxI/Kzbw==", - "license": "PolyForm-Internal-Use-1.0.0", - "dependencies": { - "@directededges/specs-schema": "^0.29.0", - "fs-extra": "^11.3.3", - "yaml": "^2.8.0" - } - }, - "packages/cli/node_modules/@directededges/specs-schema": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@directededges/specs-schema/-/specs-schema-0.29.0.tgz", - "integrity": "sha512-PfK3oeTdQbvlmuD6lAGHOOlkHvQCE0wFlqQERQQRN3w/42qRaBDK/8hMwJ/mB/wyDAg5GlB+uBA0p14jsksAZg==", - "license": "CC-BY-4.0" - }, "packages/cli/node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0f090f4f..f77089e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,8 +21,8 @@ "test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests" }, "dependencies": { - "@directededges/specs-schema": "^0.29.0", - "@directededges/specs-from-figma": "^0.28.0", + "@directededges/specs-schema": "file:../../../specs/packages/schema", + "@directededges/specs-from-figma": "file:../../../specs-from-figma/packages/specs-from-figma", "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2", From bcdf1185b11c42ebcfc9fd8e6db8d1a130dec385 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:12:31 -0400 Subject: [PATCH 16/19] adr(index): claim 070 explicit-absolute-position Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BegEfZ8hV7SGXvAehL37Aa --- adr/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/adr/INDEX.md b/adr/INDEX.md index 56e657a8..3aadb280 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 070 | Explicit `position: ABSOLUTE` for Children of Non-Auto-Layout Parents | | | 069 | Rename `clipContent` to `clipsContent` | | | 045 | Processing Provenance Signals | (reserved, draft in PR #60) | | 044 | Duplicate Layer Name Disambiguation | (reserved, draft in PR #60) | From 9b2724d576a40efe84f1cb3a3fdaaa7762786bd7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:36:48 -0400 Subject: [PATCH 17/19] fix(site): run generators in docs deploy via npm run build The site prebuild script generates both the releases page and the ADR pages, so CI must invoke it rather than calling astro build directly. Generated ADR output is ignored alongside the other build artifacts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hzttw9nAJkW2Y8LSFJuXzE --- .github/workflows/deploy-docs.yml | 5 +---- site/.gitignore | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 5221b96c..eabc3fd8 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -26,12 +26,9 @@ jobs: - name: Install site dependencies working-directory: site run: npm ci - - name: Generate releases page - working-directory: site - run: node scripts/build-releases.mjs - name: Build site working-directory: site - run: npx astro build + run: npm run build - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/site/.gitignore b/site/.gitignore index ddce69b6..7e5082c2 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .astro/ +src/content/docs/adr/ From d49c286940c45d0052679165954805fdaeebad36 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:10:33 -0400 Subject: [PATCH 18/19] release: @directededges/specs-schema v0.30.0 --- packages/schema/CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 4b5356ee..d7fd8de3 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,21 +5,21 @@ All notable changes to the Specs schema will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.30.0] - 2026-08-17 + +A spec can now declare the naming convention its Figma file follows, so a formatted property key can be turned back into the name a designer sees on the canvas. When a key can't reconstruct that name on its own — because the original contained characters formatting doesn't survive — the spec records the Figma name alongside it, on every property type. Together these make round-tripping between a formatted spec and its source file possible without a side-channel lookup of the file's own naming. ### Added -- `Config.format.figmaKeys` — naming convention the Figma file uses (`NONE` | `SENTENCE` | `TITLE`, default `NONE`); the reversal target for `format.keys` (ADR-066). `NONE` declares no convention, so the safe key grammar, the `com.figma.name` divergence extension, and reversal are all opt-in -- `FigmaPropExtension.name` — Figma property name, emitted when the prop key cannot reconstruct it (ADR-066) -- `SafeKeySentence` and `SafeKeyTitle` schema definitions — the key grammar that survives every `format.keys` value (ADR-066) -- `NumberProp.$extensions` — the only prop type that could not carry platform extensions, so a number prop had nowhere to record its Figma name under ADR-066. The JSON schema already permitted `$`-prefixed keys; the TypeScript type did not. +- **A spec can state the naming convention its Figma file uses** — `Config.format.figmaKeys` (`NONE` | `SENTENCE` | `TITLE`, default `NONE`) names the convention to reverse `format.keys` back into. `NONE` declares no convention, so the safe key grammar, the divergence extension, and reversal are all opt-in (ADR-066) +- **Properties whose key can't reconstruct their Figma name now carry that name** — `FigmaPropExtension.name` is emitted only when the key alone is insufficient (ADR-066) +- **The key grammar that survives every formatting mode is now defined** — `SafeKeySentence` and `SafeKeyTitle` describe keys that round-trip under any `format.keys` value (ADR-066) +- **Number properties can now carry platform extensions** — `NumberProp.$extensions` was the one prop type with nowhere to record its Figma name; the JSON schema already permitted `$`-prefixed keys, the TypeScript type did not ### Changed -- `FigmaAnatomyElementExtension.name` — renamed from `originalName`; now records the Figma layer name for key divergence as well as wrapper collapse (ADR-066) -- `Styles.clipsContent` — renamed from `clipContent`; boolean clip flag, type and token-bindability unchanged (ADR-069) - -### Removed +- **The Figma layer-name extension now covers key divergence, not just wrapper collapse** — `FigmaAnatomyElementExtension.name`, renamed from `originalName` (ADR-066) +- **The clip flag now reads as a verb, matching the rest of the style vocabulary** — `Styles.clipsContent`, renamed from `clipContent`; type and token-bindability unchanged (ADR-069) ### Migration From 6ffbedd5780c89dc7c66d41f92a008039989641b Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:35:58 -0400 Subject: [PATCH 19/19] release: @directededges/specs-cli v0.27.0 --- package.json | 2 +- packages/cli/CHANGELOG.md | 7 ++++++- packages/cli/package.json | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 296b7c12..b96124ff 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,6 @@ "vitest": "^3.1.1" }, "dependencies": { - "@directededges/specs-from-figma": "file:../specs-from-figma/packages/specs-from-figma" + "@directededges/specs-from-figma": "^0.29.0" } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 92d7f5f2..73fff7a7 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.27.0] - 2026-08-17 Specs can now go back into Figma. `specs render` takes a spec you already have and builds the component it describes in a connected Figma file — the reverse of `specs generate`, and @@ -51,6 +51,11 @@ the other half of a round trip you can run and re-run as a spec changes. - CSS `overflow` output — keyed on the renamed `Styles.clipsContent`, so `overflow: hidden` / `overflow: visible` is emitted for the first time (ADR-069) +### Dependency updates + +- **`@directededges/specs-schema` ^0.30.0** — specs can now record the name a designer sees in Figma alongside a formatted key, so a key that cannot reconstruct its original name no longer loses it. Number properties gained the same platform-extension capability every other property type already had, and the style that records whether an element clips its content is now spelled `clipsContent`. +- **`@directededges/specs-from-figma` ^0.29.0** — generated specs carry Figma names wherever a key diverges, and a name already well-formed in your key convention is kept as authored instead of being reformatted. Several sources of phantom variants are gone: slot content no longer records measured sizes or withdrawn host bindings as design intent, an invalid variant combination now names the combination that is actually missing, and elements that clip their content report it for the first time — expect regenerated specs to gain `clipsContent: true` on containers that clip, which is common rather than rare. + ## [0.26.0] - 2026-08-07 diff --git a/packages/cli/package.json b/packages/cli/package.json index f77089e1..a5dd491b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,8 +21,8 @@ "test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests" }, "dependencies": { - "@directededges/specs-schema": "file:../../../specs/packages/schema", - "@directededges/specs-from-figma": "file:../../../specs-from-figma/packages/specs-from-figma", + "@directededges/specs-schema": "^0.30.0", + "@directededges/specs-from-figma": "^0.29.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2",