Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 358 additions & 0 deletions rfc/001-component-dictionary/README.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions rfc/001-component-dictionary/cli-reference.md
Original file line number Diff line number Diff line change
@@ -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).
68 changes: 68 additions & 0 deletions rfc/001-component-dictionary/dictionary-composition.md
Original file line number Diff line number Diff line change
@@ -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.<emitter>` (A) |
| **Smoothed** | Agent-produced from generated + authored inputs. May drift; replayable. | `button.smoothed.md`, `button.smoothed.docs.md`, `button.smoothed.react.md` | `smoothed.<emitter>` |

## 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: [<suffix>, ...]`.
- **Pattern C — agentic adaptation.** Smoothing layer; covered separately (`specs smooth`). Outputs land at `smoothed.<emitter>` 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).
35 changes: 35 additions & 0 deletions rfc/001-component-dictionary/script-inference-boundaries.md
Original file line number Diff line number Diff line change
@@ -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.<emitter>.*` 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).
24 changes: 24 additions & 0 deletions rfc/001-component-dictionary/sketches/button.contract.ts
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments aren't a contract, they're just documentation.

I'd advocate for stronger typing. In fact that's something most React component devs miss, which leaves users guessing what props are usable together or not. This can also lead to runtime errors when an edge case isn't officially supported but ended up being shipped in the wild.

I gave it a go (I tested it, it compiles!):

// Pure types + slot signatures. No framework imports.
// Defaults and slot visibility rules are encoded as `as const` data so that
// tooling, codegen, and runtime validators can read them — comments would rot.

// ---------------------------------------------------------------------------
// Public unions
// ---------------------------------------------------------------------------

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';

// ---------------------------------------------------------------------------
// Props shape
// ---------------------------------------------------------------------------

export interface ButtonProps {
  variant?:        ButtonVariant;
  size?:           ButtonSize;
  state?:          ButtonState;
  alignContent?:   ButtonAlign;
  counter?:        boolean;
  dropdown?:       boolean;
  leadingVisual?:  string | null;
  trailingVisual?: string | null;
}

// ---------------------------------------------------------------------------
// Defaults
//
// `as const`              — preserves literal types so consumers see
//                           `ButtonDefaults.variant` as 'secondary', not string.
// `satisfies Required<…>` — every prop must have a default and every default
//                           must match its declared union. A missing or drifted
//                           default fails at compile time.
// ---------------------------------------------------------------------------

export const ButtonDefaults = {
  variant:        'secondary',
  size:           'medium',
  state:          'rest',
  alignContent:   'center',
  counter:        false,
  dropdown:       false,
  leadingVisual:  null,
  trailingVisual: null,
} as const satisfies Required<ButtonProps>;

// ---------------------------------------------------------------------------
// Slots shape
// ---------------------------------------------------------------------------

export interface ButtonSlots {
  search?:         unknown;
  button:          string;
  counterLabel?:   unknown;
  trailingVisual?: unknown;
  dropdown?:       unknown;
}

// ---------------------------------------------------------------------------
// Slot visibility rules
//
// Replaces the `/* visible when ... */` prose. The `prop` field is narrowed
// to props whose declared type matches the predicate, so e.g. `whenTrue`
// cannot reference a string prop and a typo in the prop name is rejected.
// ---------------------------------------------------------------------------

type PropKeysOfType<T> = {
  [K in keyof ButtonProps]-?: NonNullable<ButtonProps[K]> extends T ? K : never;
}[keyof ButtonProps];

type BooleanPropKey  = PropKeysOfType<boolean>;
type NullablePropKey = {
  [K in keyof ButtonProps]-?: null extends ButtonProps[K] ? K : never;
}[keyof ButtonProps];

export type SlotVisibility =
  | { kind: 'always' }
  | { kind: 'whenTrue';    prop: BooleanPropKey  }
  | { kind: 'whenNotNull'; prop: NullablePropKey };

export const ButtonSlotRules = {
  search:         { kind: 'whenNotNull', prop: 'leadingVisual'  },
  button:         { kind: 'always' },
  counterLabel:   { kind: 'whenTrue',    prop: 'counter'        },
  trailingVisual: { kind: 'whenNotNull', prop: 'trailingVisual' },
  dropdown:       { kind: 'whenTrue',    prop: 'dropdown'       },
} as const satisfies Record<keyof ButtonSlots, SlotVisibility>;

Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 35 additions & 0 deletions rfc/001-component-dictionary/sketches/button.css
Original file line number Diff line number Diff line change
@@ -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; }
Loading