diff --git a/rfc/001-component-dictionary/README.md b/rfc/001-component-dictionary/README.md new file mode 100644 index 0000000..b10c2ed --- /dev/null +++ b/rfc/001-component-dictionary/README.md @@ -0,0 +1,358 @@ +# RFC 001: Component Dictionary + +| | | +|---|---| +| **Status** | Proposed | +| **Authors** | Nathan Curtis | +| **Date** | 2026-05-08 | +| **Index** | [RFCs](../README.md) | + +--- + +## Contents + +- [Summary](#summary) +- [Motivation](#motivation) +- [Prior art and relationship to existing tools](#prior-art-and-relationship-to-existing-tools) +- [Detailed design](#detailed-design) +- [Alternatives considered](#alternatives-considered) +- [Drawbacks](#drawbacks) +- [Decisions ratified by this RFC](#decisions-ratified-by-this-rfc) +- [Unresolved questions](#unresolved-questions) +- [Future work](#future-work) +- [Companion documents](#companion-documents) + +--- + +## Summary + +This RFC proposes a deterministic emitter registry inside `specs-cli` that fans each validated `{component}.yaml` into ~16 purpose-built files — a *Component Dictionary* — so each consumer (engineers, agentic coding tools, design-system reviewers, report scripts) gets the shape closest to its workflow. The pipeline is pure: zero LLM calls, zero opinions, byte-reproducible from the spec. The contract stays canonical and derivative files are projections; inference (prose, a11y, design intent) is deferred to a separate downstream stage out of scope here. The bet: **the right shape for design-system data in the agentic-coding era is a blend of structured (YAML/JSON) and prose (MD) outputs deterministically projected from a single schema-validated contract.** + +--- + +## Motivation + +`specs-cli` produces a single artifact: a validated, compressed contract (YAML in v1) that captures everything Figma can express about a component. That artifact is correct and complete, but it is shaped for the schema — not for any particular consumer. + +### The Multi-Consumer Mismatch + +- **Humans** browsing a component want a readable reference with anatomy, props, layout, tokens, and variant behavior at a glance — not 1000+ lines of nested YAML. +- **LLMs scaffolding code** want a small retrieval doc that fits inside a single tool call's worth of context. Loading the full YAML to answer "what props does Button take?" is expensive and noisy. +- **Framework tooling** (React, Web Components, iOS, CSS, Tailwind) wants prop types, slot signatures, and styling hooks expressed in its native idiom. Every consumer that re-derives these from the YAML duplicates work and risks drift. +- **Workspace-level questions** ("which components depend on token X?", "what's the instance graph?") have no good answer today — each requires a one-off script over the YAML directory. +- **Reports and audits** — token usage maps, naming-consistency audits, anatomy censuses, variant-complexity reports, dependency graphs, color/spacing/typography system audits, maturity scorecards. Each is a script that walks the YAML and aggregates, and each re-implements the same traversal and grouping logic. Purpose-built contracts (the workspace-level dictionary files plus additional roll-ups for styling, naming, and similar dimensions) feed these scripts directly instead of asking each one to re-derive the index it needs. + +The shared trait: every downstream consumer either re-parses the contract or invents its own intermediate format. The information is already there; the open question is whether a shared deterministic projection layer is worth building, or whether per-team adapters and on-demand LLM interpretation are good enough. This proposal bets on projection. + +The agentic-coding workflow is where that bet pays off most clearly. A coding agent benefits more from a short, retrieval-shaped doc than from re-parsing 1000 lines of YAML — but only if such a doc exists, is up to date, and can be trusted as a faithful projection of the spec. That last condition is what determinism buys, and it's the load-bearing reason to prefer pre-baked projection over on-demand interpretation. + +### The Agentic Extraction Alternative + +The common alternative — point an LLM at raw Figma node trees and ask it to produce documentation — has drawbacks of both quality and cost. + +**Quality.** Asking an LLM to do structured extraction over unstructured input predictably produces: + +- **Hallucinated results** of styling, variables, layout and structure properties not in the source and **omissions** of those that are. +- **Injected opinions** — renamed props, collapsed architecture, rendering strategy that may not match design intent. +- **Unjustified and misleading confidence** in those extractions and opinions. +- **Run-to-run agentic drift** between invocations, and the reconciliation overhead any multi-pass approach inherits. + +**Cost.** A single component can run on the order of **100k tokens and tens of minutes** — to re-derive facts the schema already encodes. At 100 components per refresh: tens of millions of tokens and the better part of a day. The deterministic emitter pipeline produces the same data reference in **0 tokens and ~1 second** per component because `specs generate` does that interpretation mechanically. The schema-validated contract *is* the extraction; emitters just reshape it. Inference is then reserved for genuinely ambiguous work (accessibility, behaviors, motion... for now) running against the structured contract — at a fraction of the budget. + +### Vendor Coupling + +A separate concern, lower-volume but real for DS leads thinking past the next 18 months: a design system anchored to any single tool's API inherits that tool's pricing, roadmap, and continuity risk. The history of design tooling has enough turnover that "we're coupled to Vendor X" is something architects flinch at on principle, regardless of how well Vendor X is doing today. + +The contract's value props — schema-validated, lossless, format-as-validator — are structural and don't depend on Figma. Figma remains a strong primary ingest because it's the most structured visual database we have for design decisions, and that structure is what makes the contract possible in the first place. I see no within-a-year threat to Figma as best-in-class visual database of robust, precise design system visual decisions. + +But the architecture treats Figma as one ingest path among possible others (code analysis, prototyping tools, hand authorship), not as the source of the contract. Teams adopting the dictionary get a portable contract by virtue of the architecture, even though the only ingest adapter shipped today is Figma. Additional adapters and the questions they raise (multi-origin reconciliation, hand-authoring as a first-class workflow) are explicitly out of scope for this proposal — but the architecture doesn't preclude them. + +### Composition and Adapter Burden + +Solving the multi-consumer mismatch creates a second-order problem. Once a component has a fanout of generated files, teams accumulate supplementary content around it: accessibility specs, behavioral notes, usage guidance, examples, team-specific React conventions, design intent prose. This content has a different lifecycle than generated output (persistent vs. regenerable) and different authority (team-owned vs. canonical contract). Without architectural conventions: + +- Consumers can't tell what's authoritative, what's outdated, and what's safe to regenerate. +- Teams writing supplements next to generated files risk losing them on the next `specs generate`. +- Every team invents its own naming, folder structure, and merging logic for the layered content. +- The promise of "thin adapter layer" disappears — teams end up writing as much glue as they would have without the dictionary. + +--- + +## Prior art and relationship to existing tools + +A team adopting the dictionary already has tooling. The honest framing isn't "this is a new thing" — it's "this is what gets retired, served, or left alone in your existing stack." + +| Tool / system | Relationship | What changes | +|---|---|---| +| **Storybook** | Serves | Storybook is a runtime surface; the dictionary is a static substrate. `contract.ts` becomes argTypes; `fixture.json` seeds stories; `stories.ts` is the direct CSF feed; `tokens.json` powers token-panel addons. Reduces hand-written `.stories.ts` and addon-doc work; doesn't compete | +| **react-docgen / TypeScript inference from source** | Augments | The dictionary's `contract.ts` carries the prop-type surface that's derivable from the design system — enums, defaults, slot signatures, nullable flags. It does not replace docgen, because Figma (and any structural ingest) is an incomplete picture of a real component: event handler signatures, ref types, internal hooks, JSDoc-from-source all still come from the codebase. The win is that the ingestable portion of the type surface stops drifting; the rest is still docgen's job | +| **Code Connect** | Complementary | Code Connect maps Figma components to existing code components; the dictionary produces the shape of the code components in the first place. Different points in the pipeline; both can ship | +| **Style Dictionary, Tokens Studio, or other homegrown token tooling** | Complementary | Token-transformation tools handle paths → CSS vars → multi-platform outputs; the dictionary's `tokens.json` and `dictionary.tokens.json` carry the *usage map* (which components reference which tokens, with counts). Different jobs | +| **Hand-rolled internal Figma generators** | Replaces | Most mature DS teams have a homegrown generator that walks Figma and produces some subset of these outputs. The dictionary covers the common cases; team-specific work moves into custom emitters or sibling-authored extensions | +| **MDX docs sites, Storybook Docs, custom doc pipelines** | Serves | `docs.md` and `elements.md` are direct source material; `md` is the orientation index. Teams with their own renderers consume the YAML directly | + +**The risk to manage.** Adopting the dictionary alongside the above without retiring anything makes the architecture worse. Adoption guidance is concrete: docgen-shaped work moves to `contract.ts` consumption; homegrown generators get decommissioned in stages as emitters cover their outputs; Storybook stories move from hand-authored to `stories.ts`-seeded. A team that adds a sixth source of truth and keeps the other five hasn't gained anything. + +--- + +## Detailed design + +### Pipeline overview + +We propose adding a deterministic emitter registry to `specs generate`. Each emitter is a pure function of the validated spec; the CLI walks emitters × specs and writes the outputs. No interpretation, no inference, no Figma round-trip. The determinism is the load-bearing architectural choice — not because it's obvious, but because it's what makes every other commitment in this RFC (regenerability, auditability, edge-artifact trust) tractable. We could have chosen otherwise; the rest of this RFC is about what follows from this choice. + +The emitter layer fits into the existing pipeline as the final scripted stage: + +``` + ┌─── deterministic, scripted ───────────────────────┐ + │ │ +Designer updates Figma │ + │ │ + ▼ │ +┌──────────────────┐ Pull component data via Figma API. │ +│ specs fetch │ → raw/{component}.json (Figma payload) │ +└──────────────────┘ │ + │ │ + ▼ │ +┌──────────────────┐ Parse, validate, normalize against │ +│ specs scan │ specs-schema. │ +└──────────────────┘ → {component}.yaml (canonical, │ + │ schema-validated, deterministic) │ + ▼ │ +┌──────────────────┐ Run emitter registry: spec → file │ +│ specs generate │ fanout. │ +└──────────────────┘ → {component}.md, .docs.md, .layout.md, │ + │ .elements.md, .tokens.json, │ + │ .provenance.md, … │ + │ │ + └───────────────────────────────────────────────────┘ + │ + ▼ + ┌─── inference, agent-driven ───────────────────────┐ + │ │ +┌──────────────────┐ (optional, opt-in) Read scripted │ +│ Agent │ outputs, produce smoothed siblings: │ +│ inference │ prose, invariants, exception callouts, │ +│ pass │ usage examples, a11y suggestions. │ +└──────────────────┘ → {component}.smoothed.md, sidecar │ + │ enrichment files. User chooses to │ + │ overwrite scripted outputs or not. │ + └───────────────────────────────────────────────────┘ + │ + ▼ +Agentic coding tool Cursor / Claude Code / Copilot retrieves +(consumer) the component dictionary — scripted + + (optionally) smoothed — to scaffold, + refactor, or review code against the + design system. The `.md` index is the + primary orientation target; platform- + specific files seed scaffolding. +``` + +This RFC scopes `specs generate` and its emitter registry. Everything above generate is existing CLI behavior; the agent smoothing pass downstream is a separate workstream and the scripts/inference boundary is a hard rule (see Principles). + +Inside `specs generate`, each emitter is a Style-Dictionary-style `Selector → Transform → Format` pipeline: a pure function `(spec, options) => { filename, content }` with no I/O. The registry lives in **`specs-cli`**; custom emitters are deferred (see Decisions). + +### Guiding principles + +#### Pipeline integrity + +##### 1. Lossless ingest + +The contract produced by the ingest stage (`{component}.yaml` in v1) carries everything downstream needs. No emitter, agent, or consumer should ever re-open the source artifact to answer a question about the component. If something is missing, fix the schema and the ingest adapter rather than reaching back upstream. The property holds for Figma today and generalizes to any future adapter producing a schema-valid contract. This is what makes the rest of the pipeline reproducible and offline-friendly. + +##### 2. Favor scripts over inference + +If a transformation can be a pure function of the spec, it belongs in an emitter, not a prompt. Inference is for what genuinely resists clean rules — judgment, prose, pattern recognition. + +##### 3. Favor verbatim over interpretation + +The emitter does not rename props, invent component names, or pick which slot becomes `children` by taste. It *does* collapse Figma boolean visibility props onto a single nullable slot prop — that's mechanical, lossless representation, not a style choice. When in doubt, emit identifiers verbatim and let downstream tooling decide. + +##### 4. Confine inference downstream + +Fetch, scan, and generate are deterministic and byte-reproducible. No emitter calls an LLM, infers prose, or invents identifiers. Inference is a separate downstream stage — never woven into the pipeline itself. + +##### 5. Favor regeneration over restriction + +`specs generate` guarantees byte-for-byte reproducibility from the spec. What downstream agents or users do with those files — sidecar, overwrite, edit, replace, ignore — is their call. We make regeneration cheap so any choice is reversible; we don't prescribe sibling conventions or block in-place edits. + +#### Output design + +##### 6. The contract is the spine + +The pipeline shape is *ingest → contract spine → projection*: an adapter produces a schema-valid contract; that contract is the only canonical artifact; emitters project from it. The contract is YAML in v1, but the architecture is format-agnostic — JSON, or any schema-validated structured representation with comparable diffability and readability properties, would serve the same role. The contract's authority comes from format-as-validator, not convention: schema validation adjudicates every value, so a typo in a prop name or token path fails immediately rather than drifting silently. Prose derivatives can't enforce that; markdown's flexibility is exactly what invites hallucinations to take root. When a derivative and the contract disagree, the contract wins by definition, not by policy — which makes "spine" a structural property of the architecture rather than an aspiration. + +##### 7. Start at the consumer's edge + +Each consumer has a natural starting point: a React engineer reaches for `contract.ts`, a CSS author for `button.css`, a token-impact tool for `tokens.json`, a Storybook integrator for `button.stories.ts`, a reader for `button.md`. Load-bearing edge artifacts must be lossless within their idiom — `contract.ts`, `tokens.json`, `css`, `tailwind.css`, and the workspace dictionary files all are. Platform `.md` files ship as **worked examples** of what a mechanical projection looks like, not as starting points — losslessness there would require opinions Principle 4 forbids. The contract remains the universal canonical fallback. + +##### 8. Compose, don't collide + +Generated emitter outputs sit alongside team-authored sidecars (a11y, behavior, usage) and agent-smoothed siblings. Each class has a distinct lifecycle and naming convention so consumers compose them at retrieval time: generated files regenerate without touching authored content; authored files persist through schema changes; smoothed files replay against the latest generated baseline. + +##### 9. Favor emitter coverage over team glue + +Every generated output exists to reduce what teams hand-write to consume the design system. An emitter earns its slot by shrinking the team's adapter surface — less scaffolding to write, less polish to repeat — not by adding parallel ways to express what's already covered. Repeated team extensions across teams are signal that the supplement belongs in the emitter or upstream in the schema. + +##### 10. Format carries lifecycle + +Structured contracts (YAML, JSON, or any schema-validated representation) carry what the schema validates: anatomy, props, layout, variants, tokens, bindings — anything adversarially robust against drift. Prose (MD) carries what the schema can't: a11y rationale, behavioral notes, usage prose, design intent. The format choice tells consumers and tools what lifecycle to expect: the structured contract is regenerable, validatable, diffable on facts; prose is authored, prose-shaped, validatable only on structure. The split is enforceable by file extension alone; the three-classes composition framing follows from it. + +Note: Sketches are exploratory, not proposals. + +### Proposed outputs + +Workspace-level files are pure functions of the set of all specs in the workspace. Effort is implementation cost; value is consumer impact. Groups bundle outputs for selection (CLI flag, config preset, sidecar): `defaults` always emit; the rest are opt-in via `--emit ` or `--emit `. The `Sketch` column links to an illustrative template grounded in `button.yaml` — treat as direction, not specification. + +| File | Group | Value | Effort | Source / contents | Sketch | +|---|---|---|---|---|---| +| `{c}.md` | defaults | critical | low | Default short structural index — props, anatomy, default layout, layout deltas, typography (default per text element, marked when it varies by variant), bindings, invalid combinations, subcomponent refs, provenance. Structural shape only; per-variant styling lives in the contract. Also serves as the **vocabulary primer** agents use when drafting team-authored sidecars — anatomy keys, prop names, slot identifiers all match the contract, so authored content stays coherent across regenerations | [sketch](sketches/button.md) | +| `{c}.docs.md` | defaults | high | low | Comprehensive scripted reference — overview counts, anatomy, full API tables, subcomponent specs, layout tree + variant tree deltas, dimensions, auto-layout, typography, color tokens + variant deltas, effects + variant effects, conditional logic, token index, provenance. The data-reference form, fully scripted from the contract | [sketch](sketches/button.docs.md) | +| `{c}.tokens.json` | defaults | high | trivial | Flat list of every `$token` referenced, with `$type` and per-element usage map | [sketch](sketches/button.tokens.json) | +| `{c}.layout.md` | defaults | medium | trivial | Default tree + variant tree deltas (only variants that restructure) | [sketch](sketches/button.layout.md) | +| `{c}.elements.md` | defaults | high | low | Per-element rendering guide — for each anatomy element, default styles then a flat override table of every variant configuration that touches it. Inverts the contract's variant→element axis to element→variant | [sketch](sketches/button.elements.md) | +| `{c}.provenance.md` | defaults | low | trivial | Author, schema version, source node, generator, config | — | +| `{c}.contract.ts` | contracts | high | trivial | Framework-agnostic TS prop interface + slot signature, no framework imports. Lossless within its idiom; the load-bearing platform emitter for typed languages | [sketch](sketches/button.contract.ts) | +| `{c}.css` | styling | high | medium | One class per anatomy element, custom properties from `$token` paths, `[data-state=…]` selectors per variant. Lossless for the deterministic ruleset | [sketch](sketches/button.css) | +| `{c}.tailwind.css` | styling | medium | medium | `@layer components` block, `@apply` chains keyed on `data-*` attrs, configurable token-path → theme-path transform | [sketch](sketches/button.tailwind.css) | +| `{c}.stories.ts` | integrations | medium | low | Storybook CSF stories file — per-variant story declarations seeded from `fixture.json`, importing types from `contract.ts`. Direct feed for Storybook autodocs and addon-driven workflows | [sketch](sketches/button.stories.ts) | +| `{c}.fixture.json` | testing | medium | low | Sample prop combos covering every variant configuration plus boolean coverage | [sketch](sketches/button.fixture.json) | +| `{c}.skeleton.html` | testing | low | low | Default tree as unstyled HTML with `data-*` attrs for every prop | [sketch](sketches/button.skeleton.html) | +| `{c}.react.md` | examples | low | low | Reference rendering — what a mechanical projection of `contract.ts` into React looks like. Worked example, not a starting point. Copy, modify, throw away | [sketch](sketches/button.react.md) | +| `{c}.webcomponents.md` | examples | low | low | Reference rendering — tag, attribute reflection, named slots. Worked example, not a starting point | [sketch](sketches/button.webcomponents.md) | +| `{c}.ios.md` | examples | low | low | Reference rendering — Swift enums and struct initializer. Worked example, not a starting point | [sketch](sketches/button.ios.md) | +| `dictionary.index.md` | workspace | high | trivial | Every component with one-line summary (counts of props/anatomy/variants) | — | +| `dictionary.tokens.json` | workspace | high | low | Union of `$token` refs across all components with usage counts | — | +| `dictionary.graph.json` | workspace | medium | low | Instance dependency graph from `instanceOf` references — cycles, roots, leaves | — | + +The workspace-level dictionary files double as **inputs for report and audit scripts** — token-usage maps, naming-consistency audits, dependency analyses, and similar workspace-scale reports walk these contracts rather than re-traversing the YAML directory each time. Additional report-shaped contracts (styling roll-ups, naming concordances, anatomy censuses) are a natural extension of this group as report use cases solidify. + +Selection examples: + +``` +specs generate ./specs # defaults only +specs generate ./specs --emit defaults,platform,styling +specs generate ./specs --emit all +specs generate ./specs --emit defaults,+react,+css +``` + +#### Which artifact to start with + +**Start at the edge artifact closest to your idiom; reference the contract for definitive decisions.** Each consumer has a natural starting point (Principle 7); `button.md` is the default short index, not a universal one. + +| Consumer / question | Start with | Fall back to | +|---|---|---| +| React / typed-language engineer building a component | `button.contract.ts` (+ `button.team.react.md` if present) | YAML; `button.react.md` as a worked-example reference | +| iOS engineer building a SwiftUI view | `button.contract.ts` (port the types) + `button.ios.md` as worked example | YAML | +| CSS / Tailwind author | `button.css` / `button.tailwind.css` | YAML | +| Storybook integrator | `button.stories.ts` + `button.contract.ts` | YAML | +| Token-impact tool, instance graph analysis | `button.tokens.json` / workspace dictionary files | YAML | +| Report or audit script (token usage, naming consistency, dependency graph, maturity scorecard, etc.) | Workspace dictionary files + targeted JSON contracts | YAML for anything not yet rolled up | +| Reader (human or agent) orienting to a component | `button.md` (short structural index) | `button.docs.md` for full data reference, then YAML | +| Agent drafting team-authored sidecars (a11y, usage, migration) | `button.md` — anchors authored content to the contract's identifiers | YAML | +| Comprehensive scripted reference for docs / review | `button.docs.md` | YAML | +| Any precision question — exact token, dimension, padding, effect, per-variant value | **YAML** | — | + +**Rationale.** The contract (YAML in v1, ~3k tokens for Button) is canonical and complete but verbose and shaped for the schema, not the consumer. Each edge artifact reshapes the contract's data into a form closer to the consumer's workflow — React types, iOS enums, CSS rules, token graphs, structural index. Starting at the edge means less translation work for the consumer; falling back to the contract for precision means no answer is ever approximated. Edge artifacts should not restate values the contract carries authoritatively when a precision question has a single correct answer. + +### Architecture + +**Script / inference boundary.** Each output has a *script ceiling* (everything mechanically derivable from the contract) and an *inference seam* (where a downstream agent could usefully add value). Inference work runs in a separate downstream stage (e.g. `specs smooth`); regeneration is the contract on our side, so any downstream choice is reversible (Principle 5). Per-output ceiling/seam table and reusable patterns in [`script-inference-boundaries.md`](script-inference-boundaries.md); the speculative agent backlog in [`smoothing-backlog.md`](smoothing-backlog.md). + +**Dictionary composition.** The format split (Principle 10) is the load-bearing distinction: structured contracts carry schema-validated facts, prose carries schema-gap content (a11y, behavior, usage, design intent). Within that, three lifecycle classes — *generated* (regenerable byte-exactly by `specs generate`), *authored* (team-written sidecars and `team.` extensions), and *smoothed* (agent-produced, may drift, replayable) — follow from format + naming convention. Authored content never overrides generated facts; smoothed content never overrides authored; the contract wins everything on precision (Principle 6). Platform `.md` files (`react.md`, `webcomponents.md`, `ios.md`) ship as worked examples rather than edge artifacts — the React engineer's starting point is `contract.ts`, not `react.md`. Format split rationale, the three-classes table, authoring patterns, filesystem layouts, and per-platform losslessness in [`dictionary-composition.md`](dictionary-composition.md). + +**CLI invocation.** `specs generate --emit` accepts file names, group names, or `all` / `defaults` / `none`, composing with `+` / `-`. Resolution order is per-component sidecar > CLI flag > workspace `Config.emit` > built-in defaults. Full flag surface, the `Config.emit` schema, and per-emitter options in [`cli-reference.md`](cli-reference.md). + +### Implementation order + +1. **`defaults` group** (`md`, `docs.md`, `tokens.json`, `layout.md`, `elements.md`, `provenance.md`) — one PR. Pure walks of a single spec, no platform opinions. Highest leverage for agentic-coding consumers. +2. **`contracts` + `styling` groups** (`contract.ts`, `css`, `tailwind.css`) — second PR. The load-bearing platform emitters: lossless within their idiom, no platform opinions required. Introduces the shared enum/boolean/nullable formatter and the token-path → CSS-var / theme-path transform layer. +3. **`integrations` + `testing` groups** (`stories.ts`, `fixture.json`, `skeleton.html`) — third PR. Storybook feed plus mechanical fixture generation from variant configurations and anatomy. +4. **`examples` group** (`react.md`, `webcomponents.md`, `ios.md`) — fourth PR. Reference renderings; lowest priority since they're worked examples, not starting points. +5. **`workspace` group** (`dictionary.index.md`, `dictionary.tokens.json`, `dictionary.graph.json`) — fifth PR. First emitter pass over the full workspace; introduces cross-component aggregation. + +--- + +## Alternatives considered + +The proposal is one of several possible architectures for closing the multi-consumer mismatch. The alternatives we considered and rejected: + +**Per-team adapters against the raw contract.** Each team that wants a component dictionary builds its own renderer or extractor against the contract directly. This works for one or two teams, and is genuinely simpler in isolation. At design-system scale (multiple teams, multiple downstream consumers) it produces fragmentation — different teams' Buttons render inconsistently, the same logic gets re-implemented per team, and infrastructure cost is paid N times over. We bet that a shared projection layer is cheaper in aggregate even though per-team adapters are simpler in isolation. + +**On-demand LLM interpretation of the contract.** A coding agent could read the contract at consumption time and infer the projections it needs, treating the contract as the one true input. This works for ad-hoc cases but fails at retrieval scale: ~3k tokens per component loaded for every retrieval is expensive; inferred projections are non-deterministic between runs; the same agent asked the same question twice may get subtly different answers. Determinism + caching beats inference + tokens once you cross a small consumption threshold, and the agentic-coding workflow crosses it quickly. + +**Agentic extraction directly from Figma, skipping the contract.** Treat Figma as the source of truth and use agents to produce documentation from it on demand. We discuss this in the Motivation as the Agentic Extraction Alternative and reject it on cost (orders of magnitude more tokens and time per component) and quality (hallucinations, opinions, run-to-run drift) grounds. The structural ingest stage handled by `specs scan` is what makes the rest of this proposal cheap; replacing it with inference is a category-defeating choice. + +**A single comprehensive markdown rendering for all consumers.** Instead of a fanout, ship one comprehensive `component.md` that covers everything any consumer might need. Simpler to maintain, easier to publish, fewer files in the repo. We rejected this because consumers vary too widely in what they need — a React engineer, a Storybook integrator, and a token-rename tool require different shapes — and one-size-fits-all means no consumer gets the right shape. The fanout exists because the cost of N small purpose-built files is lower than the cost of every consumer re-parsing one giant file. + +**Inference woven into the emitters.** Allow emitters to call LLMs inline for "smart" outputs (e.g. an emitter that produces a `usage.md` with prose, or an emitter that infers a11y annotations from anatomy). Rejected because it weakens regeneration (LLM calls aren't byte-reproducible), invites drift (every regeneration produces slightly different output), and breaks the audit trail (you can't trace a derivative back to its source mechanically). Inference belongs in a separate stage that produces clearly-labeled smoothed outputs, not inside the deterministic registry. + +**Tightly-coupled Figma-specific emitter outputs.** Bypass the contract layer entirely and produce per-platform emitter outputs directly from Figma. Faster to build initially, no schema to design. Rejected because it locks the architecture to Figma's API shape, produces no canonical artifact for review or audit or version control, and recreates the vendor-coupling problem the contract's structural value is supposed to neutralize. The contract is the canonical artifact; emitters project from it. + +--- + +## Drawbacks + +This proposal commits to real costs and unproven architectural choices. The honest drawbacks: + +- **The proposal accumulates ~16 emitter types.** Building, fixture-testing, and maintaining all of them is real work. If only a subset earn their slot in practice (teams use `contract.ts`, `tokens.json`, and `css`, and ignore the rest), the maintenance cost outweighs the value. The "value" column in the Outputs table is our estimate; if it's wrong, this argument breaks. +- **The composition story is unproven at scale.** Three file classes, suffix allowlists, sidecar config, in-place-edit affordances — every DS tool in this space accumulates conventions, and most fail when teams change or don't designate someone to maintain them. Principle 9 ("repeated extensions across teams are signal") names the feedback loop without describing the mechanism that triggers it; that's a real gap. +- **Platform `.md` files may be mistaken for starting points despite the framing.** "Worked example, not a starting point" is a sharp line in the proposal but a soft line in practice — once a `react.md` exists in the repo, engineers will read it, copy from it, and probably build on it. The demotion may not survive contact with real users. +- **The proposal depends on the contract's compression-and-readability claim holding over time.** The claim is empirically validated for the current YAML implementation (~1% the size of Figma's REST API, readable by designers and design leads). If the schema bloats — adding many new fields or representations that aren't compactly expressible — the contract's value as a single canonical artifact weakens, and the rest of the proposal's foundation weakens with it. Schema discipline is upstream of this proposal but load-bearing for it. +- **The smoothing layer is unbuilt.** Many of the highest-value capabilities (a11y inference, prose generation, exception callouts, design intent) live in a stage we explicitly defer. If the smoothing layer never materializes well, the deterministic emitter outputs alone may feel thin to consumers who expected the full shape. +- **Adoption friction is real for teams with stable workflows.** A team running Storybook + react-docgen + a token pipeline + a homegrown generator already has the surface this proposal covers — even if that coverage is patchy. Switching costs may exceed marginal value for teams that are already getting by. +- **The "thin adapter layer" promise is hard to verify.** Principle 9's success metric ("how much team-authored glue does an emitter eliminate?") is conceptually right but operationally vague. Without a concrete way to measure adapter-thinning across teams, the principle becomes aspirational rather than diagnostic. + +--- + +## Decisions ratified by this RFC + +These are the firm commitments this RFC makes. As any of them firms up further (e.g. lands in shipped code, becomes load-bearing for downstream work), it can be extracted as a standalone ADR that references this RFC for context. + +- **Emitter registry:** lives in `specs-cli`. Custom emitters deferred — built-in registry only for v1. The deferred design for opt-in template-driven emitters (consumers customizing presentation without forking the registry) is sketched in [`templates-appendix.md`](templates-appendix.md). +- **Variable / identifier casing:** reuses `config.format.keys`. Per-emitter override available where a platform forces a casing (e.g. CSS). +- **Defaults vs opt-in:** see Outputs table for the full assignment. `defaults` group ships always; `contracts`, `platform`, `styling`, `testing`, and `workspace` groups are opt-in via `emit.include` or `--emit `. +- **Per-component overrides:** sidecar file pattern — `{component}.emit.yaml` next to the component's contract. Mirrors the config-level `emit` shape. Keeps the contract file clean and version-controlled separately from override intent. +- **Per-emitter conventions are configurable, not opinionated.** Where a sensible default exists but is plausibly contested by a team's idiom (e.g. token-path → theme-path mapping for Tailwind, text-slot → `children` mapping for React, struct-prefix for iOS), ship a default and expose `emit.options..*` for override rather than picking the convention silently. +- **`md` template:** lock via a small RFC *before* implementation. Goal: the structural-index format stays consistent across schema versions. RFC covers section order, compression rules, and what counts as an "invariant" worth surfacing deterministically. +- **Extending a generated edge artifact:** sibling authoring is the recommended path. Teams write `{component}.team..md` next to the generated file rather than editing the generated file directly. In-place edits are allowed (Principle 5) but break the regeneration contract for that file. +- **Supplementing with team-owned concerns the schema doesn't carry (a11y, behavior, usage, …):** suffix-based sidecars from a known starter allowlist — `a11y`, `behavior`, `usage`, `examples`, `migration`. Teams extend the allowlist via `Config.emit.includeAuthored: [, ...]`. +- **Per-platform losslessness:** `contract.ts`, `tokens.json`, `css`, `tailwind.css`, and workspace dictionary files are lossless within their idiom. Platform scaffolds (`react.md`, `webcomponents.md`, `ios.md`) are intentionally scaffold-quality, not lossless — losslessness would require opinions Principle 4 forbids. Team extensions fill the gap; Principle 9 turns repeated extensions across teams into signal for new emitter or schema work. +- **CLI ingest naming:** `specs fetch` and `specs scan` are Figma-shaped today and stay as-is in this RFC. If additional ingest adapters land, the names may evolve toward an `ingest`-prefixed form (e.g. `specs ingest --source=figma`). No rename now; the architecture treats it as a future-compatible refactor, not a v1 commitment. + +--- + +## Unresolved questions + +These are immediate questions that block or shape the next implementation step. + +- **`md` template structure** — small RFC needed before the defaults PR lands, covering section order, compression rules, and the precise algorithm for which "invariants" get surfaced (e.g. "any style that appears in ≥N variants sharing a single prop value"). The RFC should treat **agent-authored-sidecar drafting** as a primary consumer alongside human orientation, since vocabulary anchoring (identifiers and their semantics) matters more for that use case than per-variant layout deltas — affects section ordering and what gets surfaced. +- **Tailwind transform shape** — confirm the exact token-path → theme-path mapping (kebab full path? preserve token segments? collapse `default`?) when writing the emitter. +- **Composition operationalization (immediate)** — sidecar file discovery (`*.emit.yaml` glob, merge precedence); whether consumer agents need a manifest (`button.manifest.json`) or convention-by-suffix is enough; whether the authored-suffix allowlist is workspace-wide, per-component, or both. + +--- + +## Future work + +These are anticipated workstreams that are out of scope for this RFC but consistent with its architecture. Each is named here so reviewers know we've thought about it and aren't blocking the immediate proposal on it. + +- **Smoothing layer (`specs smooth`).** The downstream inference stage that elaborates deterministic emitter outputs into prose, exception callouts, a11y annotations, and design-intent notes. Architecturally specified (Principles 1, 4, 5; the [`smoothing-backlog.md`](smoothing-backlog.md) companion) but not built. +- **Hand-authoring the contract as a first-class workflow.** The architecture supports it (the schema is the contract; nothing in the pipeline assumes Figma was the source). The developer experience is unbuilt: schema-aware editing, validation feedback in CI, scaffolding from another component, possibly a dedicated authoring UI for non-engineers. Flagged because the contract framing (Principles 6, 10) implies it should be supported at some point. +- **Multi-origin reconciliation.** If multiple ingest adapters can produce a contract for the same component (Figma vs. code analysis vs. hand-authored versions), the conflict-resolution model is a separate research project. The architecture doesn't preclude it; this RFC doesn't commit to it. +- **Template-driven emitters.** Opt-in template overrides letting teams customize presentation without forking the registry. Sketched in [`templates-appendix.md`](templates-appendix.md). Deferred from v1; the v1 implementation should keep assembly logic separate from string-formation so the future split is cheap. +- **Additional report-shaped contracts.** Styling roll-ups, naming concordances, anatomy censuses, and other workspace-level aggregations that feed report and audit scripts. Natural extensions of the `workspace` group as report use cases solidify (see the Reports row in the Multi-Consumer Mismatch). +- **CLI ingest renaming.** When additional ingest adapters land (code analysis, hand-authoring, prototyping tools), `specs fetch` and `specs scan` may evolve toward an `ingest`-prefixed form. Future-compatible refactor, not a v1 commitment. +- **Principle 9 cadence threshold.** Principle 9 names the feedback loop ("repeated team extensions are signal") but not the trigger — what counts as enough teams writing the same extension to promote it into the emitter? An operational answer would make Principle 9 diagnostic rather than aspirational. +- **Schema neutrality audit.** The schema currently has Figma-shaped extension namespaces (`com.figma`). If the contract framing matures into multi-origin support, the schema's core should be audited for which fields are genuinely neutral and which carry origin-specific semantics. Not blocking; named so it's not forgotten. + +--- + +## Companion documents + +- [`script-inference-boundaries.md`](script-inference-boundaries.md) — per-output ceiling/seam table + reusable patterns + agent smoothing layer detail +- [`dictionary-composition.md`](dictionary-composition.md) — three-classes table + authoring patterns + filesystem layouts + per-platform losslessness +- [`cli-reference.md`](cli-reference.md) — full flag surface + Config schema +- [`smoothing-backlog.md`](smoothing-backlog.md) — speculative agent backlog organized by category +- [`templates-appendix.md`](templates-appendix.md) — deferred template-driven-emitters design +- [`sketches/`](sketches/) — illustrative templates for each output, grounded in `button.yaml` diff --git a/rfc/001-component-dictionary/cli-reference.md b/rfc/001-component-dictionary/cli-reference.md new file mode 100644 index 0000000..d69c968 --- /dev/null +++ b/rfc/001-component-dictionary/cli-reference.md @@ -0,0 +1,37 @@ +# CLI invocation and configuration + +Companion to [`README.md`](README.md). The main RFC references this surface from the Architecture section; this file carries the full flag list, config schema, and resolution order. + +## Selection + +`--emit` accepts file names, group names, or `all` / `defaults` / `none`, composing with `+` / `-`: + +``` +specs generate ./specs # defaults +specs generate ./specs --emit all # every built-in +specs generate ./specs --emit defaults,platform # add a whole group +specs generate ./specs --emit defaults,+react,+css # add specific files +specs generate ./specs --emit defaults,-elements # drop a default +``` + +Resolution: per-component sidecar (`component.emit.yaml`) > CLI flag > workspace `Config.emit` > built-in defaults. + +## `Config.emit` block + +`Config.emit` extends the existing `specs-schema` `Config` type and carries `preset`, `include`, `exclude`, and per-emitter `options`: + +```yaml +config: + emit: + preset: defaults # group name | 'all' | 'none' + include: [platform, css] + exclude: [skeleton] + options: + css: { keys: KEBAB } # falls back to config.format.keys + tailwind: { layer: components, tokenPathTransform: kebab } + react: { textSlotAsChildren: button } # first text slot by default +``` + +## Identifier casing + +Identifier casing reuses `config.format.keys`; per-emitter `keys` override is available where a platform forces a specific style (e.g. CSS forces kebab regardless of root config). diff --git a/rfc/001-component-dictionary/dictionary-composition.md b/rfc/001-component-dictionary/dictionary-composition.md new file mode 100644 index 0000000..6bc969d --- /dev/null +++ b/rfc/001-component-dictionary/dictionary-composition.md @@ -0,0 +1,68 @@ +# Dictionary composition + +Companion to [`README.md`](README.md). Principles 8 and 10 in the main RFC establish the architecture; this file carries the full reference — the format split, the three lifecycle classes that follow from it, authoring patterns, filesystem layouts, and per-platform losslessness rationale. + +## The format split is the architecture + +The deepest organizing principle is the format choice itself (Principle 10). Structured contracts (YAML, JSON, or any schema-validated representation) carry what the schema validates: anatomy, props, layout, variants, tokens, bindings — anything adversarially robust against drift. Prose (MD) carries what the schema can't: a11y rationale, behavioral notes, usage prose, design intent. The format choice tells a consumer (and a tool) what lifecycle to expect: + +- **Structured contract files** (YAML in v1; JSON or other schema-validated formats in principle) — schema-validated, regenerable, diffable on facts. A typo fails schema check; values can be cross-referenced and re-derived. +- **MD files** — authored or projected for humans, validatable only on structure, prose-shaped. A typo doesn't fail anything; consumers read MD like documentation, not like a contract. + +Class membership (generated / authored / smoothed) is a second-order distinction that follows from format + naming. The format alone is enough to know whether a file is a fact-carrier or a prose-carrier. + +## Lifecycle classes (within the format split) + +| Class | Lifecycle | Examples | Naming convention | +|---|---|---|---| +| **Generated** | Regenerable byte-exactly by `specs generate`. The source of truth among derivatives. | `button.md`, `button.docs.md`, `button.react.md`, `button.css`, … | Standard emitter output names | +| **Authored** | Team-written, persistent, version-controlled, never overwritten by generation. Carries concerns the schema doesn't (Pattern B) or extends a generated edge (Pattern A). | `button.a11y.md`, `button.behavior.md`, `button.usage.md`, `button.team.react.md` | Suffix from a known allowlist (B) or `team.` (A) | +| **Smoothed** | Agent-produced from generated + authored inputs. May drift; replayable. | `button.smoothed.md`, `button.smoothed.docs.md`, `button.smoothed.react.md` | `smoothed.` | + +## Authoring patterns (decided) + +- **Pattern A — extend a generated output.** Write a sibling: `button.team.react.md` extends `button.react.md`. Don't edit the generated file in place — that breaks the regeneration contract (Principle 5). Consumers compose the pair at retrieval time. +- **Pattern B — supplement with new concerns.** Write a sibling at a known suffix. Starter allowlist: `a11y`, `behavior`, `usage`, `examples`, `migration`. Teams extend the allowlist via `Config.emit.includeAuthored: [, ...]`. +- **Pattern C — agentic adaptation.** Smoothing layer; covered separately (`specs smooth`). Outputs land at `smoothed.` by default, but the smoothing tool's operator chooses (Principle 5). + +## Consumer composition order + +A retrieving agent or human reader composes the dictionary in this order: + +1. **Generated files** establish shape and canonical projections of the contract. +2. **Authored sidecars** layer team-specific concerns and extensions. +3. **Smoothed siblings** add prose / pattern recognition (when present). +4. **The contract** resolves any precision question by definition (Principle 6). + +Authored content never overrides generated *facts* — it adds to or extends them. Smoothed content never overrides authored content. The contract wins everything on precision. + +## Filesystem layouts + +Two valid layouts; teams pick: + +**Flat** — recommended for small dictionaries: + +``` +button/ + button.yaml + button.md, button.docs.md, button.react.md, ... (generated) + button.a11y.md, button.behavior.md (authored — Pattern B) + button.team.react.md (authored — Pattern A) + button.smoothed.md (smoothed) +``` + +Teams that want stricter separation can also nest under `generated/`, `authored/`, and `smoothed/` subfolders — useful when `.gitignore`-ing the regenerable `generated/` is desirable. + +## Per-platform losslessness — what edge artifacts owe their idiom + +Principle 7 says edge artifacts must be lossless within their idiom. In practice: + +| Edge artifact | Lossless within its idiom? | Why | +|---|---|---| +| `contract.ts` | Yes — by construction | Pure types and slot signatures; no platform opinions required | +| `tokens.json`, workspace dictionary files | Yes — within their slice | Pure data inversions of the spec | +| `css`, `tailwind.css` | Yes for the deterministic ruleset | One class per anatomy element, custom property per token, `[data-*]` selectors per variant. Compound selectors and shared declarations belong in Pattern A siblings if a team wants them | +| `md`, `docs.md` | Lossless for *orientation* and *data reference* respectively; neither is a per-variant values reference (by design) | Values live in the contract. `md` is the short index; `docs.md` is the comprehensive scripted reference. Neither restates per-variant precision values inline | +| `react.md`, `webcomponents.md`, `ios.md` | **Worked examples, not edge artifacts** | Losslessness would require platform opinions Principle 4 forbids. These ship as reference renderings of what a mechanical projection looks like in each idiom — copy, modify, throw away — and are explicitly not the React/iOS engineer's starting point. The load-bearing edge for typed-language consumers is `contract.ts` | + +The platform `.md` files are worked examples by design: the scripted output stops where platform opinions would begin, and team-specific completion happens via Pattern A authoring on top of `contract.ts` or via custom emitters. Principle 9 makes this a feedback loop — when many teams write the same extension, that extension belongs in the emitter (or in the schema, if it's a content question). diff --git a/rfc/001-component-dictionary/script-inference-boundaries.md b/rfc/001-component-dictionary/script-inference-boundaries.md new file mode 100644 index 0000000..fbe2df9 --- /dev/null +++ b/rfc/001-component-dictionary/script-inference-boundaries.md @@ -0,0 +1,35 @@ +# Script / inference boundaries + +Companion to [`README.md`](README.md). The main RFC establishes the boundary as a principle (1, 4, 5) and summarizes it under Architecture; this file makes it concrete per output and lists the reusable patterns that sit on the script side of the seam. + +Every emitter is a pure, scriptable transform of the spec — no LLM calls, no invented identifiers, no inferred prose. Each output has a *script ceiling* (everything mechanically derivable from the contract) and an *inference seam* (where a downstream agent could usefully add value beyond what scripts can do). Naming both per output makes implementation scope explicit and gives the smoothing layer a concrete brief. + +## Per-output ceiling and seam + +| Output / group | Script ceiling | Inference seam | +|---|---|---| +| `md`, `layout.md`, `elements.md`, `provenance.md` | Direct walk of the spec: prop tables, anatomy, layout tree, element-first override tables, metadata footer | One-line role descriptions, when-to-use guidance, exception callouts on outlier variants, "this element's behavior pattern resembles X" | +| `md` | Short structural index under a fixed template; every fact byte-traceable to a contract field | Plain-language framing, invariants that need pattern recognition (e.g. "focus universally overrides border weight"), labeling outlier configs as exceptions | +| `docs.md` | Comprehensive scripted reference — overview counts, anatomy, full API tables, layout, structure, color, effects, conditional logic, token index, provenance | Editorial prose (role descriptions, "when to use" guidance, design-intent annotations) — those belong in the smoothed sibling, not here | +| `tokens.json` | Inverted view of the contract — flat list of every `$token` referenced with per-element/per-variant usage map. Shape the contract doesn't store directly | None — inference-free by construction; any prose belongs in an adjacent `.md` output | +| `contract.ts` | Types, defaults, slot signatures, nullable/optional flags | None in v1 — doc comments could be inference territory but belong upstream in the spec | +| `react.md`, `webcomponents.md`, `ios.md` (worked examples) | Reference renderings: prop types, default-bearing call signatures, attribute reflection, slot maps grounded in `instanceOf` values. Not the engineer's starting point — `contract.ts` is | Idiomatic component naming for instance slots, which slot maps to `children`, attribute-vs-property choices, modifier-chain idioms — these are explicitly out of the worked example's scope | +| `css` | One class per anatomy element, custom property per `$token`, `[data-*]` selectors per variant configuration | Collapsing repeated declarations into shared selectors; opinionated reset rules | +| `tailwind.css` | `@layer components` block, `@apply` chains keyed on `data-*` attrs, configurable token-path → theme-path transform | Choosing between `@apply` chains and arbitrary value syntax; collapsing redundant utilities | +| `fixture.json`, `skeleton.html` | One fixture per variant entry plus boolean-prop coverage; default tree as HTML with `data-*` attrs | Hand-picked "interesting" combos, anti-pattern callouts, ARIA hints | +| `dictionary.*` (workspace) | Counts, token usage rollups, instance graph from `instanceOf` references | Composition smells, shared-base suggestions, structural-similarity rollups | +| Accessibility (`role`, `aria-*`, focus order, keyboard) | None today — the schema doesn't carry this | Entirely inference territory; the structured spec gives much richer input than raw nodes (anatomy → roles, layout → focus order, bindings → visibility rules) | + +## Reusable patterns on the script side + +A few patterns recur across emitters. Build them once at the registry level rather than per-emitter: + +- **Pattern collapse** — when N variants share a prop value and exhibit identical style deltas, collapse to a single rule (e.g. surface "focus always uses `mode/focus/outlineColor` and `strokeWeight: 2`" mechanically, not as inferred prose). +- **Configurable conventions** — for the few choices that look like opinions but are knob-able (text-slot → `children`, token-path → theme-path), expose `emit.options..*` rather than picking a default silently. +- **Verbatim identifiers** — anatomy keys, prop names, token paths emit verbatim from the spec. No casing tweaks beyond what `config.format.keys` already declares. + +Every emitter ships with a fixture test: input contract + emitter version → exact byte-equal output. + +## Agent smoothing layer + +Inference work — anything in the right column above — runs downstream of `specs generate` in a separate stage (e.g. an opt-in `specs smooth` command). How it writes its output — sibling files like `*.smoothed.md`, in-place edits, a separate tree — is the smoothing tool operator's choice, not a constraint imposed by this pipeline. Regeneration is the contract on our side: `specs generate` always restores scripted output to canonical form, so any downstream choice is reversible (Principle 5). The concrete backlog of inference work — prose generation, pattern recognition, exception callouts, platform idioms, accessibility, usage examples — lives in [`smoothing-backlog.md`](smoothing-backlog.md). diff --git a/rfc/001-component-dictionary/sketches/button.contract.ts b/rfc/001-component-dictionary/sketches/button.contract.ts new file mode 100644 index 0000000..0095a69 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.contract.ts @@ -0,0 +1,24 @@ +// No React/Vue/Svelte imports. Pure types + slot signatures. +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'invisible'; +export type ButtonSize = 'small' | 'medium' | 'large'; +export type ButtonState = 'rest' | 'focus' | 'hover' | 'pressed' | 'disabled' | 'inactive'; +export type ButtonAlign = 'center' | 'start'; + +export interface ButtonProps { + variant?: ButtonVariant; // default 'secondary' + size?: ButtonSize; // default 'medium' + state?: ButtonState; // default 'rest' + alignContent?: ButtonAlign; // default 'center' + counter?: boolean; // default false + dropdown?: boolean; // default false + leadingVisual?: string | null; // default null + trailingVisual?: string | null; // default null +} + +export interface ButtonSlots { + /* visible when leadingVisual != null */ search?: unknown; + /* always */ button: string; + /* visible when counter == true */ counterLabel?: unknown; + /* visible when trailingVisual != null */ trailingVisual?: unknown; + /* visible when dropdown == true */ dropdown?: unknown; +} diff --git a/rfc/001-component-dictionary/sketches/button.css b/rfc/001-component-dictionary/sketches/button.css new file mode 100644 index 0000000..fad7cec --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.css @@ -0,0 +1,35 @@ +/* Generated. Slots map 1:1 to anatomy keys. */ +.button { + --bg: var(--mode-button-default-bgColor-rest); + --fg: var(--mode-button-default-fgColor-rest); + --border: var(--mode-button-default-borderColor-rest); + --radius: var(--functional-size-borderRadius-medium); + --gap: var(--pattern-size-control-medium-gap); + --pad-y: var(--pattern-size-control-medium-paddingBlock); + --pad-x: var(--pattern-size-control-medium-paddingInline-normal); + + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--gap); + padding: var(--pad-y) var(--pad-x); + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.button[data-variant="primary"] { --bg: var(--mode-button-primary-bgColor-rest); /* … */ } +.button[data-variant="danger"] { --border: var(--mode-button-danger-borderColor-rest); /* … */ } +.button[data-variant="invisible"] { --bg: var(--mode-button-invisible-bgColor-rest); border: 0; } + +.button[data-size="small"] { --pad-x: var(--pattern-size-control-small-paddingInline-condensed); /* … */ } +.button[data-size="large"] { --pad-x: var(--pattern-size-control-large-paddingInline-spacious); /* … */ } + +.button[data-state="hover"] { --bg: var(--mode-button-default-bgColor-hover); } +.button[data-state="pressed"] { --bg: var(--mode-button-default-bgColor-active); } +.button[data-state="disabled"] { --border: var(--mode-button-default-borderColor-disabled); } + +.button__search, +.button__trailingVisual, +.button__dropdown { width: 16px; height: 16px; } diff --git a/rfc/001-component-dictionary/sketches/button.docs.md b/rfc/001-component-dictionary/sketches/button.docs.md new file mode 100644 index 0000000..14028b7 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.docs.md @@ -0,0 +1,285 @@ +# Button + +> Comprehensive scripted reference. Every section below is a deterministic projection of `button.yaml` — no inference, no prose narrative. For a compact orientation, see [`button.md`](button.md). For definitive precision, see `button.yaml`. + +## Overview + +Button has 8 props, 7 anatomy elements, and 51 variant configurations. Includes 1 subcomponent. + +**Variant axes.** `alignContent` (2 values), `size` (3 values), `state` (6 values), `variant` (4 values). + +**Boolean toggles.** `counter`, `dropdown`. + +## Anatomy + +| Element | Type | Notes | +|---|---|---| +| root | container | — | +| search | instance | instanceOf: `search` | +| button | text | content `"Button"` | +| counterLabel | instance | instanceOf: `counterLabel` | +| trailingVisual | instance | instanceOf: `linkExternal` | +| dropdown | instance | instanceOf: `textCaret` | +| centered | container | detected in: variant=secondary, size=medium, state=rest, alignContent=start | + +--- + +## API + +### Properties + +| Property | Type | Values | Default | Notes | +|---|---|---|---|---| +| `alignContent` | string | `center` \| `start` | center | — | +| `counter` | boolean | `true` \| `false` | `false` | — | +| `dropdown` | boolean | `true` \| `false` | `false` | — | +| `leadingVisual` | string | `search`, … | `null` | nullable; toggles `leadingVisual` visibility | +| `size` | string | `small` \| `medium` \| `large` | medium | — | +| `state` | string | `rest` \| `focus` \| `hover` \| `pressed` \| `disabled` \| `inactive` | rest | — | +| `trailingVisual` | string | `link-external`, … | `null` | nullable; toggles `trailingVisual` visibility | +| `variant` | string | `primary` \| `secondary` \| `danger` \| `invisible` | secondary | — | + +--- + +## Subcomponents + +### ButtonGroup + +**Anatomy** + +| Element | Type | Notes | +|---|---|---| +| root | container | — | +| firstButton | instance | instanceOf: `button` | +| button | instance | instanceOf: `button` | +| lastButton | instance | instanceOf: `button` | + +**Properties** + +| Property | Type | Values | Default | +|---|---|---|---| +| `2ndButton` | boolean | `true` \| `false` | `true` | +| `3rdButton` | boolean | `true` \| `false` | `false` | +| `4thButton` | boolean | `true` \| `false` | `false` | +| `5thButton` | boolean | `true` \| `false` | `false` | +| `size` | string | `medium` \| `small` | medium | +| `variant` | string | `secondary` \| `primary` \| `danger` | secondary | + +**Default layout** + +- root + - firstButton + - button + - button + - button + - button + - lastButton + +--- + +## Layout + +### Default Tree + +- root + - search + - button + - counterLabel + - trailingVisual + - dropdown + +### Variant Tree Deltas + +**alignContent=start** + +- root + - centered + - search + - button + - counterLabel + - trailingVisual + - dropdown + - dropdown + +**variant=danger, state=pressed, alignContent=start** + +- root + - search + - button + - counterLabel + - dropdown + - dropdown + - trailingVisual + +_(plus 2 more 4-axis configurations restructuring root identically to alignContent=start)_ + +--- + +## Structure + +### Dimensions & Spacing + +| Element | Property | Value | +|---|---|---| +| root | cornerRadius | `functional/size/borderRadius/medium` | +| root | strokeWeight | `1` | +| root | padding | `pattern/size/control/medium/paddingBlock` × `pattern/size/control/medium/paddingInline/normal` | +| root | itemSpacing | `pattern/size/control/medium/gap` | +| search | width, height | `16` | +| counterLabel | height | `18` | +| trailingVisual | width, height | `16` | +| dropdown | width, height | `16` | +| centered | itemSpacing | `8` | + +### Auto-Layout + +| Element | Mode | Main Align | Cross Align | H Sizing | V Sizing | +|---|---|---|---|---|---| +| root | HORIZONTAL | CENTER | CENTER | HUG | HUG | +| button | — | — | — | HUG | HUG | +| counterLabel | — | — | — | HUG | — | +| centered | HORIZONTAL | — | CENTER | HUG | HUG | + +### Typography + +| Element | Typography | Text Color | Content | +|---|---|---|---| +| button | `Body/Medium Bold` | `mode/button/default/fgColor/rest` | `"Button"` | + +--- + +## Color & Appearance + +### Tokens by Element (Default) + +| Element | Property | Value | +|---|---|---| +| root | backgroundColor | `mode/button/default/bgColor/rest` | +| root | strokes | `mode/button/default/borderColor/rest` | +| button | textColor | `mode/button/default/fgColor/rest` | + +### Variant Color Deltas + +| Variant | Element | Property | Value | +|---|---|---|---| +| state=focus | root | strokes | `mode/focus/outlineColor` | +| state=hover | root | backgroundColor | `mode/button/default/bgColor/hover` | +| state=hover | root | strokes | `mode/button/default/borderColor/hover` | +| state=pressed | root | backgroundColor | `mode/button/default/bgColor/active` | +| state=pressed | root | strokes | `mode/button/default/borderColor/hover` | +| state=disabled | root | strokes | `mode/button/default/borderColor/disabled` | +| state=disabled | button | textColor | `mode/fgColor/disabled` | +| state=inactive | root | backgroundColor | `mode/button/inactive/bgColor` | +| state=inactive | root | strokes | `null` | +| state=inactive | button | textColor | `mode/button/inactive/fgColor` | +| variant=primary | root | backgroundColor | `mode/button/primary/bgColor/rest` | +| variant=primary | root | strokes | `mode/button/primary/borderColor/rest` | +| variant=primary | button | textColor | `mode/button/primary/fgColor/rest` | +| variant=danger | root | strokes | `mode/button/danger/borderColor/rest` | +| variant=danger | button | textColor | `mode/button/danger/fgColor/rest` | +| variant=invisible | root | backgroundColor | `mode/button/invisible/bgColor/rest` | +| variant=invisible | root | strokes | `null` | +| variant=invisible | button | textColor | `mode/button/invisible/fgColor/rest` | + +_(plus ~25 more two- and three-axis variant color deltas across primary/danger/invisible × hover/pressed/disabled/inactive)_ + +### Effects (Default) + +| Element | Effects | Opacity | +|---|---|---| +| root | `_component/button/default/shadow/resting` | 1.0 | + +### Variant Effects Deltas + +| Variant | Element | Effects | +|---|---|---| +| state=focus | root | `null` | +| state=hover | root | `shadow/resting/small` | +| state=pressed | root | `shadow/inset` | +| state=disabled | root | `null` | +| state=inactive | root | `null` | +| variant=invisible | root | `null` | +| variant=primary, state=focus | root | `_component/button/primary/shadow/selected` | +| variant=primary, state=pressed | root | `_component/button/primary/shadow/selected` | +| variant=danger, state=pressed | root | `_component/button/danger/shadow/selected` | +| variant=danger, state=disabled | root | `_component/button/default/shadow/resting` | + +--- + +## Conditional Logic + +- **search** (`styles.visible`): `false` if `leadingVisual == null` else `true` +- **search** (`instanceOf`): bound to `leadingVisual` +- **counterLabel** (`styles.visible`): bound to `counter` +- **trailingVisual** (`styles.visible`): `false` if `trailingVisual == null` else `true` +- **trailingVisual** (`instanceOf`): bound to `trailingVisual` +- **dropdown** (`styles.visible`): bound to `dropdown` + +--- + +## Token Index + +### color + +| Token | Used by | +|---|---| +| `mode/button/default/borderColor/rest` | root (default) | +| `mode/button/default/bgColor/rest` | root (default) | +| `mode/button/default/fgColor/rest` | button (default) | +| `mode/focus/outlineColor` | root (state=focus, variant=primary+focus, variant=danger+focus, variant=invisible+focus) | +| `mode/button/default/borderColor/hover` | root (state=hover, state=pressed) | +| `mode/button/default/bgColor/hover` | root (state=hover) | +| `mode/button/default/bgColor/active` | root (state=pressed) | +| `mode/button/default/borderColor/disabled` | root (state=disabled) | +| `mode/fgColor/disabled` | button (state=disabled) | +| `mode/button/inactive/bgColor` | root (state=inactive, variant=primary+inactive, variant=invisible+inactive) | +| `mode/button/inactive/fgColor` | button (state=inactive across variants) | +| `mode/button/primary/borderColor/rest` | root (variant=primary) | +| `mode/button/primary/bgColor/rest` | root (variant=primary) | +| `mode/button/primary/fgColor/rest` | button (variant=primary) | + +_(plus ~20 more color tokens covering primary/danger/invisible state combinations)_ + +### dimension + +| Token | Used by | +|---|---| +| `functional/size/borderRadius/medium` | root (default + 16 focus and small/large state configurations) | +| `pattern/size/control/medium/gap` | root (default) | +| `pattern/size/control/medium/paddingBlock` | root (default) | +| `pattern/size/control/medium/paddingInline/normal` | root (default) | +| `pattern/size/control/small/gap` | root (size=small) | +| `pattern/size/control/small/paddingBlock` | root (size=small) | +| `pattern/size/control/small/paddingInline/condensed` | root (size=small) | +| `pattern/size/control/large/gap` | root (size=large) | +| `pattern/size/control/large/paddingBlock` | root (size=large) | +| `pattern/size/control/large/paddingInline/spacious` | root (size=large) | + +### effects + +| Token | Used by | +|---|---| +| `_component/button/default/shadow/resting` | root (default), root (variant=danger, state=disabled) | +| `shadow/resting/small` | root (state=hover) | +| `shadow/inset` | root (state=pressed) | +| `_component/button/primary/shadow/selected` | root (variant=primary, state=focus / state=pressed) | +| `_component/button/danger/shadow/selected` | root (variant=danger, state=pressed) | + +### typography + +| Token | Used by | +|---|---| +| `Body/Medium Bold` | button (default) | +| `Body/Small Bold` | button (size=small) | + +--- + +## Provenance + +- **Component:** Button +- **Author:** Nathan Curtis +- **Last updated:** 2026-04-24 +- **Schema:** v0.18.0 +- **Source node:** `30258:5582` (COMPONENT_SET) +- **Page ID:** `136:1805` +- **Generator config:** details: LAYERED, variantDepth: 9999, keys: CAMEL, layout: LAYOUT, tokens: TOKEN diff --git a/rfc/001-component-dictionary/sketches/button.elements.md b/rfc/001-component-dictionary/sketches/button.elements.md new file mode 100644 index 0000000..57326d5 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.elements.md @@ -0,0 +1,236 @@ +# Button — Element rules + +For each anatomy element: default styles, then a flat override table of every variant configuration that touches it. Inverts the YAML's variant→element axis to element→variant. + +## root (container) + +### Default styles + +| Property | Value | +|---|---| +| visible | true | +| layoutMode | HORIZONTAL | +| mainAxisAlignment | CENTER | +| crossAxisAlignment | CENTER | +| layoutSizingHorizontal | HUG | +| layoutSizingVertical | HUG | +| backgroundColor | `mode/button/default/bgColor/rest` | +| strokes | `mode/button/default/borderColor/rest` | +| strokeWeight | 1 | +| cornerRadius | `functional/size/borderRadius/medium` | +| effects | `_component/button/default/shadow/resting` | +| padding.top, padding.bottom | `pattern/size/control/medium/paddingBlock` | +| padding.start, padding.end | `pattern/size/control/medium/paddingInline/normal` | +| itemSpacing | `pattern/size/control/medium/gap` | + +### Variant-by-variant layered overrides + +| When | Property | Value | +|---|---|---| +| alignContent=start | width | 174 | +| alignContent=start | mainAxisAlignment | SPACE_BETWEEN | +| alignContent=start | primaryAxisSizingMode | FIXED | +| alignContent=start | layoutSizingHorizontal | FIXED | +| alignContent=start | itemSpacing | 8 | +| state=focus | cornerRadius | 6 | +| state=focus | strokes | `mode/focus/outlineColor` | +| state=focus | strokeWeight | 2 | +| state=focus | effects | null | +| state=hover | strokes | `mode/button/default/borderColor/hover` | +| state=hover | effects | `shadow/resting/small` | +| state=hover | backgroundColor | `mode/button/default/bgColor/hover` | +| state=pressed | strokes | `mode/button/default/borderColor/hover` | +| state=pressed | effects | `shadow/inset` | +| state=pressed | backgroundColor | `mode/button/default/bgColor/active` | +| state=disabled | strokes | `mode/button/default/borderColor/disabled` | +| state=disabled | effects | null | +| state=inactive | strokes | null | +| state=inactive | strokeAlign | null | +| state=inactive | strokeWeight | null | +| state=inactive | effects | null | +| state=inactive | backgroundColor | `mode/button/inactive/bgColor` | +| size=small | itemSpacing | `pattern/size/control/small/gap` | +| size=small | padding.top, padding.bottom | `pattern/size/control/small/paddingBlock` | +| size=small | padding.start, padding.end | `pattern/size/control/small/paddingInline/condensed` | +| size=large | itemSpacing | `pattern/size/control/large/gap` | +| size=large | padding.top, padding.bottom | `pattern/size/control/large/paddingBlock` | +| size=large | padding.start, padding.end | `pattern/size/control/large/paddingInline/spacious` | +| variant=primary | strokes | `mode/button/primary/borderColor/rest` | +| variant=primary | backgroundColor | `mode/button/primary/bgColor/rest` | +| variant=danger | strokes | `mode/button/danger/borderColor/rest` | +| variant=invisible | strokes | null | +| variant=invisible | strokeAlign | null | +| variant=invisible | strokeWeight | null | +| variant=invisible | effects | null | +| variant=invisible | backgroundColor | `mode/button/invisible/bgColor/rest` | +| state=focus, alignContent=start | cornerRadius | `functional/size/borderRadius/medium` | +| size=small, alignContent=start | width | 156 | +| size=small, alignContent=start | itemSpacing | 4 | +| size=small, state=focus | cornerRadius | `functional/size/borderRadius/medium` | +| size=small, state=pressed | cornerRadius | 6 | +| size=large, alignContent=start | width | 182 | +| size=large, alignContent=start | itemSpacing | 8 | +| size=large, state=focus | cornerRadius | `functional/size/borderRadius/medium` | +| variant=primary, state=focus | cornerRadius | `functional/size/borderRadius/medium` | +| variant=primary, state=focus | strokes | `mode/focus/outlineColor` | +| variant=primary, state=focus | effects | `_component/button/primary/shadow/selected` | +| variant=primary, state=hover | cornerRadius | 6 | +| variant=primary, state=hover | strokes | `mode/button/primary/borderColor/hover` | +| variant=primary, state=hover | backgroundColor | `mode/button/primary/bgColor/hover` | +| variant=primary, state=pressed | strokes | `mode/button/primary/borderColor/hover` | +| variant=primary, state=pressed | effects | `_component/button/primary/shadow/selected` | +| variant=primary, state=pressed | backgroundColor | `mode/button/primary/bgColor/active` | +| variant=primary, state=disabled | strokes | `mode/button/primary/borderColor/disabled` | +| variant=primary, state=disabled | backgroundColor | `mode/button/primary/bgColor/disabled` | +| variant=primary, state=inactive | strokes | null | +| variant=primary, state=inactive | backgroundColor | `mode/button/inactive/bgColor` | +| variant=danger, state=focus | cornerRadius | `functional/size/borderRadius/medium` | +| variant=danger, state=focus | strokes | `mode/focus/outlineColor` | +| variant=danger, state=hover | strokes | `mode/button/danger/borderColor/hover` | +| variant=danger, state=hover | backgroundColor | `mode/button/danger/bgColor/hover` | +| variant=danger, state=pressed | strokes | `mode/button/danger/borderColor/active` | +| variant=danger, state=pressed | effects | `_component/button/danger/shadow/selected` | +| variant=danger, state=pressed | backgroundColor | `mode/button/danger/bgColor/active` | +| variant=danger, state=disabled | strokes | null | +| variant=danger, state=disabled | strokeAlign | null | +| variant=danger, state=disabled | strokeWeight | null | +| variant=danger, state=disabled | effects | `_component/button/default/shadow/resting` | +| variant=danger, state=inactive | strokes | null | +| variant=invisible, state=focus | cornerRadius | `functional/size/borderRadius/medium` | +| variant=invisible, state=focus | strokes | `mode/focus/outlineColor` | +| variant=invisible, state=focus | strokeAlign | INSIDE | +| variant=invisible, state=focus | strokeWeight | 2 | +| variant=invisible, state=hover | backgroundColor | `mode/button/invisible/bgColor/hover` | +| variant=invisible, state=pressed | backgroundColor | `mode/button/invisible/bgColor/active` | +| variant=invisible, state=disabled | backgroundColor | `mode/button/invisible/bgColor/disabled` | +| variant=invisible, state=inactive | backgroundColor | `mode/button/inactive/bgColor` | +| _(plus 12 more 3- and 4-axis combinations affecting `cornerRadius` only — full set in `button.yaml`)_ | | | + +## search (instance) + +### Default styles + +| Property | Value | +|---|---| +| visible | conditional — `false` if `leadingVisual == null` else `true` | +| width | 16 | +| height | 16 | +| instanceOf | bound to `leadingVisual` | + +### Variant overrides + +_(none — `search` is unchanged across all variants)_ + +## button (text) + +### Default styles + +| Property | Value | +|---|---| +| visible | true | +| layoutSizingHorizontal | HUG | +| layoutSizingVertical | HUG | +| textColor | `mode/button/default/fgColor/rest` | +| typography | `Body/Medium Bold` | +| textAlignVertical | CENTER | +| content | `"Button"` | + +### Variant overrides + +| When | Property | Value | +|---|---|---| +| alignContent=start | textAlignHorizontal | CENTER | +| state=disabled | textColor | `mode/fgColor/disabled` | +| state=inactive | textColor | `mode/button/inactive/fgColor` | +| size=small | typography | `Body/Small Bold` | +| size=large | textAlignHorizontal | CENTER | +| variant=primary | textColor | `mode/button/primary/fgColor/rest` | +| variant=primary | textAlignHorizontal | CENTER | +| variant=danger | textColor | `mode/button/danger/fgColor/rest` | +| variant=invisible | textColor | `mode/button/invisible/fgColor/rest` | +| variant=primary, state=disabled | textColor | `mode/button/primary/fgColor/disabled` | +| variant=primary, state=inactive | textColor | `mode/button/inactive/fgColor` | +| variant=danger, state=hover | textColor | `mode/button/danger/fgColor/hover` | +| variant=danger, state=pressed | textColor | `mode/button/danger/fgColor/active` | +| variant=danger, state=disabled | textColor | `mode/button/danger/fgColor/disabled` | +| variant=danger, state=inactive | textColor | `mode/button/inactive/fgColor` | +| variant=danger, size=large | textAlignHorizontal | LEFT | +| variant=invisible, state=hover | textColor | `mode/button/invisible/fgColor/hover` | +| variant=invisible, state=disabled | textColor | `mode/button/invisible/fgColor/disabled` | +| variant=invisible, state=inactive | textColor | `mode/button/inactive/fgColor` | +| variant=invisible, size=large | textAlignHorizontal | LEFT | +| variant=danger, size=large, alignContent=start | textAlignHorizontal | CENTER | +| variant=invisible, size=large, alignContent=start | textAlignHorizontal | CENTER | + +## counterLabel (instance) + +### Default styles + +| Property | Value | +|---|---| +| visible | bound to `counter` | +| layoutSizingHorizontal | HUG | +| height | 18 | +| instanceOf | `counterLabel` | +| propConfigurations.variant | `secondary` | + +### Variant overrides + +_(none)_ + +## trailingVisual (instance) + +### Default styles + +| Property | Value | +|---|---| +| visible | conditional — `false` if `trailingVisual == null` else `true` | +| width | 16 | +| height | 16 | +| instanceOf | bound to `trailingVisual` | + +### Variant overrides + +_(none on this element directly; layout reorders `trailingVisual` under variant=danger,state=pressed,alignContent=start — see `button.layout.md`)_ + +## dropdown (instance) + +### Default styles + +| Property | Value | +|---|---| +| visible | bound to `dropdown` | +| width | 16 | +| height | 16 | +| instanceOf | `textCaret` | +| propConfigurations.type | `default` | + +### Variant overrides + +| When | Property | Value | +|---|---|---| +| alignContent=start | x | 0 | +| alignContent=start | y | 0 | +| size=small, alignContent=start | x | 0 | +| size=small, alignContent=start | y | 0 | +| size=large, alignContent=start | x | 0 | +| size=large, alignContent=start | y | 0 | + +## centered (container, detected) + +Detected only in `variant=secondary, size=medium, state=rest, alignContent=start`. Used as a layout wrapper in restructured variants — see `button.layout.md`. + +### Default styles + +| Property | Value | +|---|---| +| visible | true | +| crossAxisAlignment | CENTER | +| layoutMode | HORIZONTAL | +| layoutSizingHorizontal | HUG | +| layoutSizingVertical | HUG | +| itemSpacing | 8 | + +### Variant overrides + +_(none — `centered` only appears in restructuring variants where it inherits its default styles)_ diff --git a/rfc/001-component-dictionary/sketches/button.fixture.json b/rfc/001-component-dictionary/sketches/button.fixture.json new file mode 100644 index 0000000..30f6bb1 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.fixture.json @@ -0,0 +1,12 @@ +{ + "component": "Button", + "fixtures": [ + { "name": "default", "props": { "variant": "secondary", "size": "medium", "state": "rest", "alignContent": "center" } }, + { "name": "primary-hover", "props": { "variant": "primary", "size": "medium", "state": "hover" } }, + { "name": "danger-pressed-large", "props": { "variant": "danger", "size": "large", "state": "pressed" } }, + { "name": "with-leading", "props": { "leadingVisual": "search" } }, + { "name": "with-counter-and-dropdown","props": { "counter": true, "dropdown": true } }, + { "name": "split-aligned", "props": { "alignContent": "start", "dropdown": true, "leadingVisual": "search" } }, + { "name": "invisible-disabled", "props": { "variant": "invisible", "state": "disabled" } } + ] +} diff --git a/rfc/001-component-dictionary/sketches/button.ios.md b/rfc/001-component-dictionary/sketches/button.ios.md new file mode 100644 index 0000000..90c5b2a --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.ios.md @@ -0,0 +1,28 @@ +# Button — SwiftUI scaffolding + +## Enums + +```swift +enum ButtonVariant: String { case primary, secondary, danger, invisible } +enum ButtonSize: String { case small, medium, large } +enum ButtonState: String { case rest, focus, hover, pressed, disabled, inactive } +enum ButtonAlign: String { case center, start } +``` + +## Initializer + +```swift +struct DSButton: View { + var variant: ButtonVariant = .secondary + var size: ButtonSize = .medium + var state: ButtonState = .rest + var alignContent: ButtonAlign = .center + var counter: Bool = false + var dropdown: Bool = false + var leadingVisual: String? = nil + var trailingVisual: String? = nil + var label: String = "Button" // from anatomy.button.content + + var body: some View { /* implementer: compose slots from anatomy */ } +} +``` diff --git a/rfc/001-component-dictionary/sketches/button.layout.md b/rfc/001-component-dictionary/sketches/button.layout.md new file mode 100644 index 0000000..4ae923d --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.layout.md @@ -0,0 +1,35 @@ +# Button — Layout + +## Default + +- root + - search + - button + - counterLabel + - trailingVisual + - dropdown + +## Variant Tree Deltas + +### alignContent=start + +- root + - centered + - search + - button + - counterLabel + - trailingVisual + - dropdown + - dropdown + +### variant=danger, state=pressed, alignContent=start + +- root + - search + - button + - counterLabel + - dropdown + - dropdown + - trailingVisual + +_(Only variants that change tree shape appear; all other variants inherit `default`.)_ diff --git a/rfc/001-component-dictionary/sketches/button.md b/rfc/001-component-dictionary/sketches/button.md new file mode 100644 index 0000000..e56bab7 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.md @@ -0,0 +1,96 @@ +# Button + +> Structural index. Start here; reference `button.yaml` for definitive decisions on any styling, token, dimension, or per-variant value. The comprehensive scripted reference is in [`button.docs.md`](button.docs.md). + +## Props + +| Prop | Type | Default | Values | +|---|---|---|---| +| variant | enum | `secondary` | primary, secondary, danger, invisible | +| size | enum | `medium` | small, medium, large | +| state | enum | `rest` | rest, focus, hover, pressed, disabled, inactive | +| alignContent | enum | `center` | center, start | +| counter | boolean | `false` | true, false | +| dropdown | boolean | `false` | true, false | +| leadingVisual | string \| null | `null` | e.g. `search` | +| trailingVisual | string \| null | `null` | e.g. `link-external` | + +## Anatomy + +| Element | Type | instanceOf | Visibility / notes | +|---|---|---|---| +| root | container | — | — | +| search | instance | `search` | when `leadingVisual != null` | +| button | text | — | content `"Button"` | +| counterLabel | instance | `counterLabel` | when `counter` | +| trailingVisual | instance | `linkExternal` | when `trailingVisual != null` | +| dropdown | instance | `textCaret` | when `dropdown` | +| centered | container | — | detected: variant=secondary, size=medium, state=rest, alignContent=start | + +## Default Layout + +- root + - search + - button + - counterLabel + - trailingVisual + - dropdown + +## Layout Deltas + +### alignContent=start + +- root + - centered + - search + - button + - counterLabel + - trailingVisual + - dropdown + - dropdown + +### variant=danger, state=pressed, alignContent=start + +- root + - search + - button + - counterLabel + - dropdown + - dropdown + - trailingVisual + +_(variant=danger, size={small,large}, state=pressed, alignContent=start: same shape as alignContent=start.)_ + +## Typography + +Default value per text element. `**` marks elements whose value varies by variant — load `button.yaml` for the full set. + +| Element | Property | Default value | +|---|---|---| +| button | typography | `Body/Medium Bold` ** | +| button | textColor | `mode/button/default/fgColor/rest` ** | + +## Bindings + +- `search.visible` ← `leadingVisual != null` +- `search.instanceOf` ← `leadingVisual` +- `counterLabel.visible` ← `counter` +- `trailingVisual.visible` ← `trailingVisual != null` +- `trailingVisual.instanceOf` ← `trailingVisual` +- `dropdown.visible` ← `dropdown` + +## Invalid Combinations + +None. + +## Subcomponents + +- **ButtonGroup** — composes 2–5 Button instances. Props: `2ndButton`–`5thButton` (boolean), `size`, `variant`. See [`buttongroup.md`](buttongroup.md). + +## Provenance + +schema 0.18.0 · author Nathan Curtis · 2026-04-24 · node 30258:5582 + +--- + +Definitive decisions live in `button.yaml`: exact token paths, dimensions, per-variant color values, padding, typography, effects. diff --git a/rfc/001-component-dictionary/sketches/button.react.md b/rfc/001-component-dictionary/sketches/button.react.md new file mode 100644 index 0000000..8e90fc5 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.react.md @@ -0,0 +1,52 @@ +# Button — React scaffolding + +## Prop types + +```ts +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'invisible'; +export type ButtonSize = 'small' | 'medium' | 'large'; +export type ButtonState = 'rest' | 'focus' | 'hover' | 'pressed' | 'disabled' | 'inactive'; +export type ButtonAlign = 'center' | 'start'; + +export interface ButtonProps { + variant?: ButtonVariant; // default: 'secondary' + size?: ButtonSize; // default: 'medium' + state?: ButtonState; // default: 'rest' + alignContent?: ButtonAlign; // default: 'center' + counter?: boolean; // default: false + dropdown?: boolean; // default: false + leadingVisual?: string | null; // default: null + trailingVisual?: string | null; // default: null + children?: React.ReactNode; // mapped from anatomy.button (text-type slot) +} +``` + +## Call signature with defaults + +```tsx +export function Button({ + variant = 'secondary', + size = 'medium', + state = 'rest', + alignContent = 'center', + counter = false, + dropdown = false, + leadingVisual = null, + trailingVisual = null, + children, +}: ButtonProps) { /* … */ } +``` + +## Slot map + +| Anatomy key | Type | instanceOf | Visibility | +|---|---|---|---| +| `search` | instance | `search` | `leadingVisual != null` | +| `button` | text | — | always (mapped to `children`) | +| `counterLabel` | instance | `counterLabel` | `counter` | +| `trailingVisual` | instance | `linkExternal` | `trailingVisual != null` | +| `dropdown` | instance | `textCaret` | `dropdown` | + +## Class hooks (pairs with `button.css`) + +`data-variant`, `data-size`, `data-state`, `data-align-content` diff --git a/rfc/001-component-dictionary/sketches/button.skeleton.html b/rfc/001-component-dictionary/sketches/button.skeleton.html new file mode 100644 index 0000000..f44b099 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.skeleton.html @@ -0,0 +1,11 @@ + +
+ + Button + + + +
diff --git a/rfc/001-component-dictionary/sketches/button.stories.ts b/rfc/001-component-dictionary/sketches/button.stories.ts new file mode 100644 index 0000000..1202a38 --- /dev/null +++ b/rfc/001-component-dictionary/sketches/button.stories.ts @@ -0,0 +1,66 @@ +// Storybook CSF stories for Button. +// Types imported from button.contract.ts; story seeds derived from button.fixture.json. +// One Story per fixture entry; no rendered hierarchy (the implementer wires `render`). + +import type { Meta, StoryObj } from '@storybook/react'; +import type { ButtonProps } from './button.contract'; + +const meta: Meta = { + title: 'Components/Button', + // The implementer supplies the actual component: + // component: Button, + // render: (args) =>