From 04453fd9f74af6fedb33ae7dda7667d56e192305 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:05:39 -0500 Subject: [PATCH 01/19] style.backgroundColor,textColor,cornerSmoothing --- package.json | 2 +- schema/styles.schema.json | 8 +++++--- types/Styles.ts | 7 +++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e7d54b8..ac08539 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/anova", - "version": "0.10.0", + "version": "0.11.0", "description": "Anova UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC BY 4.0", "author": "Nathan Curtis ", diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 4b444f1..52bbbc5 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -1,5 +1,5 @@ { - "version": "0.6.0", + "version": "0.7.0", "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/styles.schema.json", "title": "Anova Styles Schema", @@ -14,7 +14,8 @@ "visible": { "$ref": "#/definitions/BooleanBindableStyleValue", "description": "Visibility. Can be bound to component prop." }, "opacity": { "$ref": "#/definitions/NumberStyleValue", "description": "Opacity 0-1" }, "locked": { "$ref": "#/definitions/BooleanStyleValue", "description": "Lock state" }, - "fills": { "$ref": "#/definitions/ColorStyleValue", "description": "Fill colors" }, + "backgroundColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Background fill color. Present on all non-text element types. Represented in Figma as fills." }, + "textColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Text fill color. Present on TEXT element type only. Represented in Figma as fills." }, "effectStyleId": { "$ref": "#/definitions/StyleIdValue", "description": "Effect style reference" }, "clipContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Clip content" }, "cornerRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Uniform corner radius" }, @@ -68,7 +69,8 @@ "topLeftRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Top-left corner radius" }, "topRightRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Top-right corner radius" }, "bottomLeftRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Bottom-left corner radius" }, - "bottomRightRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Bottom-right corner radius" } + "bottomRightRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Bottom-right corner radius" }, + "cornerSmoothing": { "$ref": "#/definitions/NumberStyleValue", "description": "Degree of corner smoothing (0 = standard circular corners, 1 = fully smooth iOS-style squircle). Applies to FRAME, COMPONENT, RECTANGLE, POLYGON, STAR, VECTOR, and ELLIPSE element types." } }, "additionalProperties": false }, diff --git a/types/Styles.ts b/types/Styles.ts index b1b04c8..fa9687d 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -5,7 +5,7 @@ export type Styles = Partial<{ visible: Style; opacity: Style; locked: Style; - fills: Style; + backgroundColor: Style; effectStyleId: Style; clipContent: Style; cornerRadius: Style; @@ -43,6 +43,7 @@ export type Styles = Partial<{ textStyleId: Style; textAlignHorizontal: Style; textAlignVertical: Style; + textColor: Style; primaryAxisAlignItems: Style; primaryAxisSizingMode: Style; counterAxisAlignItems: Style; @@ -60,6 +61,7 @@ export type Styles = Partial<{ topRightRadius: Style; bottomLeftRadius: Style; bottomRightRadius: Style; + cornerSmoothing: Style; }>; /** @@ -96,7 +98,7 @@ export type StyleKey = | 'visible' | 'opacity' | 'locked' - | 'fills' + | 'backgroundColor' | 'effectStyleId' | 'clipContent' | 'cornerRadius' @@ -134,6 +136,7 @@ export type StyleKey = | 'textStyleId' | 'textAlignHorizontal' | 'textAlignVertical' + | 'textColor' | 'primaryAxisAlignItems' | 'primaryAxisSizingMode' | 'counterAxisAlignItems' From c874b8e88decd8a25ff7b653de32785ae7a9f799 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:23:26 -0500 Subject: [PATCH 02/19] Add cornersmoothing to StyleKey type --- types/Styles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/Styles.ts b/types/Styles.ts index fa9687d..8797e10 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -150,6 +150,7 @@ export type StyleKey = | 'paddingTop' | 'paddingBottom' | 'counterAxisSpacing' + | 'cornerSmoothing' | 'topLeftRadius' | 'topRightRadius' | 'bottomLeftRadius' From f4834171a92a8c2c1cefacfe14cea7c8b1d9c51b Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:28:09 -0500 Subject: [PATCH 03/19] feat: add cornerSmoothing style property to types and schema --- schema/styles.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 52bbbc5..702977d 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -1,5 +1,5 @@ { - "version": "0.7.0", + "version": "0.11.0", "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/styles.schema.json", "title": "Anova Styles Schema", From 64f26eba83f1fe35ecbc121200714f28c17c8b12 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:48:26 -0500 Subject: [PATCH 04/19] Aligning version numbers --- package-lock.json | 4 ++-- schema/component.schema.json | 2 +- schema/components.schema.json | 2 +- schema/root.schema.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4e331d..37533f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@directededges/anova", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@directededges/anova", - "version": "0.10.0", + "version": "0.11.0", "license": "CC BY 4.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/schema/component.schema.json b/schema/component.schema.json index 6101ba7..3c454ac 100644 --- a/schema/component.schema.json +++ b/schema/component.schema.json @@ -1,5 +1,5 @@ { - "version": "0.6.0", + "version": "0.11.0", "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/anova.schema.json", "title": "Anova Plugin Output Schema", diff --git a/schema/components.schema.json b/schema/components.schema.json index bd5367e..82fb98b 100644 --- a/schema/components.schema.json +++ b/schema/components.schema.json @@ -3,7 +3,7 @@ "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/components.schema.json", "title": "Anova Components Set Schema", "description": "A set of named components, each conforming to the Anova component schema.", - "version": "0.6.0", + "version": "0.11.0", "type": "object", "properties": { "components": { diff --git a/schema/root.schema.json b/schema/root.schema.json index 85f0d96..e96525f 100644 --- a/schema/root.schema.json +++ b/schema/root.schema.json @@ -3,7 +3,7 @@ "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/root.schema.json", "title": "Anova Schema Package", "description": "Root schema for the Anova component and components set definitions.", - "version": "0.6.0", + "version": "0.11.0", "oneOf": [ { "$ref": "component.schema.json" }, { "$ref": "components.schema.json" } From 046e5011dd9c8491a7244c497d6f601ef63674cc Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:32:00 -0500 Subject: [PATCH 05/19] Metadata License --- CHANGELOG.md | 16 +++++ adr/001-metadata.license.md | 126 +++++++++++++++++++++++++++++++++++ schema/component.schema.json | 12 +++- tests/Metadata.test-d.ts | 38 +++++++++++ types/Metadata.ts | 10 +++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 adr/001-metadata.license.md create mode 100644 tests/Metadata.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2bcbe..de35c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the Anova 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.11.0] - 2026-02-24 + +### Added + +- `Metadata.license?` — optional `{ status: string; description: string }` field; absent when no license is supplied +- `styles.textColor` — new style key for text colour +- `styles.cornerSmoothing` — new style key for corner smoothing (Figma squircle factor) + +### Changed + +- `styles.fills` renamed to `styles.backgroundColor` + +### Migration + +- `fills` → `backgroundColor`: any consumer reading `component.styles.fills` must update to `component.styles.backgroundColor`. + ## [0.9.0] - 2026-02-12 ### Added diff --git a/adr/001-metadata.license.md b/adr/001-metadata.license.md new file mode 100644 index 0000000..5265ac3 --- /dev/null +++ b/adr/001-metadata.license.md @@ -0,0 +1,126 @@ +# ADR: Add `license?` to `Metadata` type and schema + +**Branch**: `001-license-check` +**Created**: 2026-02-24 +**Status**: DRAFT +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`@directededges/anova` currently exports a `Metadata` type with six fields (`author`, `lastUpdated`, `generator`, `schema`, `source`, `config`). There is no field to carry license state in the serialised component output. + +Downstream tools need a standard place in the component spec to read whether a valid license was present at generation time, and a human-readable description of that state. Adding an optional `license?` field to `Metadata` satisfies this without breaking any existing consumer. + +Current `Metadata` shape (abbreviated): + +```yaml +Metadata: + author: string + lastUpdated: string + generator: { url, version, name } + schema: { url, version } + source: { pageId, nodeId, nodeType } + config: Config + # license — ABSENT +``` + +--- + +## Decision Drivers + +- **Type-schema symmetry**: Constitution I requires every type change have a corresponding schema change before publishing; drift is a bug. +- **Additive only**: The `license` field MUST be optional so existing consumers that do not supply a license receive identical output — no MAJOR bump. +- **No logic**: Constitution II forbids algorithms or runtime behaviour; this ADR introduces only type declarations and schema properties. +- **Stable, intentional API**: Constitution III requires that every new export represent a genuine shared concept. `license?` on `Metadata` is a standard output field consumed by all downstream packages. +- **Strict TypeScript**: All new declarations must compile under strict mode with zero errors (Constitution V). + +--- + +## Options Considered + +*(Pre-decided — no alternatives evaluated.)* + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Metadata.ts` | Add optional `license?` field | MINOR | + +**Updated `Metadata` shape** (`types/Metadata.ts`): + +```yaml +# Before +Metadata: + author: string + lastUpdated: string + generator: { url, version, name } + schema: { url, version } + source: { pageId, nodeId, nodeType } + config: Config + +# After +Metadata: + author: string + lastUpdated: string + generator: { url, version, name } + schema: { url, version } + source: { pageId, nodeId, nodeType } + config: Config + license?: # optional — absent for callers that never resolve a license + status: string + description: string +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `component.schema.json` | Add `license` property to `#/definitions/Metadata/properties` (NOT in `required[]`) | MINOR | + +**Updated `Metadata` properties** (`#/definitions/Metadata`): + +```yaml +# New property added to #/definitions/Metadata/properties +license: + type: object + description: "Resolved license state at the time this component spec was generated. Absent when no license was supplied." + properties: + status: + type: string + description: + type: string + description: "Human-readable description of the license state." + required: + - status + - description + additionalProperties: false + # license is NOT added to Metadata's top-level required[] — it remains optional +``` + +### Notes + +- `license` is intentionally absent from the `Metadata` `required[]` array. Consumers that do not provide a license receive identical output to the current version. +- `additionalProperties: false` on `Metadata` is retained; adding `license` to `properties` is the correct mechanism — no schema constraint is relaxed. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `types/Metadata.ts → license?` maps to `schema/component.schema.json → #/definitions/Metadata/properties/license` + - Both changes are optional/non-required in their respective artifacts + +--- + +## Consequences + +- `Metadata` output may now include `license.status` and `license.description` when a generator resolves a license. +- Any tool performing runtime validation against `component.schema.json` will accept the new optional field automatically; no validator changes are required for consumers that never produce `license`. diff --git a/schema/component.schema.json b/schema/component.schema.json index 3c454ac..3c70ccf 100644 --- a/schema/component.schema.json +++ b/schema/component.schema.json @@ -321,7 +321,17 @@ "required": ["pageId", "nodeId", "nodeType"], "additionalProperties": false }, - "config": { "$ref": "#/definitions/Config" } + "config": { "$ref": "#/definitions/Config" }, + "license": { + "type": "object", + "description": "Resolved license state at the time this component spec was generated. Absent when no license was supplied.", + "properties": { + "status": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["status", "description"], + "additionalProperties": false + } }, "required": ["author", "lastUpdated", "generator", "schema", "source", "config"], "additionalProperties": false diff --git a/tests/Metadata.test-d.ts b/tests/Metadata.test-d.ts new file mode 100644 index 0000000..3a30f57 --- /dev/null +++ b/tests/Metadata.test-d.ts @@ -0,0 +1,38 @@ +/** + * Type-level tests for Metadata. + * These files are intentionally never executed — they are compiled with tsc + * to assert that the type shape is correct. + */ +import type { Metadata } from '../types/index.js'; + +const baseConfig: Metadata['config'] = { + processing: { subcomponentNamePattern: '{C} / _ / {S}', variantDepth: 9999, details: 'LAYERED' }, + format: { output: 'JSON', keys: 'SAFE', layout: 'LAYOUT', variables: 'NAME_WITH_COLLECTION', simplifyVariables: true, simplifyStyles: true }, + include: { subcomponents: false, variantNames: false, invalidVariants: false, invalidCombinations: true }, +}; + +// Minimal valid Metadata — license absent (optional field) +const withoutLicense: Metadata = { + author: 'test', + lastUpdated: '2026-02-24T00:00:00Z', + generator: { url: 'https://example.com', version: 1, name: 'test' }, + schema: { url: 'https://example.com/schema', version: '1.0.0' }, + source: { pageId: 'p1', nodeId: 'n1', nodeType: 'COMPONENT' }, + config: baseConfig, +}; + +// With license present — both subfields required +const withLicense: Metadata = { + ...withoutLicense, + license: { + status: 'active', + description: 'License is valid.', + }, +}; + +// status and description are strings +const _status: string = withLicense.license!.status; +const _description: string = withLicense.license!.description; + +// license is optional — can be undefined +const _optional: { status: string; description: string } | undefined = withoutLicense.license; diff --git a/types/Metadata.ts b/types/Metadata.ts index 72d3c71..411d199 100644 --- a/types/Metadata.ts +++ b/types/Metadata.ts @@ -38,4 +38,14 @@ export type Metadata = { nodeType: 'COMPONENT' | 'COMPONENT_SET' | 'FRAME'; }; config: Config; + /** + * Resolved license state at the time this component spec was generated. + * Absent when no license was supplied to the generator. + */ + license?: { + /** The resolved license status string (e.g. 'active', 'none', 'expired'). */ + status: string; + /** Human-readable description of the license state. */ + description: string; + }; }; From f425d1703ef2297a1fb03df5334cdc53b7b52218 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:32:06 -0500 Subject: [PATCH 06/19] Agent updates --- .github/agents/speckit.accept.agent.md | 6 +++--- .github/agents/speckit.implement.agent.md | 5 +++-- .github/agents/speckit.specify.agent.md | 10 +++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/agents/speckit.accept.agent.md b/.github/agents/speckit.accept.agent.md index cf79458..304de05 100644 --- a/.github/agents/speckit.accept.agent.md +++ b/.github/agents/speckit.accept.agent.md @@ -12,10 +12,10 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `REPO_ROOT`, `BRANCH`, `FEATURE_DIR`. All paths must be absolute. +1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `REPO_ROOT`, `BRANCH`. All paths must be absolute. 2. **Load context**: - - **REQUIRED**: Read `$FEATURE_DIR/adr.md` — confirm Status is `PROPOSED` (if already `ACCEPTED`, report and halt) + - **REQUIRED**: Read `$REPO_ROOT/adr/$BRANCH.md` — confirm Status is `DRAFT` (if already `ACCEPTED`, report and halt) - Confirm that `types/`, `schema/`, `package.json`, and `CHANGELOG.md` have been modified by `/speckit.implement` (check git status or file timestamps) - If no changes are detected, halt: "Run `/speckit.implement` first." @@ -27,7 +27,7 @@ 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 `$FEATURE_DIR/adr.md` header, change to `Status: ACCEPTED`. +4. **Mark ADR ACCEPTED**: In `$REPO_ROOT/adr/$BRANCH.md` header, change `Status: DRAFT` to `Status: ACCEPTED`. 5. **Report**: Confirm all gates passed and the ADR is now ACCEPTED. List the next steps: open PR → merge → `npm publish`. diff --git a/.github/agents/speckit.implement.agent.md b/.github/agents/speckit.implement.agent.md index eedb728..69b69ac 100644 --- a/.github/agents/speckit.implement.agent.md +++ b/.github/agents/speckit.implement.agent.md @@ -61,8 +61,9 @@ You **MUST** consider the user input before proceeding (if not empty). 10. **Update CHANGELOG.md**: - Prepend a new entry at the top using the existing format in the file - - Include: version number, date, concise consumer-facing description of what changed - - If MAJOR: include a `### Migration` subsection describing what callers must update + - **Format**: one top-level bullet per user-visible change; no sub-bullets; no bold + - **Names**: `.` in backticks, em dash separator — e.g. `Styles.cornerSmoothing` — corner smoothing factor + - **Sections**: use `### Added`, `### Changed`, `### Removed` as needed; add `### Migration` (MAJOR or rename only) with `.` → `.`: imperative callsite instruction 11. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. diff --git a/.github/agents/speckit.specify.agent.md b/.github/agents/speckit.specify.agent.md index 484b134..9305b5e 100644 --- a/.github/agents/speckit.specify.agent.md +++ b/.github/agents/speckit.specify.agent.md @@ -19,9 +19,9 @@ You **MUST** consider the user input before proceeding (if not empty). 1. **Setup**: - Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `REPO_ROOT` and `BRANCH`. - - Check whether `specs/$BRANCH/` already exists in the repo root. - - If the directory **exists**: the feature is already started. Skip `create-new-feature.sh`. Set `BRANCH_NAME=$BRANCH`, `FEATURE_DIR=$REPO_ROOT/specs/$BRANCH`, `SPEC_FILE=$FEATURE_DIR/adr.md`. - - If the directory **does not exist**: run `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` from repo root. Parse JSON for `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM`. + - Check whether `adr/$BRANCH.md` already exists in the repo root. + - If the file **exists**: the ADR is already started. Skip `create-new-feature.sh`. Set `BRANCH_NAME=$BRANCH`, `SPEC_FILE=$REPO_ROOT/adr/$BRANCH.md`. + - If the file **does not exist**: run `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` from repo root. Parse JSON for `BRANCH_NAME` and `FEATURE_NUM`. Set `SPEC_FILE=$REPO_ROOT/adr/$BRANCH_NAME.md`. - All paths must be absolute. 2. **Load context**: Read `.specify/memory/constitution.md`. Read `.specify/templates/adr-template.md` — this is the output template you will fill. @@ -36,7 +36,7 @@ You **MUST** consider the user input before proceeding (if not empty). - If the user input or context indicates the decision is pre-decided (e.g., "record and implement this decision"), omit this step and leave the Options Considered section of the ADR with a single entry marked *(Pre-decided — no alternatives evaluated)*. - Otherwise, identify at least two alternative approaches and for each: assess against the constitution's Decision Drivers (type-schema sync, no logic, stable API, etc.) and state which is selected and which are rejected with clear rationale. -5. **Draft the ADR**: Fill `adr-template.md` and write to `specs/[branch]/adr.md`: +5. **Draft the ADR**: Fill `adr-template.md` and write to `adr/[branch].md`: - **Status**: `DRAFT` - **Context**: Current state of the relevant types/schema and what gap or opportunity this addresses - **Decision Drivers**: Enumerate the constraints from the constitution that apply @@ -47,7 +47,7 @@ You **MUST** consider the user input before proceeding (if not empty). - **Semver Decision**: MAJOR / MINOR / PATCH with justification citing the constitution - **Consequences**: What becomes true after acceptance -6. **Report**: Output `specs/[branch]/adr.md` path and a one-paragraph summary of the decision. +6. **Report**: Output `adr/[branch].md` path and a one-paragraph summary of the decision. ## Formatting rules (apply when drafting the ADR) - **Examples over prose**: Wherever a type shape, schema property, or field change is described, include a YAML example showing the before/after or the new structure. Prefer this over sentences explaining the same idea in abstract terms. From 55ad86b097fcaea41e50002e615f9461097dbb55 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:43:13 -0500 Subject: [PATCH 07/19] ADR-002 Shadows --- adr/002-shadows.md | 217 ++++++++++++++++++++++++++++++++++++++ schema/styles.schema.json | 26 ++++- types/Styles.ts | 20 +++- types/index.ts | 2 +- 4 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 adr/002-shadows.md diff --git a/adr/002-shadows.md b/adr/002-shadows.md new file mode 100644 index 0000000..76e6353 --- /dev/null +++ b/adr/002-shadows.md @@ -0,0 +1,217 @@ +# ADR: Replace `effectStyleId` with `effects` — Add `Shadow` Type + +**Branch**: `v0.11.0` +**Created**: 2026-02-24 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`@directededges/anova` currently exposes `effectStyleId?: Style` in the `Styles` type and the `StyleKey` union. In serialised output this key carries either a raw Figma style ID string or a `FigmaStyle` reference object. It provides no structure for the shadow values themselves — downstream consumers who need shadow geometry must look up the referenced style out-of-band. + +`@directededges/anova-transformer` intends to enrich effect output in two ways: + +1. When a node's effects come from a *named Figma style*, emit the existing `FigmaStyle` reference under the key `effects` (same data, new key name). +2. When a node has *inline drop shadows* (no named style), emit the full shadow geometry as a `Shadow[]` array under `effects`. + +Both paths converge on one output key (`effects`) with a union value type. `effectStyleId` is removed entirely — no deprecation shim. This is a breaking change that requires a MAJOR bump. + +Current `Styles` shape (abbreviated): + +```yaml +# types/Styles.ts +Styles: + effectStyleId?: Style # carries string | FigmaStyle | null + # ...all other style keys... + # effects — ABSENT +``` + +--- + +## Decision Drivers + +- **Type–schema sync**: Every type change must have a corresponding schema change in the same release. No drift between `types/` and `schema/` is permitted. +- **No runtime logic**: This package declares shapes only. No processing, evaluation, or conditional logic may be added. +- **Stable public API / MAJOR for breaking changes**: Removing `effectStyleId` from `Styles`, `StyleKey`, and schema breaks any consumer reading that key. A MAJOR version bump and migration note are required. +- **Minimal new surface**: New exports must serve a genuine consumer need. `Shadow` is required so downstream consumers can type-check `effects` output values; no other new types are exported. + +--- + +## Options Considered + +*(Pre-decided — no alternatives evaluated.)* + +The decision to replace `effectStyleId` with `effects: FigmaStyle | Shadow[]` and to define `Shadow` as a flat interface was reached during feature planning for shadow effects support in downstream tooling. This ADR records the `anova` package type and schema changes required to declare that contract. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|---|---|---| +| `types/Styles.ts` | Remove `effectStyleId?: Style` from `Styles`; add `effects?: FigmaStyle \| Shadow[]` | MAJOR (removal) | +| `types/Styles.ts` | Remove `'effectStyleId'` from `StyleKey` union; add `'effects'` | MAJOR (removal) | +| `types/Styles.ts` | Add `Shadow` interface (new export) | MINOR (additive) | +| `types/index.ts` | Export `Shadow` from `'./Styles.js'` | MINOR (additive) | + +**`Shadow` interface** (`types/Styles.ts`): + +```yaml +# New interface — added to types/Styles.ts +Shadow: + visible: boolean # whether this shadow is active + x: number | VariableStyle # horizontal offset (px) + y: number | VariableStyle # vertical offset (px) + blur: number | VariableStyle # blur radius (px) + spread: number | VariableStyle # spread radius (px) + color: string | VariableStyle # #RRGGBBAA hex string, or VariableStyle reference +``` + +**`Styles` field change** (`types/Styles.ts`): + +```yaml +# Before +Styles: + effectStyleId?: Style # string | boolean | number | null | VariableStyle | FigmaStyle | ... + +# After +Styles: + effects?: FigmaStyle | Shadow[] # FigmaStyle when named style; Shadow[] when inline + # effectStyleId — REMOVED +``` + +**`StyleKey` change** (`types/Styles.ts`): + +```yaml +# Before +StyleKey: '...' | 'effectStyleId' | '...' + +# After +StyleKey: '...' | 'effects' | '...' +# 'effectStyleId' — REMOVED +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|---|---|---| +| `schema/styles.schema.json` | Remove `effectStyleId` from `#/definitions/Styles/properties` | MAJOR (removal) | +| `schema/styles.schema.json` | Add `effects` property to `#/definitions/Styles/properties` | MAJOR (see above) | +| `schema/styles.schema.json` | Add `Shadow` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `EffectsStyleValue` definition to `#/definitions` | MINOR (additive) | + +**`Shadow` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions +Shadow: + type: object + description: > + A single evaluated drop shadow. Emitted as part of the effects array when + inline shadows are present on a node. + properties: + visible: + type: boolean + description: Whether this shadow is active + x: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Horizontal offset in pixels + y: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Vertical offset in pixels + blur: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Blur radius in pixels + spread: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Spread radius in pixels + color: + oneOf: + - { type: string, description: '#RRGGBBAA hex string' } + - { $ref: '#/definitions/VariableStyle' } + description: Shadow color + required: [visible, x, y, blur, spread, color] + additionalProperties: false +``` + +**`EffectsStyleValue` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions +EffectsStyleValue: + description: > + Effect style value. FigmaStyle when the node references a named effects style; + Shadow array when effects are defined inline. + oneOf: + - { $ref: '#/definitions/FigmaStyle', description: Named effects style reference } + - { type: array, items: { $ref: '#/definitions/Shadow' }, description: Inline drop shadows } + - { type: null } +``` + +**`effects` property entry** (under `#/definitions/Styles/properties`): + +```yaml +# Added to Styles properties +effects: + $ref: '#/definitions/EffectsStyleValue' + description: > + Drop shadow output. FigmaStyle when the node references a named effects style; + Shadow[] when effects are defined inline. effectStyleId — REMOVED. + +# Removed from Styles properties: +# effectStyleId: { $ref: '#/definitions/StyleIdValue', description: 'Effect style reference' } +``` + +### Notes + +- `Shadow` fields `x`, `y`, `blur`, `spread` allow `VariableStyle` in addition to `number` to support Figma variable bindings on those fields. In practice this is uncommon; the primary case is a raw number. +- `color` allows `string` (`#RRGGBBAA`) or `VariableStyle`. The alpha channel is always present in the hex encoding. +- `visible` is always `boolean` (no `VariableStyle` variant) — Figma does not support variable binding on the `visible` field of individual effect items. +- Inline effects always serialize as an array (`Shadow[]`), even when only one shadow is present. A single-element array is valid and expected. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `Shadow` interface in `types/Styles.ts` ↔ `Shadow` definition in `schema/styles.schema.json` + - `Styles.effects?: FigmaStyle | Shadow[]` ↔ `#/definitions/Styles/properties/effects` → `EffectsStyleValue` + - `StyleKey` union member `'effects'` ↔ key present in `#/definitions/Styles/properties` + - `effectStyleId` removed from `types/Styles.ts` ↔ `effectStyleId` removed from `#/definitions/Styles/properties` + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|---|---|---| +| `anova-transformer` | **MAJOR** — serialised output key changes from `effectStyleId` to `effects`; must recompile against new types | Migrate all references from `effectStyleId` to `effects`; update processing to emit new output shape | +| `anova-kit` (CLI / MCP) | Recompile required; `effectStyleId` removed from type | If reading `styles.effectStyleId`, migrate to `styles.effects` | +| `anova-plugin` | Recompile required; `effectStyleId` removed from type | If reading `styles.effectStyleId`, migrate to `styles.effects` | + +--- + +## Semver Decision + +**Version bump**: `0.10.x → 0.11.0` (`MAJOR`) + +**Justification**: +- `effectStyleId` is removed from `Styles`, `StyleKey`, and the JSON schema with no deprecation period — a breaking change for any consumer reading that key. Per constitution Principle III: *"Any change to an existing export is a breaking change and MUST follow semantic versioning rules."* +- The addition of `Shadow` interface and `effects` key is additive (MINOR on its own) but the removal forces MAJOR. + +--- + +## Consequences + +- Consumers can now represent fully specified drop shadow geometry in component spec output — `x`, `y`, `blur`, `spread`, `color` are all available as first-class typed fields. +- Consumers reading `styles.effectStyleId` will receive `undefined` after upgrading; they must migrate to `styles.effects`. +- When `styles.effects` is a `FigmaStyle`, the named style `id` and resolved `name` are available — identical data to the former `effectStyleId` output, under a new key. +- When `styles.effects` is a `Shadow[]`, each entry carries the full shadow geometry including variable-bound fields. +- Any JSON schema validation against `styles.schema.json` that previously passed `{ "effectStyleId": { "id": "..." } }` will fail after this change — consumers must revalidate against the new schema version. +- `Shadow` is now a first-class exported type; downstream consumers can import it directly: `import type { Shadow } from '@directededges/anova'`. diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 702977d..eeb0287 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -16,7 +16,7 @@ "locked": { "$ref": "#/definitions/BooleanStyleValue", "description": "Lock state" }, "backgroundColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Background fill color. Present on all non-text element types. Represented in Figma as fills." }, "textColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Text fill color. Present on TEXT element type only. Represented in Figma as fills." }, - "effectStyleId": { "$ref": "#/definitions/StyleIdValue", "description": "Effect style reference" }, + "effects": { "$ref": "#/definitions/EffectsStyleValue", "description": "Drop shadow effects. FigmaStyle when the node references a named effects style; Shadow[] when effects are defined inline. Replaces effectStyleId." }, "clipContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Clip content" }, "cornerRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Uniform corner radius" }, "width": { "$ref": "#/definitions/NumberStyleValue", "description": "Width in pixels" }, @@ -170,13 +170,35 @@ ] }, "StyleIdValue": { - "description": "Style ID reference (textStyleId, effectStyleId)", + "description": "Style ID reference (textStyleId)", "oneOf": [ { "type": "string", "description": "Style ID when simplified" }, { "$ref": "#/definitions/FigmaStyle", "description": "Full style object when not simplified" }, { "type": "null" } ] }, + "Shadow": { + "type": "object", + "description": "A single evaluated drop shadow. Emitted as part of the effects array when inline shadows are present on a node.", + "properties": { + "visible": { "type": "boolean", "description": "Whether this shadow is active" }, + "x": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Horizontal offset in pixels" }, + "y": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Vertical offset in pixels" }, + "blur": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Blur radius in pixels" }, + "spread": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Spread radius in pixels" }, + "color": { "oneOf": [{ "type": "string", "description": "#RRGGBBAA hex string" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Shadow color" } + }, + "required": ["visible", "x", "y", "blur", "spread", "color"], + "additionalProperties": false + }, + "EffectsStyleValue": { + "description": "Effect style value. FigmaStyle when the node references a named effects style; Shadow array when effects are defined inline.", + "oneOf": [ + { "$ref": "#/definitions/FigmaStyle", "description": "Named effects style reference" }, + { "type": "array", "items": { "$ref": "#/definitions/Shadow" }, "description": "Inline drop shadows" }, + { "type": "null" } + ] + }, "ReferenceValue": { "type": "object", "description": "Reference to a component property binding. Emitted when a style is bound to a prop (e.g., visible bound to a boolean prop).", diff --git a/types/Styles.ts b/types/Styles.ts index 8797e10..9fe1e41 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -6,7 +6,7 @@ export type Styles = Partial<{ opacity: Style; locked: Style; backgroundColor: Style; - effectStyleId: Style; + effects: FigmaStyle | Shadow[]; clipContent: Style; cornerRadius: Style; width: Style; @@ -90,6 +90,22 @@ export interface FigmaStyle { name?: string; } +/** + * A single evaluated drop shadow. + * `x`, `y`, `blur`, `spread` may be a raw number or a Figma variable reference. + * `color` is an 8-digit hex string (`#RRGGBBAA`) or a Figma variable reference. + * `visible` is always a boolean — Figma does not support variable binding on + * the `visible` field of individual effect items. + */ +export interface Shadow { + visible: boolean; + x: number | VariableStyle; + y: number | VariableStyle; + blur: number | VariableStyle; + spread: number | VariableStyle; + color: string | VariableStyle; +} + /** * Style property keys that can appear in the serialized output */ @@ -99,7 +115,7 @@ export type StyleKey = | 'opacity' | 'locked' | 'backgroundColor' - | 'effectStyleId' + | 'effects' | 'clipContent' | 'cornerRadius' | 'width' diff --git a/types/index.ts b/types/index.ts index 42353bc..688c550 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,7 +25,7 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle } from './Styles.js'; +export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle, Shadow } from './Styles.js'; // Reference types export type { ReferenceValue, BindingKey } from './ReferenceValue.js'; From 4828518c91ee8e8df10494656bf53634faaa0863 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:44:49 -0500 Subject: [PATCH 08/19] ADR 002 - Shadows CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de35c23..b9c5859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Metadata.license?` — optional `{ status: string; description: string }` field; absent when no license is supplied - `styles.textColor` — new style key for text colour - `styles.cornerSmoothing` — new style key for corner smoothing (Figma squircle factor) +- `styles.effects` — new style key replacing `effectStyleId`; value is `FigmaStyle` when the node references a named effects style, or `Shadow[]` when effects are defined inline +- `Shadow` interface — exported from `@directededges/anova`; fields: `visible` (boolean), `x`, `y`, `blur`, `spread` (number or `VariableStyle`), `color` (8-digit hex `#RRGGBBAA` or `VariableStyle`) +- `EffectsStyleValue` and `Shadow` definitions in `schema/styles.schema.json` ### Changed - `styles.fills` renamed to `styles.backgroundColor` +### Removed + +- `styles.effectStyleId` — removed with no deprecation period; consumers must migrate to `styles.effects` (see Migration) + ### Migration - `fills` → `backgroundColor`: any consumer reading `component.styles.fills` must update to `component.styles.backgroundColor`. +- `effectStyleId` → `effects`: any consumer reading `styles.effectStyleId` must update to `styles.effects`. When `effects` is a `FigmaStyle`, the style `id` and `name` are available. When `effects` is a `Shadow[]`, shadow geometry is available per array entry. ## [0.9.0] - 2026-02-12 From 99d8fcf2a9c075bc055063b5e6e070e3877c921b Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:51:12 -0500 Subject: [PATCH 09/19] Anova ADR agent updates --- ...cept.agent.md => AnovaADR.accept.agent.md} | 0 ...cify.agent.md => AnovaADR.create.agent.md} | 46 +++++++++++++------ ...t.agent.md => AnovaADR.implement.agent.md} | 2 +- 3 files changed, 34 insertions(+), 14 deletions(-) rename .github/agents/{speckit.accept.agent.md => AnovaADR.accept.agent.md} (100%) rename .github/agents/{speckit.specify.agent.md => AnovaADR.create.agent.md} (54%) rename .github/agents/{speckit.implement.agent.md => AnovaADR.implement.agent.md} (99%) diff --git a/.github/agents/speckit.accept.agent.md b/.github/agents/AnovaADR.accept.agent.md similarity index 100% rename from .github/agents/speckit.accept.agent.md rename to .github/agents/AnovaADR.accept.agent.md diff --git a/.github/agents/speckit.specify.agent.md b/.github/agents/AnovaADR.create.agent.md similarity index 54% rename from .github/agents/speckit.specify.agent.md rename to .github/agents/AnovaADR.create.agent.md index 9305b5e..b3bf1e6 100644 --- a/.github/agents/speckit.specify.agent.md +++ b/.github/agents/AnovaADR.create.agent.md @@ -2,7 +2,7 @@ description: Draft an Architecture Decision Record (ADR) for a proposed change to the anova types or schema package. handoffs: - label: Implement the ADR - agent: speckit.implement + agent: AnovaADR.implement prompt: Implement the necessary changes for this ADR send: true --- @@ -17,46 +17,66 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: - - Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `REPO_ROOT` and `BRANCH`. - - Check whether `adr/$BRANCH.md` already exists in the repo root. - - If the file **exists**: the ADR is already started. Skip `create-new-feature.sh`. Set `BRANCH_NAME=$BRANCH`, `SPEC_FILE=$REPO_ROOT/adr/$BRANCH.md`. - - If the file **does not exist**: run `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` from repo root. Parse JSON for `BRANCH_NAME` and `FEATURE_NUM`. Set `SPEC_FILE=$REPO_ROOT/adr/$BRANCH_NAME.md`. +1. **Interactive setup** — ask the user two questions using VS Code's interactive question UI before doing anything else: + + **Question 1 — Branch name** + - Header: `Branch` + - Question: "Which branch should this ADR be filed under?" + - Options (single-select): + 1. `Use the current branch` — read the active git branch name via `git rev-parse --abbrev-ref HEAD` and use it as `BRANCH_NAME` + 2. `Next minor version` — read the current version from `package.json`, increment the minor component, and format as `v[MAJOR].[MINOR+1].0` + 3. `New ADR number` — count existing files in `adr/` and format as `[N+1]-[slugified user input]`; prompt user for the short description via the free-form fallback + 4. `Other` — allow free-form text input + - After the user selects, resolve `BRANCH_NAME` per the rule above before continuing. + + **Question 2 — ADR file name** + - Header: `ADR file` + - Question: "What should the ADR file be named? This becomes `adr/[name].md`. Use the format `###-short-description` (e.g. `002-shadows`)." + - Allow free-form input. No predefined options. + + Parse the answers as `BRANCH_NAME` and `ADR_NAME`. All subsequent paths must use these values. + +2. **File setup**: + - Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root. Parse `REPO_ROOT`. + - Set `SPEC_FILE=$REPO_ROOT/adr/$ADR_NAME.md`. + - Check whether `$SPEC_FILE` already exists. If it does, read it and continue editing rather than overwriting. - All paths must be absolute. -2. **Load context**: Read `.specify/memory/constitution.md`. Read `.specify/templates/adr-template.md` — this is the output template you will fill. +3. **Load context**: Read `.specify/memory/constitution.md`. Read `.specify/templates/adr-template.md` — this is the output template you will fill. -3. **Understand the change**: From the user input and any open files, determine: +4. **Understand the change**: From the user input and any open files, determine: - Which schema files in `schema/` are affected - Which types in `types/` are affected (added, removed, renamed fields) - - Which downstream consumers are affected (`anova-transformer`, `anova-kit`, `anova-plugin`) - Whether the change is MAJOR (breaking), MINOR (additive), or PATCH (non-semantic) per the constitution -4. **Evaluate options** *(skip if the decision is already made)*: +5. **Evaluate options** *(skip if the decision is already made)*: - If the user input or context indicates the decision is pre-decided (e.g., "record and implement this decision"), omit this step and leave the Options Considered section of the ADR with a single entry marked *(Pre-decided — no alternatives evaluated)*. - Otherwise, identify at least two alternative approaches and for each: assess against the constitution's Decision Drivers (type-schema sync, no logic, stable API, etc.) and state which is selected and which are rejected with clear rationale. -5. **Draft the ADR**: Fill `adr-template.md` and write to `adr/[branch].md`: +6. **Draft the ADR**: Fill `adr-template.md` and write to `$SPEC_FILE`: + - **Branch**: `BRANCH_NAME` - **Status**: `DRAFT` - **Context**: Current state of the relevant types/schema and what gap or opportunity this addresses - **Decision Drivers**: Enumerate the constraints from the constitution that apply - **Options Considered**: At least two options with pros/cons relative to the drivers - **Decision**: Precise list of type and schema changes (file, field, modification type) - **Type ↔ Schema Impact**: Confirm symmetry or document justified asymmetry - - **Downstream Impact**: Per-consumer impact table + - **Downstream Impact**: `anova-kit` only — see Key rules below - **Semver Decision**: MAJOR / MINOR / PATCH with justification citing the constitution - **Consequences**: What becomes true after acceptance -6. **Report**: Output `adr/[branch].md` path and a one-paragraph summary of the decision. +7. **Report**: Output `$SPEC_FILE` path and a one-paragraph summary of the decision. ## Formatting rules (apply when drafting the ADR) - **Examples over prose**: Wherever a type shape, schema property, or field change is described, include a YAML example showing the before/after or the new structure. Prefer this over sentences explaining the same idea in abstract terms. - **Bullets over enumeration**: Use bullet lists for Decision Drivers, Consequences, and any list of more than two items. Do not write "X, Y, and Z" as a sentence when a list would be clearer. - **Backticks for terms**: All type names, field names, file names, and schema property paths MUST be in backtick format (e.g., `Config`, `license`, `types/Config.ts`, `#/properties/license`). +- **Types and schema only**: The ADR MUST describe only changes to `types/` and `schema/` within this package. Do NOT reference implementation classes, internal files, or processing logic from downstream packages (`anova-transformer`, `anova-plugin`, `anova-kit`). Downstream impact is described in terms of the observable API surface — what types or schema keys change — not how those packages implement consumption. ## Key rules - Status MUST be `DRAFT` — never set to `ACCEPTED` in this command. - The ADR describes *what* will change and *why*. It does not contain type or schema file content — those changes are applied directly by `/speckit.implement`. +- **Downstream Impact table**: Include only `anova-kit` as a consumer row. Do not add rows for `anova-transformer` or `anova-plugin` — those packages manage their own ADR and change workflows. - If the change clearly violates a constitution gate (e.g., adds runtime logic), state the violation explicitly in the ADR and halt rather than proceeding without justification. - Use absolute paths for all file operations. diff --git a/.github/agents/speckit.implement.agent.md b/.github/agents/AnovaADR.implement.agent.md similarity index 99% rename from .github/agents/speckit.implement.agent.md rename to .github/agents/AnovaADR.implement.agent.md index 69b69ac..008ba23 100644 --- a/.github/agents/speckit.implement.agent.md +++ b/.github/agents/AnovaADR.implement.agent.md @@ -2,7 +2,7 @@ description: Applies the changes described in an ADR directly to types, schema, tests, and changelog. Runs all validation gates. Author reviews the result as a normal code diff before merging. handoffs: - label: Accept ADR - agent: speckit.accept + agent: AnovaADR.accept prompt: All gates passed — mark the ADR as ACCEPTED send: true --- From f100ed14657d0d093eaee16505f15d3a7d7dd5d2 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:35:55 -0500 Subject: [PATCH 10/19] Updated Shadows ADR --- CHANGELOG.md | 10 +- adr/002-shadows.md | 273 +++++++++++++++++++++++++++++++++----- schema/styles.schema.json | 29 +++- tests/Styles.test-d.ts | 83 ++++++++++++ types/Styles.ts | 38 +++++- types/index.ts | 2 +- 6 files changed, 392 insertions(+), 43 deletions(-) create mode 100644 tests/Styles.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c5859..7afd4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Metadata.license?` — optional `{ status: string; description: string }` field; absent when no license is supplied - `styles.textColor` — new style key for text colour - `styles.cornerSmoothing` — new style key for corner smoothing (Figma squircle factor) -- `styles.effects` — new style key replacing `effectStyleId`; value is `FigmaStyle` when the node references a named effects style, or `Shadow[]` when effects are defined inline -- `Shadow` interface — exported from `@directededges/anova`; fields: `visible` (boolean), `x`, `y`, `blur`, `spread` (number or `VariableStyle`), `color` (8-digit hex `#RRGGBBAA` or `VariableStyle`) -- `EffectsStyleValue` and `Shadow` definitions in `schema/styles.schema.json` +- `styles.effects` — new style key replacing `effectStyleId`; value is `FigmaStyle` when the node references a named effects style, or `EffectsGroup` when effects are defined inline +- `Shadow` interface — exported from `@directededges/anova`; fields: `visible` (boolean), `x`, `y`, `blur`, `spread` (number or `VariableStyle`), `color` (8-digit hex `#RRGGBBAA` or `VariableStyle`); used for both `dropShadows` and `innerShadows` entries +- `Blur` interface — exported from `@directededges/anova`; fields: `visible` (boolean), `radius` (number or `VariableStyle`) +- `EffectsGroup` interface — exported from `@directededges/anova`; fields: `dropShadows?` (`Shadow[]`), `innerShadows?` (`Shadow[]`), `layerBlur?` (`Blur`), `backgroundBlur?` (`Blur`) +- `Shadow`, `Blur`, `EffectsGroup`, and `EffectsStyleValue` definitions in `schema/styles.schema.json` ### Changed @@ -27,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Migration - `fills` → `backgroundColor`: any consumer reading `component.styles.fills` must update to `component.styles.backgroundColor`. -- `effectStyleId` → `effects`: any consumer reading `styles.effectStyleId` must update to `styles.effects`. When `effects` is a `FigmaStyle`, the style `id` and `name` are available. When `effects` is a `Shadow[]`, shadow geometry is available per array entry. +- `effectStyleId` → `effects`: any consumer reading `styles.effectStyleId` must update to `styles.effects`. When `effects` is a `FigmaStyle`, the style `id` and `name` are available. When `effects` is an `EffectsGroup`, read from `dropShadows`, `innerShadows`, `layerBlur`, or `backgroundBlur` by role. ## [0.9.0] - 2026-02-12 diff --git a/adr/002-shadows.md b/adr/002-shadows.md index 76e6353..0193f2c 100644 --- a/adr/002-shadows.md +++ b/adr/002-shadows.md @@ -1,8 +1,8 @@ -# ADR: Replace `effectStyleId` with `effects` — Add `Shadow` Type +# ADR: Replace `effectStyleId` with `effects` — Add `Shadow`, `Blur`, and `EffectsGroup` Types **Branch**: `v0.11.0` **Created**: 2026-02-24 -**Status**: ACCEPTED +**Status**: DRAFT **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* @@ -12,12 +12,28 @@ `@directededges/anova` currently exposes `effectStyleId?: Style` in the `Styles` type and the `StyleKey` union. In serialised output this key carries either a raw Figma style ID string or a `FigmaStyle` reference object. It provides no structure for the shadow values themselves — downstream consumers who need shadow geometry must look up the referenced style out-of-band. -`@directededges/anova-transformer` intends to enrich effect output in two ways: +`@directededges/anova-transformer` intends to enrich effect output. When a node's effects come from a *named Figma style*, emit the existing `FigmaStyle` reference under the key `effects`. When effects are inline, emit resolved geometry under `effects`. -1. When a node's effects come from a *named Figma style*, emit the existing `FigmaStyle` reference under the key `effects` (same data, new key name). -2. When a node has *inline drop shadows* (no named style), emit the full shadow geometry as a `Shadow[]` array under `effects`. +`effectStyleId` is removed entirely — no deprecation shim. This is a breaking change that requires a MAJOR bump. -Both paths converge on one output key (`effects`) with a union value type. `effectStyleId` is removed entirely — no deprecation shim. This is a breaking change that requires a MAJOR bump. +However, Figma's effects model is **mixed-type and order-dependent**: a single node can carry drop shadows, inner shadows, a layer blur, and a background blur simultaneously, in any order. A flat array forces downstream consumers to filter by type and understand Figma render-order semantics before they can use any value. This is a poor contract for web, iOS, and Android, where each effect type maps to a **distinct platform property** with no overlap: + +| Effect type | Web | iOS | Android | +|---|---|---|---| +| Drop shadow | `box-shadow` | `.shadow()` | `elevation` / custom | +| Inner shadow | `box-shadow inset` | No native equivalent | No native equivalent | +| Layer blur | `filter: blur()` | `.blur()` | `RenderEffect` (API 31+) | +| Background blur | `backdrop-filter: blur()` | `.ultraThinMaterial` | Limited support | + +Because each effect type maps to a wholly independent platform property, **cross-type render order is never meaningful to consumers**: + +- On Web, `box-shadow`, `filter: blur()`, and `backdrop-filter: blur()` are separate CSS properties. Their relative order in Figma's effect stack has no bearing on how they composite. +- CSS itself re-sorts shadow values: all non-inset (`drop`) values are rendered before `inset` values regardless of declaration order, so inter-type ordering within shadow groups is also irrelevant at the CSS layer. +- On iOS and Android, each effect binds to an independent modifier or render property. There is no shared stack. + +**Within a type, order does matter**: multiple drop shadows or inner shadows stack in the order they are declared (`box-shadow: A, B` differs visually from `box-shadow: B, A`). This ordering is preserved inside each array in `EffectsGroup` (`dropShadows[]`, `innerShadows[]`). No cross-group ordering mechanism is needed. + +The shape of `effects` must be decided with cross-platform translation in mind, not only Figma fidelity. Current `Styles` shape (abbreviated): @@ -36,15 +52,132 @@ Styles: - **Type–schema sync**: Every type change must have a corresponding schema change in the same release. No drift between `types/` and `schema/` is permitted. - **No runtime logic**: This package declares shapes only. No processing, evaluation, or conditional logic may be added. - **Stable public API / MAJOR for breaking changes**: Removing `effectStyleId` from `Styles`, `StyleKey`, and schema breaks any consumer reading that key. A MAJOR version bump and migration note are required. -- **Minimal new surface**: New exports must serve a genuine consumer need. `Shadow` is required so downstream consumers can type-check `effects` output values; no other new types are exported. +- **Minimal new surface**: New exports must serve a genuine consumer need. `Shadow`, `Blur`, and `EffectsGroup` are required so downstream consumers can type-check `effects` output values; no other new types are exported. +- **Platform-unbiased output**: The `effects` contract must not require consumers to understand Figma render-order semantics to extract values. Each effect role must be accessible via a predictable, named key. --- ## Options Considered -*(Pre-decided — no alternatives evaluated.)* +### Option A — Flat union `FigmaStyle | Shadow[]` *(rejected)* + +`effects` carries either a `FigmaStyle` reference or a homogeneous array of `Shadow` objects. + +```yaml +# Option A +Styles: + effects?: FigmaStyle | Shadow[] # only drop shadows; inner shadows and blurs excluded +``` + +**Pros:** +- Minimal surface area for the drop-shadow-only case +- Simple to implement in the first iteration + +**Cons:** +- Cannot represent inner shadows or blurs — the type must be revisited as soon as those effects are added, incurring another MAJOR bump +- Forces a second breaking change when the scope inevitably expands +- Does not satisfy the **platform-unbiased output** driver; consumers reading the array must still infer type from geometry context + +--- + +### Option B — Discriminated union array `FigmaStyle | Effect[]` *(rejected)* + +Define an `Effect` type with a `type` discriminant field. `effects` carries a flat mixed array. + +```yaml +# Option B — Effect union member +Effect: + type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' + # ...type-specific fields via discriminated union +``` + +**Pros:** +- Matches Figma's internal data model exactly +- Preserves Figma render order + +**Cons:** +- Mirrors Figma's bias rather than eliminating it — every consumer must `.filter(e => e.type === 'DROP_SHADOW')` before use +- Discriminated union with optional fields per variant is verbose in both TypeScript and JSON Schema +- Render order is irrelevant to web/iOS/Android since each platform has independent stacking rules +- Violates the **platform-unbiased output** driver + +--- + +### Option C — Grouped object `EffectsGroup` *(selected)* + +Inline effects are emitted as an `EffectsGroup` object with a named key per effect role. A `FigmaStyle` reference is the alternative when a named style is present. + +```yaml +# Option C +Styles: + effects?: FigmaStyle | EffectsGroup + +EffectsGroup: + dropShadows?: Shadow[] # ordered list; maps to box-shadow / .shadow() + innerShadows?: Shadow[] # ordered list; maps to inset box-shadow + layerBlur?: Blur # singular; maps to filter: blur() + backgroundBlur?: Blur # singular; maps to backdrop-filter: blur() +``` + +**Pros:** +- Each platform property maps directly to a named key — no filtering required +- `Shadow` geometry is reused for both drop and inner shadows (same fields, different render role) +- Blurs are singular by platform convention — stacking blurs has no meaningful cross-platform equivalent +- Extensible without breaking changes: adding a new effect role adds an optional key to `EffectsGroup` +- Satisfies all Decision Drivers, particularly **platform-unbiased output** and **minimal new surface** + +**Cons:** +- Slightly more surface area than Option A (`Blur` and `EffectsGroup` are new exports) +- Departs from Figma's array-order model — acceptable because render order is Figma-internal + +**Selected.** This shape is stable across the full range of Figma effect types without requiring a future breaking change. + +--- + +### Option D — Flat keys on `Styles` *(rejected)* + +Expose `dropShadows?`, `innerShadows?`, `layerBlur?`, and `backgroundBlur?` as top-level `Styles` keys, bypassing a wrapper type entirely. + +```yaml +# Option D +Styles: + dropShadows?: Shadow[] + innerShadows?: Shadow[] + layerBlur?: Blur + backgroundBlur?: Blur + # effectStyleRef? — needed separately for named style reference +``` + +**Pros:** +- No intermediate wrapper type; effect keys are immediately visible alongside all other style properties +- Consumers read `styles.dropShadows` directly without destructuring a nested object + +**Cons:** +- No single key to attach a `FigmaStyle` named-style reference to — requires a fifth key (`effectStyleRef?`) solely to carry that case; consumers must check two locations instead of one +- Adds 4–5 members to `StyleKey` and `schema/styles.schema.json` instead of 1, expanding public API surface across a wide and shallow plane +- The mutual exclusion between named style and inline geometry is unenforceable at the type level — a consumer could populate both `dropShadows` and a hypothetical `effectStyleRef` simultaneously with no type error + +--- + +### Option E — Explicit split: `effectStyleRef?` + `effects?: EffectsGroup` *(rejected)* + +Separate concerns across two keys: `effectStyleRef?` carries the `FigmaStyle` named-style reference; `effects?` carries inline resolved geometry as an `EffectsGroup`. Both are optional; at runtime exactly one is present when effects exist. + +```yaml +# Option E +Styles: + effectStyleRef?: FigmaStyle # named style reference path + effects?: EffectsGroup # inline geometry path +``` + +**Pros:** +- Eliminates the `FigmaStyle | EffectsGroup` union — consumers read the key they need without discriminating +- Each key has a single clear type -The decision to replace `effectStyleId` with `effects: FigmaStyle | Shadow[]` and to define `Shadow` as a flat interface was reached during feature planning for shadow effects support in downstream tooling. This ADR records the `anova` package type and schema changes required to declare that contract. +**Cons:** +- Mutual exclusion is a runtime constraint that cannot be expressed in the TypeScript type or JSON Schema; the type system permits both keys to be populated simultaneously, which is an invalid state +- Two `StyleKey` additions and two schema properties instead of one — wider surface for a distinction that can be encoded in the value +- Upstream tooling emitting output must know to write to one key or the other based on the source type, adding conditional logic that Option C avoids --- @@ -54,15 +187,17 @@ The decision to replace `effectStyleId` with `effects: FigmaStyle | Shadow[]` an | File | Change | Bump | |---|---|---| -| `types/Styles.ts` | Remove `effectStyleId?: Style` from `Styles`; add `effects?: FigmaStyle \| Shadow[]` | MAJOR (removal) | +| `types/Styles.ts` | Remove `effectStyleId?: Style` from `Styles`; add `effects?: FigmaStyle \| EffectsGroup` | MAJOR (removal) | | `types/Styles.ts` | Remove `'effectStyleId'` from `StyleKey` union; add `'effects'` | MAJOR (removal) | | `types/Styles.ts` | Add `Shadow` interface (new export) | MINOR (additive) | -| `types/index.ts` | Export `Shadow` from `'./Styles.js'` | MINOR (additive) | +| `types/Styles.ts` | Add `Blur` interface (new export) | MINOR (additive) | +| `types/Styles.ts` | Add `EffectsGroup` interface (new export) | MINOR (additive) | +| `types/index.ts` | Export `Shadow`, `Blur`, `EffectsGroup` from `'./Styles.js'` | MINOR (additive) | **`Shadow` interface** (`types/Styles.ts`): ```yaml -# New interface — added to types/Styles.ts +# New interface — used for both dropShadows and innerShadows entries Shadow: visible: boolean # whether this shadow is active x: number | VariableStyle # horizontal offset (px) @@ -72,6 +207,26 @@ Shadow: color: string | VariableStyle # #RRGGBBAA hex string, or VariableStyle reference ``` +**`Blur` interface** (`types/Styles.ts`): + +```yaml +# New interface — used for layerBlur and backgroundBlur entries +Blur: + visible: boolean # whether this blur is active + radius: number | VariableStyle # blur radius (px) +``` + +**`EffectsGroup` interface** (`types/Styles.ts`): + +```yaml +# New interface — inline effects grouped by role +EffectsGroup: + dropShadows?: Shadow[] # one or more drop shadows; box-shadow / .shadow() + innerShadows?: Shadow[] # one or more inner shadows; inset box-shadow + layerBlur?: Blur # singular layer blur; filter: blur() + backgroundBlur?: Blur # singular background blur; backdrop-filter: blur() +``` + **`Styles` field change** (`types/Styles.ts`): ```yaml @@ -81,7 +236,7 @@ Styles: # After Styles: - effects?: FigmaStyle | Shadow[] # FigmaStyle when named style; Shadow[] when inline + effects?: FigmaStyle | EffectsGroup # FigmaStyle when named style; EffectsGroup when inline # effectStyleId — REMOVED ``` @@ -103,17 +258,19 @@ StyleKey: '...' | 'effects' | '...' | `schema/styles.schema.json` | Remove `effectStyleId` from `#/definitions/Styles/properties` | MAJOR (removal) | | `schema/styles.schema.json` | Add `effects` property to `#/definitions/Styles/properties` | MAJOR (see above) | | `schema/styles.schema.json` | Add `Shadow` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `Blur` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `EffectsGroup` definition to `#/definitions` | MINOR (additive) | | `schema/styles.schema.json` | Add `EffectsStyleValue` definition to `#/definitions` | MINOR (additive) | **`Shadow` schema definition** (`schema/styles.schema.json`): ```yaml -# New entry under #/definitions +# New entry under #/definitions — shared by dropShadows and innerShadows Shadow: type: object description: > - A single evaluated drop shadow. Emitted as part of the effects array when - inline shadows are present on a node. + A single evaluated shadow (drop or inner). Fields are identical for both roles; + the containing key (dropShadows vs innerShadows) determines render role. properties: visible: type: boolean @@ -139,17 +296,64 @@ Shadow: additionalProperties: false ``` +**`Blur` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions — shared by layerBlur and backgroundBlur +Blur: + type: object + description: > + A single evaluated blur effect. Singular per type; the containing key + (layerBlur vs backgroundBlur) determines render role. + properties: + visible: + type: boolean + description: Whether this blur is active + radius: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Blur radius in pixels + required: [visible, radius] + additionalProperties: false +``` + +**`EffectsGroup` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions +EffectsGroup: + type: object + description: > + Inline effects grouped by role. Each key is optional; a key is omitted when + no effects of that type are present on the node. + properties: + dropShadows: + type: array + items: { $ref: '#/definitions/Shadow' } + description: Ordered list of drop shadows + innerShadows: + type: array + items: { $ref: '#/definitions/Shadow' } + description: Ordered list of inner shadows + layerBlur: + $ref: '#/definitions/Blur' + description: Layer blur (filter effect on the node itself) + backgroundBlur: + $ref: '#/definitions/Blur' + description: Background blur (backdrop filter) + additionalProperties: false +``` + **`EffectsStyleValue` schema definition** (`schema/styles.schema.json`): ```yaml # New entry under #/definitions EffectsStyleValue: description: > - Effect style value. FigmaStyle when the node references a named effects style; - Shadow array when effects are defined inline. + Effect value. FigmaStyle when the node references a named effects style; + EffectsGroup when effects are defined inline. oneOf: - { $ref: '#/definitions/FigmaStyle', description: Named effects style reference } - - { type: array, items: { $ref: '#/definitions/Shadow' }, description: Inline drop shadows } + - { $ref: '#/definitions/EffectsGroup', description: Inline effects grouped by role } - { type: null } ``` @@ -160,8 +364,8 @@ EffectsStyleValue: effects: $ref: '#/definitions/EffectsStyleValue' description: > - Drop shadow output. FigmaStyle when the node references a named effects style; - Shadow[] when effects are defined inline. effectStyleId — REMOVED. + Effect output. FigmaStyle when the node references a named effects style; + EffectsGroup when effects are defined inline. effectStyleId — REMOVED. # Removed from Styles properties: # effectStyleId: { $ref: '#/definitions/StyleIdValue', description: 'Effect style reference' } @@ -170,9 +374,12 @@ effects: ### Notes - `Shadow` fields `x`, `y`, `blur`, `spread` allow `VariableStyle` in addition to `number` to support Figma variable bindings on those fields. In practice this is uncommon; the primary case is a raw number. -- `color` allows `string` (`#RRGGBBAA`) or `VariableStyle`. The alpha channel is always present in the hex encoding. -- `visible` is always `boolean` (no `VariableStyle` variant) — Figma does not support variable binding on the `visible` field of individual effect items. -- Inline effects always serialize as an array (`Shadow[]`), even when only one shadow is present. A single-element array is valid and expected. +- `Shadow.color` allows `string` (`#RRGGBBAA`) or `VariableStyle`. The alpha channel is always present in the hex encoding. +- `Blur.radius` allows `VariableStyle` for the same reason. +- `visible` on both `Shadow` and `Blur` is always `boolean` (no `VariableStyle` variant) — Figma does not support variable binding on `visible` for individual effect items. +- `dropShadows` and `innerShadows` are arrays even when only one shadow is present. A single-element array is valid and expected. +- `layerBlur` and `backgroundBlur` are singular objects, not arrays — Figma stacks multiple blurs of the same type as a single resolved value; emitting an array would be misleading. +- An `EffectsGroup` with all keys absent is not emitted — `effects` is omitted entirely when no effects are present. --- @@ -181,7 +388,9 @@ effects: - **Symmetric**: Yes - **Parity check**: - `Shadow` interface in `types/Styles.ts` ↔ `Shadow` definition in `schema/styles.schema.json` - - `Styles.effects?: FigmaStyle | Shadow[]` ↔ `#/definitions/Styles/properties/effects` → `EffectsStyleValue` + - `Blur` interface in `types/Styles.ts` ↔ `Blur` definition in `schema/styles.schema.json` + - `EffectsGroup` interface in `types/Styles.ts` ↔ `EffectsGroup` definition in `schema/styles.schema.json` + - `Styles.effects?: FigmaStyle | EffectsGroup` ↔ `#/definitions/Styles/properties/effects` → `EffectsStyleValue` - `StyleKey` union member `'effects'` ↔ key present in `#/definitions/Styles/properties` - `effectStyleId` removed from `types/Styles.ts` ↔ `effectStyleId` removed from `#/definitions/Styles/properties` @@ -191,9 +400,7 @@ effects: | Consumer | Impact | Action required | |---|---|---| -| `anova-transformer` | **MAJOR** — serialised output key changes from `effectStyleId` to `effects`; must recompile against new types | Migrate all references from `effectStyleId` to `effects`; update processing to emit new output shape | -| `anova-kit` (CLI / MCP) | Recompile required; `effectStyleId` removed from type | If reading `styles.effectStyleId`, migrate to `styles.effects` | -| `anova-plugin` | Recompile required; `effectStyleId` removed from type | If reading `styles.effectStyleId`, migrate to `styles.effects` | +| `anova-kit` (CLI / MCP) | Recompile required; `effectStyleId` removed, `effects` shape changed to `EffectsGroup` | Migrate `styles.effectStyleId` reads to `styles.effects`; destructure by role (`dropShadows`, `layerBlur`, etc.) | --- @@ -209,9 +416,11 @@ effects: ## Consequences -- Consumers can now represent fully specified drop shadow geometry in component spec output — `x`, `y`, `blur`, `spread`, `color` are all available as first-class typed fields. +- Consumers can represent all Figma effect types — drop shadows, inner shadows, layer blur, background blur — via predictable, role-named keys without filtering an array. +- Each effect role maps directly to a platform property: `dropShadows` → `box-shadow`, `innerShadows` → `inset box-shadow`, `layerBlur` → `filter: blur()`, `backgroundBlur` → `backdrop-filter: blur()`. - Consumers reading `styles.effectStyleId` will receive `undefined` after upgrading; they must migrate to `styles.effects`. - When `styles.effects` is a `FigmaStyle`, the named style `id` and resolved `name` are available — identical data to the former `effectStyleId` output, under a new key. -- When `styles.effects` is a `Shadow[]`, each entry carries the full shadow geometry including variable-bound fields. -- Any JSON schema validation against `styles.schema.json` that previously passed `{ "effectStyleId": { "id": "..." } }` will fail after this change — consumers must revalidate against the new schema version. -- `Shadow` is now a first-class exported type; downstream consumers can import it directly: `import type { Shadow } from '@directededges/anova'`. +- When `styles.effects` is an `EffectsGroup`, each present key carries the resolved geometry for that effect role. +- New effect roles can be added to `EffectsGroup` as optional keys in future releases without a breaking change. +- Any JSON schema validation that previously passed `{ "effectStyleId": { "id": "..." } }` will fail after this change — consumers must revalidate against the new schema version. +- `Shadow`, `Blur`, and `EffectsGroup` are now first-class exported types: `import type { Shadow, Blur, EffectsGroup } from '@directededges/anova'`. diff --git a/schema/styles.schema.json b/schema/styles.schema.json index eeb0287..4b436ac 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -16,7 +16,7 @@ "locked": { "$ref": "#/definitions/BooleanStyleValue", "description": "Lock state" }, "backgroundColor": { "$ref": "#/definitions/ColorStyleValue", "description": "Background fill color. Present on all non-text element types. 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": "Drop shadow effects. FigmaStyle when the node references a named effects style; Shadow[] when effects are defined inline. Replaces effectStyleId." }, + "effects": { "$ref": "#/definitions/EffectsStyleValue", "description": "Effect output. FigmaStyle when the node references a named effects style; EffectsGroup when effects are defined inline. Replaces effectStyleId." }, "clipContent": { "$ref": "#/definitions/BooleanStyleValue", "description": "Clip content" }, "cornerRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Uniform corner radius" }, "width": { "$ref": "#/definitions/NumberStyleValue", "description": "Width in pixels" }, @@ -179,7 +179,7 @@ }, "Shadow": { "type": "object", - "description": "A single evaluated drop shadow. Emitted as part of the effects array when inline shadows are present on a node.", + "description": "A single evaluated shadow (drop or inner). Fields are identical for both roles; the containing key (dropShadows vs innerShadows) determines render role.", "properties": { "visible": { "type": "boolean", "description": "Whether this shadow is active" }, "x": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Horizontal offset in pixels" }, @@ -191,11 +191,32 @@ "required": ["visible", "x", "y", "blur", "spread", "color"], "additionalProperties": false }, + "Blur": { + "type": "object", + "description": "A single evaluated blur effect. Singular per type; the containing key (layerBlur vs backgroundBlur) determines render role.", + "properties": { + "visible": { "type": "boolean", "description": "Whether this blur is active" }, + "radius": { "oneOf": [{ "type": "number" }, { "$ref": "#/definitions/VariableStyle" }], "description": "Blur radius in pixels" } + }, + "required": ["visible", "radius"], + "additionalProperties": false + }, + "EffectsGroup": { + "type": "object", + "description": "Inline effects grouped by role. Each key is optional; a key is omitted when no effects of that type are present on the node.", + "properties": { + "dropShadows": { "type": "array", "items": { "$ref": "#/definitions/Shadow" }, "description": "Ordered list of drop shadows" }, + "innerShadows": { "type": "array", "items": { "$ref": "#/definitions/Shadow" }, "description": "Ordered list of inner shadows" }, + "layerBlur": { "$ref": "#/definitions/Blur", "description": "Layer blur (filter effect on the node itself)" }, + "backgroundBlur": { "$ref": "#/definitions/Blur", "description": "Background blur (backdrop filter)" } + }, + "additionalProperties": false + }, "EffectsStyleValue": { - "description": "Effect style value. FigmaStyle when the node references a named effects style; Shadow array when effects are defined inline.", + "description": "Effect value. FigmaStyle when the node references a named effects style; EffectsGroup when effects are defined inline.", "oneOf": [ { "$ref": "#/definitions/FigmaStyle", "description": "Named effects style reference" }, - { "type": "array", "items": { "$ref": "#/definitions/Shadow" }, "description": "Inline drop shadows" }, + { "$ref": "#/definitions/EffectsGroup", "description": "Inline effects grouped by role" }, { "type": "null" } ] }, diff --git a/tests/Styles.test-d.ts b/tests/Styles.test-d.ts new file mode 100644 index 0000000..49d0c71 --- /dev/null +++ b/tests/Styles.test-d.ts @@ -0,0 +1,83 @@ +/** + * Type-level tests for Styles, Shadow, Blur, and EffectsGroup. + * These files are intentionally never executed — they are compiled with tsc + * to assert that the type shape is correct. + */ +import type { Styles, Shadow, Blur, EffectsGroup, FigmaStyle, VariableStyle } from '../types/index.js'; + +// ─── Shadow ──────────────────────────────────────────────────────────────── + +const shadowRaw: Shadow = { + visible: true, + x: 0, + y: 4, + blur: 8, + spread: 0, + color: '#000000FF', +}; + +const shadowVariable: Shadow = { + visible: false, + x: { id: 'var:1' } satisfies VariableStyle, + y: { id: 'var:2' } satisfies VariableStyle, + blur: 4, + spread: 0, + color: { id: 'var:3' } satisfies VariableStyle, +}; + +// visible must be boolean — @ts-expect-error: string is not boolean +const _badVisible: Shadow = { + // @ts-expect-error + visible: 'yes', + x: 0, y: 0, blur: 0, spread: 0, color: '#000000FF', +}; + +// ─── Blur ────────────────────────────────────────────────────────────────── + +const blurRaw: Blur = { visible: true, radius: 12 }; +const blurVariable: Blur = { visible: false, radius: { id: 'var:4' } satisfies VariableStyle }; + +// @ts-expect-error: missing required radius +const _badBlur: Blur = { visible: true }; + +// ─── EffectsGroup ────────────────────────────────────────────────────────── + +// All keys optional — empty group is valid +const emptyGroup: EffectsGroup = {}; + +const fullGroup: EffectsGroup = { + dropShadows: [shadowRaw], + innerShadows: [shadowVariable], + layerBlur: blurRaw, + backgroundBlur: blurVariable, +}; + +// dropShadows is Shadow[], not Shadow +const _dropType: Shadow[] | undefined = fullGroup.dropShadows; + +// layerBlur is singular Blur, not array +const _layerType: Blur | undefined = fullGroup.layerBlur; + +// ─── Styles.effects ──────────────────────────────────────────────────────── + +// Named style reference +const withFigmaStyle: Styles = { + effects: { id: 'S:abc123' } satisfies FigmaStyle, +}; + +// Inline effects via EffectsGroup +const withEffectsGroup: Styles = { + effects: fullGroup, +}; + +// null is valid (effects absent in output) +const withNullEffects: Styles = { + effects: null as unknown as FigmaStyle, // value-level; type allows omission +}; + +// effects is optional — no effects key at all is valid +const withNoEffects: Styles = {}; + +// ─── effects must not be a Shadow[] array directly (old shape) ───────────── +// @ts-expect-error: Shadow[] is not assignable to FigmaStyle | EffectsGroup +const _oldEffectsShape: FigmaStyle | EffectsGroup = [shadowRaw]; diff --git a/types/Styles.ts b/types/Styles.ts index 9fe1e41..9740712 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -6,7 +6,7 @@ export type Styles = Partial<{ opacity: Style; locked: Style; backgroundColor: Style; - effects: FigmaStyle | Shadow[]; + effects: FigmaStyle | EffectsGroup; clipContent: Style; cornerRadius: Style; width: Style; @@ -91,7 +91,9 @@ export interface FigmaStyle { } /** - * A single evaluated drop shadow. + * A single evaluated shadow (drop or inner). + * Fields are identical for both roles; the containing key + * (`dropShadows` vs `innerShadows`) determines render role. * `x`, `y`, `blur`, `spread` may be a raw number or a Figma variable reference. * `color` is an 8-digit hex string (`#RRGGBBAA`) or a Figma variable reference. * `visible` is always a boolean — Figma does not support variable binding on @@ -106,6 +108,38 @@ export interface Shadow { color: string | VariableStyle; } +/** + * A single evaluated blur effect (layer blur or background blur). + * Singular per type; the containing key (`layerBlur` vs `backgroundBlur`) + * determines render role. + * `visible` is always a boolean — Figma does not support variable binding on + * the `visible` field of individual effect items. + */ +export interface Blur { + visible: boolean; + radius: number | VariableStyle; +} + +/** + * Inline effects grouped by role. + * Each key is optional; a key is omitted when no effects of that type are present. + * Maps directly to platform properties: + * - `dropShadows` → `box-shadow` / `.shadow()` + * - `innerShadows` → `inset box-shadow` + * - `layerBlur` → `filter: blur()` + * - `backgroundBlur` → `backdrop-filter: blur()` + */ +export interface EffectsGroup { + /** Ordered list of drop shadows */ + dropShadows?: Shadow[]; + /** Ordered list of inner shadows */ + innerShadows?: Shadow[]; + /** Singular layer blur (filter effect on the node itself) */ + layerBlur?: Blur; + /** Singular background blur (backdrop filter) */ + backgroundBlur?: Blur; +} + /** * Style property keys that can appear in the serialized output */ diff --git a/types/index.ts b/types/index.ts index 688c550..814c56d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,7 +25,7 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle, Shadow } from './Styles.js'; +export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle, Shadow, Blur, EffectsGroup } from './Styles.js'; // Reference types export type { ReferenceValue, BindingKey } from './ReferenceValue.js'; From e690809a80b6adb9dc875c5353327723115e5946 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:38:20 -0500 Subject: [PATCH 11/19] Change ADR name --- adr/{002-shadows.md => 002-effects-shadows-blurs.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename adr/{002-shadows.md => 002-effects-shadows-blurs.md} (100%) diff --git a/adr/002-shadows.md b/adr/002-effects-shadows-blurs.md similarity index 100% rename from adr/002-shadows.md rename to adr/002-effects-shadows-blurs.md From 744fff38a649a547fb9bb1272b75c29c7c868c3c Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:16:50 -0500 Subject: [PATCH 12/19] Accepting the Shadow ADR --- adr/002-shadows.md | 425 +++++++++++++++++++++++++++++++++++++++++++++ types/Effects.ts | 51 ++++++ types/Styles.ts | 51 +----- types/index.ts | 3 +- 4 files changed, 479 insertions(+), 51 deletions(-) create mode 100644 adr/002-shadows.md create mode 100644 types/Effects.ts diff --git a/adr/002-shadows.md b/adr/002-shadows.md new file mode 100644 index 0000000..dcbe167 --- /dev/null +++ b/adr/002-shadows.md @@ -0,0 +1,425 @@ +# ADR: Replace `effectStyleId` with `effects` — Add `Shadow`, `Blur`, and `EffectsGroup` Types + +**Branch**: `v0.11.0` +**Created**: 2026-02-24 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`@directededges/anova` currently exposes `effectStyleId?: Style` in the `Styles` type and the `StyleKey` union. In serialised output this key carries either a raw Figma style ID string or a `FigmaStyle` reference object. It provides no structure for the shadow values themselves — downstream consumers who need shadow geometry must look up the referenced style out-of-band. + +`@directededges/anova-transformer` intends to enrich effect output. When a node's effects come from a *named Figma style*, emit the existing `FigmaStyle` reference under the key `effects`. When effects are inline, emit resolved geometry under `effects`. + +`effectStyleId` is removed entirely — no deprecation shim. This is a breaking change that requires a MAJOR bump. + +However, Figma's effects model is **mixed-type and order-dependent**: a single node can carry drop shadows, inner shadows, a layer blur, and a background blur simultaneously, in any order. A flat array forces downstream consumers to filter by type and understand Figma render-order semantics before they can use any value. This is a poor contract for web, iOS, and Android, where each effect type maps to a **distinct platform property** with no overlap: + +| Effect type | Web | iOS | Android | +|---|---|---|---| +| Drop shadow | `box-shadow` | `.shadow()` | `elevation` / custom | +| Inner shadow | `box-shadow inset` | No native equivalent | No native equivalent | +| Layer blur | `filter: blur()` | `.blur()` | `RenderEffect` (API 31+) | +| Background blur | `backdrop-filter: blur()` | `.ultraThinMaterial` | Limited support | + +Because each effect type maps to a wholly independent platform property, **cross-type render order is never meaningful to consumers**: + +- On Web, `box-shadow`, `filter: blur()`, and `backdrop-filter: blur()` are separate CSS properties. Their relative order in Figma's effect stack has no bearing on how they composite. +- CSS itself re-sorts shadow values: all non-inset (`drop`) values are rendered before `inset` values regardless of declaration order, so inter-type ordering within shadow groups is also irrelevant at the CSS layer. +- On iOS and Android, each effect binds to an independent modifier or render property. There is no shared stack. + +**Within a type, order does matter**: multiple drop shadows or inner shadows stack in the order they are declared (`box-shadow: A, B` differs visually from `box-shadow: B, A`). This ordering is preserved inside each array in `EffectsGroup` (`dropShadows[]`, `innerShadows[]`). No cross-group ordering mechanism is needed. + +The shape of `effects` must be decided with cross-platform translation in mind, not only Figma fidelity. + +Current `Styles` shape (abbreviated): + +```yaml +# types/Styles.ts +Styles: + effectStyleId?: Style # carries string | FigmaStyle | null + # ...all other style keys... + # effects — ABSENT +``` + +--- + +## Decision Drivers + +- **Type–schema sync**: Every type change must have a corresponding schema change in the same release. No drift between `types/` and `schema/` is permitted. +- **No runtime logic**: This package declares shapes only. No processing, evaluation, or conditional logic may be added. +- **Stable public API / MAJOR for breaking changes**: Removing `effectStyleId` from `Styles`, `StyleKey`, and schema breaks any consumer reading that key. A MAJOR version bump and migration note are required. +- **Minimal new surface**: New exports must serve a genuine consumer need. `Shadow`, `Blur`, and `EffectsGroup` are required so downstream consumers can type-check `effects` output values; no other new types are exported. +- **Platform-unbiased output**: The `effects` contract must not require consumers to understand Figma render-order semantics to extract values. Each effect role must be accessible via a predictable, named key. + +--- + +## Options Considered + +### Option A — Flat union `FigmaStyle | Shadow[]` *(rejected)* + +`effects` carries either a `FigmaStyle` reference or a homogeneous array of `Shadow` objects. + +```yaml +# Option A +Styles: + effects?: FigmaStyle | Shadow[] # only drop shadows; inner shadows and blurs excluded +``` + +**Pros:** +- Minimal surface area for the drop-shadow-only case +- Simple to implement in the first iteration + +**Cons:** +- Cannot represent inner shadows or blurs — the type must be revisited as soon as those effects are added, incurring another MAJOR bump +- Forces a second breaking change when the scope inevitably expands +- Does not satisfy the **platform-unbiased output** driver; consumers reading the array must still infer type from geometry context + +--- + +### Option B — Discriminated union array `FigmaStyle | Effect[]` *(rejected)* + +Define an `Effect` type with a `type` discriminant field. `effects` carries a flat mixed array. + +```yaml +# Option B — Effect union member +Effect: + type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' + # ...type-specific fields via discriminated union +``` + +**Pros:** +- Matches Figma's internal data model exactly +- Preserves Figma render order + +**Cons:** +- Mirrors Figma's bias rather than eliminating it — every consumer must `.filter(e => e.type === 'DROP_SHADOW')` before use +- Discriminated union with optional fields per variant is verbose in both TypeScript and JSON Schema +- Render order is irrelevant to web/iOS/Android since each platform has independent stacking rules +- Violates the **platform-unbiased output** driver + +--- + +### Option C — Grouped object `EffectsGroup` *(selected)* + +Inline effects are emitted as an `EffectsGroup` object with a named key per effect role. A `FigmaStyle` reference is the alternative when a named style is present. + +```yaml +# Option C +Styles: + effects?: FigmaStyle | EffectsGroup + +EffectsGroup: + dropShadows?: Shadow[] # ordered list; maps to box-shadow / .shadow() + innerShadows?: Shadow[] # ordered list; maps to inset box-shadow + layerBlur?: Blur # singular; maps to filter: blur() + backgroundBlur?: Blur # singular; maps to backdrop-filter: blur() +``` + +**Pros:** +- Each platform property maps directly to a named key — no filtering required +- `Shadow` geometry is reused for both drop and inner shadows (same fields, different render role) +- Blurs are singular by platform convention — stacking blurs has no meaningful cross-platform equivalent +- Extensible without breaking changes: adding a new effect role adds an optional key to `EffectsGroup` +- Satisfies all Decision Drivers, particularly **platform-unbiased output** and **minimal new surface** + +**Cons:** +- Slightly more surface area than Option A (`Blur` and `EffectsGroup` are new exports) +- Departs from Figma's array-order model — acceptable because render order is Figma-internal + +**Selected.** This shape is stable across the full range of Figma effect types without requiring a future breaking change. + +--- + +### Option D — Flat keys on `Styles` *(rejected)* + +Expose `dropShadows?`, `innerShadows?`, `layerBlur?`, and `backgroundBlur?` as top-level `Styles` keys, bypassing a wrapper type entirely. + +```yaml +# Option D +Styles: + dropShadows?: Shadow[] + innerShadows?: Shadow[] + layerBlur?: Blur + backgroundBlur?: Blur + # effectStyleRef? — needed separately for named style reference +``` + +**Pros:** +- No intermediate wrapper type; effect keys are immediately visible alongside all other style properties +- Consumers read `styles.dropShadows` directly without destructuring a nested object + +**Cons:** +- No single key to attach a `FigmaStyle` named-style reference to — requires a fifth key (`effectStyleRef?`) solely to carry that case; consumers must check two locations instead of one +- Adds 4–5 members to `StyleKey` and `schema/styles.schema.json` instead of 1, expanding public API surface across a wide and shallow plane +- The mutual exclusion between named style and inline geometry is unenforceable at the type level — a consumer could populate both `dropShadows` and a hypothetical `effectStyleRef` simultaneously with no type error + +--- + +### Option E — Explicit split: `effectStyleRef?` + `effects?: EffectsGroup` *(rejected)* + +Separate concerns across two keys: `effectStyleRef?` carries the `FigmaStyle` named-style reference; `effects?` carries inline resolved geometry as an `EffectsGroup`. Both are optional; at runtime exactly one is present when effects exist. + +```yaml +# Option E +Styles: + effectStyleRef?: FigmaStyle # named style reference path + effects?: EffectsGroup # inline geometry path +``` + +**Pros:** +- Eliminates the `FigmaStyle | EffectsGroup` union — consumers read the key they need without discriminating +- Each key has a single clear type + +**Cons:** +- Mutual exclusion is a runtime constraint that cannot be expressed in the TypeScript type or JSON Schema; the type system permits both keys to be populated simultaneously, which is an invalid state +- Two `StyleKey` additions and two schema properties instead of one — wider surface for a distinction that can be encoded in the value +- Upstream tooling emitting output must know to write to one key or the other based on the source type, adding conditional logic that Option C avoids + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|---|---|---| +| `types/Styles.ts` | Remove `effectStyleId?: Style` from `Styles`; add `effects?: FigmaStyle \| EffectsGroup` | MAJOR (removal) | +| `types/Styles.ts` | Remove `'effectStyleId'` from `StyleKey` union; add `'effects'` | MAJOR (removal) | +| `types/Styles.ts` | Add import for `EffectsGroup` from `'./Effects.js'` | MINOR (additive) | +| `types/Effects.ts` | New file — add `Shadow`, `Blur`, `EffectsGroup` interfaces (new exports) | MINOR (additive) | +| `types/index.ts` | Export `Shadow`, `Blur`, `EffectsGroup` from `'./Effects.js'` | MINOR (additive) | + +**`Shadow` interface** (`types/Effects.ts`): + +```yaml +# New interface — used for both dropShadows and innerShadows entries +Shadow: + visible: boolean # whether this shadow is active + x: number | VariableStyle # horizontal offset (px) + y: number | VariableStyle # vertical offset (px) + blur: number | VariableStyle # blur radius (px) + spread: number | VariableStyle # spread radius (px) + color: string | VariableStyle # #RRGGBBAA hex string, or VariableStyle reference +``` + +**`Blur` interface** (`types/Effects.ts`): + +```yaml +# New interface — used for layerBlur and backgroundBlur entries +Blur: + visible: boolean # whether this blur is active + radius: number | VariableStyle # blur radius (px) +``` + +**`EffectsGroup` interface** (`types/Effects.ts`): + +```yaml +# New interface — inline effects grouped by role +EffectsGroup: + dropShadows?: Shadow[] # one or more drop shadows; box-shadow / .shadow() + innerShadows?: Shadow[] # one or more inner shadows; inset box-shadow + layerBlur?: Blur # singular layer blur; filter: blur() + backgroundBlur?: Blur # singular background blur; backdrop-filter: blur() +``` + +**`Styles` field change** (`types/Styles.ts`): + +```yaml +# Before +Styles: + effectStyleId?: Style # string | boolean | number | null | VariableStyle | FigmaStyle | ... + +# After +Styles: + effects?: FigmaStyle | EffectsGroup # FigmaStyle when named style; EffectsGroup when inline + # effectStyleId — REMOVED +``` + +**`StyleKey` change** (`types/Styles.ts`): + +```yaml +# Before +StyleKey: '...' | 'effectStyleId' | '...' + +# After +StyleKey: '...' | 'effects' | '...' +# 'effectStyleId' — REMOVED +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|---|---|---| +| `schema/styles.schema.json` | Remove `effectStyleId` from `#/definitions/Styles/properties` | MAJOR (removal) | +| `schema/styles.schema.json` | Add `effects` property to `#/definitions/Styles/properties` | MAJOR (see above) | +| `schema/styles.schema.json` | Add `Shadow` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `Blur` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `EffectsGroup` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `EffectsStyleValue` definition to `#/definitions` | MINOR (additive) | + +**`Shadow` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions — shared by dropShadows and innerShadows +Shadow: + type: object + description: > + A single evaluated shadow (drop or inner). Fields are identical for both roles; + the containing key (dropShadows vs innerShadows) determines render role. + properties: + visible: + type: boolean + description: Whether this shadow is active + x: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Horizontal offset in pixels + y: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Vertical offset in pixels + blur: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Blur radius in pixels + spread: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Spread radius in pixels + color: + oneOf: + - { type: string, description: '#RRGGBBAA hex string' } + - { $ref: '#/definitions/VariableStyle' } + description: Shadow color + required: [visible, x, y, blur, spread, color] + additionalProperties: false +``` + +**`Blur` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions — shared by layerBlur and backgroundBlur +Blur: + type: object + description: > + A single evaluated blur effect. Singular per type; the containing key + (layerBlur vs backgroundBlur) determines render role. + properties: + visible: + type: boolean + description: Whether this blur is active + radius: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Blur radius in pixels + required: [visible, radius] + additionalProperties: false +``` + +**`EffectsGroup` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions +EffectsGroup: + type: object + description: > + Inline effects grouped by role. Each key is optional; a key is omitted when + no effects of that type are present on the node. + properties: + dropShadows: + type: array + items: { $ref: '#/definitions/Shadow' } + description: Ordered list of drop shadows + innerShadows: + type: array + items: { $ref: '#/definitions/Shadow' } + description: Ordered list of inner shadows + layerBlur: + $ref: '#/definitions/Blur' + description: Layer blur (filter effect on the node itself) + backgroundBlur: + $ref: '#/definitions/Blur' + description: Background blur (backdrop filter) + additionalProperties: false +``` + +**`EffectsStyleValue` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions +EffectsStyleValue: + description: > + Effect value. FigmaStyle when the node references a named effects style; + EffectsGroup when effects are defined inline. + oneOf: + - { $ref: '#/definitions/FigmaStyle', description: Named effects style reference } + - { $ref: '#/definitions/EffectsGroup', description: Inline effects grouped by role } + - { type: null } +``` + +**`effects` property entry** (under `#/definitions/Styles/properties`): + +```yaml +# Added to Styles properties +effects: + $ref: '#/definitions/EffectsStyleValue' + description: > + Effect output. FigmaStyle when the node references a named effects style; + EffectsGroup when effects are defined inline. effectStyleId — REMOVED. + +# Removed from Styles properties: +# effectStyleId: { $ref: '#/definitions/StyleIdValue', description: 'Effect style reference' } +``` + +### Notes + +- `Shadow` fields `x`, `y`, `blur`, `spread` allow `VariableStyle` in addition to `number` to support Figma variable bindings on those fields. In practice this is uncommon; the primary case is a raw number. +- `Shadow.color` allows `string` (`#RRGGBBAA`) or `VariableStyle`. The alpha channel is always present in the hex encoding. +- `Blur.radius` allows `VariableStyle` for the same reason. +- `visible` on both `Shadow` and `Blur` is always `boolean` (no `VariableStyle` variant) — Figma does not support variable binding on `visible` for individual effect items. +- `dropShadows` and `innerShadows` are arrays even when only one shadow is present. A single-element array is valid and expected. +- `layerBlur` and `backgroundBlur` are singular objects, not arrays — Figma stacks multiple blurs of the same type as a single resolved value; emitting an array would be misleading. +- An `EffectsGroup` with all keys absent is not emitted — `effects` is omitted entirely when no effects are present. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `Shadow` interface in `types/Effects.ts` ↔ `Shadow` definition in `schema/styles.schema.json` + - `Blur` interface in `types/Effects.ts` ↔ `Blur` definition in `schema/styles.schema.json` + - `EffectsGroup` interface in `types/Effects.ts` ↔ `EffectsGroup` definition in `schema/styles.schema.json` + - `Styles.effects?: FigmaStyle | EffectsGroup` ↔ `#/definitions/Styles/properties/effects` → `EffectsStyleValue` + - `StyleKey` union member `'effects'` ↔ key present in `#/definitions/Styles/properties` + - `effectStyleId` removed from `types/Styles.ts` ↔ `effectStyleId` removed from `#/definitions/Styles/properties` + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|---|---|---| +| `anova-kit` (CLI / MCP) | Recompile required; `effectStyleId` removed, `effects` shape changed to `EffectsGroup` | Migrate `styles.effectStyleId` reads to `styles.effects`; destructure by role (`dropShadows`, `layerBlur`, etc.) | + +--- + +## Semver Decision + +**Version bump**: `0.10.x → 0.11.0` (`MAJOR`) + +**Justification**: +- `effectStyleId` is removed from `Styles`, `StyleKey`, and the JSON schema with no deprecation period — a breaking change for any consumer reading that key. Per constitution Principle III: *"Any change to an existing export is a breaking change and MUST follow semantic versioning rules."* +- The addition of `Shadow` interface and `effects` key is additive (MINOR on its own) but the removal forces MAJOR. + +--- + +## Consequences + +- Consumers can represent all Figma effect types — drop shadows, inner shadows, layer blur, background blur — via predictable, role-named keys without filtering an array. +- Each effect role maps directly to a platform property: `dropShadows` → `box-shadow`, `innerShadows` → `inset box-shadow`, `layerBlur` → `filter: blur()`, `backgroundBlur` → `backdrop-filter: blur()`. +- Consumers reading `styles.effectStyleId` will receive `undefined` after upgrading; they must migrate to `styles.effects`. +- When `styles.effects` is a `FigmaStyle`, the named style `id` and resolved `name` are available — identical data to the former `effectStyleId` output, under a new key. +- When `styles.effects` is an `EffectsGroup`, each present key carries the resolved geometry for that effect role. +- New effect roles can be added to `EffectsGroup` as optional keys in future releases without a breaking change. +- Any JSON schema validation that previously passed `{ "effectStyleId": { "id": "..." } }` will fail after this change — consumers must revalidate against the new schema version. +- `Shadow`, `Blur`, and `EffectsGroup` are now first-class exported types: `import type { Shadow, Blur, EffectsGroup } from '@directededges/anova'`. diff --git a/types/Effects.ts b/types/Effects.ts new file mode 100644 index 0000000..f7045fc --- /dev/null +++ b/types/Effects.ts @@ -0,0 +1,51 @@ +import { VariableStyle } from "./Styles.js"; + +/** + * A single evaluated shadow (drop or inner). + * Fields are identical for both roles; the containing key + * (`dropShadows` vs `innerShadows`) determines render role. + * `x`, `y`, `blur`, `spread` may be a raw number or a Figma variable reference. + * `color` is an 8-digit hex string (`#RRGGBBAA`) or a Figma variable reference. + * `visible` is always a boolean — Figma does not support variable binding on + * the `visible` field of individual effect items. + */ +export interface Shadow { + visible: boolean; + x: number | VariableStyle; + y: number | VariableStyle; + blur: number | VariableStyle; + spread: number | VariableStyle; + color: string | VariableStyle; +} + +/** + * A single evaluated blur effect (layer blur or background blur). + * Singular per type; the containing key (`layerBlur` vs `backgroundBlur`) + * determines render role. + * `visible` is always a boolean — Figma does not support variable binding on + * the `visible` field of individual effect items. + */ +export interface Blur { + visible: boolean; + radius: number | VariableStyle; +} + +/** + * Inline effects grouped by role. + * Each key is optional; a key is omitted when no effects of that type are present. + * Maps directly to platform properties: + * - `dropShadows` → `box-shadow` / `.shadow()` + * - `innerShadows` → `inset box-shadow` + * - `layerBlur` → `filter: blur()` + * - `backgroundBlur` → `backdrop-filter: blur()` + */ +export interface EffectsGroup { + /** Ordered list of drop shadows */ + dropShadows?: Shadow[]; + /** Ordered list of inner shadows */ + innerShadows?: Shadow[]; + /** Singular layer blur (filter effect on the node itself) */ + layerBlur?: Blur; + /** Singular background blur (backdrop filter) */ + backgroundBlur?: Blur; +} diff --git a/types/Styles.ts b/types/Styles.ts index 9740712..1f45d74 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -1,4 +1,5 @@ import { ReferenceValue } from "./ReferenceValue.js"; +import { EffectsGroup } from "./Effects.js"; export type Styles = Partial<{ rotation: Style; @@ -90,56 +91,6 @@ export interface FigmaStyle { name?: string; } -/** - * A single evaluated shadow (drop or inner). - * Fields are identical for both roles; the containing key - * (`dropShadows` vs `innerShadows`) determines render role. - * `x`, `y`, `blur`, `spread` may be a raw number or a Figma variable reference. - * `color` is an 8-digit hex string (`#RRGGBBAA`) or a Figma variable reference. - * `visible` is always a boolean — Figma does not support variable binding on - * the `visible` field of individual effect items. - */ -export interface Shadow { - visible: boolean; - x: number | VariableStyle; - y: number | VariableStyle; - blur: number | VariableStyle; - spread: number | VariableStyle; - color: string | VariableStyle; -} - -/** - * A single evaluated blur effect (layer blur or background blur). - * Singular per type; the containing key (`layerBlur` vs `backgroundBlur`) - * determines render role. - * `visible` is always a boolean — Figma does not support variable binding on - * the `visible` field of individual effect items. - */ -export interface Blur { - visible: boolean; - radius: number | VariableStyle; -} - -/** - * Inline effects grouped by role. - * Each key is optional; a key is omitted when no effects of that type are present. - * Maps directly to platform properties: - * - `dropShadows` → `box-shadow` / `.shadow()` - * - `innerShadows` → `inset box-shadow` - * - `layerBlur` → `filter: blur()` - * - `backgroundBlur` → `backdrop-filter: blur()` - */ -export interface EffectsGroup { - /** Ordered list of drop shadows */ - dropShadows?: Shadow[]; - /** Ordered list of inner shadows */ - innerShadows?: Shadow[]; - /** Singular layer blur (filter effect on the node itself) */ - layerBlur?: Blur; - /** Singular background blur (backdrop filter) */ - backgroundBlur?: Blur; -} - /** * Style property keys that can appear in the serialized output */ diff --git a/types/index.ts b/types/index.ts index 814c56d..c6213ad 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,7 +25,8 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle, Shadow, Blur, EffectsGroup } from './Styles.js'; +export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle } from './Styles.js'; +export type { Shadow, Blur, EffectsGroup } from './Effects.js'; // Reference types export type { ReferenceValue, BindingKey } from './ReferenceValue.js'; From 55fb8d31b98fd87bc25ddbab7f9b2621b30026f3 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:22:26 -0500 Subject: [PATCH 13/19] Effects ADR note --- adr/002-effects-shadows-blurs.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adr/002-effects-shadows-blurs.md b/adr/002-effects-shadows-blurs.md index 0193f2c..4a1e65a 100644 --- a/adr/002-effects-shadows-blurs.md +++ b/adr/002-effects-shadows-blurs.md @@ -45,6 +45,8 @@ Styles: # effects — ABSENT ``` +Note that Figma's `noise`, `textures`, and `glass` effects are considered out of scope. Because `EffectsGroup` routes by `effect.type` into named keys, unknown effect types are silently skipped during evaluation with no positional side effects on the output. If any of these effect types are formalised in a future release, they can be introduced as new optional keys on `EffectsGroup` without a breaking change. + --- ## Decision Drivers From d22bcdf52d99a4d880375c05fcef4145a632fce7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:22:43 -0500 Subject: [PATCH 14/19] Gradient ADR --- adr/002-shadows.md | 425 -------------------------------------- adr/003-gradients.md | 368 +++++++++++++++++++++++++++++++++ schema/styles.schema.json | 89 +++++++- tests/Styles.test-d.ts | 8 +- types/Gradient.ts | 70 +++++++ types/Styles.ts | 15 +- types/index.ts | 3 +- 7 files changed, 546 insertions(+), 432 deletions(-) delete mode 100644 adr/002-shadows.md create mode 100644 adr/003-gradients.md create mode 100644 types/Gradient.ts diff --git a/adr/002-shadows.md b/adr/002-shadows.md deleted file mode 100644 index dcbe167..0000000 --- a/adr/002-shadows.md +++ /dev/null @@ -1,425 +0,0 @@ -# ADR: Replace `effectStyleId` with `effects` — Add `Shadow`, `Blur`, and `EffectsGroup` Types - -**Branch**: `v0.11.0` -**Created**: 2026-02-24 -**Status**: ACCEPTED -**Deciders**: Nathan Curtis (author) -**Supersedes**: *(none)* - ---- - -## Context - -`@directededges/anova` currently exposes `effectStyleId?: Style` in the `Styles` type and the `StyleKey` union. In serialised output this key carries either a raw Figma style ID string or a `FigmaStyle` reference object. It provides no structure for the shadow values themselves — downstream consumers who need shadow geometry must look up the referenced style out-of-band. - -`@directededges/anova-transformer` intends to enrich effect output. When a node's effects come from a *named Figma style*, emit the existing `FigmaStyle` reference under the key `effects`. When effects are inline, emit resolved geometry under `effects`. - -`effectStyleId` is removed entirely — no deprecation shim. This is a breaking change that requires a MAJOR bump. - -However, Figma's effects model is **mixed-type and order-dependent**: a single node can carry drop shadows, inner shadows, a layer blur, and a background blur simultaneously, in any order. A flat array forces downstream consumers to filter by type and understand Figma render-order semantics before they can use any value. This is a poor contract for web, iOS, and Android, where each effect type maps to a **distinct platform property** with no overlap: - -| Effect type | Web | iOS | Android | -|---|---|---|---| -| Drop shadow | `box-shadow` | `.shadow()` | `elevation` / custom | -| Inner shadow | `box-shadow inset` | No native equivalent | No native equivalent | -| Layer blur | `filter: blur()` | `.blur()` | `RenderEffect` (API 31+) | -| Background blur | `backdrop-filter: blur()` | `.ultraThinMaterial` | Limited support | - -Because each effect type maps to a wholly independent platform property, **cross-type render order is never meaningful to consumers**: - -- On Web, `box-shadow`, `filter: blur()`, and `backdrop-filter: blur()` are separate CSS properties. Their relative order in Figma's effect stack has no bearing on how they composite. -- CSS itself re-sorts shadow values: all non-inset (`drop`) values are rendered before `inset` values regardless of declaration order, so inter-type ordering within shadow groups is also irrelevant at the CSS layer. -- On iOS and Android, each effect binds to an independent modifier or render property. There is no shared stack. - -**Within a type, order does matter**: multiple drop shadows or inner shadows stack in the order they are declared (`box-shadow: A, B` differs visually from `box-shadow: B, A`). This ordering is preserved inside each array in `EffectsGroup` (`dropShadows[]`, `innerShadows[]`). No cross-group ordering mechanism is needed. - -The shape of `effects` must be decided with cross-platform translation in mind, not only Figma fidelity. - -Current `Styles` shape (abbreviated): - -```yaml -# types/Styles.ts -Styles: - effectStyleId?: Style # carries string | FigmaStyle | null - # ...all other style keys... - # effects — ABSENT -``` - ---- - -## Decision Drivers - -- **Type–schema sync**: Every type change must have a corresponding schema change in the same release. No drift between `types/` and `schema/` is permitted. -- **No runtime logic**: This package declares shapes only. No processing, evaluation, or conditional logic may be added. -- **Stable public API / MAJOR for breaking changes**: Removing `effectStyleId` from `Styles`, `StyleKey`, and schema breaks any consumer reading that key. A MAJOR version bump and migration note are required. -- **Minimal new surface**: New exports must serve a genuine consumer need. `Shadow`, `Blur`, and `EffectsGroup` are required so downstream consumers can type-check `effects` output values; no other new types are exported. -- **Platform-unbiased output**: The `effects` contract must not require consumers to understand Figma render-order semantics to extract values. Each effect role must be accessible via a predictable, named key. - ---- - -## Options Considered - -### Option A — Flat union `FigmaStyle | Shadow[]` *(rejected)* - -`effects` carries either a `FigmaStyle` reference or a homogeneous array of `Shadow` objects. - -```yaml -# Option A -Styles: - effects?: FigmaStyle | Shadow[] # only drop shadows; inner shadows and blurs excluded -``` - -**Pros:** -- Minimal surface area for the drop-shadow-only case -- Simple to implement in the first iteration - -**Cons:** -- Cannot represent inner shadows or blurs — the type must be revisited as soon as those effects are added, incurring another MAJOR bump -- Forces a second breaking change when the scope inevitably expands -- Does not satisfy the **platform-unbiased output** driver; consumers reading the array must still infer type from geometry context - ---- - -### Option B — Discriminated union array `FigmaStyle | Effect[]` *(rejected)* - -Define an `Effect` type with a `type` discriminant field. `effects` carries a flat mixed array. - -```yaml -# Option B — Effect union member -Effect: - type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' - # ...type-specific fields via discriminated union -``` - -**Pros:** -- Matches Figma's internal data model exactly -- Preserves Figma render order - -**Cons:** -- Mirrors Figma's bias rather than eliminating it — every consumer must `.filter(e => e.type === 'DROP_SHADOW')` before use -- Discriminated union with optional fields per variant is verbose in both TypeScript and JSON Schema -- Render order is irrelevant to web/iOS/Android since each platform has independent stacking rules -- Violates the **platform-unbiased output** driver - ---- - -### Option C — Grouped object `EffectsGroup` *(selected)* - -Inline effects are emitted as an `EffectsGroup` object with a named key per effect role. A `FigmaStyle` reference is the alternative when a named style is present. - -```yaml -# Option C -Styles: - effects?: FigmaStyle | EffectsGroup - -EffectsGroup: - dropShadows?: Shadow[] # ordered list; maps to box-shadow / .shadow() - innerShadows?: Shadow[] # ordered list; maps to inset box-shadow - layerBlur?: Blur # singular; maps to filter: blur() - backgroundBlur?: Blur # singular; maps to backdrop-filter: blur() -``` - -**Pros:** -- Each platform property maps directly to a named key — no filtering required -- `Shadow` geometry is reused for both drop and inner shadows (same fields, different render role) -- Blurs are singular by platform convention — stacking blurs has no meaningful cross-platform equivalent -- Extensible without breaking changes: adding a new effect role adds an optional key to `EffectsGroup` -- Satisfies all Decision Drivers, particularly **platform-unbiased output** and **minimal new surface** - -**Cons:** -- Slightly more surface area than Option A (`Blur` and `EffectsGroup` are new exports) -- Departs from Figma's array-order model — acceptable because render order is Figma-internal - -**Selected.** This shape is stable across the full range of Figma effect types without requiring a future breaking change. - ---- - -### Option D — Flat keys on `Styles` *(rejected)* - -Expose `dropShadows?`, `innerShadows?`, `layerBlur?`, and `backgroundBlur?` as top-level `Styles` keys, bypassing a wrapper type entirely. - -```yaml -# Option D -Styles: - dropShadows?: Shadow[] - innerShadows?: Shadow[] - layerBlur?: Blur - backgroundBlur?: Blur - # effectStyleRef? — needed separately for named style reference -``` - -**Pros:** -- No intermediate wrapper type; effect keys are immediately visible alongside all other style properties -- Consumers read `styles.dropShadows` directly without destructuring a nested object - -**Cons:** -- No single key to attach a `FigmaStyle` named-style reference to — requires a fifth key (`effectStyleRef?`) solely to carry that case; consumers must check two locations instead of one -- Adds 4–5 members to `StyleKey` and `schema/styles.schema.json` instead of 1, expanding public API surface across a wide and shallow plane -- The mutual exclusion between named style and inline geometry is unenforceable at the type level — a consumer could populate both `dropShadows` and a hypothetical `effectStyleRef` simultaneously with no type error - ---- - -### Option E — Explicit split: `effectStyleRef?` + `effects?: EffectsGroup` *(rejected)* - -Separate concerns across two keys: `effectStyleRef?` carries the `FigmaStyle` named-style reference; `effects?` carries inline resolved geometry as an `EffectsGroup`. Both are optional; at runtime exactly one is present when effects exist. - -```yaml -# Option E -Styles: - effectStyleRef?: FigmaStyle # named style reference path - effects?: EffectsGroup # inline geometry path -``` - -**Pros:** -- Eliminates the `FigmaStyle | EffectsGroup` union — consumers read the key they need without discriminating -- Each key has a single clear type - -**Cons:** -- Mutual exclusion is a runtime constraint that cannot be expressed in the TypeScript type or JSON Schema; the type system permits both keys to be populated simultaneously, which is an invalid state -- Two `StyleKey` additions and two schema properties instead of one — wider surface for a distinction that can be encoded in the value -- Upstream tooling emitting output must know to write to one key or the other based on the source type, adding conditional logic that Option C avoids - ---- - -## Decision - -### Type changes (`types/`) - -| File | Change | Bump | -|---|---|---| -| `types/Styles.ts` | Remove `effectStyleId?: Style` from `Styles`; add `effects?: FigmaStyle \| EffectsGroup` | MAJOR (removal) | -| `types/Styles.ts` | Remove `'effectStyleId'` from `StyleKey` union; add `'effects'` | MAJOR (removal) | -| `types/Styles.ts` | Add import for `EffectsGroup` from `'./Effects.js'` | MINOR (additive) | -| `types/Effects.ts` | New file — add `Shadow`, `Blur`, `EffectsGroup` interfaces (new exports) | MINOR (additive) | -| `types/index.ts` | Export `Shadow`, `Blur`, `EffectsGroup` from `'./Effects.js'` | MINOR (additive) | - -**`Shadow` interface** (`types/Effects.ts`): - -```yaml -# New interface — used for both dropShadows and innerShadows entries -Shadow: - visible: boolean # whether this shadow is active - x: number | VariableStyle # horizontal offset (px) - y: number | VariableStyle # vertical offset (px) - blur: number | VariableStyle # blur radius (px) - spread: number | VariableStyle # spread radius (px) - color: string | VariableStyle # #RRGGBBAA hex string, or VariableStyle reference -``` - -**`Blur` interface** (`types/Effects.ts`): - -```yaml -# New interface — used for layerBlur and backgroundBlur entries -Blur: - visible: boolean # whether this blur is active - radius: number | VariableStyle # blur radius (px) -``` - -**`EffectsGroup` interface** (`types/Effects.ts`): - -```yaml -# New interface — inline effects grouped by role -EffectsGroup: - dropShadows?: Shadow[] # one or more drop shadows; box-shadow / .shadow() - innerShadows?: Shadow[] # one or more inner shadows; inset box-shadow - layerBlur?: Blur # singular layer blur; filter: blur() - backgroundBlur?: Blur # singular background blur; backdrop-filter: blur() -``` - -**`Styles` field change** (`types/Styles.ts`): - -```yaml -# Before -Styles: - effectStyleId?: Style # string | boolean | number | null | VariableStyle | FigmaStyle | ... - -# After -Styles: - effects?: FigmaStyle | EffectsGroup # FigmaStyle when named style; EffectsGroup when inline - # effectStyleId — REMOVED -``` - -**`StyleKey` change** (`types/Styles.ts`): - -```yaml -# Before -StyleKey: '...' | 'effectStyleId' | '...' - -# After -StyleKey: '...' | 'effects' | '...' -# 'effectStyleId' — REMOVED -``` - -### Schema changes (`schema/`) - -| File | Change | Bump | -|---|---|---| -| `schema/styles.schema.json` | Remove `effectStyleId` from `#/definitions/Styles/properties` | MAJOR (removal) | -| `schema/styles.schema.json` | Add `effects` property to `#/definitions/Styles/properties` | MAJOR (see above) | -| `schema/styles.schema.json` | Add `Shadow` definition to `#/definitions` | MINOR (additive) | -| `schema/styles.schema.json` | Add `Blur` definition to `#/definitions` | MINOR (additive) | -| `schema/styles.schema.json` | Add `EffectsGroup` definition to `#/definitions` | MINOR (additive) | -| `schema/styles.schema.json` | Add `EffectsStyleValue` definition to `#/definitions` | MINOR (additive) | - -**`Shadow` schema definition** (`schema/styles.schema.json`): - -```yaml -# New entry under #/definitions — shared by dropShadows and innerShadows -Shadow: - type: object - description: > - A single evaluated shadow (drop or inner). Fields are identical for both roles; - the containing key (dropShadows vs innerShadows) determines render role. - properties: - visible: - type: boolean - description: Whether this shadow is active - x: - oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] - description: Horizontal offset in pixels - y: - oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] - description: Vertical offset in pixels - blur: - oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] - description: Blur radius in pixels - spread: - oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] - description: Spread radius in pixels - color: - oneOf: - - { type: string, description: '#RRGGBBAA hex string' } - - { $ref: '#/definitions/VariableStyle' } - description: Shadow color - required: [visible, x, y, blur, spread, color] - additionalProperties: false -``` - -**`Blur` schema definition** (`schema/styles.schema.json`): - -```yaml -# New entry under #/definitions — shared by layerBlur and backgroundBlur -Blur: - type: object - description: > - A single evaluated blur effect. Singular per type; the containing key - (layerBlur vs backgroundBlur) determines render role. - properties: - visible: - type: boolean - description: Whether this blur is active - radius: - oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] - description: Blur radius in pixels - required: [visible, radius] - additionalProperties: false -``` - -**`EffectsGroup` schema definition** (`schema/styles.schema.json`): - -```yaml -# New entry under #/definitions -EffectsGroup: - type: object - description: > - Inline effects grouped by role. Each key is optional; a key is omitted when - no effects of that type are present on the node. - properties: - dropShadows: - type: array - items: { $ref: '#/definitions/Shadow' } - description: Ordered list of drop shadows - innerShadows: - type: array - items: { $ref: '#/definitions/Shadow' } - description: Ordered list of inner shadows - layerBlur: - $ref: '#/definitions/Blur' - description: Layer blur (filter effect on the node itself) - backgroundBlur: - $ref: '#/definitions/Blur' - description: Background blur (backdrop filter) - additionalProperties: false -``` - -**`EffectsStyleValue` schema definition** (`schema/styles.schema.json`): - -```yaml -# New entry under #/definitions -EffectsStyleValue: - description: > - Effect value. FigmaStyle when the node references a named effects style; - EffectsGroup when effects are defined inline. - oneOf: - - { $ref: '#/definitions/FigmaStyle', description: Named effects style reference } - - { $ref: '#/definitions/EffectsGroup', description: Inline effects grouped by role } - - { type: null } -``` - -**`effects` property entry** (under `#/definitions/Styles/properties`): - -```yaml -# Added to Styles properties -effects: - $ref: '#/definitions/EffectsStyleValue' - description: > - Effect output. FigmaStyle when the node references a named effects style; - EffectsGroup when effects are defined inline. effectStyleId — REMOVED. - -# Removed from Styles properties: -# effectStyleId: { $ref: '#/definitions/StyleIdValue', description: 'Effect style reference' } -``` - -### Notes - -- `Shadow` fields `x`, `y`, `blur`, `spread` allow `VariableStyle` in addition to `number` to support Figma variable bindings on those fields. In practice this is uncommon; the primary case is a raw number. -- `Shadow.color` allows `string` (`#RRGGBBAA`) or `VariableStyle`. The alpha channel is always present in the hex encoding. -- `Blur.radius` allows `VariableStyle` for the same reason. -- `visible` on both `Shadow` and `Blur` is always `boolean` (no `VariableStyle` variant) — Figma does not support variable binding on `visible` for individual effect items. -- `dropShadows` and `innerShadows` are arrays even when only one shadow is present. A single-element array is valid and expected. -- `layerBlur` and `backgroundBlur` are singular objects, not arrays — Figma stacks multiple blurs of the same type as a single resolved value; emitting an array would be misleading. -- An `EffectsGroup` with all keys absent is not emitted — `effects` is omitted entirely when no effects are present. - ---- - -## Type ↔ Schema Impact - -- **Symmetric**: Yes -- **Parity check**: - - `Shadow` interface in `types/Effects.ts` ↔ `Shadow` definition in `schema/styles.schema.json` - - `Blur` interface in `types/Effects.ts` ↔ `Blur` definition in `schema/styles.schema.json` - - `EffectsGroup` interface in `types/Effects.ts` ↔ `EffectsGroup` definition in `schema/styles.schema.json` - - `Styles.effects?: FigmaStyle | EffectsGroup` ↔ `#/definitions/Styles/properties/effects` → `EffectsStyleValue` - - `StyleKey` union member `'effects'` ↔ key present in `#/definitions/Styles/properties` - - `effectStyleId` removed from `types/Styles.ts` ↔ `effectStyleId` removed from `#/definitions/Styles/properties` - ---- - -## Downstream Impact - -| Consumer | Impact | Action required | -|---|---|---| -| `anova-kit` (CLI / MCP) | Recompile required; `effectStyleId` removed, `effects` shape changed to `EffectsGroup` | Migrate `styles.effectStyleId` reads to `styles.effects`; destructure by role (`dropShadows`, `layerBlur`, etc.) | - ---- - -## Semver Decision - -**Version bump**: `0.10.x → 0.11.0` (`MAJOR`) - -**Justification**: -- `effectStyleId` is removed from `Styles`, `StyleKey`, and the JSON schema with no deprecation period — a breaking change for any consumer reading that key. Per constitution Principle III: *"Any change to an existing export is a breaking change and MUST follow semantic versioning rules."* -- The addition of `Shadow` interface and `effects` key is additive (MINOR on its own) but the removal forces MAJOR. - ---- - -## Consequences - -- Consumers can represent all Figma effect types — drop shadows, inner shadows, layer blur, background blur — via predictable, role-named keys without filtering an array. -- Each effect role maps directly to a platform property: `dropShadows` → `box-shadow`, `innerShadows` → `inset box-shadow`, `layerBlur` → `filter: blur()`, `backgroundBlur` → `backdrop-filter: blur()`. -- Consumers reading `styles.effectStyleId` will receive `undefined` after upgrading; they must migrate to `styles.effects`. -- When `styles.effects` is a `FigmaStyle`, the named style `id` and resolved `name` are available — identical data to the former `effectStyleId` output, under a new key. -- When `styles.effects` is an `EffectsGroup`, each present key carries the resolved geometry for that effect role. -- New effect roles can be added to `EffectsGroup` as optional keys in future releases without a breaking change. -- Any JSON schema validation that previously passed `{ "effectStyleId": { "id": "..." } }` will fail after this change — consumers must revalidate against the new schema version. -- `Shadow`, `Blur`, and `EffectsGroup` are now first-class exported types: `import type { Shadow, Blur, EffectsGroup } from '@directededges/anova'`. diff --git a/adr/003-gradients.md b/adr/003-gradients.md new file mode 100644 index 0000000..259efe9 --- /dev/null +++ b/adr/003-gradients.md @@ -0,0 +1,368 @@ +# ADR: Gradient Support for Color Style Properties + +**Branch**: `003-gradients` +**Created**: 2026-02-25 +**Status**: ACCEPTED +**Deciders**: (author) +**Supersedes**: *(none)* + +--- + +## Context + +Anova's `types/Styles.ts` defines `backgroundColor`, `textColor`, and `strokes` as `Style` values — the catch-all union for any serialisable style property. The schema, however, already has a more specific definition for these three fields: + +In `schema/styles.schema.json`, `ColorStyleValue` is used for all color-bearing properties: + +```yaml +ColorStyleValue: + oneOf: + - type: string # hex/rgba + - $ref: VariableStyle + - $ref: FigmaStyle + - type: null +``` + +Figma uses **gradient fills** (LINEAR, RADIAL, ANGULAR, and DIAMOND paint types) as a first-class alternative to solid color fills. DIAMOND is Figma-only and has no native equivalent across CSS, iOS, or Android — it is excluded from this ADR. When a node's `backgroundColor`, `textColor`, or `strokes` is a gradient, the current contract has no representation for it. The outcomes today are: + +- **Named gradient style** (`fillStyleId` set): captured as `FigmaStyle { id }` — handled. +- **Variable bound to gradient fill**: captured as `VariableStyle { id, rawValue }` — `rawValue` is whatever the raw color processor returns (likely `null` for non-solid). +- **Inline gradient fill (no style, no variable)**: falls through `raw()` → `ColorStyle.value()` → emits `null` because no solid paint is present. + +The gap is the **inline gradient case** and the lack of a structured cross-platform shape when gradient data should be emitted rather than just a style reference. `backgroundColor` and `textColor` are treated symmetrically to `strokes` in the fill-style detection pipeline (`FILL_SPEC_KEYS` covers all three). + +This ADR also surfaces a type-schema asymmetry: the schema already isolates color properties under `ColorStyleValue`, but `types/Styles.ts` uses the catch-all `Style` for those fields with no corresponding `ColorStyle` TypeScript type. This ADR closes both gaps simultaneously. + +--- + +## Decision Drivers + +- **Type-schema symmetry (Constitution I)**: Any change to `types/Styles.ts` must have a matching change in `schema/styles.schema.json` before publish. +- **No logic permitted (Constitution II)**: Only types, interfaces, and schema definitions. No gradient parsing algorithms belong here. +- **Stable, intentional public API (Constitution III)**: New types must represent a genuine shared concept — not Figma internals. `GradientValue` must be cross-platform; Figma-internal constructs (affine transform matrices, DIAMOND type) must be excluded. +- **Minimal structure (Constitution III)**: The type must capture what is needed for cross-platform use, not faithfully mirror Figma's `GradientPaint` (which uses transform matrices and per-stop alpha separately from color). +- **Additive = MINOR (Constitution versioning)**: Adding new optional variants to existing type unions without removing existing ones qualifies as MINOR if no consumer's existing valid code breaks. +- **Strict TypeScript (Constitution V)**: The new interface must compile cleanly under `tsconfig.build.json` with no `any`. +- **`fills` arrays are out of scope**: Anova maps Figma's `fills` array down to the semantic properties `backgroundColor`, `textColor`, and `strokes` — the raw `fills` array is never surfaced in the type or schema contract. Gradient support therefore applies only to those mapped properties; there is no requirement to represent Figma's multi-fill array or its ordering. + +--- + +## Options Considered + +### Option A: Discriminated `GradientValue` + new `ColorStyle` type *(Selected)* + +Introduce a discriminated union `GradientValue = LinearGradient | RadialGradient | AngularGradient` exported from `types/Gradient.ts`. Introduce a `ColorStyle` type alias in `types/Styles.ts` that mirrors `ColorStyleValue` in the schema. Update `backgroundColor`, `textColor`, and `strokes` to use `ColorStyle` instead of `Style`. + +```yaml +# New GradientStop interface +GradientStop: + position: number # 0–1 along the gradient vector + color: string | VariableStyle # hex/rgba or variable reference + +# Discriminated variants +LinearGradient: + type: "LINEAR" + angle: number # degrees — required + stops: GradientStop[] + +RadialGradient: + type: "RADIAL" + center: { x: number; y: number } # normalised 0–1 — required + stops: GradientStop[] + +AngularGradient: + type: "ANGULAR" + center: { x: number; y: number } # normalised 0–1 — required + stops: GradientStop[] + +GradientValue: LinearGradient | RadialGradient | AngularGradient + +# New ColorStyle type (TypeScript mirror of ColorStyleValue schema) +ColorStyle: + oneOf: + - string # hex/rgba solid color + - VariableStyle # variable reference + - FigmaStyle # named Figma style reference + - ReferenceValue # prop binding + - GradientValue # ← new — inline gradient + - null +``` + +**Pros**: +- Discriminated union enforces required fields per type at compile time — no optional `angle` leaking onto RADIAL/ANGULAR shapes +- `stops[].color` supports both raw hex and variable references, matching how Figma lets individual stops be bound to variables +- `ColorStyle` closes the type-schema asymmetry: TypeScript consumers now have a named type that maps directly to `ColorStyleValue` in the schema +- DIAMOND excluded — only `LINEAR | RADIAL | ANGULAR` are cross-platform +- Additive — `Style` union is unchanged; only `backgroundColor`, `textColor`, `strokes` fields are narrowed to `ColorStyle` +- Named-style (`FigmaStyle`) and variable (`VariableStyle`) paths are preserved unchanged + +**Cons / Trade-offs**: +- Narrowing `backgroundColor` / `textColor` / `strokes` from `Style` to `ColorStyle` is technically a type-level narrowing — existing code assigning a `boolean` to `backgroundColor` would fail to compile, but this was never semantically valid and is unlikely in practice +- `anova-kit` consumers narrowing `Style` values for color fields must update to `ColorStyle` + +--- + +### Option B: Figma `GradientPaint` model *(Rejected)* + +Expose Figma's own `GradientPaint` shape: `{ type, gradientTransform: Transform, gradientStops: [{ position, color: {r, g, b, a} }] }`. + +**Rejected because**: `gradientTransform` is a 3×2 affine matrix that is Figma-specific and carries no semantic meaning outside Figma. It couples the Anova contract permanently to a single tool's internal representation, violating the cross-platform intent of Constitution III ("genuine, shared concept"). + +--- + +### Option C: CSS-like string *(Rejected)* + +Accept gradient as a raw CSS string, e.g. `"linear-gradient(90deg, #000 0%, #fff 100%)"`. No new types added — `Style` already accepts `string`. + +**Rejected because**: This is structurally opaque. Downstream tools (`anova-kit`, `anova-plugin`) cannot parse or validate gradient intent without reimplementing a CSS gradient parser. It also excludes non-CSS platforms (iOS, Android) from consuming a structured value. Constitution III requires types to represent genuine shared concepts — hiding structure inside a string is the opposite. + +--- + +### Option D: References only — no inline gradient values *(Rejected)* + +Do nothing. Gradients must always be expressed via a named Figma style (`FigmaStyle`) or variable (`VariableStyle`). Inline gradients are emitted as `null`. + +**Rejected because**: This is a lossy contract — a component with an inline gradient fill would silently emit `null`, making the output schema-valid but semantically wrong. It also does not address the user question about whether `backgroundColor` / `textColor` are handled correctly (they are, via the fill-style path, but the inline case remains broken). + +--- + +### Option E: Platform-specific definitions *(Rejected)* + +Add keyed sub-objects per platform, e.g. `{ css: "linear-gradient(...)", swiftUI: { ... }, compose: { ... } }`. + +**Rejected because**: This couples the shared type contract to n platform grammars simultaneously, multiplying the maintenance surface in violation of Constitution III (minimal, stable API). Platform adaptation belongs in downstream tooling, not in the shared type definition. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Gradient.ts` | New file — exports `GradientStop`, `GradientCenter`, `LinearGradient`, `RadialGradient`, `AngularGradient`, `GradientValue` | MINOR | +| `Styles.ts` | New `ColorStyle` type alias; `backgroundColor`, `textColor`, `strokes` narrowed from `Style` to `ColorStyle` | MINOR | +| `index.ts` | Add exports for all types from `Gradient.ts`; add `ColorStyle` | MINOR | + +**Example — new `types/Gradient.ts`**: +```yaml +# types/Gradient.ts — new file + +GradientCenter: # reusable point type for RADIAL / ANGULAR + x: number # normalised 0–1 + y: number # normalised 0–1 + +GradientStop: + position: number # 0–1 + color: string | VariableStyle # hex/rgba or variable reference + +LinearGradient: + type: "LINEAR" + angle: number # degrees — required + stops: GradientStop[] + +RadialGradient: + type: "RADIAL" + center: GradientCenter # required + stops: GradientStop[] + +AngularGradient: + type: "ANGULAR" + center: GradientCenter # required + stops: GradientStop[] + +GradientValue: LinearGradient | RadialGradient | AngularGradient +``` + +**Example — new `ColorStyle` type in `types/Styles.ts`**: +```yaml +# Before: backgroundColor / textColor / strokes typed as Style +# (Style is the catch-all union and includes boolean, number, etc.) + +# After: a ColorStyle type mirrors ColorStyleValue from the schema +ColorStyle: + oneOf: + - string # hex/rgba solid + - VariableStyle + - FigmaStyle + - ReferenceValue + - GradientValue # ← new + - null + +# Styles type — affected fields +Styles: + backgroundColor: ColorStyle # was: Style + textColor: ColorStyle # was: Style + strokes: ColorStyle # was: Style + # all other fields: Style unchanged +``` + +--- + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `styles.schema.json` | New definitions `GradientCenter`, `GradientStop`, `LinearGradient`, `RadialGradient`, `AngularGradient`, `GradientValue`; `ColorStyleValue` gains `{ "$ref": "#/definitions/GradientValue" }` | MINOR | + +**Example — new schema definitions**: +```yaml +# Under #/definitions + +GradientCenter: + type: object + required: [x, y] + properties: + x: + type: number + minimum: 0 + maximum: 1 + description: "Horizontal centre position, normalised 0–1" + y: + type: number + minimum: 0 + maximum: 1 + description: "Vertical centre position, normalised 0–1" + additionalProperties: false + +GradientStop: + type: object + required: [position, color] + properties: + position: + type: number + minimum: 0 + maximum: 1 + description: "Stop position along the gradient vector (0–1)" + color: + oneOf: + - type: string + description: "Stop color as hex or rgba string" + - $ref: "#/definitions/VariableStyle" + description: "Stop color as a variable reference" + additionalProperties: false + +LinearGradient: + type: object + required: [type, angle, stops] + properties: + type: + type: string + const: "LINEAR" + angle: + type: number + description: "Angle in degrees" + stops: + type: array + items: + $ref: "#/definitions/GradientStop" + minItems: 2 + additionalProperties: false + +RadialGradient: + type: object + required: [type, center, stops] + properties: + type: + type: string + const: "RADIAL" + center: + $ref: "#/definitions/GradientCenter" + stops: + type: array + items: + $ref: "#/definitions/GradientStop" + minItems: 2 + additionalProperties: false + +AngularGradient: + type: object + required: [type, center, stops] + properties: + type: + type: string + const: "ANGULAR" + center: + $ref: "#/definitions/GradientCenter" + stops: + type: array + items: + $ref: "#/definitions/GradientStop" + minItems: 2 + additionalProperties: false + +GradientValue: + oneOf: + - $ref: "#/definitions/LinearGradient" + - $ref: "#/definitions/RadialGradient" + - $ref: "#/definitions/AngularGradient" + +# Updated ColorStyleValue +ColorStyleValue: + oneOf: + - type: string # hex/rgba (solid, no change) + - $ref: VariableStyle # no change + - $ref: FigmaStyle # no change — named gradient style + - $ref: GradientValue # ← new — inline gradient + - type: null # no change +``` + +### Notes + +- `backgroundColor`, `textColor`, and `strokes` all reference `ColorStyleValue` in the schema; all three gain gradient support implicitly via this single definition change. +- DIAMOND is intentionally excluded — it is not natively representable in CSS, SwiftUI, or Jetpack Compose without approximation, making it unsuitable for the cross-platform contract. +- Each gradient variant is a separate schema object with `const` on its `type` field, enabling JSON Schema validators to discriminate correctly. +- `GradientValue` is distinguishable from `FigmaStyle` / `VariableStyle` / `ReferenceValue` at runtime via the presence of the `type` field with a gradient-specific constant value — the `type` key does not appear on any other `ColorStyle` variant. +- No dedicated `Angle` schema definition is introduced. CSS `linear-gradient()` and equivalent platform APIs treat angle as a wrapping modular value: `-45deg`, `405deg`, and `315deg` are all semantically valid in different contexts. A `minimum: 0, maximum: 360` constraint would cause false validation failures for legitimately out-of-range but equivalent values. The `angle` field remains `type: number` with a description of `"Angle in degrees"` — sufficient to communicate the unit without imposing invalid bounds. + +### Rationale + +- **Platform Neutrality**: `LINEAR`, `RADIAL`, and `ANGULAR` map directly to native gradient APIs across CSS, SwiftUI, and Jetpack Compose — no platform-specific translation layer is required to consume the shared type. +- **Flexibility**: Allowing `stops[].color` to be either a raw hex/rgba string or a `VariableStyle` reference means individual stop colors can participate in token-based theming without requiring the entire gradient to be wrapped in a named style. +- **Simplicity**: A discriminated union of three concrete variants is easier to narrow and validate than a single flat object with optional fields — each variant carries only the fields that are semantically meaningful for its type. +- **Completeness**: Introducing `ColorStyle` alongside `GradientValue` closes the existing type-schema asymmetry, ensuring every color-bearing property has a precise TypeScript type that corresponds 1:1 with `ColorStyleValue` in the schema. +- **Consistency**: Using `type` as the discriminant key aligns with the existing pattern in `EffectsGroup` and Figma's own paint model, making the contract familiar to consumers already working with those types. +- **Maintainability**: Each gradient variant (`LinearGradient`, `RadialGradient`, `AngularGradient`) is a standalone interface with no shared mutable state, so adding or adjusting a variant in a future ADR requires only an additive change to the `GradientValue` union — no existing variants need modification. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `GradientCenter` (TypeScript) ↔ `#/definitions/GradientCenter` (schema) + - `GradientStop` (TypeScript) ↔ `#/definitions/GradientStop` (schema) + - `LinearGradient` (TypeScript) ↔ `#/definitions/LinearGradient` (schema) + - `RadialGradient` (TypeScript) ↔ `#/definitions/RadialGradient` (schema) + - `AngularGradient` (TypeScript) ↔ `#/definitions/AngularGradient` (schema) + - `GradientValue` discriminated union ↔ `#/definitions/GradientValue` oneOf (schema) + - `ColorStyle` type alias ↔ `#/definitions/ColorStyleValue` (schema) + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `anova-kit` | Recompile; any code narrowing `backgroundColor`, `textColor`, or `strokes` must handle `GradientValue` | Add `type` discriminant guard (`'LINEAR' \| 'RADIAL' \| 'ANGULAR'`) in any switch/if chain over `ColorStyle` values | + +--- + +## Semver Decision + +**Version bump**: `0.11.0 → 0.12.0` (`MINOR`) + +**Justification**: All changes are additive — new types (`GradientCenter`, `GradientStop`, `LinearGradient`, `RadialGradient`, `AngularGradient`, `GradientValue`, `ColorStyle`) and a narrowing of three existing fields in `Styles` from the catch-all `Style` to the more precise `ColorStyle`. No existing valid gradient-assignment code would have compiled against these fields before (no gradient type existed), so there is no regression. The `Style` union itself is unchanged. Per Constitution versioning rules: "MINOR for additive types or new optional fields." + +--- + +## Consequences + +- `backgroundColor`, `textColor`, and `strokes` can now represent inline gradients in the serialized output — no data is silently dropped to `null` for in-file gradient fills. +- The new `ColorStyle` type closes the type-schema asymmetry for color properties: TypeScript consumers now have a named type that maps directly to `ColorStyleValue` in the schema. +- Named gradient Figma styles continue to be emitted as `FigmaStyle { id }` — the reference path is unchanged and preferred. +- Variable-bound gradients continue to emit as `VariableStyle { id, rawValue }` — `rawValue` semantics for gradient variables are deferred (a separate ADR may address what `rawValue` means when the variable resolves to a gradient). +- Individual gradient stops may have their color bound to a variable via `VariableStyle` — this is captured in `GradientStop.color`. +- `anova-kit` consumers must handle `GradientValue` in any code that narrowed over color-property values. TypeScript will surface missing cases at compile time via the `type` discriminant (`'LINEAR' | 'RADIAL' | 'ANGULAR'`). +- DIAMOND gradients remain out-of-scope. A future MAJOR ADR would be required to add them, as DIAMOND has no stable cross-platform equivalent. +- The `type` discriminant field does not collide with any existing `ColorStyle` variant shape, enabling safe runtime narrowing without `instanceof` checks. diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 4b436ac..4f685e7 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -99,12 +99,99 @@ { "type": "null" } ] }, + "GradientCenter": { + "type": "object", + "description": "Normalised 2-D centre point used by RADIAL and ANGULAR gradient variants. Both axes are in the range 0–1 relative to the fill bounding box.", + "properties": { + "x": { "type": "number", "minimum": 0, "maximum": 1, "description": "Horizontal centre position, normalised 0–1" }, + "y": { "type": "number", "minimum": 0, "maximum": 1, "description": "Vertical centre position, normalised 0–1" } + }, + "required": ["x", "y"], + "additionalProperties": false + }, + "GradientStop": { + "type": "object", + "description": "A single stop in a gradient definition.", + "properties": { + "position": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Stop position along the gradient vector, normalised 0–1" + }, + "color": { + "oneOf": [ + { "type": "string", "description": "Stop color as hex or rgba string" }, + { "$ref": "#/definitions/VariableStyle", "description": "Stop color as a variable reference" } + ] + } + }, + "required": ["position", "color"], + "additionalProperties": false + }, + "LinearGradient": { + "type": "object", + "description": "A linear gradient defined by an angle and two or more colour stops. Maps to linear-gradient() (CSS) or LinearGradient (SwiftUI / Compose).", + "properties": { + "type": { "type": "string", "const": "LINEAR", "description": "Discriminant" }, + "angle": { "type": "number", "description": "Angle of the gradient line, in degrees" }, + "stops": { + "type": "array", + "items": { "$ref": "#/definitions/GradientStop" }, + "minItems": 2, + "description": "Ordered list of colour stops" + } + }, + "required": ["type", "angle", "stops"], + "additionalProperties": false + }, + "RadialGradient": { + "type": "object", + "description": "A radial gradient defined by a centre point and two or more colour stops. Maps to radial-gradient() (CSS) or RadialGradient (SwiftUI / Compose).", + "properties": { + "type": { "type": "string", "const": "RADIAL", "description": "Discriminant" }, + "center": { "$ref": "#/definitions/GradientCenter", "description": "Normalised centre point of the gradient ellipse" }, + "stops": { + "type": "array", + "items": { "$ref": "#/definitions/GradientStop" }, + "minItems": 2, + "description": "Ordered list of colour stops" + } + }, + "required": ["type", "center", "stops"], + "additionalProperties": false + }, + "AngularGradient": { + "type": "object", + "description": "An angular (conic) gradient defined by a centre point and two or more colour stops. Maps to conic-gradient() (CSS) or AngularGradient (SwiftUI / Compose).", + "properties": { + "type": { "type": "string", "const": "ANGULAR", "description": "Discriminant" }, + "center": { "$ref": "#/definitions/GradientCenter", "description": "Normalised centre point of the gradient sweep" }, + "stops": { + "type": "array", + "items": { "$ref": "#/definitions/GradientStop" }, + "minItems": 2, + "description": "Ordered list of colour stops" + } + }, + "required": ["type", "center", "stops"], + "additionalProperties": false + }, + "GradientValue": { + "description": "A discriminated union of the three supported cross-platform gradient types (LINEAR, RADIAL, ANGULAR). The `type` field is the discriminant. DIAMOND is intentionally excluded.", + "oneOf": [ + { "$ref": "#/definitions/LinearGradient" }, + { "$ref": "#/definitions/RadialGradient" }, + { "$ref": "#/definitions/AngularGradient" } + ] + }, "ColorStyleValue": { - "description": "Color style value (from ColorStyle processor)", + "description": "Color style value. Covers solid color, variable reference, named Figma style, inline gradient, or null.", "oneOf": [ { "type": "string", "description": "Hex/rgba color string" }, { "$ref": "#/definitions/VariableStyle" }, { "$ref": "#/definitions/FigmaStyle" }, + { "$ref": "#/definitions/GradientValue" }, { "type": "null" } ] }, diff --git a/tests/Styles.test-d.ts b/tests/Styles.test-d.ts index 49d0c71..4cd1464 100644 --- a/tests/Styles.test-d.ts +++ b/tests/Styles.test-d.ts @@ -1,9 +1,13 @@ /** - * Type-level tests for Styles, Shadow, Blur, and EffectsGroup. + * Type-level tests for Styles, Shadow, Blur, EffectsGroup, and gradient types. * These files are intentionally never executed — they are compiled with tsc * to assert that the type shape is correct. */ -import type { Styles, Shadow, Blur, EffectsGroup, FigmaStyle, VariableStyle } from '../types/index.js'; +import type { + Styles, Shadow, Blur, EffectsGroup, FigmaStyle, VariableStyle, + ColorStyle, GradientStop, GradientCenter, LinearGradient, RadialGradient, + AngularGradient, GradientValue, +} from '../types/index.js'; // ─── Shadow ──────────────────────────────────────────────────────────────── diff --git a/types/Gradient.ts b/types/Gradient.ts new file mode 100644 index 0000000..30bc828 --- /dev/null +++ b/types/Gradient.ts @@ -0,0 +1,70 @@ +import { VariableStyle } from "./Styles.js"; + +/** + * A single stop in a gradient definition. + */ +export interface GradientStop { + /** Position along the gradient vector, normalised 0–1. */ + position: number; + /** Stop color as a hex/rgba string or a variable reference. */ + color: string | VariableStyle; +} + +/** + * Normalised 2-D point used as the gradient centre for RADIAL and ANGULAR variants. + * Both axes are in the range 0–1 relative to the fill bounding box. + */ +export interface GradientCenter { + /** Horizontal centre position, normalised 0–1. */ + x: number; + /** Vertical centre position, normalised 0–1. */ + y: number; +} + +/** + * A linear gradient, defined by an angle and two or more colour stops. + */ +export interface LinearGradient { + /** Discriminant — always `"LINEAR"`. */ + type: 'LINEAR'; + /** Angle of the gradient line, in degrees. */ + angle: number; + /** Ordered list of colour stops. Minimum two stops required. */ + stops: GradientStop[]; +} + +/** + * A radial gradient, defined by a centre point and two or more colour stops. + * Maps to `radial-gradient()` (CSS), `RadialGradient` (SwiftUI / Compose). + */ +export interface RadialGradient { + /** Discriminant — always `"RADIAL"`. */ + type: 'RADIAL'; + /** Normalised centre point of the gradient ellipse. */ + center: GradientCenter; + /** Ordered list of colour stops. Minimum two stops required. */ + stops: GradientStop[]; +} + +/** + * An angular (conic) gradient, defined by a centre point and two or more colour stops. + * Maps to `conic-gradient()` (CSS), `AngularGradient` (SwiftUI / Compose). + */ +export interface AngularGradient { + /** Discriminant — always `"ANGULAR"`. */ + type: 'ANGULAR'; + /** Normalised centre point of the gradient sweep. */ + center: GradientCenter; + /** Ordered list of colour stops. Minimum two stops required. */ + stops: GradientStop[]; +} + +/** + * A discriminated union of the three supported cross-platform gradient types. + * The `type` field is the discriminant and does not collide with any other + * `ColorStyle` variant shape. + * + * DIAMOND is intentionally excluded — it has no native equivalent in CSS, + * SwiftUI, or Jetpack Compose without approximation. + */ +export type GradientValue = LinearGradient | RadialGradient | AngularGradient; diff --git a/types/Styles.ts b/types/Styles.ts index 1f45d74..7fc9045 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -1,12 +1,13 @@ import { ReferenceValue } from "./ReferenceValue.js"; import { EffectsGroup } from "./Effects.js"; +import { GradientValue } from "./Gradient.js"; export type Styles = Partial<{ rotation: Style; visible: Style; opacity: Style; locked: Style; - backgroundColor: Style; + backgroundColor: ColorStyle; effects: FigmaStyle | EffectsGroup; clipContent: Style; cornerRadius: Style; @@ -21,7 +22,7 @@ export type Styles = Partial<{ layoutPositioning: Style; layoutSizingHorizontal: Style; layoutSizingVertical: Style; - strokes: Style; + strokes: ColorStyle; strokeAlign: Style; strokeWeight: Style; strokeTopWeight: Style; @@ -44,7 +45,7 @@ export type Styles = Partial<{ textStyleId: Style; textAlignHorizontal: Style; textAlignVertical: Style; - textColor: Style; + textColor: ColorStyle; primaryAxisAlignItems: Style; primaryAxisSizingMode: Style; counterAxisAlignItems: Style; @@ -71,6 +72,14 @@ export type Styles = Partial<{ */ export type Style = string | boolean | number | null | VariableStyle | FigmaStyle | ReferenceValue; +/** + * Colour-specific style value type. + * Mirrors `ColorStyleValue` in `schema/styles.schema.json`. + * Used for `backgroundColor`, `textColor`, and `strokes` — the three properties + * whose values are always colour-semantics and may carry gradient data. + */ +export type ColorStyle = string | VariableStyle | FigmaStyle | ReferenceValue | GradientValue | null; + /** * Variable-based style reference */ diff --git a/types/index.ts b/types/index.ts index c6213ad..3f2bf59 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,8 +25,9 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, StyleKey, VariableStyle, FigmaStyle } from './Styles.js'; +export type { Styles, Style, ColorStyle, StyleKey, VariableStyle, FigmaStyle } from './Styles.js'; export type { Shadow, Blur, EffectsGroup } from './Effects.js'; +export type { GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue } from './Gradient.js'; // Reference types export type { ReferenceValue, BindingKey } from './ReferenceValue.js'; From 423f2b57142e0fda0f377f847608e723bed156e0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:30:30 -0500 Subject: [PATCH 15/19] Updated Gradient changelog --- .github/agents/AnovaADR.implement.agent.md | 5 ++++- CHANGELOG.md | 14 ++++++++++++++ adr/003-gradients.md | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/agents/AnovaADR.implement.agent.md b/.github/agents/AnovaADR.implement.agent.md index 008ba23..a7618e9 100644 --- a/.github/agents/AnovaADR.implement.agent.md +++ b/.github/agents/AnovaADR.implement.agent.md @@ -58,16 +58,19 @@ 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 and 11 are REQUIRED before reporting completion. Do not skip to step 12.** 10. **Update CHANGELOG.md**: - Prepend a new entry at the top using the existing format in the file - **Format**: one top-level bullet per user-visible change; no sub-bullets; no bold - **Names**: `.` in backticks, em dash separator — e.g. `Styles.cornerSmoothing` — corner smoothing factor - **Sections**: use `### Added`, `### Changed`, `### Removed` as needed; add `### Migration` (MAJOR or rename only) with `.` → `.`: imperative callsite instruction + - **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 11. 11. **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 12. -12. **Report**: List every file modified (with one-line description each). State that the author should review the diff and run `/speckit.accept` once satisfied. +12. **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 10–11 were not completed. State that the author should review the diff and run `/speckit.accept` once satisfied. ## Key rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 7afd4ee..a323409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to the Anova 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). +### Added + ## [0.11.0] - 2026-02-24 ### Added @@ -17,9 +19,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Blur` interface — exported from `@directededges/anova`; fields: `visible` (boolean), `radius` (number or `VariableStyle`) - `EffectsGroup` interface — exported from `@directededges/anova`; fields: `dropShadows?` (`Shadow[]`), `innerShadows?` (`Shadow[]`), `layerBlur?` (`Blur`), `backgroundBlur?` (`Blur`) - `Shadow`, `Blur`, `EffectsGroup`, and `EffectsStyleValue` definitions in `schema/styles.schema.json` +- `GradientStop` interface — fields: `position` (number, normalised 0–1), `color` (hex/rgba string or `VariableStyle`) +- `GradientCenter` interface — fields: `x`, `y` (number, normalised 0–1); centre point for RADIAL and ANGULAR variants +- `LinearGradient` interface — fields: `type: 'LINEAR'`, `angle` (degrees), `stops` (`GradientStop[]`) +- `RadialGradient` interface — fields: `type: 'RADIAL'`, `center` (`GradientCenter`), `stops` (`GradientStop[]`) +- `AngularGradient` interface — fields: `type: 'ANGULAR'`, `center` (`GradientCenter`), `stops` (`GradientStop[]`) +- `GradientValue` type — discriminated union `LinearGradient | RadialGradient | AngularGradient`; `type` field is the discriminant; DIAMOND excluded +- `ColorStyle` type — colour-specific style union (`string | VariableStyle | FigmaStyle | ReferenceValue | GradientValue | null`); mirrors `ColorStyleValue` in `schema/styles.schema.json` +- `GradientCenter`, `GradientStop`, `LinearGradient`, `RadialGradient`, `AngularGradient`, `GradientValue` definitions in `schema/styles.schema.json` ### Changed +- `Styles.backgroundColor` — narrowed from `Style` to `ColorStyle`; inline gradients now representable +- `Styles.textColor` — narrowed from `Style` to `ColorStyle`; inline gradients now representable +- `Styles.strokes` — narrowed from `Style` to `ColorStyle`; inline gradients now representable +- `ColorStyleValue` in `schema/styles.schema.json` — gains `{ "$ref": "#/definitions/GradientValue" }` variant - `styles.fills` renamed to `styles.backgroundColor` ### Removed diff --git a/adr/003-gradients.md b/adr/003-gradients.md index 259efe9..e103955 100644 --- a/adr/003-gradients.md +++ b/adr/003-gradients.md @@ -3,7 +3,7 @@ **Branch**: `003-gradients` **Created**: 2026-02-25 **Status**: ACCEPTED -**Deciders**: (author) +**Deciders**: Nathan Curtis **Supersedes**: *(none)* --- From 8454c208c9051dc3213ec2865631eed0ac16a8d0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:06:57 -0500 Subject: [PATCH 16/19] Update Shadows ADR status --- adr/002-effects-shadows-blurs.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/adr/002-effects-shadows-blurs.md b/adr/002-effects-shadows-blurs.md index 0193f2c..f12210b 100644 --- a/adr/002-effects-shadows-blurs.md +++ b/adr/002-effects-shadows-blurs.md @@ -2,7 +2,7 @@ **Branch**: `v0.11.0` **Created**: 2026-02-24 -**Status**: DRAFT +**Status**: ACCEPTED **Deciders**: Nathan Curtis (author) **Supersedes**: *(none)* @@ -45,6 +45,8 @@ Styles: # effects — ABSENT ``` +Note that Figma's `noise`, `textures`, and `glass` effects are considered out of scope. Because `EffectsGroup` routes by `effect.type` into named keys, unknown effect types are silently skipped during evaluation with no positional side effects on the output. If any of these effect types are formalised in a future release, they can be introduced as new optional keys on `EffectsGroup` without a breaking change. + --- ## Decision Drivers From f105a1f7f429079e5f4cf32dcdd7d080e8638b14 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:33:28 -0500 Subject: [PATCH 17/19] Aspect Ratio ADR --- CHANGELOG.md | 9 +- adr/004-aspect-ratio.md | 190 ++++++++++++++++++++++++++++++++++++++ schema/styles.schema.json | 20 +++- tests/Styles.test-d.ts | 46 ++++++++- types/Styles.ts | 24 ++++- types/index.ts | 2 +- 6 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 adr/004-aspect-ratio.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a323409..bcd8ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,15 @@ All notable changes to the Anova 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). -### Added - -## [0.11.0] - 2026-02-24 +## [0.11.0] - 2026-02-25 ### Added +- `Styles.aspectRatio` — optional aspect ratio constraint emitted when a node has a locked ratio; value is `AspectRatioValue` (`{ x: number; y: number }` numerator/denominator pair, e.g. `{ x: 16, y: 9 }`) or `null` when unconstrained; field is omitted from output entirely when no ratio is set +- `AspectRatioValue` — exported interface with required `x` (numerator) and `y` (denominator) number fields; named per existing specialised-type precedent (`GradientCenter`) rather than a generic `Vector` +- `AspectRatioStyle` — exported type alias `AspectRatioValue | null`; `VariableStyle` intentionally excluded as aspect ratio is a structural lock of literal numbers in the Figma API +- `AspectRatioValue` and `AspectRatioStyleValue` definitions in `schema/styles.schema.json` +- `'aspectRatio'` added to `StyleKey` union - `Metadata.license?` — optional `{ status: string; description: string }` field; absent when no license is supplied - `styles.textColor` — new style key for text colour - `styles.cornerSmoothing` — new style key for corner smoothing (Figma squircle factor) diff --git a/adr/004-aspect-ratio.md b/adr/004-aspect-ratio.md new file mode 100644 index 0000000..2ea9b63 --- /dev/null +++ b/adr/004-aspect-ratio.md @@ -0,0 +1,190 @@ +# ADR: Add `aspectRatio` to `Styles` + +**Branch**: `004-aspect-ratio` +**Created**: 2026-02-25 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`Styles` in `types/Styles.ts` and `#/definitions/Styles` in `schema/styles.schema.json` represent every serialisable style property emitted by the Anova Figma plugin. Currently there is no field for aspect ratio constraints — a property Figma exposes on frame and component nodes when a fixed ratio is locked. + +Without an `aspectRatio` field, consumers cannot reconstruct whether a node was authored with a locked ratio, nor can they re-apply that constraint when generating code. The gap is especially visible for responsive layout tokens where "16:9 container" is a first-class design intent. + +Four candidate representations were evaluated before selecting one. + +--- + +## Decision Drivers + +- **Type–schema sync**: Every new field in `types/Styles.ts` must have a corresponding `#/definitions/Styles` property and a new or reused value-type definition in `schema/styles.schema.json` — no drift. +- **Additive only**: The change must introduce only an optional field, keeping the bump MINOR and avoiding breaking downstream consumers. +- **No runtime logic**: No parsing helpers, no conversion functions. The chosen representation must be emittable directly from the Figma API without transformation inside this package. +- **Lossless fidelity**: The serialised form must round-trip without information loss; derived/computed representations that discard original operands are lower priority. +- **Minimal surface**: Prefer the simplest shape that satisfies the above; do not introduce auxiliary types unless required. +- **Strict-safe**: All new types must compile under `strict` TypeScript with no `any`. + +--- + +## Options Considered + +### Option A: `{ x: number; y: number }` object *(Selected)* + +An object with integer (or rational) numerator and denominator components representing the two sides of the ratio. + +```yaml +# Example output +aspectRatio: + x: 16 + y: 9 +``` + +**Pros**: +- Lossless — preserves both operands exactly as authored; round-trips without ambiguity. +- No parsing required in `anova-plugin` or `anova-transformer`; values are pulled directly from the Figma API ratio fields. +- Structurally consistent with existing two-component objects in the schema (`GradientCenter`, `Shadow` x/y) — reviewers can orient quickly. +- Trivially mappable to CSS (`aspect-ratio: 16 / 9`), SwiftUI (`.aspectRatio(16/9, ...)`), Compose, etc. +- No variable binding required — aspect ratio is a structural constraint authored as literal numbers in Figma, not a token-driven value. + +**Cons / Trade-offs**: +- Slightly more verbose in JSON than a single number or string. +- Both `x` and `y` are required — a node with an irrational ratio (e.g., `1.618`) must still be expressed as `{ x: 1.618, y: 1 }`, which is valid but unusual. + +--- + +### Option B: `string` *(Rejected)* + +Represent the ratio as a human-readable string such as `"16:9"` or `"4:3"`. + +**Rejected because**: Requires a parse step (`:` delimiter or `/` delimiter?) in every consumer package. Parsing logic belongs in `anova-transformer` or `anova-kit`, not in the schema contract. A string type is also too permissive — schema validation cannot enforce ratio semantics without a regex, which is fragile and not extensible to variable references. Violates the "lossless, parseable-free contract" implied by Constitution § II. + +--- + +### Option C: `number` (pre-computed ratio) *(Rejected)* + +Represent the ratio as a single floating-point number, e.g. `1.7778` for 16:9. + +**Rejected because**: Loses the original operands (16 and 9). Consumers cannot reconstruct `aspect-ratio: 16 / 9` in CSS or a human-legible fraction for documentation without reverse-engineering the float, which is lossy and imprecise. Round-trip fidelity (Decision Driver 3) is violated. + +--- + +### Option D: Enum of fixed values *(Rejected)* + +A string union such as `"SQUARE" | "WIDESCREEN" | "PORTRAIT" | "ULTRAWIDE"`. + +**Rejected because**: Figma does not enumerate ratios as named constants — it exposes a numeric pair. Mapping from that pair to a closed enum requires transformation logic that belongs in a downstream package, not in this contract package. The enum would also become stale as design systems introduce custom ratios outside the set. Violates Constitution § II (no logic) and § III (unstable, non-minimal API). + +--- + +## Decision + +Add `aspectRatio` as an optional field on `Styles`, using a new `AspectRatioValue` object type and a corresponding `AspectRatioStyleValue` schema definition. + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `Styles.ts` | Added optional field `aspectRatio: AspectRatioStyle` to `Styles` and `StyleKey` union | MINOR | +| `Styles.ts` | Added exported interface `AspectRatioValue { x: number; y: number }` | MINOR | +| `Styles.ts` | Added exported type `AspectRatioStyle = AspectRatioValue \| null` | MINOR | + +**Example — new shape** (`types/Styles.ts`): +```yaml +# Before +Styles: + cornerRadius?: Style + # ... (no aspectRatio) + +# After +Styles: + cornerRadius?: Style + aspectRatio?: AspectRatioStyle # optional — MINOR + +AspectRatioValue: + x: number # numerator (e.g. 16) + y: number # denominator (e.g. 9) + +AspectRatioStyle: AspectRatioValue | null +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `styles.schema.json` | Added `aspectRatio` property to `#/definitions/Styles/properties` | MINOR | +| `styles.schema.json` | Added `AspectRatioValue` object definition to `#/definitions` | MINOR | +| `styles.schema.json` | Added `AspectRatioStyleValue` definition to `#/definitions` | MINOR | + +**Example — new definitions** (`schema/styles.schema.json`): +```yaml +# New definition: #/definitions/AspectRatioValue +AspectRatioValue: + type: object + description: "Aspect ratio expressed as a numerator/denominator pair." + properties: + x: + type: number + description: "Ratio numerator (e.g. 16 for 16:9)" + y: + type: number + description: "Ratio denominator (e.g. 9 for 16:9)" + required: [x, y] + additionalProperties: false + +# New definition: #/definitions/AspectRatioStyleValue +AspectRatioStyleValue: + description: "Aspect ratio value. Object pair when set; null when no ratio constraint is active." + oneOf: + - $ref: "#/definitions/AspectRatioValue" + - type: "null" + +# Addition to #/definitions/Styles/properties +aspectRatio: + $ref: "#/definitions/AspectRatioStyleValue" + description: "Aspect ratio constraint. Present only when the node has a locked ratio." +``` + +### Notes + +- `AspectRatioValue` uses `x` / `y` rather than `width` / `height` or `numerator` / `denominator` for consistency with the existing `x` / `y` pair convention in `Shadow` and `GradientCenter`. +- **Naming — `AspectRatioValue` over `Vector`**: Figma's API uses the term `Vector` for generic `{x, y}` pairs, but a shared `Vector` primitive was rejected here. The existing schema precedent is specialised, scoped types (`GradientCenter` rather than a generic point). Ratio `{x, y}` is semantically distinct from positional `{x, y}` — the components are a numerator and denominator, not coordinates. A shared `Vector` primitive would require broader justification under Constitution § III; if three or more properties converge on the same two-number pair shape, that case warrants its own ADR. +- **No `VariableStyle`**: Aspect ratio in Figma is a structural lock expressed as literal numbers, not a variable token. Including `VariableStyle` in the union would misrepresent the Figma API surface and add schema permissiveness without any current emitter support. Unlike `cornerRadius` or `width`, there is no Figma variable mode that drives aspect ratio at authoring time. +- `aspectRatio` is **not** added to the `Styles` `required` array — it is omitted from output when no ratio constraint is active, preserving the existing sparse-output contract. +- `ReferenceValue` (prop binding) is intentionally excluded — aspect ratio is not a bindable prop in the Figma API at this time. + +--- + +## Type ↔ Schema Impact + +- **Symmetric**: Yes +- **Parity check**: + - `AspectRatioValue` (TypeScript interface) ↔ `#/definitions/AspectRatioValue` (JSON Schema object) + - `AspectRatioStyle = AspectRatioValue | null` (TypeScript type alias) ↔ `#/definitions/AspectRatioStyleValue` (JSON Schema `oneOf` of object + null) + - `Styles.aspectRatio` (optional field) ↔ `#/definitions/Styles/properties/aspectRatio` (optional property, not in `required`) + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `anova-kit` | Recompile | Update to `@directededges/anova@0.12.0`; no usage change required — field is new optional | + +--- + +## Semver Decision + +**Version bump**: `0.11.0` (included in this release; no additional bump required) + +--- + +## Consequences + +- Consumers can now represent a locked aspect ratio constraint in serialised `Styles` output. +- `anova-plugin` can emit `aspectRatio` for nodes with a ratio lock; emission is opt-in (field omitted when not set). +- `anova-transformer` can read and forward `aspectRatio` without transformation — the `{x, y}` shape maps directly to CSS `aspect-ratio: x / y`, SwiftUI `.aspectRatio(x/y, ...)`, and Compose `aspectRatio(x/y)`. +- Schema consumers (runtime validators) must update to `v0.12.0` to validate documents containing `aspectRatio`; documents without the field remain valid against both `v0.11.0` and `v0.12.0`. +- `StyleKey` union in `types/Styles.ts` must include `'aspectRatio'` to keep the key enumeration in sync with the `Styles` interface — this is a compile-time parity check, not a runtime contract change. diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 4f685e7..39f2dbd 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -70,10 +70,28 @@ "topRightRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Top-right corner radius" }, "bottomLeftRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Bottom-left corner radius" }, "bottomRightRadius": { "$ref": "#/definitions/CornerStyleValue", "description": "Bottom-right corner radius" }, - "cornerSmoothing": { "$ref": "#/definitions/NumberStyleValue", "description": "Degree of corner smoothing (0 = standard circular corners, 1 = fully smooth iOS-style squircle). Applies to FRAME, COMPONENT, RECTANGLE, POLYGON, STAR, VECTOR, and ELLIPSE element types." } + "cornerSmoothing": { "$ref": "#/definitions/NumberStyleValue", "description": "Degree of corner smoothing (0 = standard circular corners, 1 = fully smooth iOS-style squircle). Applies to FRAME, COMPONENT, RECTANGLE, POLYGON, STAR, VECTOR, and ELLIPSE element types." }, + "aspectRatio": { "$ref": "#/definitions/AspectRatioStyleValue", "description": "Aspect ratio constraint. Present only when the node has a locked ratio; omitted otherwise." } }, "additionalProperties": false }, + "AspectRatioValue": { + "type": "object", + "description": "Aspect ratio expressed as a numerator/denominator pair. `x` is the numerator, `y` is the denominator (e.g. x:16, y:9 for 16:9).", + "properties": { + "x": { "type": "number", "description": "Ratio numerator (e.g. 16 for 16:9)" }, + "y": { "type": "number", "description": "Ratio denominator (e.g. 9 for 16:9)" } + }, + "required": ["x", "y"], + "additionalProperties": false + }, + "AspectRatioStyleValue": { + "description": "Aspect ratio value. Object pair when the node has a locked ratio; null when no constraint is active. VariableStyle is intentionally excluded — aspect ratio is a structural lock of literal numbers in the Figma API.", + "oneOf": [ + { "$ref": "#/definitions/AspectRatioValue" }, + { "type": "null" } + ] + }, "NumberStyleValue": { "description": "Pure number style value (from NumberStyle processor)", "oneOf": [ diff --git a/tests/Styles.test-d.ts b/tests/Styles.test-d.ts index 4cd1464..dc0cf92 100644 --- a/tests/Styles.test-d.ts +++ b/tests/Styles.test-d.ts @@ -6,7 +6,7 @@ import type { Styles, Shadow, Blur, EffectsGroup, FigmaStyle, VariableStyle, ColorStyle, GradientStop, GradientCenter, LinearGradient, RadialGradient, - AngularGradient, GradientValue, + AngularGradient, GradientValue, AspectRatioValue, AspectRatioStyle, } from '../types/index.js'; // ─── Shadow ──────────────────────────────────────────────────────────────── @@ -85,3 +85,47 @@ const withNoEffects: Styles = {}; // ─── effects must not be a Shadow[] array directly (old shape) ───────────── // @ts-expect-error: Shadow[] is not assignable to FigmaStyle | EffectsGroup const _oldEffectsShape: FigmaStyle | EffectsGroup = [shadowRaw]; + +// ─── AspectRatioValue ────────────────────────────────────────────────────── + +const ratio16x9: AspectRatioValue = { x: 16, y: 9 }; +const ratioSquare: AspectRatioValue = { x: 1, y: 1 }; +const ratioIrrational: AspectRatioValue = { x: 1.618, y: 1 }; + +// @ts-expect-error: missing required y +const _missingY: AspectRatioValue = { x: 16 }; + +// @ts-expect-error: missing required x +const _missingX: AspectRatioValue = { y: 9 }; + +// @ts-expect-error: string not assignable to number +const _stringX: AspectRatioValue = { x: '16', y: 9 }; + +// ─── AspectRatioStyle ────────────────────────────────────────────────────── + +// Object pair is valid +const ratioStyle: AspectRatioStyle = { x: 4, y: 3 }; + +// null is valid (no ratio constraint) +const noRatio: AspectRatioStyle = null; + +// VariableStyle must NOT be assignable to AspectRatioStyle +// @ts-expect-error: VariableStyle is not a valid AspectRatioStyle +const _varRatio: AspectRatioStyle = { id: 'var:1' } satisfies VariableStyle; + +// ─── Styles.aspectRatio ──────────────────────────────────────────────────── + +// Field is optional — omitting it is valid +const noAspectRatio: Styles = {}; + +// Object pair +const withRatio: Styles = { aspectRatio: { x: 16, y: 9 } }; + +// null is valid +const withNullRatio: Styles = { aspectRatio: null }; + +// @ts-expect-error: plain number is not valid +const _numberRatio: Styles = { aspectRatio: 1.777 }; + +// @ts-expect-error: string is not valid +const _stringRatio: Styles = { aspectRatio: '16:9' }; diff --git a/types/Styles.ts b/types/Styles.ts index 7fc9045..a77714b 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -64,6 +64,7 @@ export type Styles = Partial<{ bottomLeftRadius: Style; bottomRightRadius: Style; cornerSmoothing: Style; + aspectRatio: AspectRatioStyle; }>; /** @@ -92,6 +93,26 @@ export interface VariableStyle { collectionId?: string; } +/** + * Aspect ratio expressed as a numerator/denominator pair. + * `x` is the numerator (e.g. 16), `y` is the denominator (e.g. 9). + * Both components are required; irrational ratios are expressed as `{ x: 1.618, y: 1 }`. + */ +export interface AspectRatioValue { + /** Ratio numerator (e.g. 16 for 16:9) */ + x: number; + /** Ratio denominator (e.g. 9 for 16:9) */ + y: number; +} + +/** + * Aspect ratio style value. + * Present only when the node has a locked ratio; `null` when unconstrained. + * `VariableStyle` and `ReferenceValue` are intentionally excluded — aspect ratio + * is a structural lock of literal numbers in the Figma API, not a token-driven value. + */ +export type AspectRatioStyle = AspectRatioValue | null; + /** * Figma published style reference */ @@ -164,4 +185,5 @@ export type StyleKey = | 'topLeftRadius' | 'topRightRadius' | 'bottomLeftRadius' - | 'bottomRightRadius'; + | 'bottomRightRadius' + | 'aspectRatio'; diff --git a/types/index.ts b/types/index.ts index 3f2bf59..9f72a58 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,7 +25,7 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, ColorStyle, StyleKey, VariableStyle, FigmaStyle } from './Styles.js'; +export type { Styles, Style, ColorStyle, StyleKey, VariableStyle, FigmaStyle, AspectRatioValue, AspectRatioStyle } from './Styles.js'; export type { Shadow, Blur, EffectsGroup } from './Effects.js'; export type { GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue } from './Gradient.js'; From ef11c4dab1b689a0f935ffb26252bd59083ce219 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:44:01 -0500 Subject: [PATCH 18/19] Update schema/root.schema.json Co-authored-by: Alex King --- schema/root.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/root.schema.json b/schema/root.schema.json index e96525f..27d1334 100644 --- a/schema/root.schema.json +++ b/schema/root.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/root.schema.json", + "$id": "https://raw.githubusercontent.com/DirectedEdges/anova/main/schema/root.schema.json", "title": "Anova Schema Package", "description": "Root schema for the Anova component and components set definitions.", "version": "0.11.0", From 96998b7e3dd99e7bca7b8169df6dff29a23842f4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis <1165904+nathanacurtis@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:21:28 -0500 Subject: [PATCH 19/19] ADR 005-Typography --- .github/agents/AnovaADR.implement.agent.md | 8 +- CHANGELOG.md | 19 + adr/005-typography-composite.md | 454 +++++++++++++++++++++ schema/styles.schema.json | 127 +++++- tests/Styles.test-d.ts | 139 ++++++- types/Styles.ts | 64 +-- types/index.ts | 2 +- 7 files changed, 765 insertions(+), 48 deletions(-) create mode 100644 adr/005-typography-composite.md diff --git a/.github/agents/AnovaADR.implement.agent.md b/.github/agents/AnovaADR.implement.agent.md index a7618e9..48814f6 100644 --- a/.github/agents/AnovaADR.implement.agent.md +++ b/.github/agents/AnovaADR.implement.agent.md @@ -62,9 +62,11 @@ You **MUST** consider the user input before proceeding (if not empty). 10. **Update CHANGELOG.md**: - Prepend a new entry at the top using the existing format in the file - - **Format**: one top-level bullet per user-visible change; no sub-bullets; no bold - - **Names**: `.` in backticks, em dash separator — e.g. `Styles.cornerSmoothing` — corner smoothing factor - - **Sections**: use `### Added`, `### Changed`, `### Removed` as needed; add `### Migration` (MAJOR or rename only) with `.` → `.`: imperative callsite instruction + - **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) + - **Names**: `.` in backticks, em dash separator — e.g. `Styles.cornerSmoothing` — corner smoothing factor (0–1) + - **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 11. 11. **Bump version in `package.json`**: Apply the `NEW` version from the ADR's Semver Decision. diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8ead..2386985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `Styles.typography` — composite typography property; value is `FigmaStyle` (text style reference), `Typography` (inline properties), or omitted when absent +- `Typography` interface — 13 optional fields for inline text styling: `fontSize`, `fontFamily`, `fontStyle`, `lineHeight`, `letterSpacing`, `textCase`, `textDecoration`, `paragraphIndent`, `paragraphSpacing`, `leadingTrim`, `listSpacing`, `hangingPunctuation`, `hangingList` +- `Typography` and `TypographyStyleValue` definitions in `schema/styles.schema.json` +- `'typography'` added to `StyleKey` union - `Styles.aspectRatio` — optional aspect ratio constraint emitted when a node has a locked ratio; value is `AspectRatioValue` (`{ x: number; y: number }` numerator/denominator pair, e.g. `{ x: 16, y: 9 }`) or `null` when unconstrained; field is omitted from output entirely when no ratio is set - `AspectRatioValue` — exported interface with required `x` (numerator) and `y` (denominator) number fields; named per existing specialised-type precedent (`GradientCenter`) rather than a generic `Vector` - `AspectRatioStyle` — exported type alias `AspectRatioValue | null`; `VariableStyle` intentionally excluded as aspect ratio is a structural lock of literal numbers in the Figma API @@ -41,10 +45,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- `Styles.fontSize` — removed; use `typography.fontSize` instead +- `Styles.fontFamily` — removed; use `typography.fontFamily` instead +- `Styles.fontStyle` — removed; use `typography.fontStyle` instead +- `Styles.lineHeight` — removed; use `typography.lineHeight` instead +- `Styles.letterSpacing` — removed; use `typography.letterSpacing` instead +- `Styles.textCase` — removed; use `typography.textCase` instead +- `Styles.textDecoration` — removed; use `typography.textDecoration` instead +- `Styles.paragraphIndent` — removed; use `typography.paragraphIndent` instead +- `Styles.paragraphSpacing` — removed; use `typography.paragraphSpacing` instead +- `Styles.leadingTrim` — removed; use `typography.leadingTrim` instead +- `Styles.listSpacing` — removed; use `typography.listSpacing` instead +- `Styles.hangingPunctuation` — removed; use `typography.hangingPunctuation` instead +- `Styles.hangingList` — removed; use `typography.hangingList` instead +- `Styles.textStyleId` — removed; use `typography` with `FigmaStyle` instead (named text style reference) - `styles.effectStyleId` — removed with no deprecation period; consumers must migrate to `styles.effects` (see Migration) ### Migration +- `Styles.` → `Styles.typography.`: all 14 flat typography properties replaced with single composite `typography` field. When `typography` is a `FigmaStyle` (text style reference), read `id` and `name`. When `typography` is a `Typography` object, read individual fields (`fontSize`, `fontFamily`, `fontStyle`, `lineHeight`, `letterSpacing`, `textCase`, `textDecoration`, `paragraphIndent`, `paragraphSpacing`, `leadingTrim`, `listSpacing`, `hangingPunctuation`, `hangingList`). Old `textStyleId` becomes `typography: { id, name }`. - `fills` → `backgroundColor`: any consumer reading `component.styles.fills` must update to `component.styles.backgroundColor`. - `effectStyleId` → `effects`: any consumer reading `styles.effectStyleId` must update to `styles.effects`. When `effects` is a `FigmaStyle`, the style `id` and `name` are available. When `effects` is an `EffectsGroup`, read from `dropShadows`, `innerShadows`, `layerBlur`, or `backgroundBlur` by role. diff --git a/adr/005-typography-composite.md b/adr/005-typography-composite.md new file mode 100644 index 0000000..4bba77f --- /dev/null +++ b/adr/005-typography-composite.md @@ -0,0 +1,454 @@ +# ADR: Replace Typography Flat Properties with `typography` — Add `Typography` Type + +**Branch**: `005-typography-composite` +**Created**: 2026-02-26 +**Status**: ACCEPTED +**Deciders**: Nathan Curtis (author) +**Supersedes**: *(none)* + +--- + +## Context + +`@directededges/anova` currently exposes typography-related style properties as individual flat keys within the `Styles` type: `fontSize`, `fontFamily`, `fontStyle`, `lineHeight`, `letterSpacing`, `textCase`, `textDecoration`, `paragraphIndent`, `paragraphSpacing`, `leadingTrim`, `listSpacing`, `hangingPunctuation`, and `hangingList`. These coexist with `textStyleId`, which references a named Figma text style. + +Current `Styles` shape (typography subset): + +```yaml +# types/Styles.ts +Styles: + fontSize?: Style + fontFamily?: Style + fontStyle?: Style + lineHeight?: Style + letterSpacing?: Style + textCase?: Style + textDecoration?: Style + paragraphIndent?: Style + paragraphSpacing?: Style + leadingTrim?: Style + listSpacing?: Style + hangingPunctuation?: Style + hangingList?: Style + textStyleId?: Style # references a Figma text style +``` + +This flat structure creates three significant problems: + +### 1. Interdependent resolution semantics + +When a text node references a named text style (`textStyleId`), that style defines baseline values for all typography properties. Individual property overrides (e.g., `fontSize: 18`) are then layered on top. Figma resolves this via a shallow merge: inline values win, style-defined values fill gaps. + +Variants compound this complexity. A component's default variant might specify `textStyleId: "heading"`, while a size variant might override only `fontSize: 24`. When both variants apply, consumers must: +1. Resolve the text style reference to get baseline typography values +2. Merge the default variant's textStyleId-derived values +3. Layer the size variant's inline fontSize override + +No current consumer (`anova-transformer`, `anova-kit`) has a documented, tested resolution algorithm for this case. The interdependency between `textStyleId` and individual properties is implicit and fragile. + +### 2. Mutual exclusion not enforced + +The type system permits both `textStyleId` and inline typography properties to coexist on the same element in the same variant. There is no compile-time or schema-level constraint that enforces "either use a text style reference OR inline values, not both mixed arbitrarily." Downstream consumers cannot distinguish intentional overrides from malformed data. + +### 3. Wide API surface for a single logical concept + +Typography is a composite property grouping 13 individual keys. When only a text style reference is present, all 13 keys are absent from serialized output, replaced by a single `textStyleId`. This asymmetry makes it unclear whether: +- The node has no typography styling (keys absent, `textStyleId` absent) +- The node uses a text style (keys absent, `textStyleId` present) +- The node uses inline typography (keys present, `textStyleId` absent or coexisting) + +The `effects` property (ADR 002) solved analogous problems by introducing `EffectsGroup`, which consolidates inline shadow/blur geometry under a single structured key and uses `FigmaStyle | EffectsGroup` to distinguish named-style-reference from inline-values. Typography should follow the same pattern. + +--- + +## Decision Drivers + +- **Type–schema sync**: Every type change must have a corresponding schema change in the same release. No drift between `types/` and `schema/` is permitted. +- **No runtime logic**: This package declares shapes only. No processing, evaluation, or conditional logic may be added. +- **Stable public API / MAJOR for breaking changes**: Removing 13 individual typography keys from `Styles`, `StyleKey`, and schema breaks any consumer reading those keys. A MAJOR version bump and migration note are required. +- **Platform-unbiased output**: The `typography` contract must not require consumers to understand Figma text-style resolution to extract values. Named style reference vs inline values must be unambiguous. +- **Minimal new surface**: New exports must serve a genuine consumer need. `Typography` is required so downstream consumers can type-check `typography` output values. +- **Follow established patterns**: ADR 002 established `EffectsGroup` for grouped inline values. Typography should use the same structure. + +--- + +## Options Considered + +### Option A: Typography composite object — `FigmaStyle | Typography` *(selected)* + +Replace all individual typography keys with a single `typography` property that carries either a `FigmaStyle` reference (when a named text style is used) or a `Typography` object (when typography is defined inline). + +```yaml +# Option A +Styles: + typography?: FigmaStyle | Typography # named style OR inline values + +Typography: + fontSize?: number | 'mixed' | VariableStyle # mixableNumber primitive + fontFamily?: string | number | 'mixed' # font primitive + fontStyle?: string | number | 'mixed' # font primitive + lineHeight?: string | number | VariableStyle # lineHeight primitive — "150%", "auto", or px value + letterSpacing?: number | 'mixed' | VariableStyle # mixableNumber primitive + textCase?: string | 'mixed' | VariableStyle # mixableString primitive + textDecoration?: string | 'mixed' | VariableStyle # mixableString primitive + paragraphIndent?: number | VariableStyle # pureNumber primitive + paragraphSpacing?: number | VariableStyle # pureNumber primitive + leadingTrim?: number | 'mixed' | VariableStyle # mixableNumber primitive + listSpacing?: number | VariableStyle # pureNumber primitive + hangingPunctuation?: boolean | VariableStyle # boolean primitive + hangingList?: boolean | VariableStyle # boolean primitive +``` + +#### Typography shape + +| Spec property | Figma property | Figma type | Spec shape | Primitive | Default | +|---|---|---|---|---|---| +| `fontSize` | `fontSize` | `number` | `MixedNumberStyleValue` (number \| 'mixed' \| VariableStyle \| null) | mixableNumber | `16` | +| `fontFamily` | `fontName.family` | `string` | `FontStyleValue` (string \| number \| 'mixed' \| null) | font | `null` | +| `fontStyle` | `fontName.style` | `string` | `FontStyleValue` (string \| number \| 'mixed' \| null) | font | `null` | +| `lineHeight` | `lineHeight` | `{ value: number, unit: "PIXELS" \| "PERCENT" \| "AUTO" }` | `LineHeightStyleValue` (string \| number \| VariableStyle \| null) — "150%", "auto", or px | lineHeight | `"AUTO"` | +| `letterSpacing` | `letterSpacing` | `{ value: number, unit: "PIXELS" \| "PERCENT" }` | `MixedNumberStyleValue` (number \| 'mixed' \| VariableStyle \| null) | mixableNumber | `0` | +| `textCase` | `textCase` | `enum ("ORIGINAL" \| "UPPER" \| "LOWER" \| "TITLE" \| "SMALL_CAPS" \| "SMALL_CAPS_FORCED")` | `MixedStringStyleValue` (string \| VariableStyle \| null) | mixableString | `"ORIGINAL"` | +| `textDecoration` | `textDecoration` | `enum ("NONE" \| "UNDERLINE" \| "STRIKETHROUGH")` | `MixedStringStyleValue` (string \| VariableStyle \| null) | mixableString | `"NONE"` | +| `paragraphIndent` | `paragraphIndent` | `number` | `NumberStyleValue` (number \| VariableStyle \| null) | pureNumber | `0` | +| `paragraphSpacing` | `paragraphSpacing` | `number` | `NumberStyleValue` (number \| VariableStyle \| null) | pureNumber | `0` | +| `leadingTrim` | `leadingTrim` | `enum ("NONE" \| "CAP_HEIGHT")` | `MixedNumberStyleValue` (number \| 'mixed' \| VariableStyle \| null) | mixableNumber | `"NONE"` | +| `listSpacing` | `listSpacing` | `number` | `NumberStyleValue` (number \| VariableStyle \| null) | pureNumber | `0` | +| `hangingPunctuation` | `hangingPunctuation` | `boolean` | `BooleanStyleValue` (boolean \| VariableStyle \| null) | boolean | `false` | +| `hangingList` | `hangingList` | `boolean` | `BooleanStyleValue` (boolean \| VariableStyle \| null) | boolean | `false` | + +**Pros:** +- Mutual exclusion enforced at the type level: either `FigmaStyle` (named style reference) or `Typography` (inline values), never both +- Single key to check (`styles.typography`) instead of 14 separate lookups +- Mirrors `effects: FigmaStyle | EffectsGroup` pattern from ADR 002 — consistent structure +- Downstream resolution becomes unambiguous: if `typography` is a `FigmaStyle`, resolve the style reference; if `Typography`, use inline values directly +- Variant layering is simplified: shallow merge of `Typography` objects rather than 13 independent keys +- Platform-unbiased: web, iOS, Android consumers read structured typography data without Figma-specific resolution logic + +**Cons:** +- MAJOR breaking change: removes 14 keys (`textStyleId` + 13 individual properties) from `Styles` and `StyleKey` +- Downstream consumers must migrate from `styles.fontSize` to `styles.typography?.fontSize` (when inline) +- Larger schema diff than Option B or C + +**Selected.** This shape eliminates interdependency, enforces mutual exclusion, and aligns with the `EffectsGroup` precedent. + +--- + +### Option B: Retain interdependent properties — `textStyleId` + individual properties *(rejected)* + +Keep the current flat structure. Document the resolution algorithm but make no structural changes. + +```yaml +# Option B — no change +Styles: + fontSize?: Style + fontFamily?: Style + fontStyle?: Style + # ...all current keys remain... + textStyleId?: Style +``` + +**Pros:** +- No breaking change — fully backward compatible +- No migration cost for downstream consumers + +**Cons:** +- Does not solve the interdependency problem; resolution semantics remain implicit +- Mutual exclusion still unenforced — malformed data permitted by type system +- Wide API surface (14 keys) for a single logical concept +- Violates **platform-unbiased output** driver: consumers must implement Figma-specific shallow merge to read typography + +**Rejected:** Preserves the problems described in Context rather than addressing them. + +--- + +### Option C: Only inline properties — remove `textStyleId` *(rejected)* + +Remove `textStyleId` but keep individual flat properties. All typography must be expressed as resolved inline values. + +```yaml +# Option C +Styles: + fontSize?: Style + fontFamily?: Style + fontStyle?: Style + # ...all current individual keys... + # textStyleId — REMOVED +``` + +**Pros:** +- Eliminates interdependency by removing the reference case entirely +- Simpler downstream consumption: all values are direct, no style lookup required + +**Cons:** +- MAJOR breaking change (removes `textStyleId`) +- Loses semantic information: when a designer uses a named text style, that intent is erased in the output +- Downstream tooling (e.g., `anova-kit`'s audit commands) cannot detect text style references for governance checks +- Still exposes 13 individual keys for a single logical concept — does not address wide API surface +- Named text styles are a first-class Figma primitive; removing support diverges from Figma's model unnecessarily + +**Rejected:** Solves interdependency at the cost of semantic fidelity and governance visibility. + +--- + +## Decision + +### Type changes (`types/`) + +| File | Change | Bump | +|------|--------|------| +| `types/Styles.ts` | Remove `fontSize`, `fontFamily`, `fontStyle`, `lineHeight`, `letterSpacing`, `textCase`, `textDecoration`, `paragraphIndent`, `paragraphSpacing`, `leadingTrim`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textStyleId` (14 keys) | MAJOR (removal) | +| `types/Styles.ts` | Add `typography?: FigmaStyle \| Typography` to `Styles` | MAJOR (see above) | +| `types/Styles.ts` | Remove `'fontSize'`, `'fontFamily'`, `'fontStyle'`, `'lineHeight'`, `'letterSpacing'`, `'textCase'`, `'textDecoration'`, `'paragraphIndent'`, `'paragraphSpacing'`, `'leadingTrim'`, `'listSpacing'`, `'hangingPunctuation'`, `'hangingList'`, `'textStyleId'` from `StyleKey` union; add `'typography'` | MAJOR (removal) | +| `types/Styles.ts` | Add `Typography` interface (new export) | MINOR (additive) | +| `types/index.ts` | Export `Typography` from `'./Styles.js'` | MINOR (additive) | + +**`Typography` interface** (`types/Styles.ts`): + +```yaml +# New interface — inline typography properties grouped +Typography: + fontSize?: number | 'mixed' | VariableStyle # font size in pixels (mixableNumber primitive) + fontFamily?: string | number | 'mixed' # font family name; 'mixed' when text has multiple families (font primitive) + fontStyle?: string | number | 'mixed' # style name or numeric (e.g., 400, "Bold"); 'mixed' allowed (font primitive) + lineHeight?: string | number | VariableStyle # "150%", "auto", or pixel value (lineHeight primitive) + letterSpacing?: number | 'mixed' | VariableStyle # letter spacing in pixels; 'mixed' allowed (mixableNumber primitive) + textCase?: string | 'mixed' | VariableStyle # "UPPER", "LOWER", "TITLE", "ORIGINAL", or 'mixed' (mixableString primitive) + textDecoration?: string | 'mixed' | VariableStyle # "UNDERLINE", "STRIKETHROUGH", "NONE", or 'mixed' (mixableString primitive) + paragraphIndent?: number | VariableStyle # paragraph indent in pixels (pureNumber primitive) + paragraphSpacing?: number | VariableStyle # spacing between paragraphs in pixels (pureNumber primitive) + leadingTrim?: number | 'mixed' | VariableStyle # leading trim value (mixableNumber primitive) + listSpacing?: number | VariableStyle # spacing for list items in pixels (pureNumber primitive) + hangingPunctuation?: boolean | VariableStyle # whether hanging punctuation is enabled (boolean primitive) + hangingList?: boolean | VariableStyle # whether hanging list is enabled (boolean primitive) +``` + +**`Styles` field change** (`types/Styles.ts`): + +```yaml +# Before +Styles: + fontSize?: Style + fontFamily?: Style + fontStyle?: Style + lineHeight?: Style + letterSpacing?: Style + textCase?: Style + textDecoration?: Style + paragraphIndent?: Style + paragraphSpacing?: Style + leadingTrim?: Style + listSpacing?: Style + hangingPunctuation?: Style + hangingList?: Style + textStyleId?: Style + +# After +Styles: + typography?: FigmaStyle | Typography # FigmaStyle when named style; Typography when inline + # All 14 typography keys — REMOVED +``` + +**`StyleKey` change** (`types/Styles.ts`): + +```yaml +# Before +StyleKey: + | '...' + | 'fontSize' + | 'fontFamily' + | 'fontStyle' + | 'lineHeight' + | 'letterSpacing' + | 'textCase' + | 'textDecoration' + | 'paragraphIndent' + | 'paragraphSpacing' + | 'leadingTrim' + | 'listSpacing' + | 'hangingPunctuation' + | 'hangingList' + | 'textStyleId' + | '...' + +# After +StyleKey: + | '...' + | 'typography' + | '...' + # All 14 typography keys — REMOVED +``` + +### Schema changes (`schema/`) + +| File | Change | Bump | +|------|--------|------| +| `schema/styles.schema.json` | Remove `fontSize`, `fontFamily`, `fontStyle`, `lineHeight`, `letterSpacing`, `textCase`, `textDecoration`, `paragraphIndent`, `paragraphSpacing`, `leadingTrim`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textStyleId` from `#/definitions/Styles/properties` | MAJOR (removal) | +| `schema/styles.schema.json` | Add `typography` property to `#/definitions/Styles/properties` | MAJOR (see above) | +| `schema/styles.schema.json` | Add `Typography` definition to `#/definitions` | MINOR (additive) | +| `schema/styles.schema.json` | Add `TypographyStyleValue` definition to `#/definitions` | MINOR (additive) | + +**`Typography` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions — inline typography properties +Typography: + type: object + description: > + Inline typography properties grouped into a composite object. + All fields are optional; only properties set on the text node are present. + properties: + fontSize: + oneOf: + - { type: number } + - { type: string, const: 'mixed' } + - { $ref: '#/definitions/VariableStyle' } + description: Font size in pixels; 'mixed' allowed (mixableNumber primitive) + fontFamily: + oneOf: + - { type: string } + - { type: number } + - { type: string, const: 'mixed' } + description: Font family name; number for weight, 'mixed' when varied (font primitive) + fontStyle: + oneOf: + - { type: string } + - { type: number } + - { type: string, const: 'mixed' } + description: Style name (e.g., "Bold") or numeric (e.g., 400); 'mixed' allowed + lineHeight: + oneOf: + - { type: string, description: "Percentage (e.g., '150%') or 'auto'" } + - { type: number, description: "Pixel value" } + - { $ref: '#/definitions/VariableStyle' } + description: Line height + letterSpacing: + oneOf: + - { type: number } + - { type: string, const: 'mixed' } + - { $ref: '#/definitions/VariableStyle' } + description: Letter spacing in pixels; 'mixed' allowed + textCase: + oneOf: + - { type: string } + - { $ref: '#/definitions/VariableStyle' } + description: Text case ("UPPER", "LOWER", "TITLE", "ORIGINAL", or 'mixed') + textDecoration: + oneOf: + - { type: string } + - { $ref: '#/definitions/VariableStyle' } + description: Text decoration ("UNDERLINE", "STRIKETHROUGH", "NONE", or 'mixed') + paragraphIndent: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Paragraph indent in pixels + paragraphSpacing: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Spacing between paragraphs in pixels + leadingTrim: + oneOf: + - { type: number } + - { type: string, const: 'mixed' } + - { $ref: '#/definitions/VariableStyle' } + description: Leading trim value + listSpacing: + oneOf: [{ type: number }, { $ref: '#/definitions/VariableStyle' }] + description: Spacing for list items in pixels + hangingPunctuation: + oneOf: [{ type: boolean }, { $ref: '#/definitions/VariableStyle' }] + description: Whether hanging punctuation is enabled + hangingList: + oneOf: [{ type: boolean }, { $ref: '#/definitions/VariableStyle' }] + description: Whether hanging list is enabled + additionalProperties: false +``` + +**`TypographyStyleValue` schema definition** (`schema/styles.schema.json`): + +```yaml +# New entry under #/definitions — discriminated union for typography value +TypographyStyleValue: + description: > + Typography value. FigmaStyle when the text node references a named text style; + Typography when typography is defined inline. + oneOf: + - { $ref: '#/definitions/FigmaStyle', description: "Named text style reference" } + - { $ref: '#/definitions/Typography', description: "Inline typography properties" } + - { type: 'null' } +``` + +**`Styles` schema change** (`schema/styles.schema.json`): + +```yaml +# Before — #/definitions/Styles/properties +properties: + fontSize: { $ref: '#/definitions/MixedNumberStyleValue', description: 'Font size in pixels' } + fontFamily: { $ref: '#/definitions/FontStyleValue', description: 'Font family' } + fontStyle: { $ref: '#/definitions/FontStyleValue', description: 'Font style' } + lineHeight: { $ref: '#/definitions/LineHeightStyleValue', description: 'Line height' } + letterSpacing: { $ref: '#/definitions/MixedNumberStyleValue', description: 'Letter spacing' } + textCase: { $ref: '#/definitions/MixedStringStyleValue', description: 'Text case' } + textDecoration: { $ref: '#/definitions/MixedStringStyleValue', description: 'Text decoration' } + paragraphIndent: { $ref: '#/definitions/NumberStyleValue', description: 'Paragraph indent' } + paragraphSpacing: { $ref: '#/definitions/NumberStyleValue', description: 'Paragraph spacing' } + leadingTrim: { $ref: '#/definitions/MixedNumberStyleValue', description: 'Leading trim' } + listSpacing: { $ref: '#/definitions/NumberStyleValue', description: 'List spacing' } + hangingPunctuation: { $ref: '#/definitions/BooleanStyleValue', description: 'Hanging punctuation' } + hangingList: { $ref: '#/definitions/BooleanStyleValue', description: 'Hanging list' } + textStyleId: { $ref: '#/definitions/StyleIdValue', description: 'Text style reference' } + # ...other properties... + +# After — #/definitions/Styles/properties +properties: + typography: { $ref: '#/definitions/TypographyStyleValue', description: 'Typography properties. FigmaStyle when a named text style is used; Typography when defined inline.' } + # All 14 typography keys — REMOVED + # ...other properties... +``` + +--- + +## Type ↔ Schema Impact + +**Symmetric**: Yes. Every type change has a corresponding schema update: +- `types/Styles.ts` `Typography` interface → `schema/styles.schema.json` `#/definitions/Typography` +- `types/Styles.ts` `typography?: FigmaStyle | Typography` → `schema/styles.schema.json` `#/definitions/TypographyStyleValue` (oneOf union) +- Removal of 14 flat keys from `Styles` type → removal of 14 properties from `#/definitions/Styles/properties` +- Removal of 14 literals from `StyleKey` type → (no direct schema equivalent; `StyleKey` is not exported to schema) + +**Parity check**: +- `Typography.fontSize` (`number | 'mixed' | VariableStyle`) → `#/definitions/Typography/properties/fontSize` (oneOf: number, 'mixed', VariableStyle) +- `Typography.fontFamily` (`string | number | 'mixed'`) → `#/definitions/Typography/properties/fontFamily` (oneOf: string, number, 'mixed') +- `Typography.fontStyle` (`string | number | 'mixed'`) → `#/definitions/Typography/properties/fontStyle` (oneOf: string, number, 'mixed') +- `Typography.lineHeight` (`string | number | VariableStyle`) → `#/definitions/Typography/properties/lineHeight` (oneOf: string, number, VariableStyle) +- All 13 fields follow the same primitive-aligned pattern. + +--- + +## Downstream Impact + +| Consumer | Impact | Action required | +|----------|--------|-----------------| +| `anova-kit` | MAJOR — code accessing individual typography keys will break | Migrate reads from `styles.fontSize` to `styles.typography?.fontSize` (when inline) or resolve `styles.typography` when `FigmaStyle`. Update audit/governance logic to check `typography` key instead of `textStyleId`. | + +**Note**: Per mode instructions, only `anova-kit` is listed. `anova-transformer` and `anova-plugin` manage their own ADR workflows. + +--- + +## Semver Decision + +**Version bump**: Incorporated into `0.11.0` (already bumped; not yet released) + +**Justification**: This change is being merged into the existing `0.11.0` release. Removing 14 exported keys from the `Styles` type and `StyleKey` union is a breaking change per Constitution III ("Removing or renaming an exported type or a named field within a type is a breaking change"). Any consumer accessing `styles.fontSize`, `styles.textStyleId`, or any of the other 12 typography keys will experience a compile-time error after upgrading. + +This breaking change is consistent with the MAJOR nature of the `0.11.0` release, which already includes other breaking changes (e.g., ADR 002's replacement of `effectStyleId` with `effects`). + +--- + +## Consequences + +- Consumers can now represent typography as either a named text style reference OR inline values, with compile-time enforcement of mutual exclusion +- Variant layering is simplified: shallow merge of `Typography` objects instead of 13 independent key merges +- Downstream resolution becomes unambiguous: discriminate on `FigmaStyle` vs `Typography` type +- Any tool validating against `schema/styles.schema.json` must update to the new version (MAJOR bump enforces this) +- Governance tooling (e.g., `anova-kit` text style audits) gains a single stable key (`typography`) to check instead of dual-path logic across `textStyleId` + individual overrides +- The pattern established in ADR 002 (`EffectsGroup`) is extended to typography, creating consistency across composite style properties +- Old flat typography keys (`fontSize`, `fontFamily`, etc.) are removed — no deprecation shim; consumers must migrate in one step diff --git a/schema/styles.schema.json b/schema/styles.schema.json index 39f2dbd..1e23232 100644 --- a/schema/styles.schema.json +++ b/schema/styles.schema.json @@ -37,20 +37,7 @@ "strokeBottomWeight": { "$ref": "#/definitions/StrokeStyleValue", "description": "Bottom stroke weight" }, "strokeLeftWeight": { "$ref": "#/definitions/StrokeStyleValue", "description": "Left stroke weight" }, "strokeRightWeight": { "$ref": "#/definitions/StrokeStyleValue", "description": "Right stroke weight" }, - "fontSize": { "$ref": "#/definitions/MixedNumberStyleValue", "description": "Font size in pixels" }, - "fontFamily": { "$ref": "#/definitions/FontStyleValue", "description": "Font family" }, - "fontWeight": { "$ref": "#/definitions/FontStyleValue", "description": "Font weight" }, - "lineHeight": { "$ref": "#/definitions/LineHeightStyleValue", "description": "Line height" }, - "letterSpacing": { "$ref": "#/definitions/MixedNumberStyleValue", "description": "Letter spacing" }, - "textDecoration": { "$ref": "#/definitions/MixedStringStyleValue", "description": "Text decoration" }, - "textCase": { "$ref": "#/definitions/MixedStringStyleValue", "description": "Text case" }, - "paragraphIndent": { "$ref": "#/definitions/NumberStyleValue", "description": "Paragraph indent" }, - "paragraphSpacing": { "$ref": "#/definitions/NumberStyleValue", "description": "Paragraph spacing" }, - "leadingTrim": { "$ref": "#/definitions/MixedNumberStyleValue", "description": "Leading trim" }, - "listSpacing": { "$ref": "#/definitions/NumberStyleValue", "description": "List spacing" }, - "hangingPunctuation": { "$ref": "#/definitions/BooleanStyleValue", "description": "Hanging punctuation" }, - "hangingList": { "$ref": "#/definitions/BooleanStyleValue", "description": "Hanging list" }, - "textStyleId": { "$ref": "#/definitions/StyleIdValue", "description": "Text style reference" }, + "typography": { "$ref": "#/definitions/TypographyStyleValue", "description": "Typography properties. FigmaStyle when a named text style is used; Typography when defined inline." }, "textAlignHorizontal": { "$ref": "#/definitions/StringStyleValue", "description": "Horizontal text alignment" }, "textAlignVertical": { "$ref": "#/definitions/StringStyleValue", "description": "Vertical text alignment" }, "primaryAxisAlignItems": { "$ref": "#/definitions/StringStyleValue", "description": "Primary axis item alignment" }, @@ -351,6 +338,118 @@ "required": ["id"], "additionalProperties": false }, + "Typography": { + "type": "object", + "description": "Inline typography properties grouped into a composite object. All fields are optional; only properties set on the text node are present.", + "properties": { + "fontSize": { + "oneOf": [ + { "type": "number" }, + { "type": "string", "const": "mixed" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Font size in pixels; 'mixed' allowed (mixableNumber primitive)" + }, + "fontFamily": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "string", "const": "mixed" } + ], + "description": "Font family name; number for weight, 'mixed' when varied (font primitive)" + }, + "fontStyle": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "string", "const": "mixed" } + ], + "description": "Style name (e.g., \"Bold\") or numeric (e.g., 400); 'mixed' allowed (font primitive)" + }, + "lineHeight": { + "oneOf": [ + { "type": "string", "description": "Percentage (e.g., '150%') or 'auto'" }, + { "type": "number", "description": "Pixel value" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Line height (lineHeight primitive)" + }, + "letterSpacing": { + "oneOf": [ + { "type": "number" }, + { "type": "string", "const": "mixed" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Letter spacing in pixels; 'mixed' allowed (mixableNumber primitive)" + }, + "textCase": { + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Text case (\"UPPER\", \"LOWER\", \"TITLE\", \"ORIGINAL\", or 'mixed') (mixableString primitive)" + }, + "textDecoration": { + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Text decoration (\"UNDERLINE\", \"STRIKETHROUGH\", \"NONE\", or 'mixed') (mixableString primitive)" + }, + "paragraphIndent": { + "oneOf": [ + { "type": "number" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Paragraph indent in pixels (pureNumber primitive)" + }, + "paragraphSpacing": { + "oneOf": [ + { "type": "number" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Spacing between paragraphs in pixels (pureNumber primitive)" + }, + "leadingTrim": { + "oneOf": [ + { "type": "number" }, + { "type": "string", "const": "mixed" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Leading trim value (mixableNumber primitive)" + }, + "listSpacing": { + "oneOf": [ + { "type": "number" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Spacing for list items in pixels (pureNumber primitive)" + }, + "hangingPunctuation": { + "oneOf": [ + { "type": "boolean" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Whether hanging punctuation is enabled (boolean primitive)" + }, + "hangingList": { + "oneOf": [ + { "type": "boolean" }, + { "$ref": "#/definitions/VariableStyle" } + ], + "description": "Whether hanging list is enabled (boolean primitive)" + } + }, + "additionalProperties": false + }, + "TypographyStyleValue": { + "description": "Typography value. FigmaStyle when the text node references a named text style; Typography when typography is defined inline.", + "oneOf": [ + { "$ref": "#/definitions/FigmaStyle", "description": "Named text style reference" }, + { "$ref": "#/definitions/Typography", "description": "Inline typography properties" }, + { "type": "null" } + ] + }, "FigmaStyle": { "type": "object", "description": "Reference to a Figma style (color, text, effect). May include resolved name depending on config.format.simplifyStyles setting. When simplified, emits as string instead of object.", diff --git a/tests/Styles.test-d.ts b/tests/Styles.test-d.ts index dc0cf92..6db477f 100644 --- a/tests/Styles.test-d.ts +++ b/tests/Styles.test-d.ts @@ -1,10 +1,10 @@ /** - * Type-level tests for Styles, Shadow, Blur, EffectsGroup, and gradient types. + * Type-level tests for Styles, Shadow, Blur, EffectsGroup, Typography, and gradient types. * These files are intentionally never executed — they are compiled with tsc * to assert that the type shape is correct. */ import type { - Styles, Shadow, Blur, EffectsGroup, FigmaStyle, VariableStyle, + Styles, Shadow, Blur, EffectsGroup, Typography, FigmaStyle, VariableStyle, ColorStyle, GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue, AspectRatioValue, AspectRatioStyle, } from '../types/index.js'; @@ -129,3 +129,138 @@ const _numberRatio: Styles = { aspectRatio: 1.777 }; // @ts-expect-error: string is not valid const _stringRatio: Styles = { aspectRatio: '16:9' }; + +// ─── Typography ──────────────────────────────────────────────────────────── + +// All keys optional — empty typography group is valid +const emptyTypography: Typography = {}; + +// Typography with all raw primitive values +const fullTypographyRaw: Typography = { + fontSize: 16, + fontFamily: 'Inter', + fontStyle: 'Regular', + lineHeight: '150%', + letterSpacing: 0, + textCase: 'ORIGINAL', + textDecoration: 'NONE', + paragraphIndent: 0, + paragraphSpacing: 12, + leadingTrim: 0, + listSpacing: 8, + hangingPunctuation: false, + hangingList: false, +}; + +// Typography with VariableStyle values +const fullTypographyVariable: Typography = { + fontSize: { id: 'var:fontSize' } satisfies VariableStyle, + fontFamily: 'Inter', // font primitive does not accept VariableStyle + fontStyle: 'Regular', // font primitive does not accept VariableStyle + lineHeight: { id: 'var:lineHeight' } satisfies VariableStyle, + letterSpacing: { id: 'var:letterSpacing' } satisfies VariableStyle, + textCase: { id: 'var:textCase' } satisfies VariableStyle, + textDecoration: { id: 'var:textDecoration' } satisfies VariableStyle, + paragraphIndent: { id: 'var:paragraphIndent' } satisfies VariableStyle, + paragraphSpacing: { id: 'var:paragraphSpacing' } satisfies VariableStyle, + leadingTrim: { id: 'var:leadingTrim' } satisfies VariableStyle, + listSpacing: { id: 'var:listSpacing' } satisfies VariableStyle, + hangingPunctuation: { id: 'var:hangingPunctuation' } satisfies VariableStyle, + hangingList: { id: 'var:hangingList' } satisfies VariableStyle, +}; + +// Typography with 'mixed' values (for multi-selection) +const mixedTypography: Typography = { + fontSize: 'mixed', + fontFamily: 'mixed', + fontStyle: 'mixed', + letterSpacing: 'mixed', + textCase: 'mixed', + textDecoration: 'mixed', + leadingTrim: 'mixed', +}; + +// fontSize accepts number, 'mixed', or VariableStyle +const _fontSizeNumber: number | 'mixed' | VariableStyle | undefined = mixedTypography.fontSize; + +// fontFamily/fontStyle accept string, number, 'mixed', or VariableStyle (number for registered families) +const _fontFamily: string | number | 'mixed' | undefined = fullTypographyRaw.fontFamily; + +// hangingPunctuation accepts boolean or VariableStyle +const _hangingBool: boolean | VariableStyle | undefined = fullTypographyRaw.hangingPunctuation; + +// @ts-expect-error: fontSize must not accept string +const _badFontSize: Typography = { fontSize: '16px' }; + +// @ts-expect-error: fontFamily must not accept VariableStyle (font primitive restriction) +const _badFontFamily: Typography = { fontFamily: { id: 'var:1' } satisfies VariableStyle }; + +// @ts-expect-error: fontStyle must not accept VariableStyle (font primitive restriction) +const _badFontStyle: Typography = { fontStyle: { id: 'var:2' } satisfies VariableStyle }; + +// @ts-expect-error: hangingPunctuation must not accept string +const _badHanging: Typography = { hangingPunctuation: 'yes' }; + +// ─── Styles.typography ───────────────────────────────────────────────────── + +// Named text style reference +const withTextStyle: Styles = { + typography: { id: 'S:textStyle123' } satisfies FigmaStyle, +}; + +// Inline typography via Typography +const withTypographyGroup: Styles = { + typography: fullTypographyRaw, +}; + +// null is valid (typography absent in output) +const withNullTypography: Styles = { + typography: null as unknown as FigmaStyle, // value-level; type allows omission +}; + +// typography is optional — no typography key at all is valid +const withNoTypography: Styles = {}; + +// ─── Verify flat typography properties removed from Styles ──────────────── + +// @ts-expect-error: fontSize no longer exists on Styles +const _oldFontSize: Styles = { fontSize: 16 }; + +// @ts-expect-error: fontFamily no longer exists on Styles +const _oldFontFamily: Styles = { fontFamily: 'Inter' }; + +// @ts-expect-error: fontStyle no longer exists on Styles +const _oldFontStyle: Styles = { fontStyle: 'Regular' }; + +// @ts-expect-error: lineHeight no longer exists on Styles +const _oldLineHeight: Styles = { lineHeight: { value: 24, unit: 'PIXELS' } }; + +// @ts-expect-error: letterSpacing no longer exists on Styles +const _oldLetterSpacing: Styles = { letterSpacing: 0 }; + +// @ts-expect-error: textCase no longer exists on Styles +const _oldTextCase: Styles = { textCase: 'ORIGINAL' }; + +// @ts-expect-error: textDecoration no longer exists on Styles +const _oldTextDecoration: Styles = { textDecoration: 'NONE' }; + +// @ts-expect-error: paragraphIndent no longer exists on Styles +const _oldParagraphIndent: Styles = { paragraphIndent: 0 }; + +// @ts-expect-error: paragraphSpacing no longer exists on Styles +const _oldParagraphSpacing: Styles = { paragraphSpacing: 12 }; + +// @ts-expect-error: leadingTrim no longer exists on Styles +const _oldLeadingTrim: Styles = { leadingTrim: 'NONE' }; + +// @ts-expect-error: listSpacing no longer exists on Styles +const _oldListSpacing: Styles = { listSpacing: 8 }; + +// @ts-expect-error: hangingPunctuation no longer exists on Styles +const _oldHangingPunctuation: Styles = { hangingPunctuation: false }; + +// @ts-expect-error: hangingList no longer exists on Styles +const _oldHangingList: Styles = { hangingList: false }; + +// @ts-expect-error: textStyleId no longer exists on Styles +const _oldTextStyleId: Styles = { textStyleId: 'S:123' }; diff --git a/types/Styles.ts b/types/Styles.ts index a77714b..45c3f19 100644 --- a/types/Styles.ts +++ b/types/Styles.ts @@ -29,20 +29,7 @@ export type Styles = Partial<{ strokeBottomWeight: Style; strokeLeftWeight: Style; strokeRightWeight: Style; - fontSize: Style; - fontFamily: Style; - fontWeight: Style; - lineHeight: Style; - letterSpacing: Style; - textDecoration: Style; - textCase: Style; - paragraphIndent: Style; - paragraphSpacing: Style; - leadingTrim: Style; - listSpacing: Style; - hangingPunctuation: Style; - hangingList: Style; - textStyleId: Style; + typography: FigmaStyle | Typography; textAlignHorizontal: Style; textAlignVertical: Style; textColor: ColorStyle; @@ -93,6 +80,40 @@ export interface VariableStyle { collectionId?: string; } +/** + * Inline typography properties grouped into a composite object. + * All fields are optional; only properties set on the text node are present. + * Maps to transformer primitive types: font, mixableNumber, mixableString, pureNumber, boolean, lineHeight. + */ +export interface Typography { + /** Font size in pixels (mixableNumber primitive) */ + fontSize?: number | 'mixed' | VariableStyle; + /** Font family name; 'mixed' when text has multiple families (font primitive) */ + fontFamily?: string | number | 'mixed'; + /** Style name or numeric (e.g., 400, "Bold"); 'mixed' allowed (font primitive) */ + fontStyle?: string | number | 'mixed'; + /** Line height: "150%", "auto", or pixel value (lineHeight primitive) */ + lineHeight?: string | number | VariableStyle; + /** Letter spacing in pixels; 'mixed' allowed (mixableNumber primitive) */ + letterSpacing?: number | 'mixed' | VariableStyle; + /** Text case: "UPPER", "LOWER", "TITLE", "ORIGINAL", or 'mixed' (mixableString primitive) */ + textCase?: string | 'mixed' | VariableStyle; + /** Text decoration: "UNDERLINE", "STRIKETHROUGH", "NONE", or 'mixed' (mixableString primitive) */ + textDecoration?: string | 'mixed' | VariableStyle; + /** Paragraph indent in pixels (pureNumber primitive) */ + paragraphIndent?: number | VariableStyle; + /** Spacing between paragraphs in pixels (pureNumber primitive) */ + paragraphSpacing?: number | VariableStyle; + /** Leading trim value (mixableNumber primitive) */ + leadingTrim?: number | 'mixed' | VariableStyle; + /** Spacing for list items in pixels (pureNumber primitive) */ + listSpacing?: number | VariableStyle; + /** Whether hanging punctuation is enabled (boolean primitive) */ + hangingPunctuation?: boolean | VariableStyle; + /** Whether hanging list is enabled (boolean primitive) */ + hangingList?: boolean | VariableStyle; +} + /** * Aspect ratio expressed as a numerator/denominator pair. * `x` is the numerator (e.g. 16), `y` is the denominator (e.g. 9). @@ -151,20 +172,7 @@ export type StyleKey = | 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' - | 'fontSize' - | 'fontFamily' - | 'fontWeight' - | 'lineHeight' - | 'letterSpacing' - | 'textDecoration' - | 'textCase' - | 'paragraphIndent' - | 'paragraphSpacing' - | 'leadingTrim' - | 'listSpacing' - | 'hangingPunctuation' - | 'hangingList' - | 'textStyleId' + | 'typography' | 'textAlignHorizontal' | 'textAlignVertical' | 'textColor' diff --git a/types/index.ts b/types/index.ts index 9f72a58..09150a4 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,7 +25,7 @@ export type { Config } from './Config.js'; export { DEFAULT_CONFIG } from './Config.js'; // Style types -export type { Styles, Style, ColorStyle, StyleKey, VariableStyle, FigmaStyle, AspectRatioValue, AspectRatioStyle } from './Styles.js'; +export type { Styles, Style, ColorStyle, StyleKey, VariableStyle, FigmaStyle, AspectRatioValue, AspectRatioStyle, Typography } from './Styles.js'; export type { Shadow, Blur, EffectsGroup } from './Effects.js'; export type { GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue } from './Gradient.js';