diff --git a/.github/agents/Specs.adr.accept.agent.md b/.github/agents/Specs.adr.accept.agent.md index 192950f..fab8eec 100644 --- a/.github/agents/Specs.adr.accept.agent.md +++ b/.github/agents/Specs.adr.accept.agent.md @@ -39,9 +39,11 @@ You **MUST** consider the user input before proceeding (if not empty). - 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`). - 1. Read the ADR's `## Semver Decision` section to find the schema target version. - 2. Find the active in-flight release branch with `git branch -r --list 'origin/release/*'`. If one already exists that targets this schema version, use it as `$RELEASE_BRANCH` (the ADR folds into the in-progress release rather than opening a new one). - 3. Only if no matching release branch exists, create one from `main` following the `release/-` convention, naming every package the release will publish. + 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`. diff --git a/.github/agents/Specs.adr.create.agent.md b/.github/agents/Specs.adr.create.agent.md index 96f7dea..73367c6 100644 --- a/.github/agents/Specs.adr.create.agent.md +++ b/.github/agents/Specs.adr.create.agent.md @@ -26,10 +26,10 @@ You **MUST** consider the user input before proceeding (if not empty). 1. **Gather intent** — collect all information from the user before doing any exploration. This step has two parts: **Step 1a — Detect release branch (silent, no user prompt)** - Before asking any questions, run `git rev-parse --abbrev-ref HEAD` to get the current branch. Infer `RELEASE_BRANCH`: - - If the branch looks like a semver release (e.g., `0.13.0`), use it directly as `RELEASE_BRANCH`. - - If the branch matches an ADR name pattern (e.g., `009-color-values`), determine the release branch by reading `package.json` version. - - If the branch is `main` or doesn't match either pattern, fall back to reading `package.json` version and using the current minor version as `RELEASE_BRANCH`. + Before asking any questions, find the active release branch with `git branch -r --list 'origin/release/*'`. Infer `RELEASE_BRANCH`: + - If exactly one `origin/release/*` branch exists, use it as `RELEASE_BRANCH`. Do not cross-reference it against `package.json` version — the branch name reflects where the release started, not the final published version. + - If multiple `origin/release/*` branches exist, pick the most recently created one (latest commit date) as the default and surface it in Question 2 for confirmation. + - If no `origin/release/*` branch exists, fall back to `main` and note this in Question 2 so the user can confirm or provide a branch. **Step 1b — Sync release branch and determine next ADR number (silent, no user prompt)** Run `git fetch origin $RELEASE_BRANCH` and fast-forward the local branch if behind (`git pull origin $RELEASE_BRANCH`). Then determine `NEXT_ADR_NUMBER` by finding the highest existing ADR number across **both** sources (zero-padded to 3 digits): diff --git a/adr/059-border-style.md b/adr/059-border-style.md new file mode 100644 index 0000000..e13b7e6 --- /dev/null +++ b/adr/059-border-style.md @@ -0,0 +1,179 @@ +# ADR: Stroke Dash Pattern + +**Branch**: `059-border-style` +**Created**: 2026-06-26 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`Styles` already carries `strokes` (color), `strokeAlign`, and `strokeWeight` — but has no way to express whether a stroke is dashed or its dash geometry. Figma exposes `strokeDashes` as an array of alternating dash and gap lengths. Code platforms expose dash geometry as a structured object with explicit `dash` and `gap` components. + +Without this property, a component spec cannot describe common stroke treatments (dashed outlines, focus rings, divider rules) that are meaningful to both design and code consumers. + +--- + +## Decision Drivers + +- **Additive-only**: new fields must be optional to remain `MINOR` +- **Structural, not token-bindable**: dash geometry is a layout primitive in every target platform — not a value that would be token-bound; must use a named structural type (`StrokeDashPattern | null`) rather than `Style` +- **Code-platform naming first**: field names must reflect code-platform consensus before Figma API terminology (constitution rule VI) +- **No redundancy**: presence/absence of the dash pattern object is a complete discriminant between solid and dashed strokes; a separate enum field is not needed if it carries no independent information +- **Naming consistency with existing stroke family**: new fields should share the `stroke` prefix established by `strokes`, `strokeWeight`, and `strokeAlign` +- **Type ↔ schema symmetry**: new types require matching schema definitions + +--- + +## Options Considered + +### Option A: `strokeDashPattern` only *(Selected)* + +Add a single `strokeDashPattern?: StrokeDashPattern | null` field to `Styles`. Presence signals a dashed stroke; absence (or `null`) signals a solid stroke. No separate style enum. + +**Pros**: +- Presence/absence is a complete discriminant — `null` means solid, `{ dash, gap }` means dashed +- No redundancy: a `strokeStyle: DASHED` field without a `strokeDashPattern` is incomplete; the geometry *is* the style declaration +- Naming is consistent with the existing `stroke*` family (`strokes`, `strokeWeight`, `strokeAlign`) +- Simpler contract — one field instead of two; consumers check for presence, not enum value + +**Cons / Trade-offs**: +- Consumers that want to emit `DASHED` without specifying geometry cannot do so — but Figma always provides explicit `strokeDashes` values, so this case does not arise in practice + +--- + +### Option B: `strokeStyle` + `strokeDashPattern` *(Rejected)* + +Add both a `strokeStyle: 'SOLID' | 'DASHED' | null` enum field and a `strokeDashPattern` geometry field. + +**Rejected because**: `strokeStyle` is fully redundant — its value can always be inferred from whether `strokeDashPattern` is present. Two fields for one concept without independent information violates the minimal-surface driver. Additionally, `strokeStyle` risks reader confusion: `strokes` is the color field in this same `Styles` object, and `strokeStyle` sounds like a style variant of that color rather than a line-style enum. + +--- + +### Option C: `borderStyle` + `borderDashPattern` *(Rejected)* + +Use the `border` prefix (`borderStyle: 'SOLID' | 'DASHED'`, `borderDashPattern: { dash, gap }`) following the CSS box-model vocabulary. + +**Rejected because**: CSS box model is the only major platform that uses "border" for this concept. SwiftUI uses `StrokeStyle` / `.stroke()`; Jetpack Compose uses `Stroke` and `PathEffect.dashPathEffect`. The existing `Styles` fields already establish the `stroke` prefix for this semantic family. Switching to `border` would create an inconsistent naming split within the same object — `strokes`/`strokeWeight`/`strokeAlign` on one side, `borderStyle`/`borderDashPattern` on the other — for no cross-platform naming gain. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Styles.ts` | Add `StrokeDashPattern` interface — `{ dash: number; gap: number }` | MINOR | +| `Styles.ts` | Add `strokeDashPattern?: StrokeDashPattern \| null` to `Styles` | MINOR | +| `Styles.ts` | Add `'strokeDashPattern'` to `StyleKey` union | MINOR | + +**Example — new type** (`types/Styles.ts`): + +```yaml +StrokeDashPattern: + dash: number # dash segment length in pixels + gap: number # gap segment length in pixels +``` + +**Example — new Styles field**: + +```yaml +# Before +styles: + strokes: "#FF0000" + strokeWeight: 2 + +# After — strokeDashPattern presence indicates a dashed stroke +styles: + strokes: "#FF0000" + strokeWeight: 2 + strokeDashPattern: + dash: 8 + gap: 4 +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `styles.schema.json` | Add `StrokeDashPattern` definition — object with required `dash` and `gap` (both `number`) | MINOR | +| `styles.schema.json` | Add `StrokeDashPatternStyleValue` definition — `oneOf [StrokeDashPattern, null]` | MINOR | +| `styles.schema.json` | Add `strokeDashPattern` property to `Styles` referencing `StrokeDashPatternStyleValue` | MINOR | + +**Example — new schema definitions** (`schema/styles.schema.json`): + +```yaml +StrokeDashPattern: + type: object + description: "Dash geometry for a dashed stroke. dash and gap are in pixels." + properties: + dash: + type: number + description: "Dash segment length in pixels" + gap: + type: number + description: "Gap segment length in pixels" + required: [dash, gap] + additionalProperties: false + +StrokeDashPatternStyleValue: + description: > + Dash pattern for a stroke. Present (non-null) when the stroke is dashed; + null or absent when the stroke is solid. Structural property — not token-bindable. + oneOf: + - $ref: "#/definitions/StrokeDashPattern" + - type: null + +# New Styles property +Styles.properties.strokeDashPattern: + $ref: "#/definitions/StrokeDashPatternStyleValue" + description: > + Dash geometry for a dashed stroke. Presence indicates a dashed stroke; + null or absent indicates solid. dash and gap are in pixels. +``` + +### Notes + +- `strokeDashPattern` is not token-bindable — it is a structural property typed as a named type rather than `Style`, consistent with `LayoutMode`, `Position`, and `WrapAlignment`. +- Solid strokes are represented by omitting `strokeDashPattern` or setting it to `null`. No separate `strokeStyle` field is needed. +- Figma's `strokeDashes` array maps to `{ dash, gap }` using index 0 and 1. Patterns with more than two alternating values collapse to the first pair — transformer responsibility, not schema. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `StrokeDashPattern` → `StrokeDashPattern` definition (object `{ dash, gap }`) + - `Styles.strokeDashPattern` → `Styles.properties.strokeDashPattern` + - `StyleKey` addition is type-only; no schema counterpart needed (it is an internal utility type, not schema-serialized) + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `specs-from-figma` | New optional output field | Map Figma `strokeDashes` array to `strokeDashPattern: { dash, gap }` when non-empty; omit or emit `null` when the stroke is solid | +| `specs-cli` | Recompile against new types | No behavior change; new field passes through to output automatically | +| `specs-plugin-2` | Recompile against new types | No behavior change; new field rendered by existing style output path | + +--- + +## Semver Decision + +**Version bump**: `0.26.x → 0.27.0` (`MINOR`) + +**Justification**: One new optional field on an existing type. No existing type signature, field name, or schema property is removed or renamed. Additive change → `MINOR` per constitution versioning rule. + +--- + +## Consequences + +- Consumers can represent dashed stroke treatments (dashed outlines, focus rings, divider rules) in component specs +- `strokeDashPattern` is a nullable structural property — consumers that do not support dashed strokes can safely ignore it +- Solid strokes require no change to existing output; the field is absent when not applicable +- Any tool validating against `schema/styles.schema.json` must update to the new version to accept the new property diff --git a/adr/060-subcomponent-source-metadata.md b/adr/060-subcomponent-source-metadata.md index 1e81a58..7afea08 100644 --- a/adr/060-subcomponent-source-metadata.md +++ b/adr/060-subcomponent-source-metadata.md @@ -2,7 +2,7 @@ **Branch**: `060-subcomponent-source-metadata` **Created**: 2026-06-27 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* @@ -196,9 +196,9 @@ Subcomponent: ## Semver Decision -**Version bump**: `0.27.x → 0.28.0` (`MINOR`) +**Version bump**: within `0.27.x` — no version change required -**Justification**: All changes are additive — a new optional field on `Subcomponent` and a new exported type `SubcomponentSource`. No existing field is removed, renamed, or narrowed. Per the constitution: "MINOR for additive types or new optional fields." +**Justification**: This change ships as part of the active `release/schema-0.27.0-cli-0.23.0` release. All changes are additive (optional field, new exported type); no bump beyond the release version is warranted. --- diff --git a/adr/INDEX.md b/adr/INDEX.md index 17683f2..63f8d11 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,7 +4,6 @@ | # | Title | Highlights | |---|-------|------------| -| 060 | Subcomponent Figma Source Identity — `Subcomponent.source` | | | 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` | | @@ -36,6 +35,8 @@ | # | Title | Highlights | |---|-------|------------| +| 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` | | 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 | diff --git a/package-lock.json b/package-lock.json index 234bf11..01dd134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,28 +10,42 @@ "packages/cli" ], "dependencies": { - "@directededges/specs-from-figma": "^0.19.0" + "@directededges/specs-from-figma": "file:../specs-from-figma" }, "devDependencies": { "typescript": "^5.3.3", "vitest": "^3.1.1" } }, - "node_modules/@directededges/specs-cli": { - "resolved": "packages/cli", - "link": true - }, - "node_modules/@directededges/specs-from-figma": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@directededges/specs-from-figma/-/specs-from-figma-0.19.0.tgz", - "integrity": "sha512-p+vHuBkUPdLAaSLi3T8WmEETs6Kt9cxI5BeJwxUkZqDDX/yJ5MDoSZpNCCigKf4mXIkoop8zOYLLf5aPOwZavw==", + "../specs-from-figma": { + "name": "@directededges/specs-from-figma", + "version": "0.25.0", "license": "PolyForm-Internal-Use-1.0.0", "dependencies": { - "@directededges/specs-schema": "^0.21.0", + "@directededges/specs-schema": "^0.27.0", "fs-extra": "^11.3.3", "yaml": "^2.8.0" + }, + "devDependencies": { + "@figma/plugin-typings": "^1.126.0", + "@figma/rest-api-spec": "^0.36.0", + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.0.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.5", + "javascript-obfuscator": "^5.3.0", + "typescript": "^5.3.2", + "vitest": "^4.0.17" } }, + "node_modules/@directededges/specs-cli": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/@directededges/specs-from-figma": { + "resolved": "../specs-from-figma", + "link": true + }, "node_modules/@directededges/specs-schema": { "resolved": "packages/schema", "link": true @@ -1604,11 +1618,11 @@ }, "packages/cli": { "name": "@directededges/specs-cli", - "version": "0.16.0", + "version": "0.23.0", "license": "MIT", "dependencies": { - "@directededges/specs-from-figma": "^0.19.0", - "@directededges/specs-schema": "^0.21.0", + "@directededges/specs-from-figma": "^0.25.0", + "@directededges/specs-schema": "^0.27.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", "tslib": "^2.6.2", @@ -2053,7 +2067,7 @@ }, "packages/schema": { "name": "@directededges/specs-schema", - "version": "0.21.0", + "version": "0.27.0", "license": "CC-BY-4.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e4ac363..a4a1184 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,21 @@ 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). +## [0.23.0] - 2026-07-01 + +Specs generated by the CLI now capture dashed stroke styling and record where each subcomponent lives back in the source Figma file — both come from upstream engine and schema updates, with no CLI-side changes required to pick them up. + +### Changed + +- **Generated specs now include dashed stroke data** — Any element with a dashed (not solid) stroke now emits `strokeDashPattern` in its styles, typed as `{ dash, gap }`. Previously dashed strokes fell through as unrecognized styling and were dropped from output. +- **Subcomponent entries now include their Figma source location** — Each entry under `subcomponents` in generated specs now carries a `source` field (`pageId`, `nodeId`, `nodeType`) pointing back to the originating Figma node. + +### Dependency updates + +- **`@directededges/specs-schema` bumped to `^0.27.0`** — Adds the `StrokeDashPattern` type backing dashed stroke output and `SubcomponentSource` backing the new subcomponent `source` field. +- **`@directededges/specs-from-figma` bumped to `^0.25.0`** — Implements dashed stroke extraction (ADR-059) and subcomponent source population (ADR-060). Also stops reformatting VARIANT enum values and defaults, so generated specs now match Figma's raw variant option strings exactly. + + ## [0.22.0] - 2026-06-22 Label and icon components can now produce cleaner specs when `collapsePrimitiveWrapper` is enabled — a plain frame wrapping a lone text or glyph element is stripped out and the primitive becomes the spec root. This release also fixes `specs fetch` crashing on large Figma files and adds clearer diagnostic output for 403 access errors. diff --git a/packages/cli/package.json b/packages/cli/package.json index 0688e0e..33695c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.22.0", + "version": "0.23.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", @@ -20,8 +20,8 @@ "test": "cd ../.. && vitest -c vitest.config.ts packages/cli/tests" }, "dependencies": { - "@directededges/specs-schema": "^0.26.0", - "@directededges/specs-from-figma": "^0.24.0", + "@directededges/specs-schema": "^0.27.0", + "@directededges/specs-from-figma": "^0.25.0", "commander": "^11.1.0", "fs-extra": "^11.2.0", "yaml": "^2.3.4", diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 8d8b104..7de72d1 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,16 @@ 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). +## [0.27.0] - 2026-07-01 + +Dashed strokes are now first-class, typed data instead of falling through as an unrecognized style, and subcomponents carry enough Figma node identity for reverse-direction tools to resolve them back to a canvas location without a side-channel lookup. + +### Added + +- **Dashed strokes are typed and preserved in output** — `Styles.strokeDashPattern` is typed as `StrokeDashPattern` (`{ dash: number; gap: number }`) or `null`; presence signals a dashed stroke, null or absent signals solid. Maps from Figma `strokeDashes[0]` (dash) and `strokeDashes[1]` (gap). +- **Subcomponents carry their Figma source identity** — `Subcomponent.source` is an optional `SubcomponentSource` (`{ pageId, nodeId, nodeType }`) enabling reverse-direction tools to resolve `SubcomponentRef` entries back to Figma nodes without side-channels. + + ## [0.26.0] - 2026-06-22 Specs can now skip redundant wrapper frames around a lone text or glyph child. When `Config.processing.collapsePrimitiveWrapper` is enabled, a plain container holding a single text or icon leaf is collapsed away, promoting the leaf directly to spec root. This produces cleaner specs for simple label or icon components that Figma wraps in an auto-layout frame purely for sizing. diff --git a/packages/schema/package.json b/packages/schema/package.json index 09e12c2..341e4b0 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.26.0", + "version": "0.27.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", diff --git a/packages/schema/schema/component.schema.json b/packages/schema/schema/component.schema.json index bc7d491..d516b24 100644 --- a/packages/schema/schema/component.schema.json +++ b/packages/schema/schema/component.schema.json @@ -941,9 +941,30 @@ ], "additionalProperties": false }, + "SubcomponentSource": { + "type": "object", + "description": "Figma source identity for a subcomponent node. Carries pageId, nodeId, and nodeType to enable reverse-direction tools to resolve a subcomponent back to its originating Figma node.", + "properties": { + "pageId": { + "type": "string", + "description": "The Figma page ID containing this subcomponent's node." + }, + "nodeId": { + "type": "string", + "description": "The Figma node ID for this subcomponent's component node." + }, + "nodeType": { + "type": "string", + "description": "The Figma node type.", + "enum": ["COMPONENT", "COMPONENT_SET", "FRAME"] + } + }, + "required": ["pageId", "nodeId", "nodeType"], + "additionalProperties": false + }, "Subcomponent": { "type": "object", - "description": "A subcomponent's data, derived from ComponentData but excluding metadata and subcomponents.", + "description": "A subcomponent's data, derived from ComponentData but excluding metadata and subcomponents. The optional source field carries Figma node identity for reverse-direction tooling.", "allOf": [ { "$ref": "#/definitions/Component" @@ -960,7 +981,13 @@ ] } } - ] + ], + "properties": { + "source": { + "$ref": "#/definitions/SubcomponentSource", + "description": "Figma source identity for this subcomponent's node. Populated by the generator; absent in specs produced before ADR-060." + } + } }, "NumberProp": { "type": "object", diff --git a/packages/schema/schema/styles.schema.json b/packages/schema/schema/styles.schema.json index 117abbe..a4aa5d0 100644 --- a/packages/schema/schema/styles.schema.json +++ b/packages/schema/schema/styles.schema.json @@ -37,6 +37,7 @@ "strokes": { "$ref": "#/definitions/ColorStyleValue", "description": "Stroke colors" }, "strokeAlign": { "$ref": "#/definitions/MixedStringStyleValue", "description": "Stroke alignment" }, "strokeWeight": { "$ref": "#/definitions/SidesStyleValue", "description": "Stroke weight. Scalar when uniform; Sides object when per-side values differ." }, + "strokeDashPattern": { "$ref": "#/definitions/StrokeDashPatternStyleValue", "description": "Dash pattern for a stroke. Present (non-null) when the stroke is dashed; null or absent when solid." }, "typography": { "$ref": "#/definitions/TypographyStyleValue", "description": "Typography properties. TokenReference when the node references a named text style; Typography when defined inline." }, "textAlignHorizontal": { "$ref": "#/definitions/StringStyleValue", "description": "Horizontal text alignment" }, "textAlignVertical": { "$ref": "#/definitions/StringStyleValue", "description": "Vertical text alignment" }, @@ -335,6 +336,23 @@ { "type": "null" } ] }, + "StrokeDashPattern": { + "type": "object", + "description": "Dash geometry for a dashed stroke. dash and gap are in pixels and correspond to index 0 and 1 of Figma's strokeDashes array.", + "properties": { + "dash": { "type": "number", "description": "Dash segment length in pixels" }, + "gap": { "type": "number", "description": "Gap segment length in pixels" } + }, + "required": ["dash", "gap"], + "additionalProperties": false + }, + "StrokeDashPatternStyleValue": { + "description": "Dash pattern for a stroke. Present (non-null) when the stroke is dashed; null or absent when solid. Structural property — not token-bindable.", + "oneOf": [ + { "$ref": "#/definitions/StrokeDashPattern" }, + { "type": "null" } + ] + }, "LayoutModeStyleValue": { "description": "Layout mode value. Structural property — not token-bindable.", "oneOf": [ diff --git a/packages/schema/tests/Styles.test-d.ts b/packages/schema/tests/Styles.test-d.ts index 1380a78..fcdb911 100644 --- a/packages/schema/tests/Styles.test-d.ts +++ b/packages/schema/tests/Styles.test-d.ts @@ -9,6 +9,7 @@ import type { AngularGradient, GradientValue, AspectRatioValue, AspectRatioStyle, Sides, Corners, ItemSpacing, LayoutMode, WrapAlignment, MainAxisAlignment, CrossAxisAlignment, Position, PositionOffset, + StrokeDashPattern, } from '../types/index.js'; // ─── ColorStyle ──────────────────────────────────────────────────────────── @@ -793,3 +794,48 @@ const _oldY: Styles = { y: 16 }; // @ts-expect-error: layoutPositioning no longer exists on Styles const _oldLayoutPositioning: Styles = { layoutPositioning: 'ABSOLUTE' }; + +// ─── StrokeDashPattern ──────────────────────────────────────────────────────── + +// Valid object with both required fields +const dashSolid: StrokeDashPattern = { dash: 8, gap: 4 }; +const dashEqual: StrokeDashPattern = { dash: 4, gap: 4 }; + +// @ts-expect-error: missing required gap +const _missingGap: StrokeDashPattern = { dash: 8 }; + +// @ts-expect-error: missing required dash +const _missingDash: StrokeDashPattern = { gap: 4 }; + +// @ts-expect-error: string not assignable to number for dash +const _stringDash: StrokeDashPattern = { dash: '8px', gap: 4 }; + +// @ts-expect-error: string not assignable to number for gap +const _stringGap: StrokeDashPattern = { dash: 8, gap: '4px' }; + +// ─── Styles.strokeDashPattern (StrokeDashPattern | null) ───────────────────── + +// Present = dashed stroke +const withDashPattern: Styles = { strokeDashPattern: { dash: 8, gap: 4 } }; + +// null = solid stroke (explicit) +const withSolidExplicit: Styles = { strokeDashPattern: null }; + +// absent = solid stroke (omitted is valid — Styles fields are all optional) +const withSolidOmitted: Styles = {}; + +// Combined with other stroke fields +const dashedBorder: Styles = { + strokes: '#FF0000', + strokeWeight: 1, + strokeDashPattern: { dash: 6, gap: 3 }, +}; + +// @ts-expect-error: TokenReference is not valid for strokeDashPattern (not token-bindable) +const _dashToken: Styles = { strokeDashPattern: { $token: 'Border.Dash', $type: 'string' } satisfies TokenReference }; + +// @ts-expect-error: plain number is not valid for strokeDashPattern +const _dashNumber: Styles = { strokeDashPattern: 8 }; + +// @ts-expect-error: string is not valid for strokeDashPattern +const _dashString: Styles = { strokeDashPattern: 'dashed' }; diff --git a/packages/schema/tests/Subcomponent.test-d.ts b/packages/schema/tests/Subcomponent.test-d.ts new file mode 100644 index 0000000..7cc0da9 --- /dev/null +++ b/packages/schema/tests/Subcomponent.test-d.ts @@ -0,0 +1,37 @@ +import type { Subcomponent, SubcomponentSource, Subcomponents } from '../types/Subcomponent.js'; + +// SubcomponentSource — required fields +const source: SubcomponentSource = { + pageId: '790:6766', + nodeId: '1477:200', + nodeType: 'COMPONENT', +}; + +// SubcomponentSource — nodeType accepts all three values +const _frame: SubcomponentSource = { pageId: 'p', nodeId: 'n', nodeType: 'FRAME' }; +const _set: SubcomponentSource = { pageId: 'p', nodeId: 'n', nodeType: 'COMPONENT_SET' }; + +// Subcomponent — source is optional; a minimal subcomponent remains valid without it +const minimal: Subcomponent = { + title: 'My Sub', + anatomy: { root: { type: 'container' } }, + default: { layout: ['root'], elements: { root: {} } }, +}; + +// Subcomponent — source can be present +const withSource: Subcomponent = { + title: 'My Sub', + anatomy: { root: { type: 'container' } }, + default: { layout: ['root'], elements: { root: {} } }, + source, +}; + +// Subcomponents record +const subs: Subcomponents = { + '1B': withSource, + '2A': minimal, +}; + +// SubcomponentSource is exported from index +import type { SubcomponentSource as IndexSource } from '../types/index.js'; +const _indexed: IndexSource = source; diff --git a/packages/schema/types/Styles.ts b/packages/schema/types/Styles.ts index 69a095a..acc3ec5 100644 --- a/packages/schema/types/Styles.ts +++ b/packages/schema/types/Styles.ts @@ -41,6 +41,8 @@ export type Styles = Partial<{ strokeAlign: Style; /** Stroke weight. Scalar when uniform; `Sides` object when per-side values differ. @since 1.0.0 */ strokeWeight: Style | Sides; + /** Dash pattern for a stroke. Present (non-null) when the stroke is dashed; null or absent when solid. Structural property — not token-bindable. @since 0.27.0 */ + strokeDashPattern: StrokeDashPattern | null; typography: TokenReference | Typography; textAlignHorizontal: Style; textAlignVertical: Style; @@ -257,6 +259,19 @@ export type MainAxisAlignment = 'START' | 'END' | 'CENTER' | 'SPACE_BETWEEN'; */ export type CrossAxisAlignment = 'START' | 'END' | 'CENTER' | 'STRETCH' | 'BASELINE'; +/** + * Dash geometry for a dashed stroke. + * Presence on `Styles.strokeDashPattern` indicates a dashed stroke; null or absent indicates solid. + * `dash` and `gap` are in pixels and correspond to index 0 and 1 of Figma's `strokeDashes` array. + * @since 0.27.0 + */ +export interface StrokeDashPattern { + /** Dash segment length in pixels */ + dash: number; + /** Gap segment length in pixels */ + gap: number; +} + /** * Bi-axial item spacing values using absolute visual axes. * Used for `itemSpacing` when horizontal and vertical gaps differ. @@ -318,6 +333,7 @@ export type StyleKey = | 'strokes' | 'strokeAlign' | 'strokeWeight' + | 'strokeDashPattern' | 'typography' | 'textAlignHorizontal' | 'textAlignVertical' diff --git a/packages/schema/types/Subcomponent.ts b/packages/schema/types/Subcomponent.ts index 9e1ac0a..94e5a94 100644 --- a/packages/schema/types/Subcomponent.ts +++ b/packages/schema/types/Subcomponent.ts @@ -1,11 +1,36 @@ import { Component } from "./Component.js"; +/** + * Figma source identity for a subcomponent node. + * Carries the Figma-specific provenance needed to resolve a subcomponent + * back to its originating node — pageId, nodeId, and nodeType. + * Intentionally a subset of Metadata.source; kept independent so the two + * can evolve separately. + * @since 0.27.0 + */ +export type SubcomponentSource = { + /** The Figma page ID containing this subcomponent's node. */ + pageId: string; + /** The Figma node ID for this subcomponent's component node. */ + nodeId: string; + /** The Figma node type. */ + nodeType: 'COMPONENT' | 'COMPONENT_SET' | 'FRAME'; +}; + /** * Represents a subcomponent in the data model. - * - * A subcomponent is like a component but excludes metadata and nested subcomponents. + * + * A subcomponent is like a component but excludes full metadata and nested subcomponents. + * The optional `source` field carries the Figma node identity needed for reverse-direction + * tools (e.g. spec → Figma writers) to instantiate subcomponent-referenced elements. */ -export type Subcomponent = Omit; +export type Subcomponent = Omit & { + /** + * Figma source identity for this subcomponent's node. + * Populated by the generator; absent in specs produced before ADR-060. + */ + source?: SubcomponentSource; +}; /** * Record of subcomponents keyed by name diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index 2014b4a..22d14aa 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -12,7 +12,7 @@ export type { Anatomy, AnatomyElement, ElementTypeRef, SubcomponentRef } from '. export type { Props, AnyProp, BooleanProp, StringProp, EnumProp, SlotProp, NumberProp, FigmaCodeOnlySource, FigmaPropExtension, PropExtensions } from './Props.js'; export type { Variant, Variants } from './Variant.js'; export type { Metadata } from './Metadata.js'; -export type { Subcomponent, Subcomponents } from './Subcomponent.js'; +export type { Subcomponent, Subcomponents, SubcomponentSource } from './Subcomponent.js'; export type { InstanceExample, InstanceExamples } from './InstanceExample.js'; // Element and structure types @@ -28,7 +28,7 @@ export type { Config, ResolvedConfig, ColorFormat, VariantStateEntry, TransformE export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, ColorStyle, ColorObject, StyleKey, TokenReference, AspectRatioValue, AspectRatioStyle, Typography, Sides, Corners, ItemSpacing, LayoutMode, WrapAlignment, MainAxisAlignment, CrossAxisAlignment, Position, PositionOffset } from './Styles.js'; +export type { Styles, Style, ColorStyle, ColorObject, StyleKey, TokenReference, AspectRatioValue, AspectRatioStyle, Typography, Sides, Corners, ItemSpacing, LayoutMode, WrapAlignment, MainAxisAlignment, CrossAxisAlignment, Position, PositionOffset, StrokeDashPattern } from './Styles.js'; export type { Shadow, Blur, Effects } from './Effects.js'; export type { GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue } from './Gradient.js'; diff --git a/site/astro.config.mjs b/site/astro.config.mjs index d74affb..95fdb20 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -133,7 +133,7 @@ export default defineConfig({ items: [ { label: 'Overview', slug: 'config' }, { label: 'Folders', slug: 'config/folders' }, - { label: 'Data Sources', slug: 'config/data-sources' }, + { label: 'sources', slug: 'config/data-sources' }, { label: 'Output', slug: 'config/output' }, { label: 'Examples', slug: 'config/examples' }, { @@ -148,6 +148,7 @@ export default defineConfig({ { label: 'inferNumberProps', slug: 'config/infer-number-props' }, { label: 'collapsePrimitiveWrapper', slug: 'config/collapse-primitive-wrapper' }, { label: 'instanceExamples', slug: 'config/instance-examples', badge: pro }, + { label: 'states', slug: 'config/states', badge: experimental }, ], }, { @@ -157,6 +158,7 @@ export default defineConfig({ { label: 'keys', slug: 'config/keys' }, { label: 'layout', slug: 'config/layout' }, { label: 'tokens', slug: 'config/tokens', badge: pro }, + { label: 'color', slug: 'config/color' }, ], }, { diff --git a/site/src/assets/specs-workflow.png b/site/src/assets/specs-workflow.png new file mode 100644 index 0000000..d82807f Binary files /dev/null and b/site/src/assets/specs-workflow.png differ diff --git a/site/src/content/docs/config/color.md b/site/src/content/docs/config/color.md index b9a8fd3..c432fad 100644 --- a/site/src/content/docs/config/color.md +++ b/site/src/content/docs/config/color.md @@ -3,21 +3,27 @@ title: "Color" description: "Control how color values are formatted in the spec output" --- -Color value output format. - -## Options - -- **Default**: `HEX` -- **Values**: - - `HEX` - 6-digit hex string (`#FF6600`). Default — matches historical behaviour and maximises human readability - - `HEXA` - 8-digit hex string with alpha (`#FF6600FF`) - - `RGB` - CSS `rgb()` functional notation (`rgb(255, 102, 0)`) - - `RGBA` - CSS `rgba()` functional notation with alpha (`rgba(255, 102, 0, 1)`). This is what Figma labels "CSS" in its colour picker - - `HSLA` - CSS `hsla()` functional notation (`hsla(24, 100%, 50%, 1)`) - - `HSB` - Figma's native colour model (`hsb(24, 100%, 100%)`), also known as HSV. Not a CSS function - - `OKLCH` - CSS Color Level 4 perceptually uniform cylindrical model (`oklch(0.7 0.15 50 / 1)`) - - `OKLAB` - CSS Color Level 4 perceptually uniform rectangular model (`oklab(0.7 0.1 0.1 / 1)`) - - `OBJECT` - Full `ColorObject` with `colorSpace`, `components`, `alpha`, and optional `hex`. Preserves colour space fidelity with no lossy conversion +Controls how color values are serialized wherever a `Color` appears in spec output — including solid fill and stroke styles, text color, shadow color, and gradient stops. By default, colors are emitted as compact hex strings, but you can switch to any CSS functional notation, Figma's native HSB model, or a fully structured object that preserves the original color space without lossy conversion. + +This is a formatting concern only — it doesn't affect which colors are extracted or how they're resolved from Figma. Token-bound colors are unaffected; `format.color` applies to literal, unbound color values. + +## Default + +`HEX` + +## Values + +| Option | Description | Example | +|--------|-------------|---------| +| `HEX` | 6-digit hex string. Default — matches historical behaviour and maximises human readability | `#FF6600` | +| `HEXA` | 8-digit hex string with alpha | `#FF6600FF` | +| `RGB` | CSS `rgb()` functional notation | `rgb(255, 102, 0)` | +| `RGBA` | CSS `rgba()` functional notation with alpha. This is what Figma labels "CSS" in its colour picker | `rgba(255, 102, 0, 1)` | +| `HSLA` | CSS `hsla()` functional notation | `hsla(24, 100%, 50%, 1)` | +| `HSB` | Figma's native colour model, also known as HSV. Not a CSS function | `hsb(24, 100%, 100%)` | +| `OKLCH` | CSS Color Level 4 perceptually uniform cylindrical model | `oklch(0.7 0.15 50 / 1)` | +| `OKLAB` | CSS Color Level 4 perceptually uniform rectangular model | `oklab(0.7 0.1 0.1 / 1)` | +| `OBJECT` | Full `ColorObject` with `colorSpace`, `components`, `alpha`, and optional `hex`. Preserves colour space fidelity with no lossy conversion | — | ## Path diff --git a/site/src/content/docs/config/data-sources.md b/site/src/content/docs/config/data-sources.md index e96ad8d..6449935 100644 --- a/site/src/content/docs/config/data-sources.md +++ b/site/src/content/docs/config/data-sources.md @@ -1,11 +1,23 @@ --- -title: "Data Sources" +title: "sources" description: "Configure which Figma files to fetch and process" --- -## `sources` +`sources` tells the CLI which Figma files to fetch data from and what to download from each one. Each entry is a named alias you choose, mapped to a Figma file key and a list of data types: -Map of aliases to Figma file keys and which data to fetch/load. +- `file` — the full Figma document (components, frames, nodes). This is typically your component library file. Required for `generate`. +- `variables` — Figma variable collections and their values. Used for token resolution during `generate`. +- `styles` — Figma styles (color, text, effect). Used for style resolution during `generate`. + +Most projects have one source. You'd add a second when your design system spans more than one Figma file: + +- A shared foundations or tokens file holds the variable collections that your component library references — variables won't resolve without it. +- Your components are split across multiple Figma library files, and you want to generate specs from more than one in the same run. +- A separate file owns the styles (color palettes, typography) used by components in another file. + +The `generate` command resolves variables and styles across all configured sources. + +## Example ```yaml sources: @@ -17,13 +29,30 @@ sources: data: ['variables','styles'] ``` -For each alias, the CLI uses deterministic filenames in `dataDirectory`: +## Alias + +The alias (e.g. `library`, `foundations`) is a name you assign to each source. It determines the filenames the CLI writes to `dataDirectory`: - `${alias}.file.json` (only if `data` includes `file`) - `${alias}.variables.json` (only if `data` includes `variables`) - `${alias}.styles.json` (only if `data` includes `styles`) -### Branch Keys +## `key` + +The Figma file key for this source. Found in the file URL: `figma.com/design//...`. + +- **Type**: string +- **Required**: yes + +## `data` + +Which data types to fetch from this file. + +- **Type**: array +- **Required**: yes +- **Options**: `file`, `variables`, `styles` + +## Branch Keys The `key` field accepts either a main file key or a **branch file key**. To fetch from a Figma branch, replace the key with the branch's key (found in the branch URL: `figma.com/design//...`). diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 1f51b60..2695aeb 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -24,6 +24,8 @@ head: --- import { Card, CardGrid, LinkCard, LinkButton } from '@astrojs/starlight/components'; +import { Image } from 'astro:assets'; +import specsWorkflow from '../../assets/specs-workflow.png'; ## Generate specs your way @@ -52,6 +54,18 @@ import { Card, CardGrid, LinkCard, LinkButton } from '@astrojs/starlight/compone +## How Specs fits in your workflow + +Specs is the primary way teams hand off design intent in Figma to developers and agents building components in code. Rather than re-describing a component in a ticket or a screenshot, the component itself becomes the source of truth — captured once, structured consistently, and reusable everywhere downstream. + +Diagram titled 'Specs workflow'. A legend distinguishes scripted steps, shown in orange, from agentic steps, shown in blue. On the left, Figma assets connect, via scripted orange arrows, to the Plugin and to CLI generate, which both feed a central Specs data icon; CLI write completes a scripted loop back from Specs data to Figma assets, alongside CLI generate and the Plugin, indicating specs can both be pulled from and written back into Figma. A separate agentic blue arrow connects Specs data to a Prototypes node near Figma, showing agents can work directly against Figma prototypes. On the right, a scripted arrow leads from Specs data to CLI transform, which produces generated code; agentic arrows carry that code onward to a Prototype Library and to a React Library. Specs data also fans out directly via agentic arrows to React, Android, and iOS libraries, representing agents and engineers building production libraries straight from spec data. Below, a scripted arrow leads from Specs data to CLI analyze, which produces library data (tokens, props, and more), which an agentic arrow turns into reports on drift and quality. + +- **Plugin** — generate and review specs directly on the Figma canvas. +- **`fetch` → `scan` → `generate`** — pull specs in bulk, whether scheduled, per release, or on change. +- **Write bridge** *(under development)* — write components from spec into Figma assets. +- **[`transform`](/specs/cli/transforms/)** — drive generated code: CSS, TSX scaffolds, TS contracts, Storybook stories, and more. +- **[`analyze`](/specs/cli/analyze/)** — feed monitored analyses that flag drift and quality gaps over time. + ## Computation over inference Extracting a spec is mechanical: enumerate the variants, diff the styles, record what changes. That's a job for computation, not guesswork — which is why Specs is faster, cheaper, and repeatable where pointing an agent at raw Figma is none of those. diff --git a/site/src/content/docs/schema/styles.md b/site/src/content/docs/schema/styles.md index 78d6f2e..37046ee 100644 --- a/site/src/content/docs/schema/styles.md +++ b/site/src/content/docs/schema/styles.md @@ -6,7 +6,7 @@ description: "Style properties and value types" The `Styles` object holds visual properties for an element. Every property is optional. Which properties are evaluated depends on the element type. ```ts -type Styles = Partial<{ /* 48 properties */ }>; +type Styles = Partial<{ /* 49 properties */ }>; ``` ## Properties @@ -18,6 +18,7 @@ Combined view: every style property, grouped by category and then by name, with | Border | `cornerRadius` | ✓ | | | | | | Border | `cornerSmoothing` | ✓ | | | ✓ | | | Border | `strokeAlign` | ✓ | | | ✓ | ✓ | +| Border | `strokeDashPattern` | ✓ | | | ✓ | ✓ | | Border | `strokeWeight` | ✓ | | | ✓ | ✓ | | Color | `backgroundColor` | ✓ | | | ✓ | | | Color | `fillColor` | | | ✓ | ✓ | ✓ | @@ -81,6 +82,8 @@ Several spec style keys differ from the Figma node property they read from. Spec | `centerHorizontalOffset` | `x` (constraint CENTER) | [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | | `centerVerticalOffset` | `y` (constraint CENTER) | [ADR 041](https://github.com/DirectedEdges/specs/blob/main/adr/041-layout-positioning.md) | +| `strokeDashPattern` | `strokeDashes` | [ADR 059](https://github.com/DirectedEdges/specs/blob/main/adr/059-border-style.md) | + All other style keys — `width`, `height`, `opacity`, `padding`, `itemSpacing`, `cornerRadius`, `strokeWeight`, `rotation`, etc. — use the same name as the Figma node property. ## Values @@ -109,6 +112,7 @@ Most properties accept a `Style` value. Some properties accept specialized shape | `Position` | Layout positioning mode enum | `"AUTO"`, `"ABSOLUTE"` | | `PositionOffset` | Positional offset value | `24` (px), `"25%"` (SCALE), `null` | | `AspectRatio` | Width-to-height ratio | `{ x: 16, y: 9 }` | +| `StrokeDashPattern` | Dash geometry for a dashed stroke — presence signals dashed; null or absent signals solid | `{ dash: 8, gap: 4 }` | ### Relating properties to values @@ -126,3 +130,4 @@ Most properties accept a `Style` value. Some properties accept specialized shape - `position` accepts `Position | null` — not token-bindable. - `top`, `bottom`, `start`, `end`, `centerHorizontalOffset`, `centerVerticalOffset` accept `PositionOffset` (`number | string | null`) — not token-bindable. - `aspectRatio` accepts `AspectRatio | null`. +- `strokeDashPattern` accepts `StrokeDashPattern | null` — not token-bindable; presence signals a dashed stroke, null or absent signals solid. diff --git a/site/src/content/docs/schema/subcomponents.md b/site/src/content/docs/schema/subcomponents.md index 1d242e7..c27abcc 100644 --- a/site/src/content/docs/schema/subcomponents.md +++ b/site/src/content/docs/schema/subcomponents.md @@ -5,10 +5,12 @@ description: "Embedded child component definitions and $ref linking" -Subcomponents are smaller components embedded within a parent component's spec. They follow the same structure as a top-level `Component` but without `metadata` or nested subcomponents. +Subcomponents are smaller components embedded within a parent component's spec. They follow the same structure as a top-level `Component` but without `metadata` or nested subcomponents. An optional `source` field carries the Figma node identity needed for reverse-direction tooling. ```ts -type Subcomponent = Omit; +type Subcomponent = Omit & { + source?: SubcomponentSource; +}; type Subcomponents = Record; ``` @@ -24,6 +26,33 @@ A `Subcomponent` contains: | `default` | [`Variant`](/specs/schema/variants.md/#variant) | Yes | Default variant | | `variants` | [`Variant[]`](variants.md) | No | Variant overrides | | `invalidVariantCombinations` | [`PropConfigurations[]`](prop-configurations.md) | No | Invalid prop combinations | +| `source` | `SubcomponentSource` | No | Figma source identity for this subcomponent's node | + +### SubcomponentSource + +| Property | Type | Description | +|----------|------|-------------| +| `pageId` | `string` | Figma page ID containing this subcomponent's node | +| `nodeId` | `string` | Figma node ID for this subcomponent's component node | +| `nodeType` | `'COMPONENT' \| 'COMPONENT_SET' \| 'FRAME'` | Figma node type | + +```yaml +subcomponents: + formLabel: + title: Form / Label + source: + pageId: "790:6766" + nodeId: "1477:200" + nodeType: COMPONENT + anatomy: + root: + type: container + default: + layout: + - root + elements: + root: {} +``` ## Linking