# Technical Documentation: facebook/astryx

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/facebook/astryx)

### Overview (README.md)

<div align="center">

<img src="https://lookaside.facebook.com/assets/astryx/Astryx-Banner.png" alt="Astryx" width="100%" />

# Astryx

An open source design system that's fully customizable and built for how we build now: <br/> by people and the agents working alongside them.

**Currently in Beta** · Built on [React 19+](https://react.dev) and [StyleX](https://stylexjs.com)

[](https://www.npmjs.com/package/@astryxdesign/core)
[](LICENSE)
[](https://github.com/facebook/astryx/wiki/Contributing)

[](https://astryx.atmeta.com)
[](https://astryx.atmeta.com/storybook/)
[](https://astryx.atmeta.com/sandbox/)

**[Docs](https://astryx.atmeta.com)** · **[Storybook](https://astryx.atmeta.com/storybook/)** · **[Sandbox](https://astryx.atmeta.com/sandbox/)** · **[Contributing](https://github.com/facebook/astryx/wiki/Contributing)** · **[Discord](https://discord.com/invite/XnsUcFykEP)**

</div>

## Overview

Astryx is an open source design system that grew inside Meta over the last eight years, where it became the most-used and largest design system in the company — powering 13,000+ apps and shaped by the engineers, designers, and product teams who depend on it every day.

It ships 150+ accessible components, brand-level theming, dark mode, ready-to-ship templates, and a CLI as one cohesive system. You import pre-built CSS and use typed React components — no build plugin, no styling library to adopt — and both people and AI assistants build with the same tooling.

**What makes Astryx different:**

- **Open internals.** Components are built to be composed at any level, not locked behind a closed top-level API. The building blocks you'd reach for are exported directly, and when you need to go deeper, swizzle ejects a component's full source into your project to own.
- **No styling lock-in.** Astryx authors its styles with StyleX, but that's invisible to consumers. Override with `className` using Tailwind, CSS modules, or plain CSS — whatever your project already uses.
- **Customize without wrapping.** A theme is a set of CSS custom property overrides, so a designer can make Astryx unmistakably theirs without forking or wrapping component source.
- **Built for people and agents.** The API, docs, and CLI are designed together so a person and an AI assistant build the same way, from the same reference.

## Getting Started

Astryx requires **React 19** or later (`react` and `react-dom` are peer dependencies of `@astryxdesign/core`).

Install Astryx, a theme, and its peer dependencies:

```bash
# npm
npm install @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
npm install -D @astryxdesign/cli

# pnpm
pnpm add @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
pnpm add -D @astryxdesign/cli

# yarn
yarn add @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
yarn add -D @astryxdesign/cli
```

You can also install the CLI once for your computer, so `astryx` works in any folder and every coding agent knows Astryx; in a project, it still runs that project's own version:

```bash
npm i @astryxdesign/cli
```

Project install, global install, or both? `astryx docs global/project-global-or-both` compares them (both is recommended), and `astryx docs global` explains how global mode works.

The simplest setup is a few CSS imports plus a theme provider — no build plugin, no PostCSS or Babel config. See the **[@astryxdesign/core README](packages/core/README.md#quick-start)** for the full guide (Next.js, Tailwind, Vite, and CDN).

For reliable CLI access, add a script to your `package.json`:

```json
"scripts": {
  "astryx": "node node_modules/@astryxdesign/cli/clients/cli/bin/astryx.mjs"
}
```

Then use it as `npm run astryx -- component --list`. This avoids path errors when AI assistants or new developers invoke the CLI directly.

## Packages

| Package                                    | Description                                                                                          | README                             |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [`@astryxdesign/core`](packages/core)      | Components, theme system, and utilities                                                              | [README](packages/core/README.md)  |
| [`@astryxdesign/cli`](packages/cli)        | CLI tooling: component docs, templates, scaffolding, themes, and codemods                            | [README](packages/cli/README.md)   |
| [`@astryxdesign/build`](packages/build)    | Build plugins for StyleX source builds                                                               | [README](packages/build/README.md) |
| [`@astryxdesign/theme-*`](packages/themes) | Seven ready-made, fully customizable themes (neutral, butter, chocolate, matcha, stone, gothic, y2k) | [README](packages/themes)          |

> `@astryxdesign/lab` (experimental components) is used internally for Storybook and the sandbox and is not published to npm. `@astryxdesign/vega` (Vega/Vega-Lite chart wrapper) and `@astryxdesign/charts` (chart components) are published to npm only under the `@canary` dist-tag — there is no stable release yet.

## Principles

These are the promises Astryx makes to the people building on it.

- **Guidance over enforcement.** Components give you capability rather than guardrails that fight you. Design opinions live in docs and examples — if you pass a value, the component renders it.
- **Strong, documented conventions.** Every component follows the same naming, prop, and composition rules, and every one is thoroughly documented — so once you've learned a few, the rest feel familiar, and both people and AI can predict how an unfamiliar component behaves.
- **One system for humans and AI.** The API, conventions, docs, and CLI are designed together so people and AI assistants build the same way. Every change that made Astryx easier for AI made it easier for people too.
- **Earned by measurement.** We test conventions rather than assert them, hold the results loosely, and revisit them when a new situation proves them wrong.

## Architecture

### Foundations

The building blocks for visually cohesive and accessible interfaces: typography, color, layout, and accessibility.

### Components

A library of 150+ reusable UI building blocks with full TypeScript support.

### Patterns

Battle-tested design solutions for common interactions and workflows: table pages, detail page layouts, form wizards, navigation patterns, data entry flows.

## Project Structure

| Directory   | Purpose                                                     |
| ----------- | ----------------------------------------------------------- |
| `apps/`     | Example apps, the docsite, and Storybook                    |
| `packages/` | Published packages: core, cli, build, themes                |
| `internal/` | Internal tooling: test utilities, eslint plugin, vibe tests |

## Contributing

We welcome contributions! See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide.

Questions, or want a hand with a PR? Come find us in
**[Discord](https://discord.com/invite/XnsUcFykEP)**.

Our conventions and review rubrics live in the
[Contributing wiki](https://github.com/facebook/astryx/wiki/Contributing) —
including [API Conventions](https://github.com/facebook/astryx/wiki/API-Conventions),
[Design Conventions](https://github.com/facebook/astryx/wiki/Design-Conventions),
the [Component Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle),
the [Contributing Templates](https://github.com/facebook/astryx/wiki/Contributing-Templates),
and the [Blog Review Rubric](https://github.com/facebook/astryx/wiki/Blog-Review-Rubric#three-required-scored-criteria). Read the relevant rubric before opening a PR.

Quick start for contributors: this repo uses **Node 22+ on an active LTS line**
and **pnpm 11**. Install pnpm directly, or enable
[Corepack](https://nodejs.org/api/corepack.html) once so the pinned pnpm version
installs automatically:

```bash
corepack enable
pnpm install
```

If `corepack` is missing, install pnpm directly or install Corepack manually;
see the troubleshooting notes in
[CONTRIBUTING.md](CONTRIBUTING.md#troubleshooting).

## License

MIT

---

### README (`docs/README.md`)

# Astryx knowledge map

This directory holds maintainer knowledge that must be reviewable with the code
it governs. Consumer documentation remains in component `.doc.mjs` files and
`packages/cli/assets/docs/`.

## Placement

- `architecture/`: the shipped system, its boundaries, and invariants.
- `contributing/`: practical contributor workflows that project current owner records without replacing them.
- `design/`: human-owned visual and interaction specifications.
- `themes/`: guidance and index for package-local theme specifications.
- `families/`: contracts shared by sibling components.
- `specs/`: consequential proposed or shipped system changes and their decisions.
- `templates/knowledge/`: authoring templates. Templates never live among records.
- `schemas/knowledge/`: versioned structural requirements for templates and records.

Component contracts live beside the public component in every component-bearing
package registered by `scripts/component-packages.cjs`:

- directory-layout packages (`core`, `lab`) use
  `packages/<package>/src/<ComponentRoot>/<PublicName>.spec.md`;
- flat packages (`charts`, `richtext`, `vega`) use
  `packages/<package>/src/<PublicName>.spec.md`.

In a directory-layout package, `<PublicName>` normally matches the root directory;
a public member such as `NavMenu/NavHeadingMenu.spec.md` is valid only when an
exact top-level or full inline consumer-doc entry in that root declares the same
public name. In a flat package, the public named export and matching TSX module
define the component boundary.

Independently contractible public hooks, plugins, utilities, and subsystems use
`kind: module` records named `<PublicName>.spec.md` at least one directory below
the same component root. Their canonical id is
`module:<ParentComponent>/<PublicName>`; the module's `parent_component` and the
parent component's `modules` list must agree. Private transform helpers do not
require records.

Component-local discovery and PR routing ignore hidden path segments,
`*.generated.spec.md`, and fixture, test, generated, build-output, coverage, and
`node_modules` directories. Those files are not knowledge records or spec-only
changes. Theme contracts live beside the package as
`packages/themes/<theme>/<theme>.spec.md`.

## Authority

Every knowledge record declares `authority: draft | current | archived`.

- `draft` documents are not policy; unresolved evidence and owner decisions are
  recorded as blockers inside the document.
- Initial promotion to `current` requires explicit owner approval recorded in
  the document metadata. Any current `.github/ENGOWNERS` member may approve every
  record kind; current design and theme records and normative design assets also
  accept current `.github/DESIGNOWNERS` members. Legacy v1 `owners` metadata is
  descriptive and does not grant approval rights.
- Pull requests that create, change, or archive a `current` record wait on the
  `spec-owner-approval` status for their exact current head. Draft-only records
  pass that status after validation without an owner review.
- Any ENGOWNER can approve another author's current-record PR through GitHub
  review. A DESIGNOWNER may do the same for current design or theme records and
  normative design assets. When an eligible approver is also the PR author, they
  comment `/approve-spec <full-head-sha>`. Any new commit invalidates that
  approval.
- A configured review app may relay an eligible owner's exact-head decision;
  the gate counts it as that owner's command. `.github/REVIEW_GATE.md`
  describes the relay.
- Only `current` documents guide implementation and review.
- A specification describes durable ideal behavior independently of any one pull
  request. Pull requests and issues may appear only as clearly non-authoritative
  examples, historical evidence, or references. A specification never exists to
  approve, reject, classify, designate, or authorize a particular pull request.
- Before creating or materially expanding a record, search current records and
  open pull requests by canonical owner/id, affected paths and exported symbols,
  and semantic behavior terms. Extend or project the existing canonical owner by
  default; create a new record only for a distinct fact boundary and explain why.
- Current records rely only on other current records. `modules` and
  `parent_component` are structural ownership links, so they may connect active
  draft/current records without making draft behavior authoritative. Other draft
  relationships are listed on the candidate record; promotion updates affected
  backlinks under owner review.
- `archived` documents declare `archive_reason` (`superseded`, `withdrawn`, or
  `historical`) and link `superseded_by` when a replacement exists.

## Templates and schemas

Templates create documents; schemas keep existing documents structurally
aligned. They are intentionally separate:

- `template_version` records the authoring form used to create a document.
- `schema_version` records the contract the document must satisfy.
- Editorial template changes may increment only `template_version`.
- Adding or changing required metadata or sections creates a new
  `schema_version`; published schema files are immutable.
- Every active record (`draft` or `current`) must use the latest schema that
  defines its kind. Adding a new kind does not migrate unrelated records;
  structurally changing an existing kind migrates only active records of that
  kind. Archived records may retain an older schema that remains checked in.

Published schema versions may extend an earlier schema. The checker composes the
chain and tracks the latest version that defines each record kind. A new kind is
therefore additive; changing an existing kind migrates only that kind.

An optional `review-applicability:v1` JSON block can route selected claims from a
record as cross-cutting review baselines. This is an independently versioned
metadata overlay, like `anatomy-theming:v1`, so adding it does not change a
record kind's required shape or migrate records that do not opt in. The block
must declare `scope: "global"` and map each semantic trigger to one or more exact
claim ids from the same record. Validation rejects malformed, empty, duplicate,
or stale claim routes. Routing requires the reviewed head, verifies the supplied
base authority commit is its `origin/main` merge base, includes only
`authority: current` records, and returns only the matched claims; draft records
and nonmatching routes are omitted.

An optional `path-applicability:v1` JSON block lists the repository paths a
record governs when no placement, path field, name, or inherited link reaches
them: `{"paths": ["apps/docsite/**"]}`. Patterns are repository-relative globs:
`**` is a whole segment spanning any depth, `*` matches within one segment, and
`?` matches one character. Like `review-applicability:v1`, it is an
independently versioned overlay, so opting in does not migrate a record kind.
A pattern must start with a literal path segment. Validation rejects
malformed, unsafe, repeated, whole-repository, and no-match patterns. A match only locates the record for a path; claim scope and
precedence are unchanged. `pnpm check:knowledge` also fails when a current record
is reached from no tracked path that is not itself a record
(`architecture:knowledge-contracts` INV25 and INV26).

New record kinds should prefer one typed relationship list over parallel fields.
If `system-spec` receives a future schema revision, replace its legacy
`affects_*` fields with one typed `affects` list containing ids such as
`architecture:*`, `family:*`, `theme:*`, and `contributing:*`; this PR does not
change the v1 system-spec shape.

Run `pnpm specs:for <path>` to list the current records linked to any repository
path: the record's own placement, `applies_to` and `verified_by` entries, family
and design membership, `affects_consumer_docs` ids, system-spec numbers the file
cites in its text (not inside a record path), and the references those records
inherit. Only visible records are followed, so a draft never adds a link unless
`--include-draft` is passed. A reference scoped to one claim or
decision (`spec:AST-NNN/FR3`) lists that claim, not the whole record. The specs that
decided a linked record are listed separately, as are the global claims the
matched component and module triggers route (the same selection as
`review-global-baselines.mjs`). A path with no linked record is reported as
such. Records are read from the working tree, so on a pull-request branch the
result includes that branch's proposed records and is not review authority.
`pnpm specs:for --unrouted` lists tracked files with no governing record;
`--unreached` lists current records that no other path reaches.

Run `pnpm check:knowledge` to validate templates and records. Pure spec-record
pull requests do not add Changesets because they do not publish package changes.

## Visibility

This repository and its wiki are public. Internal operations, credentials,
services, routing, schedules, and private recovery procedures stay in an
access-controlled private home and must not be named or linked here.

---

### Architecture README (`docs/architecture/README.md`)

# Architecture records

Architecture documents describe the shipped system in present tense. They are
not proposals and must not describe an unshipped future as current.

New records start as `draft`. They become `current` only after explicit owner
approval. Archived records remain for context and state why they no longer
govern. Use `docs/templates/knowledge/architecture.md`.

---

### Contributing README (`docs/contributing/README.md`)

# Contributing guides

These guides turn current Astryx owner records into practical contribution steps.
They do not create policy or replace architecture, specifications, family
contracts, or component contracts.

- [Pull request intents](pull-requests.md): choose one primary intent, use the
  matching template, and supply the right evidence without absorbing tagalongs.
- [API conventions](api-conventions.md): shape, propose, implement, and review a
  public component API.
- [CLI conventions](cli-conventions.md): propose, implement, and review a change
  to the `packages/cli` command surface.
- [Writing specification records](spec-writing.md): the six-month test, the
  rubric an author runs over a record before review, and the results a record
  pull request reports.

---

### Design README (`docs/design/README.md`)

# Design specifications

Design specs record human-owned visual and interaction intent: hierarchy,
anatomy, state representation, allowed variation, and representative examples.
They may describe one component or a cross-component pattern.

## State taxonomy

State records are split by who drives the change. These are the only three
state-taxonomy records:

| Record                              | Owns                                                                                                         |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| [User states](user-states.md)       | Person-driven rest, hover, press, focus, selection, and manipulation states                                  |
| [System states](system-states.md)   | System-driven disabled, loading, processing, status, and transient feedback states                           |
| [Agentic states](agentic-states.md) | Agent-driven thinking, streaming, tool execution, waiting, synchronization, inspection, and rendering states |

## Other records

| Record                                                            | Owns                                                                                 |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [Spatial hierarchy](spatial-hierarchy.md)                         | Perceptual grouping through proximity, spacing tiers, and alignment                  |
| [Control rhythm](control-rhythm.md)                               | Alignment and density relationships among mixed controls                             |
| [Shape relationships](shape-relationships.md)                     | Role-based and concentric geometry for nested surfaces                               |
| [Elevation hierarchy](elevation-hierarchy.md)                     | Agreement between perceived depth and actual layer order                             |
| [Typography hierarchy](typography-hierarchy.md)                   | Legible, distinguishable text roles across themes and layouts                        |
| [Iconography](iconography.md)                                     | Icon meaning, size, artwork, color, composition, and accessibility                   |
| [Color emphasis](color-emphasis.md)                               | Semantic color roles, contrast intent, and local emphasis                            |
| [Motion](motion.md)                                               | Meaningful movement, timing hierarchy, easing intent, and reduced motion             |
| [Ordered collection reordering](ordered-collection-reordering.md) | Drag preview, candidate insertion, commit, and settle intent for ordered collections |
| [Template composition](template-composition.md)                   | Page- and block-level layout, hierarchy, spacing, component fidelity, and theming    |

Create a separate record when a subject has a different owner, approval
lifecycle, requirement set, evidence set, or reason to change independently.
Keep implementation mechanics, public API syntax, audit results, and consumer
usage outside these records and link to their canonical owners instead.

Design specs do not own implementation mechanics, public prop syntax, current
audit results, or consumer usage guidance. Component and family contracts link
to stable design requirement IDs instead of copying their rationale.

Public-safe normative screenshots, diagrams, and visual state references live
under `docs/design/assets/<design-id>/`. Each referenced asset records alt text,
state, theme/mode, viewport when relevant, and what decision it demonstrates.
Generated audit screenshots remain audit evidence rather than design authority.
Private design sources stay private and are never named or linked here.

New design specs start as `draft`. Initial promotion to `current`, later changes,
and normative asset updates require exact-head approval from any current member
of `.github/ENGOWNERS` or `.github/DESIGNOWNERS`. A mixed PR still needs an
ENGOWNER for non-design current records. The design record names its content
owners separately from this repository gate.

A DESIGNOWNER author may attest the exact PR head for the design-approval group
by marking it ready for review. That evidence also counts in a mixed PR, while
every other applicable code or spec-owner group remains required. The existing
gate may enable squash auto-merge only when every changed path is a recognized
spec record, every required group has approved, and all branch checks pass.
Normative assets and indexes are outside that scope; any code path prevents the
spec-only auto-merge path.

Use `docs/templates/knowledge/design-spec.md`.

---

### Families README (`docs/families/README.md`)

# Component family contracts

Family contracts own behavior shared by sibling components, such as input
sizing, end lanes, overlay dismissal, or status presentation. A component spec
links to its family instead of copying the shared rule.

New records start as `draft`. They become `current` only after explicit owner
approval. Archived records remain for context and state why they no longer
govern. Use `docs/templates/knowledge/family-contract.md`.

---

### Specs README (`docs/specs/README.md`)

# System specs

This directory contains records for consequential shared-system changes. Each
spec lives under `docs/specs/<id>-<slug>/spec.md`; an optional sibling `plan.md`
is used only for multi-step implementation.

Templates live separately under `docs/templates/knowledge/`. Existing records
are validated against `docs/schemas/knowledge/`; changing a template does not
silently change accepted history.

A system spec is appropriate when work changes more than one component, a
shared primitive, architecture, public API policy, theming, accessibility
policy, distribution, or lifecycle. Focused fixes that restore an existing
contract do not need a new spec.

Only records with `authority: current` are authoritative. Draft records may
carry unresolved evidence or owner decisions; they do not govern review.
Archived records state why they no longer govern and link a replacement when
one exists. Initial promotion to `current` requires explicit owner approval.

---

### Themes README (`docs/themes/README.md`)

# Theme specifications

Theme specifications are the canonical owner for decisions that belong to one
published theme package: its audience and visual intent, inherited base, portable
token choices, theme-local roles, tonal palette families and selected tones,
component/state mappings, compatibility, artifact shape, and measured evidence.

Theme specifications are colocated with their package at
`packages/themes/<theme>/<theme>.spec.md`, analogous to component specs. This
page is guidance and an index, not a canonical record. Start a record from
`docs/templates/knowledge/theme-spec.md` and use an id of the form
`theme:<package-theme-name>` (for example, `theme:neutral`).

Current records:

- [Neutral](../../packages/themes/neutral/neutral.spec.md) — current
- [Stone](../../packages/themes/stone/stone.spec.md) — current
- [Gothic](../../packages/themes/gothic/gothic.spec.md) — current
- [Matcha](../../packages/themes/matcha/matcha.spec.md) — current
- [Butter](../../packages/themes/butter/butter.spec.md) — current
- [Y2K](../../packages/themes/y2k/y2k.spec.md) — current

A theme record sits between system theming architecture and consumer/component
records. It uses one typed `references` list for architecture, design, system,
and other knowledge relationships rather than separate relation fields:

- architecture and system specs own cross-theme APIs, vocabulary boundaries,
  inheritance rules, validation, compiler behavior, and shared artifact policy;
- design records own cross-theme visual and accessibility methodology, including
  how contrast evidence is judged;
- architecture/tooling owns shared measurement implementation;
- package-local theme specs own application of that methodology: selected
  token/palette mappings, required pairings/states, exceptions, measured receipts,
  and known gaps;
- component and family records own observable component behavior; and
- consumer docs own supported syntax, examples, and usage guidance.

Only `current` records are policy. A draft may link another draft while decisions
are being developed, but current records may depend only on current records.
Current theme records require exact-head approval from the committed union of
`.github/ENGOWNERS` and `.github/DESIGNOWNERS`. Theme records do not declare an
`owners` field, and record metadata never grants approval rights.

---

### Packages Charts Docs README (`packages/charts/docs/README.md`)

# Charts planning and verification

These package-local records track the canary-only Charts package while its API,
visuals, and verification mature:

- [`CHARTV2_PHASE1_PLAN.md`](./CHARTV2_PHASE1_PLAN.md): implementation sequence and evidence.
- [`CHARTV2_STAGE1_DESIGN.md`](./CHARTV2_STAGE1_DESIGN.md): API and correctness research.
- [`CHARTV2_READINESS.md`](./CHARTV2_READINESS.md): launch-readiness snapshot.
- [`CHARTV2_VERIFICATION_CHECKLIST.md`](./CHARTV2_VERIFICATION_CHECKLIST.md): verification matrix.

The historical `ChartV2` name remains in these filenames because the records
predate extraction into `@astryxdesign/charts`. Current consumer guidance lives
in the package [`README`](../README.md) and `.doc.mjs` files.

---

### Packages Cli Assets Docs README (`packages/cli/assets/docs/README.md`)

# /packages/cli/assets/docs

Reference topics for people **building with** Astryx. Not docs about building Astryx itself.

One `{topic}.doc.mjs` per topic, plus optional `{topic}.doc.dense.mjs` / `{topic}.doc.zh.mjs` prose overlays. `foundation/discovery/docs-discovery.mjs` picks up any `{topic}.doc.mjs` here with no registration; `api/docs/_adapter.mjs` merges the overlays.

What you add reaches `astryx docs <topic>`, `astryx search`, the `--json` API, the agent-docs block and the doc site — and ships on npm.

## Who you are writing for

Someone building a product with Astryx. Their questions:

- what a component is for, and when to reach for something else
- the props, their defaults, and what each does to what they see
- how to compose it, and the pattern to copy
- what it costs — bundle size, the a11y obligations they inherit
- how to theme it, and which targets are stable

**The test for anything you add: does a caller act on it?** They are not reviewing a PR, promoting a component out of lab, or attaching evidence to a checklist.

## Tells that you are writing for us instead

- second person aimed at the wrong reader — "reviewers should…", "before promoting a component…", "attach evidence for…"
- an internal rubric, readiness gate, audit checklist, or sign-off that the reader must satisfy for Astryx maintainers
- a table of things to verify rather than things to use
- anything about lab → core, which is our lifecycle, not theirs
- Storybook, Playwright, CI or the Simulator named as tools the reader runs

One subtlety: a statement about the **system's behavior** is caller-facing even when it sounds like process. "A component's theme targets are stable once published" tells a caller what they can rely on; "reviewers must check that theme targets are stable" is ours. Same fact, different reader — **rewrite it rather than move it**.

A public authoring-quality rubric is also caller-facing when it helps someone evaluate an artifact they create through Astryx. It must be complete and actionable from public inputs. It must not include Astryx's internal approval, promotion, evidence-publication, or CI process. A current system spec must assign the shipped guide as the rubric's owner.

## Where the rest goes

The material is usually good; the finding is placement, not quality. It goes in the [wiki](https://github.com/facebook/astryx/wiki) — **as a section on the page that already covers it, not a new page.** The wiki is at nearly 60 pages, several of them overlapping, because every stray section got its own.

| what you wrote                                      | where it goes                                                                                                                                                                                |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| how a component is graded — checks, scoring         | [Component-Audit-Rubric](https://github.com/facebook/astryx/wiki/Component-Audit-Rubric)                                                                                                     |
| lab → core promotion, what a component must satisfy | [Component-Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle)                                                                                                           |
| how to build a new component                        | [Component-Authoring-Guide](https://github.com/facebook/astryx/wiki/Component-Authoring-Guide), [Creating-New-Components](https://github.com/facebook/astryx/wiki/Creating-New-Components)   |
| what a component must be hardened against           | [Component-Hardening-Protocol](https://github.com/facebook/astryx/wiki/Component-Hardening-Protocol), [Hardening-Audit-Guide](https://github.com/facebook/astryx/wiki/Hardening-Audit-Guide) |
| a11y requirements as checks we run                  | [Accessibility-Checklist](https://github.com/facebook/astryx/wiki/Accessibility-Checklist)                                                                                                   |
| how the system is put together                      | [System-Architecture](https://github.com/facebook/astryx/wiki/System-Architecture), [Theming-Infrastructure](https://github.com/facebook/astryx/wiki/Theming-Infrastructure)                 |
| API naming and shape decisions                      | [API-Conventions](https://github.com/facebook/astryx/wiki/API-Conventions), [API-Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration)                                       |
| contributor workflow, PR process                    | [Contributing](https://github.com/facebook/astryx/wiki/Contributing), [Contributing-with-AI-Assistants](https://github.com/facebook/astryx/wiki/Contributing-with-AI-Assistants)             |
| release mechanics                                   | [Release-Process](https://github.com/facebook/astryx/wiki/Release-Process)                                                                                                                   |
| what a nightly agent role does                      | the Night-Watch pages, from [Night-Watch-Overview](https://github.com/facebook/astryx/wiki/Night-Watch-Overview)                                                                             |

**Fits no row?** It is still not caller-facing. Default it to [Contributing](https://github.com/facebook/astryx/wiki/Contributing), or `CONTRIBUTING.md` when it is a step someone follows with the repo cloned. Never default it back to this directory.

Worked example: a responsive-and-interaction readiness rubric is grading criteria → **Component-Audit-Rubric**, or **Component-Lifecycle** if it is a promotion gate.

## Imports in code samples

Import each Core export from the path `astryx component <Name>` prints: the
component's own entry when Core has one (`@astryxdesign/core/VStack`,
`@astryxdesign/core/Heading`), and `@astryxdesign/core/theme` for `Theme`,
`defineTheme` and the rest of the theme API. A reader copies one sample and then
reads `astryx component`, and two paths for one export read as two different
things. The no-build CDN page is the exception: its import map names only the
package root.

## Sections are read one at a time

`astryx docs <topic> --index` lists a topic's sections, and readers then open
one section by its key. A section's key is its `id`, or a key derived from its
title when it has none. Give a section an `id` when its title may change, since
readers and extensions link to the key. Two sections in one topic cannot share
a key. Keep each section small enough to read on its own: `astryx doctor` warns on
any section over 32 KB.

---

### Release (`docs/release.md`)

# Releasing to npm with trusted publishing

The public [Release Process](https://github.com/facebook/astryx/wiki/Release-Process)
is the operational authority. This page explains the repository and npm mechanics.

Astryx publishes its public `@astryxdesign/*` packages from GitHub Actions through
npm trusted publishing (OIDC). CI carries no long-lived npm publish token. Every
stable package is built from an immutable release tag and published with provenance.

## Release sequence

1. Run the canonical full-scope `release-check` on an exact commit from current
   `main`. Cut only from that checked SHA while it remains in current `main`
   history. A minor cut additionally requires Cindy, the release owner, to
   explicitly sign off on the exact `vX.Y.0` immediately before the branch is
   created; scheduled or standing authority never substitutes for that sign-off.
2. Create the single marked `release/vX.Y.Z` branch. Its version is the fixed-group
   version declared by the cut commit. The marker and plan freeze the cut SHA,
   Changeset paths and hashes, branch identity, and plan digest.
3. On that branch, run `pnpm version-packages`. It admits the frozen Changesets
   against the declared tier, writes changelogs under that already-declared version,
   promotes staged codemods, refreshes exact internal pins, and deletes only the
   frozen Changesets. It never chooses or bumps the version. Refresh and verify the
   lockfile before the Version Packages PR merges.
4. Clear the public editorial gate on the generated PR before it merges, so a
   failed criterion is fixed on that PR rather than by moving the release head
   afterwards. For a patch, self-grade the complete generated changelogs and draft
   GitHub Release notes against the public [Blog Review
   Rubric](https://github.com/facebook/astryx/wiki/Blog-Review-Rubric#three-required-scored-criteria)
   under the `update` profile, record the result on the PR, and fix and re-grade
   every failed criterion. A minor requires Cindy's explicit human editorial
   approval of the changelogs, release notes, and required public blog, recorded
   the same way. Then merge the generated PR to the release branch, run exact-head
   branch CI, and tag and publish that exact branch head. Stable publication from
   `main` is refused.
5. Squash the published branch outputs back into current `main` through
   `chore/merge-vX.Y.Z-to-main`. The trusted validator requires the immutable tag,
   active branch head, and plan digest; exact generated bytes and frozen deletions;
   and preservation of every post-cut Changeset, commit, unrelated field, and main's
   current version declaration. Branch lifecycle files never land on `main`.
6. In a separate owner PR, declare the next plan across the fixed-group versions,
   exact internal pins, and lockfile. The released version's patch successor is the
   default. A minor successor is used only when the owner explicitly names it.

After a cut, main's declaration remains its future plan and may change independently
of the frozen release branch. Required pull-request lint does not compare it with the
cut or newest stable tag. Canary publication owns that ordering boundary: it publishes
only from one plain declaration above stable, skips equality, and fails below-stable or
split declarations. Trusted merge-back preserves main's declaration; the separate
next-plan PR advances it after the release.

## Changesets and codemods

Feature PRs add normal Changesets with `pnpm changeset:new`. Pending Changesets do
not choose the release version. While packages are `0.x`, `[breaking]` work is
admissible only when main already declares the next minor; a patch declaration
refuses it. A PR that lowers a minor plan to a patch is itself refused while a
`[breaking]` Changeset is pending.

Core codemods for unreleased changes are staged in
`packages/cli/assets/codemods/transforms/next/`. During the Version Packages PR,
`pnpm version-packages` runs `scripts/promote-codemod-next.mjs` after changelogs are
written. It copies every staged entry except `README.md` into
`transforms/v<declared-version>/`, clears the promoted files from `next`, and updates
the registry. Review those generated files with the changelogs.

## Stable and canary publication

Both publication paths live in `.github/workflows/release.yml`, because npm permits
one trusted GitHub workflow per package.

- **Stable:** explicit dispatch from the immutable `vX.Y.Z` tag with the exact
  release branch, version, head, tag, and plan digest. It publishes `latest`.
- **Canary:** automatic main push. It publishes
  `<declared-version>-canary.<short-sha>` to `canary` only while the declaration is
  strictly above the newest stable tag. It never authorizes or mutates stable release
  state.

The publish step uses pnpm-native publishing:

```sh
pnpm publish ./packages/<package> \
  --tag latest --provenance --access public --no-git-checks
```

## Trusted-publisher setup

Trust is configured per package. It must name `release.yml` and allow direct
`npm publish`. A new public package must exist on npm before trust can be
registered, so once per new package an npm organization owner runs, in their own
terminal (npm opens two-factor prompts in the browser that only the owner can
complete):

```sh
npm i npm@latest
npm --version   # 11.15 or later
npm login --registry https://registry.npmjs.org
node scripts/npm/setup-trusted-publishing.mjs
node scripts/npm/setup-trusted-publishing.mjs \
  --bootstrap --setup-trust --workflow release.yml
```

- The default invocation is audit-only.
- `--bootstrap` claims a missing package name with a deprecated
  `0.0.0-bootstrap.0` placeholder under the `bootstrap` dist-tag. npm makes a
  package's first version its `latest`, so a plain install of the package
  resolves to the empty placeholder until the package's first stable release.
- `--setup-trust` registers `release.yml` for `facebook/astryx` with direct
  publish allowed. The script refuses npm older than 11.15: older npm ignores
  the publish permission, creating a publisher that can only stage releases,
  and lists every existing publisher as a conflict.
- Use `--replace` only when an existing trust record is intentionally being
  replaced, never to clear a conflict reported by npm older than 11.15.

Then open the package's trusted-publisher settings on npmjs.com
(`https://www.npmjs.com/package/<name>/access`) and confirm the
`facebook/astryx` `release.yml` entry allows `npm publish`, not only
`npm stage publish`. The same page is the manual path when the script cannot
finish: add a GitHub Actions publisher with owner `facebook`, repository
`astryx`, workflow `release.yml`, no environment, and `npm publish` allowed.

npm expires a new trusted publisher that no publish has validated within 48
hours. The next push to `main` publishes the package's canary and validates it.
If no canary publishes in that window, recreate the publisher.

Registry reads can keep reporting a just-created package as missing for a few
minutes. A re-run of `--bootstrap` in that window gets `409` for the placeholder
version; the name is already claimed, and the script continues to trust setup.

The repo pins pnpm 11. The CI Node line must bundle npm 11.5.1 or later for OIDC
publishing; the local trust setup command requires npm 11.15 or later.

---

### Architecture Cli Surface (`docs/architecture/cli-surface.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:cli-surface
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-20
owners: [josephfarina]
applies_to: [packages/cli]
verified_by:
  [
    pnpm check:cli-structure,
    clients/cli/commands/json-contract.test.mjs,
    clients/cli/commands/interactive-guard.test.mjs,
    clients/cli/cli-exit-codes.test.mjs,
    clients/cli/error-envelope-code.test.mjs,
    foundation/response/error-codes.test.mjs,
    foundation/agent-docs/agent-docs.test.mjs,
    foundation/agent-docs/agent-docs.path-safety.test.mjs,
    clients/cli/commands/init.behavior.test.mjs,
    api/init/remove/remove.test.mjs,
    foundation/integrations/autolink.test.mjs,
    foundation/runtime/package-set.test.mjs,
    foundation/runtime/hosted-runtime.test.mjs,
    foundation/integrations/manifest-writer.test.mjs,
    foundation/integrations/contribution-inventory.test.mjs,
    foundation/discovery/theme-discovery.test.mjs,
    authoring/doctypes/doctypes-new.test.mjs,
    scripts/check-cli-theme-bundle.test.mjs,
    clients/cli/commands/integration-authoring.test.mjs,
    clients/cli/commands/integration-real-world.test.mjs,
    api/integration/add-contribution.test.mjs,
    api/integration/pack-check.test.mjs,
    api/integration/validate-integration.test.mjs,
    foundation/integrations/cli-peer-version.test.mjs,
    api/theme/integration-themes.test.mjs,
    clients/cli/commands/upgrade.integration-policy.test.mjs,
    clients/cli/formatters/index.test.mjs,
  ]
deciding_specs: [spec:AST-017/DEC-4, spec:AST-042/DEC-1, spec:AST-042/DEC-2]
---

# CLI surface architecture

## Purpose

The Astryx CLI is how an agent reaches Astryx. Everything an agent needs while
building with the design system — what exists, what it does, how to use it,
what is wrong with the code in front of it — is reachable through one command
surface, in one predictable shape, with no person in the loop.

The CLI has one audience: an agent running it in a subprocess. A person at a
terminal is a supported reader of the output, never the caller the design
serves. Every rule below follows from that one fact.

This record describes the shipped surface: how a command is invoked, what it
may emit, how it fails, and where its code lives. It does not describe what
the commands do.

## System model

A run has five stages, and each one is a place where the surface is enforced
rather than left to the command author.

1. **Preflight.** `clients/cli/bin/astryx.mjs` gates the Node version using
   only built-ins, before importing anything that could fail on an old
   runtime. The gate honours `--json` with a hand-rolled envelope carrying
   `ERR_NODE_VERSION`.
2. **Release choice.** `foundation/release/handoff.mjs` chooses the release
   that answers the folder (`spec:AST-081` FR2) before anything the project
   provides loads. When that release is not this copy, the run is handed to
   it once, with the same arguments, folder, and standard streams, and ends
   with its exit status (`spec:AST-081` FR9); a release that cannot run fails
   the run with `ERR_VERSIONS_UNAVAILABLE` before any write.
3. **Dispatch.** `clients/cli/index.mjs` parses with Commander, sets JSON mode
   in `preAction` before any command body runs, and rejects a command that
   cannot support `--json` before any side effect.
4. **Work.** The command parses its arguments and calls a function in `api/`.
   The command is a thin wrapper: parse, call, render. The API function is the
   unit that is scriptable and typed, and it is the source of truth for the
   `type` discriminator on its own envelope. `spec:AST-042` FR1 makes this the
   contract; INV20–INV22 fix where each part lives.
5. **Render.** Exactly one of two paths. In `--json` mode, `jsonOut` writes a
   single envelope. Otherwise the formatters render the same values as text,
   from a closed set of blocks (INV23). Nothing else may write to stdout.

Discovery of components, templates, themes, codemods and docs is not per-command. It
goes through the `Project` seam in `foundation/config`, which resolves the
integrations named in `astryx.config` and then autolinks any DECLARED dependency
that ships a root `astryx.integration.*` manifest — a config entry is how a
project pins an integration, not how the CLI finds one. Each integration is
loaded independently. A manifest load failure withdraws that package. An error
in one contribution kind does not hide other valid kinds, and invalid template
or component files do not hide valid siblings.

A programmatic host (`spec:AST-081` FR1) can serve the CLI from package sets it
keeps elsewhere: package roots whose `node_modules` each hold a CLI, the Core
equal to it, and the integrations the host installs with them. The host entry,
`@astryxdesign/cli/host`, activates the root that serves a run in-process,
through the package-private implementation in `foundation/runtime`. The working
folder stays the caller's: the project, its config, local themes, and the
package being authored resolve as they do without a host, and no write lands in
a package root. Core lookups, and integration lookups outside a project, consult
the active root only after the project's own resolution misses. With no active
set, every lookup behaves exactly as it does without the seam (INV30). Which
release answers a hosted run, the host entry's inputs, and the lines a hosted
run prints are `spec:AST-081`'s.

AST-017 DEC-4 owns stable response-entry fields and requires their complete type,
test, applicable text, and consumer-documentation projections. A `template.list`
entry can carry optional `replaces`, naming the Core id that a winning
integration template supersedes; the Core entry is omitted from the default list
and remains available through explicit Core package selection.

## Boundaries and invariants

- **INV1 — The CLI never asks a question.** No prompt, no TTY detection, no
  read of stdin for control flow. A non-TTY environment is the only supported
  condition, not a fallback. A command with nothing to do exits with a result,
  never a question.
- **INV2 — Every `--json` emission is one valid envelope.** Success is
  `{apiVersion, type, data}` plus optional `package` (INV28) and `meta`, which
  carries `answeredBy` on every success (`spec:AST-081` FR5). Failure is
  `{apiVersion, error, code}` plus optional `suggestions`. There is no third
  shape, no partial write, and no raw stack trace: an uncaught throw becomes an
  envelope at the bin error boundary.
- **INV3 — The code is the contract; the prose is not.** `code` is stable and
  append-only. Once shipped, a code's meaning never changes and the code is
  never removed. The `error` string may be reworded at any time. A consumer branches on `code`.
  `ERR_PROJECT_REQUIRED` and `ERR_VERSIONS_UNAVAILABLE` carry the refusals and
  failures `spec:AST-081` FR2 and FR4 define.
- **INV4 — Human output is a projection of the JSON, produced only by the
  formatters.** `emit` accepts a renderer-produced `Block` and nothing else, so
  a stray string cannot reach stdout. Output is plain ASCII: no colour, no TTY
  detection, no width wrapping, byte-for-byte identical printed or piped.
- **INV5 — Text field names mirror JSON keys one to one.** The two views cannot
  drift, and every field stays greppable.
- **INV6 — One command, one file.** A command is
  `clients/cli/commands/<name>.mjs`, or `clients/cli/commands/<name>/index.mjs`
  when it is a group, with a sibling `<name>.doc.mjs`. A subcommand's doc is
  `<parent>-<child>.doc.mjs`.
- **INV7 — Every command ships a `CommandDoc`, and the generated surfaces come
  from it.** Help text, the README command and error-code tables, and the
  manifest are generated. None of them is written by hand. Every command and
  subcommand's `CommandDoc` declares `outsideProject`, which the manifest
  publishes (`spec:AST-081` FR4).
- **INV8 — Exit codes do not depend on the output mode.** The same condition
  exits the same way with and without `--json`, so a command works as a gate
  without parsing stdout.
- **INV32 — Warnings fail a command only under `--strict`.** Without
  `--strict`, a command exits 1 for an error, never for a finding it reports
  as a warning, so a warning cannot turn a passing build red. A condition a
  command's own mode exists to detect, such as stale outputs under
  `theme build --check`, is an error, not a warning. A command that can gate a
  build on its warnings offers `--strict`, which changes only how the same
  findings end the run: it reads exactly the data the default run reports and
  adds no check, reports each warning, and each check that did not run, as an
  error in the command's own issue format with the finding's own code, and
  exits 1, the same way in text and JSON (INV8). A command offers `--strict`
  only through a record that names it; `astryx integration check`
  (`spec:AST-039` FR14) is the one that does (DEC-4).
- **INV33 — Documented component props agree with the source.**
  `astryx doctor integration components` MUST compare every prop table a
  component descriptor documents with the props of the export it describes, read
  with the TypeScript the project installs. It MUST warn about a documented prop
  the code lacks, a prop the package's own source declares that the table omits,
  a `required` flag the code contradicts, and a documented type that does not
  match the code under the loose comparison the command documents. It MUST NOT
  warn that a table omits `ref`, the React-reserved `key`, the internal and
  styling props the published `ComponentPropDoc` type tells authors to skip
  (`xstyle`, `className`, `style`, and `data-testid`), or a prop its declaration
  marks `@internal`. It MUST list a documented type it cannot judge as unchecked
  rather than report a match or a mismatch. Without the project's TypeScript it
  MUST say that the comparison was skipped and why, and MUST NOT report a pass.
- **INV34 — Every descriptor satisfies its published authoring type.**
  `astryx doctor integration validate` MUST check each descriptor an
  integration's roots hold, and each translation a descriptor carries, against
  the type `@astryxdesign/cli/authoring` publishes for that kind in an
  integration, as the TypeScript the project installs checks a value written
  with that type; a component or hook translation MAY carry `name` and
  `displayName`, which are ignored at render. It MUST warn about every unknown
  field, missing required
  field, and value outside its field's type, naming the field and the change
  that fixes it, including a field the compiler reaches only once an earlier
  unknown field is gone. A value the loader accepts and the CLI reads MUST
  satisfy that type. Where an integration may author more than Core's type for a
  kind allows, such as any template `category`, `@astryxdesign/cli/authoring`
  MUST publish an additional integration type for that kind, and Core's type
  MUST stay unchanged. Without the project's TypeScript it MUST say that the
  descriptors were not type-checked and why, and MUST NOT report a pass.
- **INV37 — Every item source has its descriptor.** `astryx doctor integration
validate` MUST warn on a PascalCase `.tsx` at the top of the components root
  with no same-stem component doc (`source_without_component_doc`), and on a
  `.tsx` at the top of the templates root with no same-stem template doc
  (`source_without_template_doc`). Test, spec and story files (`.test`,
  `.spec` and `.stories` stems) and subfolders are skipped, and a helper
  belongs in a subfolder.
- **INV35 — One command checks an integration, and can gate its build.** The
  `astryx integration check` command, which takes no package argument, MUST run
  the `validate`, `components`, `templates`, and `docs` checks against the
  integration package in the current folder, report each check's own result and
  a summary, and exit 1 exactly when one of those checks would exit 1 on its
  own. With `--strict` it MUST also exit 1 on any warning and on any check, or
  comparison within a check, that did not run, such as a package with no
  integration manifest or a comparison that needs the project's TypeScript,
  reporting each as an error from the same findings the default run reports
  (INV32). An item a comparison lists as unchecked,
  such as a prop type it cannot read, does not fail `--strict`. `--strict` also
  fails on three doc faults the `docs` check finds in prose (DEC-8): a
  copied passage, where two topics repeat 10 or more words word for word (a
  run of mostly function words, or one that three or more topics share, is not
  a copy); fluff, a listed padding phrase or a topic whose share of content
  words is under the published floor; and a stray pointer, a code span that is
  exactly `astryx docs <name>` for a doc that exists, in a topic's prose,
  description or summary or a component doc's text, where a `{@link}` to that
  doc would be checked. It also fails on doc structure a reader silently
  drops: a prop documented twice, an overlay key that names no prop, two
  sections with one key, an overlay beside no topic, and an empty section.
  Its exit codes and JSON shape are a compatibility
  contract for the builds that run it, from the first stable release that
  ships `integration check`: from then on, a new check, or a new failure code,
  MAY join it only in a minor release that names the addition, and a change
  that can fail a build that passes, including a stricter rule for an existing
  finding, MAY ship only in a minor release that names it. Before that
  release, its checks and failure codes MAY change in any release. A name a
  patch release adds to Core or the CLI (a component, template, or topic) MAY
  raise a conflict with a package's own name. Each `doctor integration`
  subcommand MUST keep its exit codes, and its JSON MAY change only by
  additions. Because builds fail on its findings, it MUST NOT report a finding
  the package's code does not contradict, other than the doc faults of DEC-8:
  a check that cannot judge an item lists it as not checked instead (DEC-7).
  When the running CLI is outside a package's `@astryxdesign/cli` peer range,
  `astryx doctor integration validate` and `integration check` MUST list a
  `cli_peer_version` note, which fails nothing, `--strict` included, and
  `integration verify` does not report it: below the range it names the
  project's commands to install a newer Core and CLI together and run
  `upgrade --apply`, and above it, it asks for a release of the package that
  supports this CLI or, for the package in the current folder, to test with
  this CLI and widen the range.
- **INV36 — The person filing a gap report chooses where it goes.**
  `astryx gap-report` MUST send a report to the configured report handlers, or
  with none to the owning package's `issuesUrl` (`spec:AST-031` FR8), unless
  the caller names a destination with `--to <package>`. A destination is a
  loaded integration whose manifest sets `issuesUrl`. With `--to`, the report
  goes only to that `issuesUrl`, the way the zero-handler fallback sends it: a
  GitHub issues URL is filed only with `--confirm-public` and reports
  `consent_required` without it, and any other URL comes back as
  `routed_only`. A `--to` that names no destination MUST fail with
  `ERR_UNKNOWN_PACKAGE` before anything is sent, listing the destinations it
  accepts. While at least one destination exists, the categories listing and
  the receipt of a run without `--to` MUST list the default and every
  destination, in text and JSON, and MUST tell the agent to ask the person
  where to file and to pass the person's choice with `--to`. The command's
  docs and agent guidance MUST say the same: an agent asks the person, never
  picks a package destination on its own, and leaves out `--to` when the person
  does not choose (DEC-9).
- **INV9 — A command that cannot support `--json` is rejected before any side
  effect**, not part-way through the work.
- **INV10 — Every write is confined.** Output paths pass `assertWithin`, which
  canonicalises symlinks and rejects a NUL byte; an escape is
  `ERR_PATH_TRAVERSAL`. Bounded inputs are capped rather than trusted. The only
  writes outside a project or output folder are the per-user ones
  `spec:AST-081` FR7 lists: releases and records in the per-user cache, the
  settings and state files, and the computer-level skill file with its links or
  copy in the home folder. None follows a link in its place, and none replaces
  anything the CLI did not create.
- **INV10a — Refusing a write outside its confined root is a compatible fix.**
  When a current record already confines the writes of a CLI operation (a
  command or its programmatic API) to a root, a change that makes the operation
  refuse a write outside that root, before writing anything, is a compatible
  `[fix]` and patch-eligible under the `spec:AST-017` release process. This
  holds for every route the write takes (a traversal segment, an absolute path,
  a symlink, or a default location derived from an input outside the root), and
  even when the latest stable release accepted the input and its documentation
  did not forbid the write: a write outside the confined root is not a supported
  workflow, so a caller that relied on it is not a released victim. The
  Changeset cites the confinement rule the released behavior violated, names
  each input that is now refused and how it fails, and gives the migration.
  Inputs whose writes stay inside the root keep working. Refusing one of them,
  moving outputs instead of refusing, or confining an operation that no current
  record confines is classified like any other change.
- **INV10b — An upgrade acts on the project it runs in; a folder below that
  project is never a separate root.** `astryx upgrade` operates on the project
  `spec:AST-040` FR9 finds: the nearest folder at or above where it runs whose
  `package.json` declares an Astryx package, or that an Astryx workspace lists.
  A folder that installs `@astryxdesign/core` but declares no Astryx package
  and is not a listed workspace member is not a supported upgrade root: an
  upgrade run there that migrates only that folder leaves the rest of the
  project on the old release, so a caller who relied on it is not a released
  victim (`spec:AST-017` FR54). Acting on the project above such a folder is a
  compatible `[fix]` and patch-eligible. A folder that declares an Astryx
  package is its own project, so a run there operates on that folder.
- **INV11 — A broken integration degrades a command run, it does not fail
  it.** The `Project` seam loads each integration independently and records
  the failure, and the command's own work proceeds without that integration's
  contributions. The diagnostic command is the exception by purpose (INV29).
- **INV29 — `astryx doctor` fails on a configured integration that cannot
  load.** An integration named in `astryx.config` that is not installed, has
  no manifest, or whose manifest throws or fails its schema is a failed
  `configured-integrations` check, so `doctor` exits 1; an `astryx.config`
  that imports but from which the CLI cannot load the project is a failed
  `config` check with the same exit. What counts as configured is every name
  `astryx.config` lists, including a package set aside for a provider-ID
  conflict and the package being authored when its own config names it (its
  load error is the outcome that counts). An installed integration the config
  does not name stays informational. This invariant covers loading only; what
  `doctor` reports about the contributions of an integration that loads is
  owned by the checks that read them.
- **INV12 — Human chatter never touches stdout in JSON mode.** `humanLog` and
  `humanWarn` are the only chatter primitives, and both are no-ops under
  `--json`. The run-level lines `spec:AST-081` defines, which print before or
  after a command (who answered, the fallback warning, install progress, and
  update notices), go to standard error and print nothing under `--json`.
- **INV13 — Agent docs have one rendered source of truth.** Init and upgrade
  render configured integration `agentDocs` through the existing `Project`
  seam. Upgrade compares complete block bytes even when Core is unchanged and,
  when codemods or hooks run, writes the prepared block only after they succeed.
  The computer-level agent text that `astryx init --user` writes renders from
  the same source (`spec:AST-081` FR8).
- **INV31 — Agent docs reach every agent from the project's own files.** By
  default, init writes the agent-docs block so that every agent CLI reads at
  least one copy, whether it starts at the project root or in a folder below
  it, from files the project commits (DEC-2). `--agent` and `--agent-docs-path`
  name the files instead; an `--agent` preset still never leaves a copy in both
  Claude Code files, except as the ignored-root rule below says.
  - `AGENTS.md` at the project root holds the block. Init creates it when it is
    missing.
  - The project's Claude Code file holds a full copy: root `CLAUDE.md`, else
    `.claude/CLAUDE.md`. It holds the copy even when it imports `AGENTS.md`,
    because Claude Code does not follow an import of a file above the folder it
    starts in. While root `CLAUDE.md` holds a copy, `.claude/CLAUDE.md` holds
    none, except while root `CLAUDE.md` is untracked and ignored by the
    repository's ignore rules, including its local exclude file and configured
    excludes: then a `.claude/CLAUDE.md` the project commits (tracked, or not
    ignored) holds a copy too, as it does when the repository cannot say, in
    the default layout and with the `claude` and `all` presets, so a fresh
    clone still gives Claude Code one.
    Init creates no Claude Code file, and a Claude Code file that is
    `AGENTS.md` under another name holds no second copy.
  - `GEMINI.md` holds a one-line managed import of `AGENTS.md`. Init creates
    it when it is missing, and adds no import to a `GEMINI.md` that already
    imports `AGENTS.md` the way Gemini CLI reads imports. Settings that point
    Gemini CLI at `AGENTS.md` do not replace the import, because Gemini CLI
    reads a project's settings only when it starts at the project root.
  - Every other agent file the project has holds a full copy, unless it imports
    `AGENTS.md`. When an agent file imports an agent file other than
    `AGENTS.md`, or two agent files are one file, the project keeps its own
    layout: every agent file that imports no other agent file holds a copy.
  - A managed import counts as a copy only while `AGENTS.md` holds the block.
    Doctor fails when it does not.
  - Init writes at the project root: the nearest folder at or above the
    working folder whose `package.json` declares an `@astryxdesign` package,
    else the nearest folder with a `package.json`, else the working folder.
    The search stops at a repository root and never counts the home folder. A
    path given with `--agent-docs-path` stays relative to the working folder.
  - No mode writes agent docs into the home folder, or into a settings folder
    in it, a folder of the home folder whose name starts with a dot, outside a
    project. A folder inside a settings folder belongs to a project when it,
    or a folder above it below the settings folder, has a `package.json`; the
    settings folder itself never does. Init reports the folder it refused and
    still succeeds, and removing a block there stays allowed. The
    computer-level skill file `spec:AST-081` FR8 writes
    (`~/.agents/skills/astryx/SKILL.md`, with its folder links or copy in other
    agents' skill folders) is not a project's agent docs: only
    `astryx init --user` and the global copy's background step write it, as
    INV10 and FR8 state.
  - Upgrade refreshes every copy where it is and moves none.
  - The managed block is Astryx-owned: `spec:AST-040`'s protections cover
    consumer-owned content, not the block; INV10 still confines every write.
  - `--remove-agents` removes the block at the project root and in the working
    folder. Every file it would change resolves inside its own folder, or it
    changes nothing.
- **INV14 — An installed integration is discoverable without configuration, and
  autolinking it can only add.** A dependency the project DECLARES in
  package.json, and that ships a root `astryx.integration.*` manifest, is
  loaded; `node_modules` is never walked, so nothing the project did not declare
  can contribute. Enumeration reads only the dependency KEY, never its value, so
  an npm alias or a non-semver protocol resolves like any other. A config entry
  keeps precedence over the same package autolinked, and a dependency whose
  manifest fails to load is dropped rather than raised as the consuming
  project's issue. Outside a project, a hosted run also loads each integration
  its host approves, by name, from the package root that serves it (INV30).
  Integrations installed for the computer (`spec:AST-081` FR12) contribute
  only outside a project, from the packages installed beside the global copy.
  Inside a project they contribute nothing: `discover` and `doctor` load the
  manifest of each one the project does not install only to list as available
  the ones whose peer range admits the answering release and whose manifest
  loads.
- **INV15 — Integration authoring writes one complete, discoverable contribution.**
  `integration add <kind> <name>` and its typed per-kind APIs share one receipt
  contract. A dry run reports the same planned paths without writing. A real run
  refuses to clobber authored files, stages related writes atomically, declares
  a contribution root only with valid bytes behind it, and verifies visibility
  through the same discovery seam consumers use.
- **INV16 — Authoring preserves package policy and local source wins while it is
  being edited.** Writers never create `files` or `exports`; when either field
  already exists, they add only the required manifest, root, or public subpath
  and preserve every author-owned entry. Generated component and template export
  keys are extensionless public subpaths even when they target authored `.ts` or
  `.tsx` source. The integration beside the current package.json replaces the
  same installed package in place, preserving its configured order while making
  working bytes authoritative.
- **INV17 — Pack verification examines the artifact consumers receive.**
  `integration verify` runs the package lifecycle through `npm pack`,
  compares the required file inventory with the actual tarball, extracts that
  tarball into a scratch consumer, and reruns contribution discovery. It rejects
  advertised component imports ending in `.ts` or `.tsx`, resolves every exact
  advertised component and inferred template import through Node's package
  resolver, and fails when the packed artifact has no usable public export. This
  contract does not require project-local TypeScript or promise validation under
  TypeScript's Node16 or bundler resolution modes.
- **INV18 — Integration diagnostics are read-only and contribution-specific.**
  Doctor reports malformed or unreachable roots and contribution conflicts
  without rewriting the package. A manifest load failure withdraws the package.
  A contribution-kind error remains visible without hiding other valid kinds.
  Invalid template and component files do not hide valid siblings. A
  package-scoped theme lookup still surfaces that package's blocking catalog
  error instead of misreporting the theme as unknown.
- **INV19 — Integration themes are importable packages; editable source is an
  explicit eject.** The manifest's `themes` root contains one directory per slug.
  Every theme source has a mandatory same-stem, strongly typed `ThemeDoc`; there
  is no root item catalog. Discovery derives the source entry and required named
  export from the shared stem, parses source without executing it, and rejects
  escaped local imports and missing or type-only exports. The theme directory is
  the recursive copy and pack boundary; dot entries and files npm never publishes
  belong to no theme. A dot-folder, or a folder holding neither a descriptor nor
  a `<name>Theme` source, is not a theme and is neither read nor packed, and doctor
  warns about one that looks like a theme; a folder with a theme source and no
  descriptor fails. The one exception is the project's local `src/themes` root:
  a source copy made by the released `theme add` without its descriptor is
  reported as unmigrated, skipped without failing theme commands, and remains
  unmanaged until upgrade adds its missing descriptor. `integration add theme`
  creates public built-module and stylesheet exports and keeps CSS side effectful.
  A package may also export a font stylesheet. `integration verify` rebuilds
  source, compares the local built module and stylesheet, and proves every
  declared theme export resolves from the packed package. `theme list` retains
  package ownership, and `theme add --import` records that owner and imports only built
  outputs. The deprecated plain `theme add` keeps copying source until its scheduled
  cleanup. `theme eject` is the explicit source fork; it copies the complete
  theme directory and same-stem descriptor, with the local descriptor marked as
  not maintained by the package owner.
- **INV20 — A command's API subject has one layout.** A command's behavior lives
  in `api/<subject>/`. `<subject>.mjs` is the subject's entry, and `api/index.mjs`
  re-exports what it exports. A subject with more than one operation puts each in
  a leaf, `<leaf>/<leaf>.mjs`, and the entry dispatches to the leaves. Every
  exported function has a colocated `FunctionDoc` — `<subject>.doc.mjs` for the
  entry function, `<function>.doc.mjs` for any other — and response typedefs in a
  `*.type.mjs` file. Tests sit beside the module they cover as
  `<module>.test.mjs`. A private helper is an underscore-prefixed file, such as
  `_site.mjs`. `api/blog` is the reference layout.
- **INV21 — An API subject reaches the environment only through its adapter.**
  Within `api/<subject>/`, reading or writing files, network requests,
  subprocesses, loading the project or its modules, and discovery that reads the
  disk happen in the subject's adapter: `_adapter.mjs` and any underscore-prefixed
  helper that only it imports. The adapter may call foundation seams such as the
  `Project` seam and `assertWithin`. The entry and the leaves shape inputs and
  results and call the adapter; they do not import filesystem, network, or
  subprocess modules, the `Project` seam, or discovery and loader functions. A
  subject that needs no environment access has no adapter.
  `api/blog/_adapter.mjs` is the reference adapter.
- **INV22 — A command handler parses, calls, and renders.** A handler in
  `clients/cli/commands/` registers through `defineCommand` with its
  `CommandDoc`, and an executable command's `CommandDoc` names in `fn` the API
  function it calls. The handler parses its arguments, calls that function, and
  renders the result through `jsonOut` or the formatter kit. It reads no files,
  loads no project, runs no discovery, and starts no subprocess or network
  request; it may phrase hints with the command name `spec:AST-081` FR5's picker
  chooses. Only command handlers, their `CommandDoc` files, and
  their tests live in that directory. `clients/cli/commands/blog.mjs` is the
  reference handler.
- **INV23 — The formatter kit is closed.** Text output is built only from the
  block constructors in `clients/cli/formatters/index.mjs` — `section`, `text`,
  `list`, `record`, `records`, and `code` — and printed through `emit`. A handler
  does not pad strings to a width, align columns, or draw tables, and it defines
  no renderer of its own; it maps its data onto records, lists, and sections. A
  block kind is added to the kit, with its tests and its line in the help
  "Output format" list, only as `spec:AST-042` FR3 allows.
- **INV24 — Every discoverable integration item owns one typed descriptor.** New
  authoring emits `<source-stem>.doc.mjs`, annotated with its public type from
  `@astryxdesign/cli/authoring`, beside the source or payload it describes. The
  descriptor is the sole per-item metadata authority; `astryx.integration.*`
  locates roots and integration-level capabilities but never catalogs items:
  there is no catalog file under a root and no per-item map or list in the
  manifest (`spec:AST-039/FR11`). A released alternate reader is an isolated
  compatibility path, not a second authoring convention. A component
  descriptor's `name` is the runtime export it documents: discovery records
  the component under that name when its same-stem source exports it and not
  the stem, and records each full entry a descriptor lists in `components`
  under its own name when the module the descriptor imports exports it
  (`spec:AST-039/FR4`). A name recorded only this way never takes an unscoped
  lookup that Core, a legacy docs package, or a component named after its
  source answers: that owner keeps it unless a declared replacement claims it.
- **INV25 — Every CLI doc names the group that reads it.** Each command, API
  function, schema, and enum doc the CLI ships declares a `namespace`. The docs
  tree adopts `cli/commands` and `cli/api` docs, one route each under
  `cli/commands` and `cli/api/<kind>s`; `authoring` docs are sections of the
  field reference, `astryx docs authoring/field-reference`, and appear in its
  list; `astryx docs authoring <name>` reads one. `astryx doctor` fails when a
  CLI doc has no namespace, names one nothing reads, or disagrees with the
  authoring list, so every doc the CLI ships stays readable from the CLI.
- **INV26 — The docs tree gives each doc one home (`spec:AST-046`).** The tree
  is compiled in `foundation/doc-compiler/tree.mjs` from namespace docs, the
  guides they place, and the typed docs they adopt; the CLI's own tree files
  live in `assets/docs/tree/`. A namespace never lists or scans its children.
  `astryx docs <route>` reads it one level at a time, and `astryx doctor` warns
  on any tree error diagnostic or unplaced CLI doc.
  An integration's namespace docs and placed guides join the same tree, named
  by its provider id; the CLI's own docs keep a contested route.
  Every flat topic sits in the generated level for its category, Guides or
  Foundations, under its own name, so every doc has a named home in the tree.
  Everything an integration author reads lives under `authoring`.
- **INV27 — Every read says where to go next (`spec:AST-047`).** A read names
  its place and ends with its moves (up, down, across, and out) as runnable
  commands, and its JSON carries the same moves as `links`; a search hit
  carries `parent`. Nothing a read shows can go stale: moves are derived, and a
  test proves every command a doc shows is real. `astryx docs` and
  `astryx search` meet it first.
  A link between docs names its target by doc identity and is resolved on
  every read; Doctor warns on one that names no doc.
- **INV28 — Every result names the package each artifact comes from.** A
  `--json` result about one artifact (a component, doc, template, or hook)
  carries `package` in its envelope, directly after `type`. A result
  that lists artifacts gives each listed artifact its own `package`, and a
  doc's sections each name the package that wrote them. Core's components,
  hooks, templates, and codemods name `@astryxdesign/core`; the docs the CLI
  ships name `@astryxdesign/cli`; an integration's artifacts name the
  integration's package, with nothing written for it by the integration's
  author. Two lists of plain names, `hook --list` and `swizzle --list`, hold
  only Core's artifacts and name `package` once, in the envelope; `build`'s
  `frame` and `foundation` are fixed lists of Core component names. Text
  output names the same package. Verbatim source output (`--source`,
  `--showcase`, and a template's source) keeps stdout to the source alone, so
  it pipes byte for byte, and names the package on stderr.

- **INV30 — A host's package set fills only what the project leaves open, from
  one root.** A package set is a package root whose `node_modules` holds a CLI,
  the Core equal to it, and the integrations installed with them. A programmatic
  host passes its package sets, in its order of preference, through the host
  entry (`spec:AST-081` FR1); which release answers a hosted run, and every line
  a hosted run prints, are `spec:AST-081` FR2, FR3, FR5, and FR9's. Only the
  host entry activates a package set: no CLI flag, environment variable, config
  key, or command does, and no `foundation/runtime` module is exported.
  - A project's installed packages always resolve from the project. Inside a
    project, a root fills only the `@astryxdesign/*` packages the project leaves
    uninstalled whose copy in the root ships no integration manifest, so it
    contributes no integration (`spec:AST-081` FR12). A run resolves from at
    most one root, so a CLI never pairs with a Core or an integration from
    another set. A run handed to the project's own CLI (`spec:AST-081` FR9) uses
    no root.
  - Whenever the copy that started the run answers, outside a project, in a
    project that installs no CLI of its own, as the release `spec:AST-081` FR2
    names, or as FR2's fallback, the root that holds that copy serves the run.
  - When `spec:AST-081` FR2 has a hosted run in a project answered by a named
    release other than the copy that started the run, the matched release or the
    release of a project CLI the host declined, a root qualifies only when its
    CLI is that release and it holds, for every package the project declares
    that a root may fill, the version the project would install: the valid
    lockfile version, else a published version that satisfies the declared
    range. A declared value that is not a version range, such as a path or a
    protocol, matches only through its lockfile version, and an unrequested
    prerelease does not satisfy a range. The first root in the host's order that
    qualifies serves.
  - A hosted run installs nothing: a release `spec:AST-081` FR2 names comes only
    from the copy that started the run, when it is that release, or from a root
    that qualifies; when neither does, that release cannot be obtained and FR2's
    fallback applies.
  - Outside a project, each integration the host approves resolves by name from
    the root that serves the run, in addition to what `spec:AST-081` FR12 finds
    there, and loads with FR12's validation, peer-range check, and provider
    identity rules. An approved name that root does not hold is left out.
  - Enumeration through a root reads only the packages INV14 or `spec:AST-081`
    FR12 name, and a root's own config is never read.
  - Every run a package set serves records, for each package it resolves, its
    name, version, and whether it comes from the project or the package set, and
    the host entry returns that record with the run's result (`spec:AST-081`
    FR1); results still name each artifact's package (INV28).
  - Project health APIs (`doctor` and its integration checks) judge only the
    project's own install: a package set never satisfies or fails a project
    check, and `spec:AST-081` FR12 decides what they list as available.

Some modules predate INV20–INV23 and do not meet them yet; `spec:AST-042` lists
the known gaps.

## Change coupling

A change to any of the following requires this record to be re-read, and
updated in the same pull request when it moves an invariant:

- adding, removing, or renaming a command or subcommand;
- adding an error code, or changing what an existing code means;
- a change to a stable JSON envelope or discriminated response-entry field
  follows `spec:AST-017/DEC-4`;
- adding a formatter or block kind (INV23, `spec:AST-042` FR3), or writing to
  stdout from anywhere other than `emit` and `jsonOut`;
- changing the file layout under `clients/cli/commands`;
- adding an API subject, leaf, adapter, or exported function, or giving an API
  module access to the environment (INV20–INV21);
- adding a command, API function, schema, or enum doc, or a namespace a CLI
  doc may declare (INV25);
- adding a docs-tree namespace or guide, or changing how a doc gets its home or
  its route (INV26);
- adding a read that offers no moves, or changing how a move is spelled in text
  or JSON (INV27);
- changing an integration writer's receipt, no-clobber/rollback behavior,
  package.json mutation policy, or public subpath spelling;
- changing what `integration verify` executes, resolves, or proves about
  the tarball;
- adding `--strict`, or any other way to fail on warnings, to a command
  (INV32);
- changing local, configured, or autolinked integration precedence;
- changing how a host's package set is matched to a project, which
  integrations it contributes, what a run it serves records, or which lookups
  consult it (INV30);
- changing an integration item descriptor, the theme directory boundary, or the consumer copy contract;
- changing which files hold the agent-docs block, how init finds the project
  root, where it refuses to write agent docs, or where it removes them (INV31);
- adding a kind of gap-report destination, or changing who chooses one
  (INV36).

`pnpm check:cli-structure` enforces the layout. The contract tests listed in
`verified_by` enforce the envelope, the exit codes, the error codes, and the
non-interactive guarantee.

## Owning code

- `clients/cli/bin/astryx.mjs` — Node floor gate and the error boundary that
  converts an uncaught throw into an envelope.
- `foundation/release` — the release choice, the per-user release cache, the
  hand-off, `meta.answeredBy`, and the update check and its notices
  (`spec:AST-081`).
- `clients/cli/index.mjs` — Commander wiring, JSON-mode gate, dispatch.
- `clients/cli/commands/<name>.mjs` — one command: parse, call the API, render.
- `clients/cli/commands/<name>.doc.mjs` — the `CommandDoc` that generates help
  and the published tables.
- `clients/cli/formatters/index.mjs` — `Block`, `emit`, `section`, `text`,
  `list`, `record`, `records`, `code`, and the shared ASCII vocabulary.
- `foundation/response/json.mjs` — `API_VERSION`, `jsonOut`, `jsonError`,
  `toErrorEnvelope`, `humanLog`, `humanWarn`, the JSON-mode flag.
- `foundation/response/error-codes.mjs` — the frozen, append-only code set.
- `foundation/config` — the `Project` discovery seam and integration precedence.
- `host/index.mjs` and `host/index.d.ts` — the supported programmatic host
  entry, `@astryxdesign/cli/host` (`spec:AST-081` FR1).
- `foundation/runtime` — the package-private package-set implementation: the
  active set and the lookups that consult it (`package-root.mjs`), matching a
  project to a root, the approved integrations, and provenance
  (`package-set.mjs`), and lockfile pins (`locked-versions.mjs`).
- `foundation/integrations/manifest-writer.mjs` — safe manifest-root patching,
  verification, and rollback.
- `foundation/integrations/contribution-inventory.mjs` — the shared local and
  packed contribution identity/file contract.
- `api/integration` — contribution writers, diagnostics, and packed-artifact
  verification.
- `foundation/discovery/theme-discovery.mjs` and `api/theme` — typed integration
  theme descriptor discovery, package-aware selection, source copy, and build.
- `foundation/agent-docs` — the shared expected-block renderer and managed-file
  writer used by init and upgrade, and the layout that decides which files hold
  the block (INV31).
- `api/index.mjs` — the public programmatic API, `@astryxdesign/cli/api`, which
  re-exports every subject's functions.
- `api/<subject>/…` — the scriptable functions the commands wrap, laid out as
  INV20 describes; each owns the `type` on its own envelope.
- `api/<subject>/_adapter.mjs` — the subject's only access to the environment
  (INV21).

## Deciding specs

- `spec:AST-017/DEC-4` — stable response fields and their complete projections
  are current compatibility authority.
- `spec:AST-042/DEC-1` — every command is a thin layer over one programmatic
  function.
- `spec:AST-042/DEC-2` — every command's text output uses one shared block
  vocabulary.
- `spec:AST-081` — which release answers a run, the hand-off, what every
  command does outside a project, and `meta.answeredBy`.

### DEC-1 — Doctor is the one command a broken configured integration fails

**Decider:** `cixzhang`, `2026-10-12`

A project owner who names an integration in `astryx.config` and then cannot
load it — not installed, no manifest, a manifest that throws or fails its
schema — gets a red `doctor` run that names the package and the reason,
instead of a green one while every command silently runs without that
integration's components, templates, and docs. Command runs keep degrading
(INV11): a broken integration must never take a build or a read down with it,
and `doctor` exists to say what is wrong. Rejected: `doctor` warns and exits 0
(the owner's CI stays green over a misconfigured project); every command fails
on a broken integration (a read or a build stops for a problem it can work
around); counting only the integrations that happened to load (a package set
aside or replaced by the package being authored would vanish from the count).

### DEC-2 — Every agent gets the agent docs, and an extra copy beats none

**Decider:** `josephfarina`, `2026-10-14`

Agent CLIs read different files. Codex CLI reads only `AGENTS.md`; Claude Code
reads its own files, and `AGENTS.md` only when the project has none; Gemini CLI
reads `GEMINI.md`; Copilot CLI reads `AGENTS.md`, `CLAUDE.md` and `GEMINI.md`
together. No placement gives every agent exactly one copy in every project, so
the agent docs give each agent at least one, from files the project commits,
and accept extra copies where agents share files (INV31). Rejected: keeping
the Claude Code copy only in `.claude/CLAUDE.md`, which many projects keep out
of version control, so that a fresh clone has none.

### DEC-3 — Refusing a write outside its confined root is a fix

**Decider:** `josephfarina`, `2026-10-15`

A write outside the confined root lands wherever its path points, with the
caller's file permissions, and can replace a file the caller never named as an
output: source, configuration, or another project's files. Agents run the CLI
unattended and pass generated paths, so one wrong path loses work with nothing
to undo. Holding the refusal for a minor, or warning while the write still
happens, keeps that exposure open to protect a use the confinement already
forbids, so the refusal ships as a `[fix]` (INV10a). The boundary is narrow:
the confinement is already current, the refusal comes before any write, the
Changeset names each refused input and its migration, and writes inside the
root keep working.

Rejected: a patch warning with the refusal held for a minor (the warning prints
after the file is replaced); and letting every current invariant outrank
released documentation (any correction could then claim the patch tier).

### DEC-4 — A command fails on warnings only when its caller asks, with `--strict`

**Decider:** `josephfarina`, `2026-10-16`

A warning that changes an exit code turns every build that runs the command
red as soon as a release adds a check or a finding, so no command fails on a
warning by default. A package that wants its build held to its warnings asks
for it with `--strict`, which reports the same findings as errors in the
format its failures already use: a strict run never knows something the
default run does not, and reading a strict failure needs nothing new.
Rejected: a command that fails on warnings by default (a CLI upgrade turns
green builds red); a gate flag or command with another name for each case
(each one a new contract to keep).

### DEC-5 — Integration doc checks are command contracts

**Decider:** `josephfarina`, `2026-10-18`

INV33, INV34 and INV35 state the prop and descriptor-type checks that
`doctor integration` runs and the `integration check` command that builds run.
They are command contracts beside INV32, so this record governs them, and
`spec:AST-039` FR12, FR13 and FR14 point here.
Rejected: governing them only from `spec:AST-039` (a draft record does not
govern).

### DEC-6 — Integration authoring guidance requires the strict check in every build

**Decider:** `josephfarina`, `2026-10-19`

Integration authoring guidance requires each integration package to run
`astryx integration check --strict` in its own build; the CLI flag stays
opt-in (DEC-4).
Rejected: guidance that leaves the check optional (packages ship drift their
users find first).

### DEC-7 — The integration check reports only what the code contradicts

**Decider:** `josephfarina`, `2026-10-19`

Every integration runs `integration check --strict` in its build (DEC-6), so a
false finding fails a package for a fault it does not have, and its only way
out, dropping `--strict`, also drops the check for every real fault. A check
that cannot tell whether the code contradicts a doc lists the item as not
checked, which `--strict` does not fail. The one exception is the doc-quality
findings of DEC-8.
Rejected: reporting a doubtful item as a warning (`--strict` fails it).

### DEC-8 — The strict check fails on copied passages, fluff and stray pointers in docs

**Decider:** `josephfarina`, `2026-10-20`

People and agents read integration docs on a budget. A fact written in two
topics goes stale in one of them at the next edit, and padding costs every
reader. So these doc-quality findings fail `--strict` by owner decision, as
the one exception to DEC-7. A copied passage (a run of identical words, after
the filters for function-word runs and for boilerplate that three or more
topics share) and a listed padding phrase (outside code and quotes) are exact
matches. The density floor is a threshold, not a contradiction, and a section
with no content is fluff, its limiting case. Doc structure a reader silently
drops fails `--strict` as doc quality too: a prop documented twice, an overlay
key that names no prop, two sections with one key, and an overlay beside no
topic. The phrase
list and the floor are published in the check's docs, and every finding
names the phrase, or the measured density and the floor, so its fix is
mechanical. A stray pointer (a code span that is exactly
`astryx docs <name>` for a doc that exists, in a topic's prose, description
or summary or a component doc's text) is exact as well: the `{@link}`
that replaces it prints the command that opens the doc, and doctor's link
check then fails it when the doc goes away. A command with more words, or one
in a code block, is a walkthrough, not a pointer. Each is a warning, so `integration check --strict` fails on it and
an agent that runs the build fixes it, while `doctor integration docs` keeps
exit 0. `integration check` first ships with these rules, so no build that
passed before can newly fail.
Rejected: listing them as information only, which an agent treats as optional.

### DEC-9 — The person chooses where a gap report goes

**Decider:** `josephfarina`, `2026-10-20`

Report handlers usually feed one shared place, while an integration names its
own tracker in `issuesUrl`. Which place should get a report is a judgment about
the gap, so the person decides: the CLI lists the places, the agent asks, and
`--to` carries the answer. Without a choice the report goes to the shared
default.
Rejected: copying every report to the integration's own `issuesUrl`
automatically (reports land in places nobody picked); letting the agent pick
the destination.

### DEC-10 — An upgrade migrates the whole project it runs in

**Decider:** `josephfarina`, `2026-10-21`

An upgrade that acts on one folder of a project reads none of the project's
configuration, refreshes none of its agent docs, and migrates only part of
its source, leaving the project half on each release with nothing to say so.
`spec:AST-040` FR9 makes the project the unit of an upgrade, and a folder that
installs Core without declaring it is a hoisted install or a stray
`node_modules`, not an app of its own, so following FR9 there is a `[fix]`
(INV10b). The boundary is narrow: a folder that declares an Astryx package
keeps its own upgrade.

Rejected: treating every folder with an installed Core as a project (a
workspace member then migrates against a Core its workspace does not use).

## Verification

| Invariant | Evidence                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Failure signal                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1      | `clients/cli/commands/interactive-guard.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                          | The subprocess hangs: `signal === 'SIGTERM'` and `status === null`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| INV2      | `clients/cli/commands/json-contract.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                              | `--json` stdout does not parse, or parses to a shape outside the two.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| INV3      | `foundation/response/error-codes.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                                 | A shipped code disappears, or an envelope carries an unregistered one.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| INV4      | `clients/cli/formatters/index.test.mjs`, type tests                                                                                                                                                                                                                                                                                                                                                                                                                        | `emit` accepts a bare string, or output varies between pipe and TTY.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| INV6      | `pnpm check:cli-structure`                                                                                                                                                                                                                                                                                                                                                                                                                                                 | A command's file or its doc is missing, or sits at the wrong path.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| INV8      | `clients/cli/cli-exit-codes.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                                      | The same condition exits differently with and without `--json`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| INV10b    | `api/upgrade/run/run.test.mjs` (a folder that installs Core without declaring it upgrades the project above it; a member its workspace lists and one that declares Astryx keep their own), `clients/cli/commands/upgrade.subfolder-path.test.mjs`                                                                                                                                                                                                                          | A folder that installs Core without declaring Astryx upgrades only itself, or a declaring folder or a listed member upgrades the project above it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| INV14     | `foundation/integrations/autolink.test.mjs`, `foundation/config/project.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                          | An undeclared package contributes, or configured/local precedence changes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| INV15     | `api/integration/add-contribution.test.mjs`, `clients/cli/commands/integration-authoring.test.mjs`                                                                                                                                                                                                                                                                                                                                                                         | Dry-run/write paths differ, a writer clobbers bytes, or the new contribution is not discoverable.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| INV16     | `foundation/integrations/manifest-writer.test.mjs`, `api/integration/add-contribution.test.mjs`                                                                                                                                                                                                                                                                                                                                                                            | A writer invents package policy, replaces an author entry, or local bytes lose precedence.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| INV17     | `api/integration/pack-check.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                                      | Lifecycle output, packed files, identities, or public imports diverge without failing the gate.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| INV18     | `api/integration/validate-integration.test.mjs`, `api/theme/integration-themes.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                   | Diagnostics mutate files, or a selected broken theme package is reported as merely unknown.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| INV19     | `clients/cli/commands/integration-real-world.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                                     | A CLI-authored theme, nested palette, or guides fail across pack, install, list, add, and build.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| INV20     | `pnpm check:cli-structure` checks that each subject has a `FunctionDoc`, typedefs, and a test; per-function coverage and leaf layout are checked in review                                                                                                                                                                                                                                                                                                                 | A subject or exported function lacks its `FunctionDoc`, typedefs, or tests, or an operation sits outside its subject.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| INV21     | Review of imports under `api/**`; no mechanical check yet                                                                                                                                                                                                                                                                                                                                                                                                                  | A module other than a subject's adapter reads or writes files, contacts the network, starts a subprocess, loads the project, or runs discovery.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| INV22     | Review of imports under `clients/cli/commands/**`; the docs drift harness checks each `CommandDoc` against the live command                                                                                                                                                                                                                                                                                                                                                | A handler reaches the environment itself, an executable command's `CommandDoc` names no `fn`, or a non-command file sits in the directory.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| INV23     | `clients/cli/formatters/index.test.mjs` for the kit; review of handlers; no mechanical check yet                                                                                                                                                                                                                                                                                                                                                                           | A handler pads, aligns, or draws text itself, or a block kind is missing from the help "Output format" list.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| INV24     | `api/integration/add-contribution.test.mjs`, `api/integration/add-theme.test.mjs`, `foundation/discovery/theme-discovery.test.mjs`                                                                                                                                                                                                                                                                                                                                         | New authoring emits an untyped or non-`.doc.mjs` item, adding one item edits a shared file, or an item catalog becomes authoritative.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| INV25     | `foundation/discovery/cli-self-docs.test.mjs`, `api/doctor/doctor.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                                | A CLI doc with no namespace, a namespace no topic reads, or no section in its topic passes doctor.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| INV28     | `test/inv28-package-provenance.test.mjs`, `clients/cli/commands/text-json-parity.test.mjs`, `cli-api-types-verify`                                                                                                                                                                                                                                                                                                                                                         | A result about one artifact has no envelope `package`, a listed artifact has no `package`, or text omits the package the envelope names.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| INV29     | `api/doctor/doctor.test.mjs` (configured-integrations: not installed, no manifest, manifest throws; self-named authored package, direct and through a symlinked `node_modules` or `cwd`; provider-conflict set-aside; an astryx.config that imports but cannot load the project → failed `config` check)                                                                                                                                                                   | A configured integration that cannot load leaves `doctor` at exit 0, or an installed-but-unnamed one fails it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| INV30     | `foundation/runtime/package-set.test.mjs`, `foundation/runtime/hosted-runtime.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                    | A run resolves from two roots; a root contributes an integration inside a project, including an `@astryxdesign/*` package that ships an integration manifest; a run the starting copy answers resolves from a root other than the one that holds it, or is announced as a fallback when that copy is the release `spec:AST-081` FR2 names; a run another named release answers resolves from a root whose CLI is not that release, from one that lacks a version the project would install, or from a later root when an earlier one qualifies; a hosted run installs a release, or a named-release run with no qualifying root does not fall back as FR2 says; a lookup consults a root before the project's own install, or with no set active; an approved integration resolves from another root or skips its validation; a root's config is read; a run writes into a package root or outside the places `spec:AST-081` FR7 names; the host entry's result lacks a resolved package or its source; or a project check judges the package set. |
| INV31     | `foundation/agent-docs/agent-docs.test.mjs`, `foundation/agent-docs/agent-docs.path-safety.test.mjs`, `clients/cli/commands/init.behavior.test.mjs`, `api/init/remove/remove.test.mjs`, `clients/cli/commands/doctor.test.mjs`                                                                                                                                                                                                                                             | A layout test finds the block missing from a file INV31 names or present in a file it excludes, a managed import of a block-less `AGENTS.md` passes doctor, agent docs land in the home folder or in a settings folder outside a project, or `--remove-agents` leaves a block at the project root or in the working folder.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| INV32     | `test/cli-strict-guard.test.mjs` lists every command offering `--strict` and every command whose exit depends on warnings; `api/integration/check/check.test.mjs` compares a strict run with the default run                                                                                                                                                                                                                                                               | A second command offers `--strict`, a command exits 1 on a warning alone without `--strict`, or a strict run reports a finding the default run does not or outside the command's issue format.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| INV33     | `foundation/discovery/prop-drift.test.mjs`, `foundation/discovery/doc-consistency.test.mjs`, `foundation/discovery/type-text.test.mjs`, `clients/cli/commands/doctor-integration.test.mjs`, `test/core-prop-docs.test.mjs`                                                                                                                                                                                                                                                 | A removed or renamed prop or a changed union passes unreported, a missing compiler reads as a pass, or a table that leaves out `ref`, `key`, `xstyle`, `className`, `style`, `data-testid`, or an `@internal` prop draws a warning.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| INV34     | `foundation/discovery/doc-types.test.mjs`, `foundation/discovery/doc-consistency.test.mjs`, `clients/cli/commands/doctor-integration.test.mjs`, `test/core-doc-types.test.mjs`                                                                                                                                                                                                                                                                                             | An unknown, missing, or mistyped field passes unreported, or a missing compiler reads as a pass.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| INV35     | `api/integration/check/check.test.mjs`, `clients/cli/commands/integration-check.test.mjs`, `test/integration-check-failures.test.mjs`, `api/search/search.integration-check.test.mjs`, `foundation/discovery/prop-drift.test.mjs`, `foundation/discovery/type-match.test.mjs`, `foundation/discovery/doc-types.test.mjs`, `api/integration/authoring-checks.test.mjs`, `foundation/discovery/stray-pointers.test.mjs`, `foundation/integrations/cli-peer-version.test.mjs` | The default exit differs from a check's own, a warning or a skipped comparison passes `--strict`, text and JSON exit differently, a new failing finding is added without the list changing, a search for ci does not find the command first, a finding is reported on a doc the code does not contradict (for a type, one TypeScript accepts), or a copied passage or a fluff finding passes `--strict`, or a function-word run or shared boilerplate is reported as a copy, or a CLI outside a package's `@astryxdesign/cli` peer range is reported as a warning, fails `--strict`, reaches `integration verify`, or goes unlisted.                                                                                                                                                                                                                                                                                                                                                                                                               |
| INV36     | `api/gap-report/gap-report.test.mjs`, `clients/cli/commands/gap-report.test.mjs`                                                                                                                                                                                                                                                                                                                                                                                           | A run without `--to` reaches an `issuesUrl` the handlers did not choose, `--to` runs a handler or files on GitHub without `--confirm-public`, an unknown `--to` sends anything, or the destinations list or its line telling the agent to ask the person is missing while a destination exists.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| INV37     | `api/integration/validate-integration.test.mjs`, `api/integration/validate-integration-fixes.test.mjs`, `api/integration/pack-check.test.mjs`                                                                                                                                                                                                                                                                                                                              | A top-level source with no doc passes `--strict` (a dotted template id such as `landing.page` included), or a test, spec or story file or a subfolder helper is flagged.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |

## Open questions

- **OQ1 — Should the envelope's `meta` carry the resolved integration set?**
  (`human-api`) An agent cannot currently tell from the output whether a thin
  result means "nothing matches" or "an integration failed to load".

---

### Architecture Component Size Cascade (`docs/architecture/component-size-cascade.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:component-size-cascade
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-07
owners: [cixzhang]
applies_to:
  [
    packages/core/src/SizeContext/,
    packages/core/src/ButtonGroup/,
    packages/core/src/InputGroup/,
    packages/core/src/Toolbar/,
    packages/core/src/SideNav/,
  ]
verified_by:
  [
    packages/core/src/SizeContext/SizeContext.test.tsx,
    packages/core/src/ButtonGroup/ButtonGroup.test.tsx,
    packages/core/src/InputGroup/InputGroup.test.tsx,
    packages/core/src/Toolbar/Toolbar.test.tsx,
    packages/core/src/SideNav/SideNav.test.tsx,
  ]
deciding_specs: []
---

# Component size cascade architecture



```json
{
  "scope": "global",
  "triggers": {
    "layout": ["INV1", "INV2", "INV3", "INV4", "INV5", "INV9"]
  }
}
```

## Purpose

A person should encounter coherent control sizing inside a component-owned group
without every child repeating the same prop. An explicit child choice must still
win, and components outside the declared cascade must keep their own size contract.

This record owns the shared runtime precedence and scope of Astryx's standard element
size cascade. Component and family contracts decide participation, geometry, and
intentional overrides.

## System model

The standard cascade has three roles:

1. A **participating consumer** accepts the standard `sm | md | lg` element-size
   axis and resolves it through the shared size context.
2. A **provider owner** establishes a default size for documented eligible
   descendants.
3. A **component default** supplies the fallback when no explicit or inherited
   value applies.

A consumer resolves one effective size in this order:

1. explicit component prop;
2. nearest active provider value; and
3. the consumer's documented component default.

The effective size then flows through the consumer's own geometry, rendered state,
and theming contract. This architecture does not decide those component-specific
mappings.

## Boundaries and invariants

- **INV1 — The standard axis is shared.** Participating components use the
  canonical standard element-size vocabulary `sm | md | lg`. A component with a
  genuinely different semantic axis remains outside this cascade and documents its
  own values.
- **INV2 — Participation is explicit.** A component inherits size only when its
  current component or family contract adopts the cascade. Merely exposing a prop
  named `size` does not enroll it.
- **INV3 — Explicit intent wins.** A participating consumer's explicit size prop
  MUST override every inherited provider value.
- **INV4 — The nearest provider wins.** Without an explicit prop, a participating
  consumer MUST use the nearest active provider's value. Nested provider scopes do
  not leak through one another.
- **INV5 — Local default is final.** Without an explicit prop or active provider
  value, the consumer MUST use its documented component default. The shared helper's
  generic default does not silently rewrite a component's published default.
- **INV6 — Null resets the inherited scope.** A provider value of `null` suppresses
  an outer inherited value for descendants in that scope, causing each consumer to
  use its own fallback unless another nearer provider applies.
- **INV7 — Provider ownership is declared.** A group or container provides only to
  the eligible descendant set named by its current contract. That owner decides
  whether it first inherits an outer size, resolves an explicit group prop, or
  intentionally starts a new scope.
- **INV8 — Propagation does not erase child ownership.** A provider supplies a
  default, not a forced value. Sibling descendants resolve independently, and one
  child's explicit override MUST NOT alter another child or the provider.
- **INV9 — Public, rendered, and visual size agree.** After resolution, a
  participating component's public value, rendered state, geometry, and theme-facing
  size metadata MUST describe the same effective size under its own contract.
- **INV10 — Exported transport is not component admission.** `SizeContext`,
  `useSize`, and `SizeProvider` are public exports and the current transport.
  Their public compatibility belongs to `architecture:public-component-api`.
  Their existence or use does not by itself authorize a new public size axis or
  enroll a component in the standard cascade.

## Allowed variation

- A provider owner may establish a fixed descendant size without consuming the
  cascade for its own layout when its current contract says so.
- A family may constrain which descendants inherit, or intentionally flatten a
  nested member to preserve connected geometry.
- A component may use another closed size vocabulary only outside this standard
  cascade; generic helper typing is implementation capability, not shared semantics.
- Themes may vary tokens or geometry for a resolved value without changing
  precedence or provider ownership.

## Change coupling

Focused review triggers when a change:

- adds or removes `useSize` or `SizeProvider` from a component;
- changes a participating component's explicit, inherited, or fallback precedence;
- changes a provider's eligible descendants or whether it inherits an outer scope;
- adds a nonstandard value to the shared `ElementSize` type; or
- makes rendered/theming size differ from the resolved public value.

Review checks the provider and representative children with explicit, inherited,
local-default, nested, and null-reset states.

## Owning code

- `packages/core/src/SizeContext/SizeContext.ts` owns shared resolution and
  transport.
- `packages/core/src/ButtonGroup/`, `packages/core/src/InputGroup/`,
  `packages/core/src/Toolbar/`, and `packages/core/src/SideNav/` own the current
  provider placement, inherited or fixed provider value, and descendant scope.
- Participating component and family contracts own admission, component defaults,
  eligible descendants, geometry, and intentional overrides.
- `architecture:public-component-api` owns export reachability and compatibility
  for `SizeContext`, `useSize`, and `SizeProvider`.
- `architecture:component-theming-surface` and component style owners own
  theme-facing size metadata and visual meaning after resolution.

## Deciding specs

None. Public admission of a new size concept still follows `spec:AST-002`; this
record governs only the already-adopted shared cascade.

## Promotion evidence gaps

This authority-only draft does not add tests. Before promotion to `current`, focused
evidence must directly cover:

- a nested `null` provider resetting an outer value to the consumer's local default;
- ButtonGroup and InputGroup inherited, explicit-group, and explicit-child
  precedence; and
- Toolbar intentionally starting a fixed scope beneath an unrelated outer provider.

Current SideNav tests already cover its fixed `sm` footer/action sub-scopes and
explicit child override. These gaps are verification work, not new semantics.

## Verification

| Invariant | Evidence                                                                                                                              | Failure signal                                                                                             |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| INV1–INV2 | `ElementSize` plus current component/family adoption records                                                                          | a nonparticipant inherits accidentally or a participant invents a parallel standard value                  |
| INV3–INV6 | `SizeContext.test.tsx` explicit/provider/default, nested-provider, null-reset, sibling, and rerender fixtures                         | inherited size overrides explicit intent, outer scope crosses a nearer provider, or local fallback is lost |
| INV7–INV8 | focused ButtonGroup, InputGroup, Toolbar, and SideNav provider-owner tests plus the shared sibling fixture                            | a provider reaches an ineligible descendant, changes scope silently, or one child changes another          |
| INV9      | the changed participant's owning component/family geometry, rendered-state, and theming evidence; representative, not exhaustive here | resolved size and painted or exposed effective size disagree                                               |
| INV10     | package export inventory plus `architecture:public-component-api` review                                                              | exported transport is treated as private, or transport use is treated as component admission               |

---

### Architecture Component Style Authoring (`docs/architecture/component-style-authoring.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:component-style-authoring
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-07
owners: [cixzhang]
applies_to: [packages/core/src/, packages/lab/src/]
verified_by:
  [
    internal/stylex-capabilities/scan.mjs,
    internal/eslint-plugin-astryx/no-border-shorthand.test.mjs,
    internal/eslint-plugin-astryx/no-stylex-null-override.test.mjs,
    internal/eslint-plugin-astryx/no-style-only-wrapper.test.mjs,
  ]
deciding_specs: []
---

# Component style authoring architecture



```json
{
  "scope": "global",
  "triggers": {
    "styling": ["INV1", "INV2", "INV3", "INV4", "INV5"]
  }
}
```

## Purpose

Astryx component styling should lower to valid maintained CSS, compose predictably
with supported consumer styling, keep relational state scoped to the intended
component, and avoid DOM wrappers with no runtime responsibility.

This record owns five component-level authoring outcomes. It does not redefine
semantic tokens, public theme targets, extensible prop axes, public styling props,
icon resolution, bidirectionality, or React Server Component boundaries.

## System model

Component-owned styling begins in Core or Lab component source and lowers through
the repository's maintained StyleX pipeline. The author chooses among three kinds
of work:

1. **CSS-resolvable presentation.** The browser can derive the visual state,
   relationship, responsive condition, fallback, or transition from CSS.
2. **Behavioral state.** JavaScript owns information or interaction that CSS
   cannot derive. The component exposes only the resulting style inputs to StyleX.
3. **Structure with responsibility.** A DOM element exists because it owns
   semantics, behavior, a ref, a formatting context, or spacing outside a child's
   border box—not merely to relocate styles.

The pinned StyleX capability registry determines which syntax and helpers the
maintained pipeline supports now. Capability entries and lint matchers are
versioned enforcement facts, not permanent architecture vocabulary. Capability-
dependent review is testable only when the registry's recorded StyleX version
matches the installed package at the audited revision. A mismatch is untestable,
not evidence of conformance or violation.

## Boundaries and invariants

- **INV1 — Component-local styles lower through StyleX.** Shipped,
  component-owned style declarations use the repository's maintained StyleX
  lowering path. Separately owned global CSS, theme-compiler output, and consumer-
  authored styling remain with their named owners and are outside this invariant.
  Component-local raw CSS remains in scope when the maintained StyleX path supports
  the same result.
- **INV2 — CSS owns CSS-resolvable presentation.** When the pinned browser and
  StyleX capability set can derive a visual state, relationship, responsive
  condition, fallback, or transition in CSS, component code does not recreate that
  presentation with React state, Effects, observers, or animation-frame
  bookkeeping. JavaScript remains appropriate for behavior or information CSS
  cannot derive.
- **INV3 — Declarations remain valid and statically composable.** Supported
  component StyleX input lowers to valid maintained CSS and preserves independent
  property composition without requiring runtime property-removal or
  property-key-deduplication semantics. Exact shorthand, reset, and null patterns
  are versioned tooling rules rather than permanent architecture vocabulary.
- **INV4 — Relational state is component-scoped.** Ancestor, descendant, and
  sibling styling reacts only to the intended component-owned relationship. State
  from an unrelated outer container MUST NOT activate an inner component's visual
  state. Scoped markers are the current mechanism, not the permanent contract
  wording.
- **INV5 — Every wrapper has a named responsibility.** A wrapper exists only when
  it owns a semantic role, behavior or ref, a formatting context the child cannot
  absorb without changing its contract, or spacing outside the child's border box.
  A wrapper that only relocates styles to one child is removable when that child
  owns a compatible style-bearing DOM surface. A wrapper may instead own that
  boundary when the child has no compatible surface. Source clarity alone is not a
  normative responsibility.

## Authoring guidance

The following guidance helps satisfy the invariants but does not independently
create permanent API or compatibility requirements:

- Check `internal/stylex-capabilities/CAPABILITIES.md` before adding raw CSS or a
  JavaScript workaround. Prefer the supported CSS-native mechanism.
- Use logical properties and values for direction-sensitive styling. The canonical
  bidirectionality owner decides exceptions and observable RTL behavior.
- Use longhands, fallbacks, and explicit reset values in the forms accepted by the
  current StyleX version and repository lint rules. Those exact accepted forms may
  evolve without changing INV3.
- Guard hover-only visual enhancement by hover capability while preserving an
  operable keyboard, click, and touch path under the interaction owner.
- Component-local geometry may use literals when no semantic theme role exists and
  the value is a named component design decision. Theme-token authority decides
  whether a reusable semantic role belongs in the portable token vocabulary.
- A secondary render path composed into a host may rely on that host's structure
  and styling. INV5 applies to the DOM owner for that path rather than requiring
  every branch to look like the component's default render.

A guidance deviation becomes an architecture violation only when it breaks an
invariant above or another current owner.

## Delegated authority

This record does not absorb adjacent audit rules:

- **Icons and inline SVG.** `architecture:icon-resolution-and-component-slots`
  and the Icon component contract own semantic glyph resolution. Inline SVG is a
  defect when it reimplements a glyph that belongs to the shared registry;
  component-owned non-glyph vector shapes remain allowed when their component owns
  semantics and accessibility. Icon-scope and global/theme resolution changes
  remain with the icon and theme-authoring owners.
- **Tokens and literals.** `architecture:theme-tokens` owns portable semantic
  values. This record does not establish a universal literal allowlist.
- **Targets, anatomy, and extensible axes.** `architecture:component-theming-surface`
  owns target admission, painting anatomy, inheritance/delegation, anti-
  proliferation, guaranteed properties, and which visual axes may be theme-
  extensible.
- **Public styling and DOM contracts.** `architecture:public-component-api` owns
  `BaseProps`, `xstyle`, `className`, `style`, ref reachability, and exceptions for
  rootless, SVG-owning, provider, overlay, or otherwise non-DOM-owning components.
- **Bidirectionality.** A separate current RTL/directionality owner must define
  universal logical-direction outcomes. Physical-property lint and helper syntax
  are enforcement evidence, not authority supplied by this record.
- **React module boundaries.** A separate current RSC/module-boundary owner must
  define client entry points, directive placement, mixed/server-safe barrels,
  built-output preservation, and RSC-usable exports. This record does not turn
  `'use client'` placement into style policy.

## Audit-row routing

This record supplies shared authority for:

- **Q6 / T14:** wrapper responsibility through INV5;
- the durable composability outcome behind **Q7** through INV3;
- **T8:** component-local StyleX lowering and CSS-over-JS ownership through
  INV1–INV2;
- the valid-lowering outcome behind **T9** through INV3;
- **T10:** CSS-over-JS ownership through INV2; and
- **T11:** relational-state isolation through INV4.

Other intended rows remain with existing or future owners:

- **Q4 / T17:** icon architecture and the Icon component contract;
- **T2b:** theme-token architecture; literal examples remain audit guidance;
- **T13:** the future current bidirectionality owner plus lint enforcement;
- **T18:** icon resolution/scope and theme-authoring normalization;
- **T19:** component-theming-surface axis admission plus public API reachability;
- **T20:** public-component-api and its BaseProps enforcement;
- **T27:** component-theming-surface; numeric target-count bands remain audit
  heuristics; and
- **T30:** the future current RSC/module-boundary owner.

## Change coupling

Focused style-authoring review triggers when a component change adds or alters:

- a raw/global stylesheet or component-local non-StyleX styling path;
- JavaScript state, an Effect, observer, or animation frame used only for
  presentation CSS can derive;
- a StyleX construct outside the pinned capability set;
- shorthand/reset/null behavior that can prevent independent consumer property
  composition;
- ancestor, descendant, or sibling visual state;
- a DOM wrapper around one component or one visual child; or
- a component-owned inline SVG that may represent a shared semantic glyph.

Review checks the invariant owned here and follows the delegated authority above
before assigning a verdict.

## Owning code

- `packages/core/src/` and `packages/lab/src/` — component-owned StyleX
  declarations and rendered structure.
- `internal/stylex-capabilities/` — versioned StyleX capability facts and scans.
- `internal/eslint-plugin-astryx/` — current authoring-pattern enforcement; lint
  implementation does not define permanent architecture by itself.

## Deciding specs

None. The icon, theme-token, component-theming, public-API, bidirectionality, and
RSC owners retain the delegated decisions above.

## Verification

| Invariant           | Evidence                                                                                       | Failure signal                                                                                                                                                          |
| ------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1                | StyleX capability scan plus component-source mutation                                          | Shipped component styling bypasses the maintained lowering path without another named owner, including component-local raw CSS where that path supports the same result |
| INV2                | Equivalent CSS/JS mutation fixtures and real-browser presentation                              | JavaScript recreates CSS-resolvable presentation, flashes intermediate state, or disagrees with CSS conditions                                                          |
| INV3                | StyleX compile fixtures and independent consumer-property override mutations                   | Input lowers to invalid CSS or changing one supported property cannot compose without removing another                                                                  |
| INV4                | Nested unrelated-owner browser fixture across hover, focus-within, active, and disabled states | Outer component state activates an inner component's visual treatment                                                                                                   |
| INV5                | Wrapper mutations in default and host-composed render paths                                    | Removing a wrapper changes no semantic, behavior/ref, formatting-context, outside-border-spacing, or required style-bearing DOM boundary                                |
| Capability registry | Registry generation against the installed StyleX package                                       | Recorded and installed StyleX versions differ, so capability-dependent cases are untestable                                                                             |

---

### Architecture Component Test Sufficiency (`docs/architecture/component-test-sufficiency.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:component-test-sufficiency
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-08
owners: [cixzhang]
applies_to:
  [
    packages/core/src/,
    packages/lab/src/,
    packages/charts/src/,
    packages/richtext/src/,
    packages/vega/src/,
  ]
verified_by: [component verification maps and their linked test evidence]
deciding_specs: []
---

# Component test sufficiency architecture



```json
{
  "scope": "global",
  "triggers": {
    "testing": [
      "INV1",
      "INV2",
      "INV3",
      "INV4",
      "INV5",
      "INV6",
      "INV7",
      "INV10",
      "INV11"
    ]
  }
}
```

## Purpose

Astryx component tests should give maintainers confidence that current promised
behavior will fail clearly when it regresses, without turning every possible input
combination or audit inventory row into a permanent test.

This record defines how current component, module, family, architecture, system,
design, and theme requirements plus applicable objective standards become a rational
observable test plan. It owns test sufficiency and test quality. It does not create
product behavior.

## System model

Test sufficiency starts from applicable authority, not from a universal list of
states every component must implement:

1. Assemble the component's current public behavior from every applicable current
   component, module, family, architecture, system, design, and theme record, plus
   applicable objective standards.
2. Identify the promises whose failure would harm a user, consumer, accessibility
   path, compatibility boundary, or regression-prone integration seam.
3. Partition inputs, states, and boundaries by observable behavior and risk.
4. Check whether existing evidence already fails for the distinct promise and
   partition. If it does, cite that evidence and add no duplicate test.
5. Select the evidence layer that can observe the failure: compile-time, unit/DOM,
   browser behavior, rendered pixels, accessibility tree, real assistive technology,
   documentation/schema validation, or review.
6. Add a new automated test only when no current mutation-sensitive evidence covers
   the promise at that layer.
7. Record the relationship in the owning verification map.

The number of tests follows the number of distinct behaviors and risks. It is not a
fixed target and does not equal the number of public props, source branches, audit
rows, or inventory entries.

## Boundaries and invariants

- **INV1 — Authority and evidence are distinct.** A test may prove behavior
  required by any applicable current component, module, family, architecture,
  system, design, or theme authority, or by an applicable objective standard. Where `spec:AST-029` permits observational audit, a test may
  also prove behavior exposed through an established public interface recorded in
  the audit receipt. That evidence may support a test-sufficiency verdict but MUST
  NOT create or settle product authority. Missing or draft-only authority remains
  a separately reported authority gap.
- **INV2 — Critical promises have regression evidence.** Tests cover every
  applicable critical promise and regression-prone seam named by the owning
  verification map. Critical promises include behavior whose failure breaks the
  primary task, accessibility, a public callback or state contract, compatibility,
  data integrity, cleanup/lifetime, or a documented composition boundary whose
  failure breaks a current consumer outcome or a named regression-prone integration
  seam.
- **INV3 — Previously fixed behavior defects remain protected.** A fix to a
  defect in a current observable behavior includes mutation-sensitive regression
  evidence at a capable layer that reproduces the broken outcome before the fix
  and passes after it. A documentation correction that leaves source, types,
  behavior, and public machine-readable schema unchanged is not a behavior-defect
  fix. Existing regression evidence remains unless authority removes the promise
  or equivalent evidence demonstrably subsumes the case.
- **INV4 — Partitions follow behavior and risk.** Inputs, states, platforms, and
  boundaries MAY share one representative case only when current evidence shows
  they exercise the same observable path and have no distinct regression risk.
  Different behavior, ownership, platform semantics, failure modes, or known
  regression history requires a separate partition.
- **INV5 — Assertions observe the contract.** Tests assert public behavior,
  owner-observable state, rendered user outcomes, accessibility semantics,
  compile-time consumer contracts, or external-system effects through a stable
  seam. A class, attribute, DOM shape, generated type, or source artifact is valid
  evidence when current authority explicitly makes it contractual or an `AST-029`
  receipt records it as the established public interface for this audit. Tests MUST
  NOT rely solely on private call order, private state shape, internal collaborators,
  or incidental structure when the promised outcome can be observed more directly.
- **INV6 — Evidence is mutation-sensitive.** A test counted as sufficient MUST fail
  when its promised behavior is removed, inverted, routed to the wrong owner, or
  otherwise materially broken. An assertion that recomputes the implementation,
  repeats a type/source declaration, checks a constant against itself, or snapshots
  unrelated output does not prove the promise.
- **INV7 — Coverage remains bounded and intentional.** Test volume has a named
  behavioral, evidence-layer, or risk reason. Multiple evidence layers are not
  duplicates when each observes a distinct required contract boundary, such as
  compile-time API, DOM semantics, browser behavior, or assistive-technology
  output. Repeated cases within the same partition, broad defensive matrices,
  exhaustive combinations, and snapshot volume without additional behavioral
  signal MUST NOT be required or credited as sufficiency.
- **INV8 — Verification maps own durable evidence relationships.** Verification
  maps in applicable current component, module, family, architecture, system,
  design, and theme records name the critical contract, representative partitions,
  evidence seam, and mutation or failure signal. When
  no current map exists, the `spec:AST-029` audit receipt records the provisional
  evidence relationship; the missing map is an authority or traceability gap, not
  by itself a test-coverage gap. One test MAY prove several requirements, and one
  requirement MAY need several tests. There is no one-row-to-one-test rule.
- **INV9 — Audit remediation follows its process owner.** Audit-authored behavior-
  test remediation follows `spec:AST-029/DEC-4`: establish the current or
  established public seam, demonstrate the new case red before production changes,
  and implement one minimal observable vertical slice. Store that chronology in
  the pull-request or audit-run evidence. Red/green history is change evidence; it
  is not part of standing suite sufficiency after the change lands.
- **INV10 — Gaps and hardening are distinct.** Missing coverage is a gap only when
  an applicable critical promise or known regression-prone partition lacks
  mutation-sensitive evidence. Additional equivalent inputs, private implementation
  checks, speculative platform combinations, and defensive edge cases without a
  named risk are optional hardening and do not lower the sufficiency verdict.
- **INV11 — New tests require a new proof obligation.** Classify the changed
  claim, not the file edited. A change MUST NOT add a component functional test
  merely because it edits documentation, private styling source, a private or
  generated class name, a private wrapper, or another implementation detail. A
  current public class or target contract is not covered by that exception and
  requires focused contract evidence when changed. New automated evidence is
  required only when the change adds or repairs a distinct current promise or risk
  partition that existing evidence cannot detect at the correct layer. Otherwise
  the change cites existing evidence or uses the applicable non-test proof, such as
  typed documentation validation, source review, or rendered browser evidence.

## Rational partitioning

A partition is defined by a difference that can change the promised outcome or its
risk. Representative dimensions include:

- semantic state or transition;
- controlled, uncontrolled, optimistic, rejected, or interrupted ownership where
  the current contract distinguishes them;
- enabled, disabled, read-only, busy, required, or invalid behavior where
  applicable;
- input modality, direction, browser capability, or platform when the owner or an
  objective standard predicts different behavior;
- zero, one, many, empty, overflow, boundary, or invalid data when the algorithm or
  user outcome changes;
- default, composed, replaceable-part, nested-owner, portal/top-layer, or teardown
  paths when ownership or lifetime changes; and
- exact previously broken inputs and seams.

Inputs are equivalent only when evidence supports the same observable contract,
outcome, owner, and regression risk. A shared implementation path may support that
judgment, but it is neither required nor sufficient. Different literal values do
not require separate cases when none of those dimensions changes.

## When no new test is warranted

A correct change may require **no new automated test**. Reviewers stop adding tests
when existing evidence already detects the affected promise, or when an automated
component test cannot observe the changed claim.

| Change                                                                                                                                    | Required evidence                                                                                         | Do not add                                                                                     |
| ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Consumer-doc prose or example is corrected to match unchanged current source, types, and behavior                                         | Existing doc parser/typecheck/schema validation when applicable, plus review against the canonical source | A component runtime test that repeats the prose, prop name, or example                         |
| A recurring deterministic docs/source relationship is missing a guard                                                                     | One generalized validator at the shared source-of-truth boundary                                          | One component-specific test per corrected document                                             |
| Visual paint or geometry values change while the set of states, selectors, state ownership, timing, and public structure remain unchanged | Real-browser before/after evidence and the applicable stable visual-regression path                       | A jsdom assertion that a private class exists, or a functional test that cannot observe pixels |
| A private class, wrapper, StyleX declaration, or DOM arrangement changes without changing a current structural contract                   | Existing behavior evidence plus rendered evidence when appearance changes                                 | An assertion that freezes the new private implementation                                       |
| Existing mutation-sensitive evidence already covers the same promise and partition                                                        | Link the existing evidence and show why its failure signal covers this change                             | A second test with equivalent setup and assertions                                             |
| A refactor preserves all observable contracts                                                                                             | Run the applicable existing suites and review the unchanged public seams                                  | A test whose only purpose is to restate the refactor's new structure                           |
| A machine-readable public schema, documented public target/class, or compile-time consumer contract changes                               | Focused contract/inventory/type evidence owned by that public surface                                     | A broader functional test when the structural contract check directly detects the failure      |

A visual implementation and its private class can be wrong together. For example,
making a Button red and asserting that the new private class name is present proves
neither the rendered color nor the user's visual outcome. The class assertion is
valid only when current authority makes that exact class a public compatibility
contract; even then, it proves class reachability, not pixels.

Documentation can itself carry a public machine-readable contract. In that case,
test its parsed schema or generated projection at the shared boundary. Do not freeze
ordinary prose word-for-word or duplicate source declarations in component tests.

A documentation correction that exposes an untested behavior defect follows the
behavior rule instead: first identify the current promise and uncovered partition,
then add the smallest observable regression test for that behavior—not for the
wording that revealed it.

## Test-quality exclusions

The following do not establish sufficiency by themselves:

- a type test that merely copies a declaration without exercising the consumer's
  compile-time contract;
- a tautological expected value calculated by the same algorithm as production;
- a test added beside a new private class or StyleX declaration that only asserts
  that exact class or declaration is present;
- a DOM/class assertion used as a substitute for a visual claim such as color,
  spacing, alignment, clipping, or motion;
- a test that freezes ordinary documentation prose or repeats an example already
  validated by the shared documentation/type pipeline;
- assertions against private function calls, hook internals, or implementation
  ordering when public behavior is observable;
- snapshots whose changed bytes do not identify the promised behavior;
- repeated cases with no distinct behavior or risk;
- Cartesian products or adversarial inputs generated without a current promise,
  objective standard, prior defect, or named failure hypothesis;
- tests that only prove rendering did not throw, unless crash-free rendering at that
  named compatibility boundary is the promised observable outcome; and
- tests added after a fix without evidence that the new case could detect the
  original defect.

These tests may support debugging or optional hardening. They are not credited as
the only evidence for a critical promise.

## Missing authority versus missing coverage

Auditors classify the boundary before recommending tests:

- **Current behavior authority, sufficient evidence:** no coverage gap.
- **Current behavior authority, missing or insensitive evidence:** test-coverage
  gap.
- **No current behavior authority:** product-authority gap; route to the canonical
  owner before prescribing expected output.
- **Draft authority only:** private owner hold; a test may preserve current facts
  but cannot make the draft decision current.
- **Objective standard applies independently:** test the required observable
  standard outcome and link the standard owner.
- **Existing evidence covers an equivalent partition:** optional hardening, not a
  gap.

## Verification-map contract

A verification row identifies:

- the current requirement or group of inseparable requirements;
- the stable observable seam and evidence type;
- representative partitions and why they are sufficient; and
- the mutation or failure that must make the evidence fail.

Verification maps do not enumerate every audit inventory row, source branch, prop,
or test file. They point to evidence at the level needed to keep the durable
promise reviewable.

## Future audit-rubric migration handoff

This architecture does not own rubric weights, score computation, versioning
implementation, or wiki editing. It defines the test-sufficiency authority that a
separate rubric activation MUST consume. When this record becomes current, that
separately reviewed activation replaces Component Audit Rubric section 4's generic
product-behavior checklist as follows:

- retire **B1–B16** as product-behavior rule IDs;
- do not reuse those IDs and do not renumber unrelated stable audit IDs;
- preserve the weighted **Behavior** scoring slot;
- populate that slot from every applicable current component, module, family,
  architecture, system, design, and theme behavior requirement plus applicable
  objective standards;
- score the **Behavior** slot from observed conformance to the assembled applicable
  behavior requirements and objective standards;
- score the **Testing** slot from INV1–INV8 and INV10–INV11 evidence sufficiency
  and quality, and retain INV9 as pull-request or audit-run evidence;
- exclude non-applicable requirements and keep the rubric's existing explicit
  unmeasured treatment when required evidence cannot be obtained;
- preserve one-defect/one-section ownership so one missing behavior or test is not
  deducted twice; and
- bump the rubric minor version because replacing B1–B16 changes scoring method and
  comparability.

The future activation maps the former rows as follows:

| Former rows | Future source                                                                                                                                                   |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B1–B11      | Every applicable current component/module/family/architecture/system/design/theme behavior requirement and objective standard; no generic replacement behaviors |
| B12         | Already retired in rubric v1.16; keep the ID reserved                                                                                                           |
| B13         | Named risk partitions and documented composition seams from applicable current authority; no open-ended adversarial matrix                                      |
| B14         | Applicable current component/module/family/architecture/system/design/theme contracts and objective standards for the owning behavior layer                     |
| B15         | Current critical promises and regression-prone seams under INV2–INV4; no generic latent-regression catch-all                                                    |
| B16         | Retire from Behavior; preserve the PR-only red/green obligation under `spec:AST-029/DEC-4` and the Testing/review process                                       |

This specification PR does not edit the wiki rubric, scoring code, audit prompt,
tests, or tooling. The replacement activates only after this record is current and
the separately reviewed rubric minor-version change lands.

## Change coupling

Focused test-sufficiency review triggers when a change:

- adds or removes a current behavioral promise;
- changes a verification map or its linked evidence;
- fixes a defect or closes an audit coverage gap;
- claims several inputs/states are equivalent;
- adds a broad matrix, snapshot family, implementation-coupled test layer, or a
  new test for a docs/visual/private-implementation-only change; or
- changes which objective standard or platform distinction applies.

Review evaluates the changed requirement and representative unchanged partitions;
it does not require unrelated full-suite expansion.

## Owning code

- Applicable current component, module, family, architecture, system, design, and
  theme verification maps — promises, representative partitions, evidence, and
  failure expectations.
- Colocated tests and checked browser/accessibility bindings — executable evidence
  linked by those maps.
- This record — shared sufficiency, partitioning, assertion-quality, and gap-
  classification rules.

## Deciding specs

None. `spec:AST-029` continues to own audit backfill, evidence receipts, mode, and
Night Watch operation; it explicitly does not own the general component-test
quality rubric. Applicable current component, module, family, architecture, system,
design, and theme contracts plus objective standards remain the sources of expected
behavior.

## Verification

| Invariant         | Evidence                                                                                                             | Mutation or failure expectation                                                                                                                                                                                                                                                                                                           |
| ----------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV10–INV11 | Authority-vs-coverage, established-public-interface, and no-new-test decision fixtures                               | Missing product authority is hidden, established public-interface evidence is denied a sufficiency result, optional hardening blocks acceptance, a docs/value-only-visual/private-only change receives a duplicate or non-observing component test, or a current public class/target change is excused as a private implementation detail |
| INV2–INV3         | Critical-promise, behavior-defect, documentation-correction, and historical-regression fixtures                      | A primary, accessibility, callback, compatibility, composition, or previously fixed behavior defect loses its only mutation-sensitive evidence and still passes, or an unchanged prose-only documentation correction is required to add a regression test                                                                                 |
| INV4              | Rational-partition fixtures                                                                                          | Proven-equivalent values require duplicate tests, private implementation identity is required to establish equivalence, or behaviorally different states collapse into one case                                                                                                                                                           |
| INV5              | Public/runtime/compile-time seam replacement fixtures                                                                | Incidental private assertions pass while the promised user or consumer outcome is broken, contractual DOM/type/class/target evidence is incorrectly rejected, a DOM/class assertion is credited for pixels it cannot observe, or selector/state behavior is misclassified as a value-only visual change                                   |
| INV6              | Tautology, declaration-copy, snapshot-noise, and behavior mutations                                                  | An insensitive assertion receives credit, or a meaningful behavior mutation leaves the credited test green                                                                                                                                                                                                                                |
| INV7              | Same-partition duplicates, justified multi-layer evidence, exhaustive Cartesian, and broad defensive matrix fixtures | Distinct required evidence layers are collapsed as duplicates, or volume without a named distinct risk is credited as sufficiency                                                                                                                                                                                                         |
| INV8              | Verification-map relationship fixtures                                                                               | Every audit row mechanically becomes a test, evidence cannot be traced to authority, or one test cannot satisfy inseparable requirements                                                                                                                                                                                                  |
| INV9              | Pull-request and audit-run red/green trace fixtures                                                                  | Audit-authored remediation lacks a failing pre-change case, production remediation precedes the new case, or horizontal test generation replaces one observable vertical slice                                                                                                                                                            |
| B1–B16 migration  | Rubric-version, section-ownership, and Behavior-slot migration fixture                                               | Generic B rules remain product authority, B12 is reactivated or reused, test evidence is double-scored in Behavior and Testing, B16 remains a Behavior check, unrelated IDs are renumbered, the Behavior weight disappears, or scoring changes without a minor-version bump                                                               |

---

### Architecture Component Theming Surface (`docs/architecture/component-theming-surface.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:component-theming-surface
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-07
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/,
    packages/lab/src/,
    packages/charts/src/,
    packages/richtext/src/,
    packages/core/src/utils/themeProps.ts,
    packages/core/src/theme/themingTargets.test.ts,
    packages/core/src/theme/extensibleAxes.test.ts,
    packages/core/src/theme/derivedVarRegistry.test.ts,
    docs/contributing/api-conventions.md,
    docs/templates/knowledge/component-spec.md,
    scripts/check-knowledge.mjs,
  ]
verified_by:
  [
    scripts/check-knowledge.mjs,
    packages/core/src/theme/themingTargets.test.ts,
    packages/core/src/theme/extensibleAxes.test.ts,
    packages/core/src/theme/derivedVarRegistry.test.ts,
  ]
deciding_specs:
  [
    spec:AST-012/DEC-2,
    spec:AST-012/DEC-3,
    spec:AST-012/DEC-5,
    spec:AST-017/DEC-1,
  ]
---

# Component theming surface



```json
{
  "scope": "global",
  "triggers": {
    "theming": [
      "INV3",
      "INV4",
      "INV5",
      "INV6",
      "INV7",
      "INV8",
      "INV9",
      "INV10",
      "INV12",
      "INV14",
      "INV15"
    ]
  }
}
```

This record defines how component anatomy relates to the public theming API.

## Purpose

Theme authors need stable names for the visible component parts they can style,
without exposing every wrapper, slot, state, or implementation detail as a
permanent selector.

Component anatomy is the semantic inventory. The theming surface is an explicit,
qualified projection of that inventory—not a one-to-one copy.

## System model

A machine-readable block in each component spec maps every consumer-facing
anatomy entry to one theming disposition. The map is not included in generated
consumer docs:

1. **`target`** — this component promises a stable public target for the visible
   part, such as `selector-popup`.
2. **`inherits`** — the part has no separate target. It uses styles from this
   component's parent target, such as a Button label inheriting from `button`.
3. **`delegatesTo`** — another Astryx component owns the part and its target, such
   as a Selector label using `Field/field-label`.
4. **`none` with classified reason** — no current public target reaches this
   stable anatomy part. The required reason begins with exactly one factual
   classification:
   - `intentional:` — an approved boundary makes the part non-themeable now;
   - `reachability-gap:` — a current target should reach the part, but does not;
   - `unsettled:` — whether or how to expose the part still needs an owner decision.

`none` records current reachability. It does not by itself decide that a part must
remain unthemeable or authorize a future public target.

Every current, non-deprecated public target maps to an anatomy entry or to an
explicit family owner. Consumer `.doc.mjs` files continue to own anatomy names,
descriptions, and public target documentation. Component specs own the exact map
and explain only non-obvious rationale or exceptions.

Visual prop axes and states are selector capabilities of a target. They are not
separate anatomy parts or separate targets. Runtime `themeProps()` reflection,
public `.doc.mjs` `theming.targets[].visualProps` / `states` metadata, and checked
component-spec metadata describe those selector axes without exposing maintainer
dispositions to consumers; they do not declare which CSS properties have
guaranteed behavior.

An adaptation rule uses the same component target, axis, value-domain, and
extension validation as root `components`; it does not create a parallel
component-value policy. Built-in values and values accepted by authoritative open
primitive domains may be styled only inside a rule and need no matching root style
declaration. A custom value whose validity depends on theme enrollment is
introduced on the effective root `components` map, where build tooling can
generate unconditional module augmentation; adaptation rules may then restyle
that value under conditions.

The shared guaranteed-property catalog is:

| Category     | Exact authoring keys                                                                                                                                                                                            |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Paint        | `color`, `backgroundColor`, `opacity`, `outlineColor`, `outlineOffset`, `outlineStyle`, `outlineWidth`, `boxShadow`, `textShadow`                                                                               |
| Border/shape | `borderColor`, `borderStyle`, `borderWidth`, `borderRadius`                                                                                                                                                     |
| Typography   | `fontFamily`, `fontSize`, `fontStyle`, `fontWeight`, `letterSpacing`, `lineHeight`, `textAlign`, `textDecorationColor`, `textDecorationLine`, `textDecorationStyle`, `textDecorationThickness`, `textTransform` |
| Padding      | `padding`, `paddingBlock`, `paddingBlockStart`, `paddingBlockEnd`, `paddingInline`, `paddingInlineStart`, `paddingInlineEnd`                                                                                    |

The initial catalog contains every standard CSS key used by the seven shipped
package themes except `height`: `backgroundColor`, `borderColor`, `borderRadius`,
`borderStyle`, `borderWidth`, `color`, `fontFamily`, `fontSize`, `fontWeight`,
`lineHeight`, `padding`, `paddingBlock`, and `paddingInline`. It adds the explicit
paint, typography, and logical-padding keys needed to make those categories
coherent. `height` remains a target-specific addition because it changes layout.

The catalog is a vocabulary, not an automatic guarantee on every target. Each
target's `guaranteedProperties` lists the exact rational subset it supports from
this catalog and any exact reviewed target-specific additions. Omission means
best effort; no exception entry is required.

The list is literal. A listed shorthand does not imply its longhands; a logical
property does not imply a physical counterpart; and one property does not imply
an alias such as `background`, `inlineSize`, or `outline`. Properties outside the
catalog, including `height`, `width`, `gap`, positioning, overflow, and grid/flex
structure, require an explicit target addition to become guaranteed.

Every declared property must produce a rational, observable result on the anatomy
part the target owns. Generic component styling may accept other CSS properties,
but acceptance alone is best effort, not a compatibility promise.

## Boundaries and invariants

- **INV1 — Participation is capability-based.** Core components participate.
  Lab components are exempt until they opt into public component theming or are
  prepared for promotion. At that point they must provide the same targets,
  metadata, anatomy dispositions, and validation as other participating
  components. Consuming semantic tokens alone does not enroll a component.
- **INV2 — Anatomy anchors public targets.** Every current target represents a
  stable semantic part, either locally or through an explicit family owner.
- **INV3 — Anatomy does not imply target proliferation.** Every participating
  anatomy entry has one component-spec mapping, but inheritance, delegation, and
  factual `none` are valid outcomes. A `none` reason distinguishes an intentional
  current boundary from a reachability gap or unsettled future exposure; it never
  silently decides future themeability. When desktop and touch render different
  parts or use different theming owners, they use separate anatomy rows.
- **INV4 — Targets paint.** A target belongs on a stable visible element that
  paints theme-controlled output—not an event wrapper, speculative internal
  structure, or a node created only for layout plumbing.
- **INV5 — Composition preserves ownership.** A part rendered by a shared Astryx
  primitive delegates to that primitive's target unless the parent guarantees a
  distinct public visual contract.
- **INV6 — State stays on the owning target.** Variant, size, selection, disabled,
  and interaction state are reflected as target capabilities through
  `themeProps`; they do not create parallel targets solely for each state.
- **INV7 — Guarantees use one catalog and exact target declarations.** The shared
  catalog defines the supported vocabulary, not an automatic target promise. Each
  target's `guaranteedProperties` lists the rational catalog subset it guarantees
  and any reviewed target-specific additions. Every listed property must produce
  an observable result on the owned anatomy part, with representative compiler
  and runtime evidence.
- **INV8 — Names imply nothing beyond themselves.** No shorthand, longhand,
  logical/physical counterpart, alias, or custom property is guaranteed unless
  its exact authoring key appears in that target's declaration. Omitted properties
  remain best effort; no exception mechanism is needed.
- **INV9 — Generic styling is best effort.** A target may accept CSS properties
  outside its declared guaranteed set through the generic styling pipeline. That
  acceptance does not promise a useful effect or compatibility across releases.
- **INV10 — Public semantic variables are admitted, not inferred.** When no
  guaranteed CSS property can express a caller-owned need, a component may expose
  a reviewed public custom property with purpose-based meaning, stable default or
  fallback, defined target/state scope, consumer docs, and compatibility
  coverage. It must pass the admission bar in
  `architecture:public-component-api`; an implementation gap does not
  automatically justify a new variable.
- **INV11 — Private expansion stays private.** The compiler may translate a
  guaranteed public property into one or more private `--_*` variables so the
  owning component can route, compose, or transform the value across its internal
  painters. By contract, those variables must not be authored directly or relied
  on by consumers. Exact mappings remain checked code/metadata, not copied prose;
  known enforcement gaps are recorded in `architecture:theme-compilation`.
- **INV12 — Aliases are compatibility, not anatomy.** Removed target aliases do
  not count as current semantic parts. Starting in 0.7.0, runtime target emission,
  component metadata, CLI discovery, theme build, and generated docs expose only
  canonical targets. Maintained themes, CLI templates, and copyable examples use
  canonical keys. The breaking release preserves the complete old-to-new mapping
  and migration path below under `spec:AST-017`.
- **INV13 — Family ownership is explicit.** A family document may own a shared
  target for several components, but local docs link to that owner rather than
  leaving ownership to inference.
- **INV14 — Extensible axes need a theme-independent fallback.** A prop axis may
  be theme-extensible only when it is visual and an unavailable custom value has
  one safe, deterministic baseline that does not depend on the active theme.
  `Heading.type` qualifies because required `Heading.level` provides that
  baseline. `Icon.size` does not: no missing custom size can be inferred without
  silently changing geometry, alignment, or composition. Behavioral, structural,
  placement, directional, and state-machine axes remain closed regardless. A
  theme may redefine an existing value on a closed axis, but it may not add one.
- **INV15 — Conditional styling does not create conditional API.** Adaptation
  component writes use the same shared validation as root component writes. A rule
  may style an independently valid built-in or open-primitive value without
  duplicating that target or value in root `components`. A custom value that
  becomes valid only through theme enrollment must be enrolled on the effective
  root surface before a rule uses it, so generated types never depend on a media
  condition. An unresolved shared-validation result carries into adaptations
  unchanged and tightens automatically when the shared contract becomes
  authoritative; it is not a permanent adaptation exemption or an extension
  point.

## Approved deprecated-surface removal

Version **0.7.0** removes the deprecated component-theming compatibility surface
in this section. This is a breaking change under `spec:AST-017`; the release
includes the exact old-to-new mapping and a staged migration codemod.

The approved target-alias migration is:

| Deprecated target             | Canonical target         |
| ----------------------------- | ------------------------ |
| `base-table`                  | `table`                  |
| `checkbox`                    | `checkbox-indicator`     |
| `codeblock`                   | `code-block`             |
| `codeblock-copy-button`       | `code-block-copy-button` |
| `codeblock-header`            | `code-block-header`      |
| `codeblock-title`             | `code-block-title`       |
| `date-input-clear-icon`       | `input-clear-icon`       |
| `date-range-input-clear-icon` | `input-clear-icon`       |
| `hovercard`                   | `hover-card`             |
| `multi-selector-clear-icon`   | `input-clear-icon`       |
| `navicon`                     | `nav-icon`               |
| `popover-surface`             | `popover`                |
| `progressbar`                 | `progress-bar`           |
| `progressbar-fill`            | `progress-bar-fill`      |
| `progressbar-mark`            | `progress-bar-mark`      |
| `progressbar-track`           | `progress-bar-track`     |
| `radio`                       | `radio-indicator`        |
| `radio-dot`                   | `radio-indicator-dot`    |
| `selector-clear-icon`         | `input-clear-icon`       |
| `statusdot`                   | `status-dot`             |
| `textarea`                    | `text-area`              |

The 0.7.0 removal also deletes the compatibility machinery used only for those
aliases: the `themeProps(..., {legacyNames})` option,
`ThemePropsOptions.legacyNames`, `ComponentThemingTarget.deprecatedFor`, and
alias-only filtering or discovery behavior.

Deprecated bare prop/value and state selector classes such as `.primary`, `.sm`,
and `.checked` are no longer emitted. Their stable replacement is the canonical
`astryx-*` target plus its explicit reflected `data-*` attribute. Semantic
`defineTheme({components})` keys remain supported; only the 21 deprecated target
spellings in the table above move to their canonical keys. Because an unqualified
bare class does not identify its prop/state axis and may be consumer-authored, the
codemod leaves it for manual review.

Canonical target names, semantic component keys, reflected `data-*` selectors,
and current target behavior are outside this removal authorization and remain
supported. Built-in and generated themes must migrate to canonical targets before
the aliases disappear. Static inline theme keys receive a supported migration;
dynamic theme objects and custom CSS receive the complete mapping above.

This record does not own semantic token definitions, theme authoring precedence,
how themes become output, or the design rationale for a component's appearance.

### DEP-0013 — Deprecate the 21 component-theming target aliases

| Field            | Value                                                                                                                                                                                                                                                                                                                                                                                                    |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id               | `DEP-0013`                                                                                                                                                                                                                                                                                                                                                                                               |
| cleanup          | `CLN-0022`                                                                                                                                                                                                                                                                                                                                                                                               |
| package          | `@astryxdesign/core`, `@astryxdesign/cli`                                                                                                                                                                                                                                                                                                                                                                |
| surface          | Theme target names: the 21 aliases in the mapping table above, as `defineTheme({components})` keys and as `.astryx-<alias>` CSS classes, plus the machinery that exists only for them: `themeProps(..., {legacyNames})`, `ThemePropsOptions.legacyNames`, `ComponentThemingTarget.deprecatedFor`, and alias-only discovery and filtering                                                                 |
| old contract     | Each alias is emitted beside its canonical target on the same element, accepted by theme build, and listed by CLI discovery                                                                                                                                                                                                                                                                              |
| replacement      | The canonical target in the mapping table above, as both theme key and CSS class                                                                                                                                                                                                                                                                                                                         |
| direct authority | `architecture:component-theming-surface` INV12 and this section. Owners: `cixzhang`, `imdreamrunner`                                                                                                                                                                                                                                                                                                     |
| warning          | Theme build warns `Deprecated component target "<alias>". Use "<canonical>" instead.`; CLI discovery labels each alias with its replacement                                                                                                                                                                                                                                                              |
| migration        | Run `astryx upgrade --from <0.6 version> --apply --path .`; review each `TODO(astryx upgrade)` it adds                                                                                                                                                                                                                                                                                                   |
| codemod          | `migrate-deprecated-theme-surface`: renames static keys in theme `components` maps and alias classes in CSS. It adds a TODO when an old and a canonical key coexist. The four clear-icon aliases keep their owner scope in CSS with `:where(.astryx-<owner> *)`; a theme key cannot carry that scope, so the codemod adds a TODO. Computed keys and CSS inside strings are reported cases, not rewritten |
| downstream       | Maintained themes, CLI templates, and copyable examples use only canonical keys; the bundled and probe themes build with no alias                                                                                                                                                                                                                                                                        |
| transition       | `no prior transition`                                                                                                                                                                                                                                                                                                                                                                                    |
| state            | `proposed`                                                                                                                                                                                                                                                                                                                                                                                               |
| target plan      | `CLN-0022` in 0.7.0                                                                                                                                                                                                                                                                                                                                                                                      |

### CLN-0022 — Canonical theme targets only

| Field     | Value                                                                                                                                                                                                                                                                    |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| id        | `CLN-0022`                                                                                                                                                                                                                                                               |
| lifecycle | `DEP-0013`                                                                                                                                                                                                                                                               |
| delta     | Components stop emitting the 21 alias classes. Theme build treats an alias key as an unknown component and suggests the canonical key. CLI discovery and generated docs list only canonical targets. `legacyNames`, `ThemePropsOptions`, and `deprecatedFor` are removed |
| rollback  | Restore alias emission, metadata, and discovery from the final 0.6 baseline                                                                                                                                                                                              |
| state     | `pending` — lands in a declared minor with its `[breaking]` Changeset                                                                                                                                                                                                    |

### DEP-0014 — Deprecate bare prop and state classes

| Field            | Value                                                                                                                                                                                                                                                                                                                                                                                                 |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id               | `DEP-0014`                                                                                                                                                                                                                                                                                                                                                                                            |
| cleanup          | `CLN-0023`                                                                                                                                                                                                                                                                                                                                                                                            |
| package          | `@astryxdesign/core`                                                                                                                                                                                                                                                                                                                                                                                  |
| surface          | Rendered CSS classes: one bare class per visual prop or state value on every themed element (`.primary`, `.sm`, `.checked`; a value that starts with a digit keeps its prop prefix, such as `.level-2`), repeated on alias targets                                                                                                                                                                    |
| old contract     | A selector that qualifies an Astryx target with a bare value class, such as `.astryx-button.primary`, matches that prop or state                                                                                                                                                                                                                                                                      |
| replacement      | The canonical target plus its reflected `data-*` attribute, such as `.astryx-button[data-variant="primary"]`. Theme `components` keys are unchanged                                                                                                                                                                                                                                                   |
| direct authority | `architecture:component-theming-surface`, this section. Owners: `cixzhang`, `imdreamrunner`                                                                                                                                                                                                                                                                                                           |
| warning          | Consumer docs label the classes deprecated and name the replacement                                                                                                                                                                                                                                                                                                                                   |
| migration        | Run `astryx upgrade --from <0.6 version> --apply --path .`. Replace unqualified bare classes by hand, only where they target Astryx                                                                                                                                                                                                                                                                   |
| codemod          | `migrate-deprecated-theme-surface`: rewrites target-qualified bare classes in `.css` files to reflected `data-*` selectors, using every bare class the final 0.6 release emitted. It adds a TODO when a class Astryx once emitted has no known meaning on the target it qualifies. Unqualified classes cannot be classified, because a bare class does not name its axis and may be application-owned |
| downstream       | Generated CSS for every maintained theme contains no target-qualified bare class; the probe theme and visual gate exercise only `data-*` selectors                                                                                                                                                                                                                                                    |
| transition       | `no prior transition`                                                                                                                                                                                                                                                                                                                                                                                 |
| state            | `proposed`                                                                                                                                                                                                                                                                                                                                                                                            |
| target plan      | `CLN-0023` in 0.7.0                                                                                                                                                                                                                                                                                                                                                                                   |

### CLN-0023 — Reflected attributes only

| Field     | Value                                                                                                                      |
| --------- | -------------------------------------------------------------------------------------------------------------------------- |
| id        | `CLN-0023`                                                                                                                 |
| lifecycle | `DEP-0014`                                                                                                                 |
| delta     | `themeProps()` emits one canonical target class and reflected `data-*` attributes; no bare prop or state class is rendered |
| rollback  | Restore bare-class emission from the final 0.6 baseline                                                                    |
| state     | `pending` — lands in a declared minor with its `[breaking]` Changeset                                                      |

## Change coupling

- Adding or renaming a `themeProps()` target updates its component-spec map,
  `.doc.mjs` public target metadata, generated/CLI discovery, and validation
  together. Copyable examples and maintained themes use canonical keys.
- The 0.7.0 deprecated-surface removal preserves every canonical target and
  semantic key, migrates built-in/generated themes first, and supplies the exact
  alias and bare-selector migration evidence named above.
- Adding consumer anatomy requires a deliberate component-spec mapping to a
  target, inheritance, delegation, or factual `none`. It does not automatically
  create CSS API. A `none` entry classifies the current state as `intentional`,
  `reachability-gap`, or `unsettled`; changing reachability updates the mapping,
  while future exposure still follows normal public-target review.
- Opening a prop axis to theme-defined values classifies the axis and records its
  no-match fallback in the owning component contract or governing system spec.
  Review proves the axis is visual, the fallback is safe and theme-independent,
  and every ineligible axis stays closed. A focused component test covers the
  custom value with no matching active theme rule. `extensibleAxes.test.ts`
  checks only the structural wiring: the public map, `themeProps()` reflection,
  and consumer metadata.
- Adding a property to a target's `guaranteedProperties` records its purpose and
  scope, proves a rational observable effect on the owned anatomy part, and adds
  representative compiler/runtime coverage. Catalog membership alone does not add
  a target guarantee; target-specific behavior outside the catalog is admitted by
  the same reviewed declaration. Common property-to-variable behavior belongs to
  theme compilation.
- Adding a public semantic custom property first shows why no guaranteed CSS
  property expresses the need, then updates its component contract, consumer
  docs, stable fallback/default, scope, runtime evidence, and compatibility
  coverage together. It is not the default response to an unsupported property.
- Moving target ownership to a family updates every member's disposition and
  link in the same reviewed change.
- Expanding participation to another package first extends the consistency guard
  to that package; partial public targets without metadata are defects.

## Owning code

- Component `.doc.mjs` `usage.anatomy[]` owns the consumer-facing semantic part
  inventory and contains no theming disposition or maintainer rationale.
- Component `.doc.mjs` `theming.targets[]` owns discoverable canonical public
  targets and their visual prop/state selector axes. Removed aliases are not
  present in this metadata and do not count as anatomy. `visualProps` does not
  declare CSS property support.
- The approved property catalog is owned by this record. Target
  `guaranteedProperties` declarations will live in component `.doc.mjs`
  `theming.targets[]` once the authoring schema supports them; there is not yet a
  machine-readable field.
- Component `.doc.mjs` `theming.vars[]` owns documented custom properties and
  marks private implementation variables; `theming.derived[]` records checked
  public-property expansion metadata.
- A colocated component `.spec.md` `### Theming anatomy` block owns the exact,
  machine-readable anatomy-to-target dispositions. It is optional during
  migration, checked against consumer anatomy, and excluded from generated
  consumer docs. Nearby component-spec prose owns non-obvious rationale,
  exceptions, and links.
- Runtime `themeProps()` calls emit one canonical target and reflected `data-*`
  state/prop selectors.
- CLI discovery and theme build consume canonical target metadata; copyable
  examples omit removed aliases.
- `scripts/check-knowledge.mjs`, `themingTargets.test.ts`,
  `extensibleAxes.test.ts`, and `derivedVarRegistry.test.ts` enforce the
  source/metadata/generated relationship. `extensibleAxes.test.ts` proves only
  structural consistency; component contracts, review, and focused component
  tests prove whether an axis may be open and whether its fallback is safe.
- Family contracts own shared target semantics when multiple components adopt
  one part contract.

## Deciding specs

- `spec:AST-012/DEC-2` and `spec:AST-012/DEC-3` constrain adaptation component
  writes to the closed ordered rule model. `spec:AST-012/DEC-5` makes component
  validation shared with root themes and reserves effective-root presence for
  values whose validity depends on theme enrollment.
- `spec:AST-017/DEC-1` owns published compatibility classification and migration:
  compatibility-path removal is breaking. This record owns the exact deprecated
  theming cohort and its approved 0.7.0 removal window.

## Verification

| Invariant    | Evidence                                                                                                                                                                         | Failure signal                                                                                                                                                                |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1         | Cross-package target/documentation inventory                                                                                                                                     | An exported target in a participating package is invisible to metadata or CLI validation                                                                                      |
| INV2, INV3   | Bidirectional anatomy-disposition/target check                                                                                                                                   | A current target has no semantic part owner, anatomy mechanically creates targets, or `none` silently becomes future policy                                                   |
| INV4, INV5   | Component review plus rendered DOM inspection                                                                                                                                    | Public target lands on non-painting plumbing or aliases a child primitive without distinct semantics                                                                          |
| INV6         | `themingTargets.test.ts` and `extensibleAxes.test.ts`                                                                                                                            | State/variant is invisible to the owner target or becomes an unnecessary parallel target                                                                                      |
| INV7, INV8   | Existing property fixtures (partial; gaps below)                                                                                                                                 | A declared property is missing evidence, or an unlisted counterpart is treated as implied                                                                                     |
| INV9         | API docs and compatibility review                                                                                                                                                | Generic property acceptance is presented as a supported compatibility promise                                                                                                 |
| INV10, INV11 | Existing registry/public-var/runtime tests (partial; gaps below)                                                                                                                 | A public semantic var bypasses admission, or a consumer must write a private var to reach promised behavior                                                                   |
| INV12, INV13 | Canonical-only runtime emission and discovery, removed-alias rejection, complete codemod mapping/tests, canonical maintained-theme/template inventory, and family-owner fixtures | A removed alias is emitted or discovered, the migration mapping is incomplete, canonical targets change, removed compatibility metadata survives, or ownership stays implicit |
| INV14        | Component contract, owner review, focused no-match fallback test, and structural `extensibleAxes.test.ts` coverage                                                               | An ineligible axis opens, a missing rule changes behavior unpredictably, or the map/reflection/docs wiring drifts                                                             |
| INV15        | Shared root/adaptation component-validation fixtures covering finite, open, enrolled, and unresolved domains                                                                     | Root and adaptation disagree, a valid open value requires a root style, a rule conditionally enrolls a custom value, or unresolved behavior becomes adaptation-specific       |

Known conformance and verification gaps:

- Component validation has unresolved domains while the shared contract remains
  incomplete. Root and adaptation surfaces carry the same result and diagnostic;
  this is shared validation debt, not an adaptation-specific exception or API
  admission. When the shared contract becomes authoritative, both surfaces tighten
  automatically. Independently valid built-in and open primitive values do not
  require matching root style declarations.

- The component schema has no machine-readable axis-classification or fallback
  field. `extensibleAxes.test.ts` therefore checks only structural consistency;
  it cannot infer whether an axis is visual or its fallback is semantically safe.
  Component contracts or governing system specs, owner review, and focused
  fallback tests provide that evidence without treating the structural guard as
  admission policy.
- Existing map-backed axes have not all been audited against INV14.
  `Pagination.variant` and `Banner.container` are known open structural axes and
  remain conformance gaps until their vocabularies are closed. Passing the
  structural guard does not make them eligible for extension.
- The shared catalog is approved, but the component-doc authoring schema has no
  `guaranteedProperties` field. The follow-up must add the exact-name field,
  migrate each current target with its rational catalog subset and reviewed
  additions, expose the declarations to discovery, and reject unknown or implied
  names. Until that migration lands, the catalog does not automatically guarantee
  any property on any target.
- CI does not yet require representative compiler and runtime evidence for each
  declared target/property pair. The follow-up must make missing output-path or
  observable runtime evidence fail validation; layout-sensitive additions also
  require internal-coupling and directionality evidence.
- Runtime/build private-variable rejection and media-surface derived expansion are
  separate compiler conformance gaps owned and specified by
  `architecture:theme-compilation`.

### Migration work from the 2026-08-30 audit

- Forty-three participating Core component docs have no anatomy inventory.
- Several anatomy docs rely on parent/family-owned targets without an explicit
  owner link.
- Lab components stay exempt until they declare public theming or prepare for
  promotion. The existing promotion guard checks participating Lab components.

Backfill component-spec maps and Core anatomy before making bidirectional
validation mandatory. The audit inventory is migration evidence, not policy.

---

### Architecture Container Padding (`docs/architecture/container-padding.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:container-padding
authority: current
archive_reason: null
superseded_by: null
approved_by: imdreamrunner
approved_at: 2026-10-04
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/Layout/container.stylex.ts,
    packages/core/src/Layout/padding.stylex.ts,
    packages/core/src/Layout/edgeCompensation.stylex.ts,
    packages/core/src/Card/,
    packages/core/src/Dialog/,
    packages/core/src/BottomSheet/,
    packages/core/src/Lightbox/,
    packages/core/src/MobileNav/,
    packages/core/src/Section/,
    packages/core/src/ScrollableArea/,
    packages/core/src/Layout/,
    packages/core/src/Divider/,
    packages/core/src/Item/,
    packages/core/src/List/,
    packages/core/src/TabList/,
    packages/core/src/Table/,
    packages/core/src/Toolbar/,
    packages/core/src/Layer/,
  ]
verified_by:
  [
    packages/core/src/Section/Section.test.tsx,
    packages/core/src/ScrollableArea/ScrollableArea.test.tsx,
    packages/core/src/Layout/Layout.test.tsx,
    packages/core/src/Layout/LayoutSlots.test.tsx,
    packages/core/src/Layout/overlayPaddingReset.test.tsx,
    packages/core/src/BottomSheet/BottomSheetPanel.test.tsx,
    packages/core/src/Layout/__tests__/edgeCompensation.test.tsx,
    packages/core/src/List/List.test.tsx,
    packages/core/src/TabList/TabList.test.tsx,
  ]
deciding_specs: [spec:AST-002/DEC-1, spec:AST-002/DEC-2, spec:AST-025/DEC-2]
---

# Container padding architecture



```json
{
  "scope": "global",
  "triggers": {
    "layout": ["INV1", "INV3", "INV6", "INV8", "INV9", "INV10", "INV11"],
    "public-api": ["INV3", "INV7", "INV9", "INV10", "INV11"]
  }
}
```

## Purpose

Padded containers and their descendants need one shared account of the inset at
each logical edge. Without it, a full-bleed child cannot cancel the padding it
actually received, a nested region cannot preserve one content line, and an
overlay can inherit geometry from a visual box it no longer occupies.

This record describes the shared internal protocol. It does not make its CSS
variables public API. `spec:AST-025/DEC-2` adds ScrollableArea as an explicit
participant with no padding and no bleed by default.

## System model

The protocol has four layers:

1. **Public component inputs and theme properties.** Component props use the
   public spacing scale. Theme component overrides may set supported padding
   properties. `architecture:theme-authoring-contract` and
   `architecture:component-theming-surface` own those public surfaces.
2. **Container lowering.** `container()` resolves Card, Section, Dialog, and
   BottomSheet padding into internal logical-edge variables. An explicit
   component padding prop may publish the same geometry through the maps in
   `padding.stylex.ts`.
3. **Descendant geometry.** Four inherited `--container-padding-*` variables
   carry the inset that bleed descendants subtract. Container publishers and
   explicit region-padding paths couple those variables to their padding.
   Layout also carries an outer/inner split through
   `--layout-padding-outer-*` and `--layout-padding-inner-*`; automatic region
   placement applies that split, but not every current path republishes the
   resulting edge exactly (see the conformance gap below).
4. **Boundary reset.** An overlay leaves its ancestor's visual box while
   remaining a DOM descendant. `overlayPaddingReset` zeroes values descendants
   subtract and invalidates values descendants add, so readers fall through to
   their own defaults. It does not clear public theme properties.

### Current and admitted publishers and consumers

| Role                       | Participants                                                   | Contract responsibility                                                                                                                                                  |
| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Container publisher        | Card, Section, Dialog, BottomSheet, ScrollableArea             | Resolve component padding or publish explicit content-box padding as logical-edge and Layout inset variables                                                             |
| Region publisher           | LayoutHeader, LayoutContent, LayoutFooter, LayoutPanel         | Publish baseline or explicit-padding geometry; automatic outer-edge publication has the conformance gap below                                                            |
| Bleed consumer             | Section, ScrollableArea, Layout, Divider, Table, TabList, List | Subtract inherited inset on the edges each component is designed to escape; ScrollableArea does so only with `isFullBleed`, TabList and List only with `edge="bleed"`    |
| Edge-compensation consumer | List/ListItem rows with `edge="outset"`                        | Move visible content toward the container content line while retaining component-owned interaction padding; exact alignment depends on each component's bounded geometry |
| Alignment consumer         | Toolbar and edge-compensating child components                 | Read the current inline inset to align visible content rather than stacked touch-target padding                                                                          |
| Boundary owner             | Layer surfaces and dialog-based overlay roots                  | Apply `overlayPaddingReset` before descendants read inherited page geometry                                                                                              |

The participant list is the shared container system, not a Layout-owned family:
Section and ScrollableArea publish content inset, Section and opt-in full-bleed
ScrollableArea instances consume inherited inset, Layout redistributes inset,
Table and Divider consume bleed geometry, TabList consumes bleed geometry with
`edge="bleed"`, List consumes edge-compensation geometry with
`edge="outset"` and bleed geometry with `edge="bleed"`, and Toolbar consumes
alignment geometry. Participation here does not by itself make any of those
components a member of `family:layout-regions`.

BottomSheet publishes the way Dialog does. Its Sheet panel is the overlay
boundary owner, and its scrolling Content area is a container publisher that
pads caller content and publishes the applied inset. The inset resolves from the
`padding` prop on the public spacing scale, then the theme's `padding` properties
on the `bottom-sheet` component, then `--spacing-4` on every logical edge,
matching Dialog. This prop and theme property are BottomSheet's projection of
container lowering.

Edge compensation is a two-sided geometry contract, not a token contract:

1. **Host side.** An eligible container or region state publishes the padding
   currently available at each logical edge through the existing private
   `--container-padding-*` variables. Those values must match that state's
   applied padding and follow nesting and overlay reset; missing values mean zero.
   A state with a known publication mismatch is ineligible. Publication does not
   itself opt a descendant into compensation.
2. **Component side.** A participating component owns the inset retained around
   its content and derives that inset from the same density- and theme-aware
   source as its normal padding. The component consumes only the logical edges
   its contract applies its public value to and never requires the container to
   understand its component tokens.

For a new self-compensating row or surface, the default eligible host state is
visually neutral: it contributes no background, border, clipping, or interactive
paint whose boundary would be undermined by the adjustment. A visibly bounded or
clipping state requires a component-specific contract and evidence for that host.
Controls and cards whose outer box is already the intended alignment boundary,
static content outside an admitted collection, and surfaces intended to reach the
outer container edge are otherwise ineligible. TabList is the existing specialized
projection: INV11 owns its wider host and overflow geometry.

The repository also has a container-owned slot-compensation mechanism. Ghost
Buttons and Tabs mark their transparent padding, while Banner and Toolbar move an
edge slot by their own known inset. It shares the content-alignment intent but
does not read inherited container padding or give those children a
caller-controlled compensation API. This revision leaves that mechanism
unchanged.

`edge` is the shared public vocabulary for edge treatment. Its values name where
a participating component's paint and content sit relative to the host's content
edges, and each states its bounded geometry:

- `padded`: the component keeps its own inset and reads no host padding. It is
  a self-compensating component's default, and omission means it.
- `flush`: the component removes its own inset, so content and paint share the
  component's edges. It reads no host padding.
- `outset`: paint reaches past the host content line by the smaller of the
  component's own inset and the host inset, and the component keeps its own
  inset inside that paint. Content reaches the host content line when the host
  inset is at least the component's inset.
- `bleed`: paint reaches the host's padding edge, consuming the full inherited
  inset. Content sits on the host content line, or at the component's own inset
  from its edge when the host inset is smaller.

A component applies a value to the logical edges its contract names, resolves
each edge independently, and exposes only the values its current contract and
evidence support. Outside an eligible host, `outset` and `bleed` read zero host
inset and place content as `padded` does. The amount stays component-owned
(INV9). This vocabulary does not promise that the component box reaches the
container's outer edge except under `bleed`, and new APIs use it rather than
exposing margin values or another name.

`isFullBleed` moves a component's whole visible surface to the host's padding
edge with no content line of its own to keep: Divider's line, and ScrollableArea's
viewport, whose content padding is its own prop. `bleed` makes the same promise
for paint and adds that content stays on the host content line. A new full-bleed
projection that keeps a content line uses `edge="bleed"`. TabList's
`isFullBleed` behaves as `bleed`, so `component:TabList` spells that mode
`edge="bleed"` and keeps `isFullBleed` as its deprecated alias.
`edgeCompensation` is the released name of `outset`: List's
`edgeCompensation="inline"` is a deprecated alias of `edge="outset"` through its
compatibility window.

A component with a documented anatomy container that applies edge treatment MAY
expose an anatomy-targeted `<anatomy>Edge` prop with the same values, applied to
that container's documented edges. It keeps the amount component-owned and the
component's documented omission behavior. DialogHeader's and DrawerHeader's
released `endContentEdgeCompensation` props are outside this form: their
`inline`, `block`, and `all` values choose the axes of the end slot's
compensation rather than a treatment. Both stay supported as released. How
those axis values map onto `endContentEdge` treatment values is an open
question owned by `module:Dialog/DialogHeader` and `component:Drawer`; each
deprecates its old prop through its own compatibility window once that mapping
is decided.

### Edge-compensation ledger

| Participant                       | Source requirement                                                                                                                                                                                      | Canonical owner                  | Why it belongs                                                                                                                                                             | Done criteria                                                                                                                                                                                           | Status                                                    |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| Container and region publishers   | The host owns the actual padding available at each logical edge                                                                                                                                         | `architecture:container-padding` | A consumer cannot compensate safely without a per-edge upper bound from the host                                                                                           | Eligible states publish values matching applied padding, nesting, and overlay reset; missing values resolve to zero; known mismatch states remain excluded                                              | current where conformant                                  |
| TabList / Tab                     | The strip moves its first and last visible tab content toward the host lines while retaining tab interaction padding, focus-ring room, overflow controls, its divider rail, and its selection indicator | `component:TabList`              | The shipped behavior is `bleed` under the released name `isFullBleed`                                                                                                      | Preserve bounded content movement, full host-inset consumption, divider span, overflow, clipping, scroll controls, indicator geometry, logical direction, compatibility, and defaults                   | current behavior; `edge="bleed"` canonical, alias pending |
| List / ListItem, using Item inset | An optional List header is not horizontally inset, while every row keeps Item's density- and theme-aware inline padding                                                                                 | `component:List`                 | The caller knows whether the List sits in a neutral container beside content that should share its logical content line, and whether its rows need room around their paint | `padded` unchanged by default; `outset` respects each row's own inset and the available host padding; `bleed` reaches the host's padding edge; header stays in place; required geometry evidence passes | current through `edge`; `edgeCompensation` deprecated     |
| Button                            | An uncontained ghost Button may need its icon or label aligned while retaining its interaction inset                                                                                                    | No current component projection  | Its transparent interaction padding can create the same alignment need                                                                                                     | Exclude filled and bordered presentations; prove composition, focus/hover paint, logical direction, and unchanged defaults; obtain owner approval                                                       | candidate only                                            |

`component:List` owns the only new API projection admitted by this revision,
its `edge` prop.
`component:TabList` owns its existing behavior and its `edge="bleed"` rename and
deprecation path. Button remains a candidate and cannot authorize
implementation; a future projection requires a component contract, evidence, and
the named owner's explicit approval.

This record adds no EdgeAlignment wrapper or new token vocabulary. Existing
container-padding participation remains the single host provider, and each
component contract owns its local retained inset and public projection.

`--_section-padding-propagated` is separate from the public
`--astryx-section-padding` property. The private value carries one ancestor
Section's explicit padding. An overlay can therefore drop ancestor geometry
without dropping the active theme's Section default.

## Boundaries and invariants

- **INV1 — Internal geometry is not theme API.** `--container-padding-*`,
  `--layout-padding-*`, and `--_section-padding-propagated` are implementation
  protocol. Themes use documented component properties instead of setting or
  depending on these names.
- **INV2 — Coupled publication paths stay coupled.** Where current source
  publishes geometry alongside a padding declaration—container lowering,
  Section's logical-edge overrides, explicit Layout-region padding, and
  full-bleed reset—the matching variable changes with that declaration. This
  invariant does not claim parity for every automatic Layout-region edge.
- **INV3 — Bleed is opt-in and edge-specific.** A descendant escapes padding
  only when its component contract opts into bleed, and only on the edges that
  contract covers. The presence of an inherited variable does not make every
  descendant full bleed.
- **INV4 — Layout distinguishes outer and inner edges.** Named Layout regions
  apply outer inset where they touch the shell and inner inset where they meet
  another region. Slot presence and each region's explicit padding select the
  applied edge styles. Exact descendant-variable parity for automatic outer
  edges is not guaranteed by this invariant.
- **INV5 — Section propagation has narrower authority than theme defaults.** An
  ancestor Section's explicit padding may propagate to nested Sections, but it
  does not replace the public theme property and does not cross an overlay
  boundary.
- **INV6 — Overlay boundaries terminate stale geometry.** The outermost styled
  overlay root zeroes `--container-padding-*`, invalidates
  `--layout-padding-*` and `--_section-padding-propagated`, and leaves public
  theme properties intact.
- **INV7 — Composition keeps component ownership.** This protocol carries
  geometry only. Section variants, Layout region semantics, Toolbar behavior,
  Table structure, Divider presentation, and component theming remain with
  their component or family contracts.
- **INV8 — Participation is explicit.** Padding alone does not enroll a
  component. Stack and Center currently apply local padding without publishing
  this protocol, so descendants cannot assume full-bleed compensation there.
- **INV9 — Edge compensation has two geometry owners, not shared tokens.** A
  participating host publishes only the padding currently available at each
  logical edge. A participating component owns the inset retained around its
  content and derives compensation from the same component-owned source as its
  applied padding. Neither side names or duplicates the other's theme tokens.
- **INV10 — The public vocabulary names intent.** `edge` names a component's
  edge treatment with the values `padded`, `flush`, `outset`, and `bleed`
  defined above. Each component contract states the edges a value applies to,
  its bounded geometry, and when content reaches the host line, and exposes only
  the values it admits. Omission means `padded`, except that an anatomy
  container keeps its documented omission behavior. `isFullBleed` remains for a
  surface whose public promise is to consume the full host inset without a
  content line of its own. `edgeCompensation` survives as List's deprecated
  alias of `edge="outset"` and in the released `endContentEdgeCompensation` of
  DialogHeader and DrawerHeader until their `endContentEdge` mapping is
  decided.
- **INV11 — Participation and host eligibility are explicit.** A new
  self-compensating row or surface requires a component contract and defaults to
  visually neutral host states. A component-specific contract may admit a bounded
  or clipping host only with evidence for that geometry. A host state whose
  published inset does not match its applied padding is ineligible until that
  conformance gap is fixed. Container publication alone never opts a child in,
  and arbitrary consumer CSS that changes a component's padding is outside the
  shared alignment guarantee.

### Known conformance gap

LayoutContent and LayoutPanel do not currently republish every automatic
outer-edge inset they apply:

- LayoutContent's no-start path changes inline-start padding and writes the outer
  value to both inline geometry variables, while its no-end path changes
  inline-end padding without changing the matching variable.
- LayoutPanel changes padding on shell-facing inline and block edges while its
  baseline geometry variables remain on the inner values.

A full-bleed descendant in those states can therefore compensate against a value
that differs from the region's applied padding. Explicit region padding and
`padding={0}` do update/reset the geometry variables. No current browser matrix
covers every slot and descendant-bleed combination, so this record does not claim
exact region parity. Correcting the mismatch is runtime work with compatibility
evidence, not part of this documentation stack.

## Change coupling

- Changing a publisher's padding precedence, logical edges, or container vars
  updates its declarations and the matching bleed/alignment evidence together.
- Adding a protocol participant identifies whether it publishes, consumes, or
  resets geometry and adds a focused cross-component regression before its
  contract relies on that role.
- An edge-compensation host publishes actual available inset per logical edge;
  it does not publish a token choice or infer a child's retained padding.
- A component exposing an edge treatment uses `edge`, or `<anatomy>Edge` for an
  anatomy container, declares only the values it currently supports, and keeps
  its effective inset and compensation on one derived source. Adding another
  value requires per-edge, asymmetric, zero-padding, RTL, and theme-override
  evidence.
- Moving an existing compensation or full-bleed API to `edge` preserves the old
  name as a deprecated alias through its approved compatibility window, moves
  maintained examples to `edge`, and tests both paths before removal.
- Changing `container()` or the public-to-private padding lowering reviews Card,
  Section, Dialog, BottomSheet, Layout regions, and overlay behavior together.
- Changing overlay hosting or adding an overlay root verifies that stale page
  geometry stops at the new boundary without suppressing public theme values.
- A component other than BottomSheet that wants a new public full-bleed mode or
  padding prop follows its component/family API review; this architecture does
  not authorize that API. `component:List` owns List's `edge` projection,
  including its full-bleed `bleed` value, and `component:TabList` owns its
  existing behavior and migration. This record does
  not authorize another component projection.

## Owning code

- `packages/core/src/Layout/container.stylex.ts` — lowers container theme and
  explicit padding into internal geometry.
- `packages/core/src/Layout/padding.stylex.ts` — owns public spacing-step maps,
  logical-edge setters, Section propagation, and the overlay reset.
- `packages/core/src/Layout/edgeCompensation.stylex.ts` — owns the container-side
  alignment adjustment for marked edge content.
- Card, Section, Dialog, BottomSheet, ScrollableArea, and Layout region
  implementations — publish their current geometry.
- Section, ScrollableArea, Layout, Divider, Table, TabList, List, and Toolbar
  implementations — consume the current geometry for bleed or alignment.
- TabList currently lowers its component-owned projection; List lowers its
  `edge` projection. `component:TabList` and `component:List` define their local
  padding, row/strip geometry, and private propagation.
- Overlay roots — apply the reset at the visual boundary.

## Deciding specs

- `spec:AST-002/DEC-1` and `spec:AST-002/DEC-2` require the caller-owned
  opt-in and its name to describe a dependable result rather than the margin or
  token mechanism.
- `spec:AST-025/DEC-2` and `component:ScrollableArea/FR5` establish the
  contrasting full-bleed contract: ScrollableArea consumes the full inherited
  inset only through explicit `isFullBleed`, with no content line of its own to
  keep. List's `edge="outset"` cancels at most its own Item inset and may stop
  inside the container padding. List's `edge="bleed"` and TabList's
  `isFullBleed` consume the full host inset with their paint and pad back in by
  `max(host inset - own inset, 0)`, so their content lands on the host line, or
  further in when the host inset is smaller than their own.

## Verification

| Invariant        | Evidence                                                                       | Failure signal                                                                                                                                       |
| ---------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV2, INV5       | `Section.test.tsx` per-edge and nested-propagation tests                       | A per-edge prop leaves a stale geometry variable, or nested Sections lose the shipped propagation order                                              |
| INV2, INV3, INV8 | `ScrollableArea.test.tsx` padding and full-bleed tests                         | The content box publishes inset different from its applied padding, or the viewport escapes inherited padding without explicit full bleed            |
| INV4             | Layout source review plus `Layout.test.tsx` and `LayoutSlots.test.tsx`         | Slot presence stops selecting the shipped applied outer/inner edge styles; exact republished geometry remains limited by the named conformance gap   |
| INV2             | `BottomSheetPanel.test.tsx` padding tests                                      | The Content area publishes an inset that differs from its applied padding, or the `padding` prop or theme `padding` does not reach it                |
| INV6             | `overlayPaddingReset.test.tsx`                                                 | An overlay inherits page inset, loses its theme's Section padding, or lets ancestor Section propagation cross the boundary                           |
| INV3, INV7       | `Layout/__tests__/edgeCompensation.test.tsx` plus component tests              | An unmarked child is compensated, or marked edge content loses direct-child discoverability                                                          |
| INV9, INV10      | `List.test.tsx` plus real-browser geometry evidence                            | The header moves; a row reads the wrong logical edge, diverges from themed/density padding, applies the wrong bounded movement, or loses paint inset |
| INV10, INV11     | `TabList.test.tsx` plus real-browser overflow, divider, and indicator evidence | Full host-inset consumption, bounded content movement, divider span, interaction, focus, overflow, scroll controls, or indicator geometry regresses  |

The List evidence must cover each `edge` value in its neutral root and in each
bounded host List admits (Card, Section, Dialog, BottomSheet), with the optional
header, interactive and static rows, all densities, a theme
`item.paddingInline` override, LTR and RTL, host inset smaller than/equal
to/larger than Item inset, uniform/asymmetric/zero/missing container padding,
hover/selection paint geometry, and a swiped row under `outset` and `bleed`. TabList uses its separately scoped evidence; Buttons, menu rows, and
toolbar items are not admitted or verified by List's evidence.

---

### Architecture Icon Resolution And Component Slots (`docs/architecture/icon-resolution-and-component-slots.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:icon-resolution-and-component-slots
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/Icon/,
    packages/core/src/theme/defineTheme.ts,
    packages/core/src/Selector/,
    packages/cli/,
  ]
verified_by:
  [
    packages/core/src/Icon/globalIconRegistry.test.tsx,
    packages/core/src/theme/defineTheme.test.ts,
    packages/core/src/Selector/Selector.test.tsx,
    packages/cli/api/theme/build/build.test.mjs,
  ]
deciding_specs: []
---

# Icon resolution and component slots



```json
{
  "scope": "global",
  "triggers": {
    "component-slots": ["INV1", "INV2", "INV3", "INV6", "INV7", "INV10"]
  }
}
```

This record defines how shared icons and component-owned icon roles fit together.
It is the approved target architecture. Existing extension keys continue to work
until a separate compatibility decision changes them.

## Purpose

A theme author should be able to change an icon used for one component role
without changing every use of the same shared icon.

The system needs two separate choices:

1. which shared icon name a component role uses; and
2. which artwork the active theme uses for that shared icon name.

Keeping those choices separate avoids turning every component detail into a new
global icon name.

## Current baseline

Current `main` has a shared `IconName` registry and also supports namespaced
extension keys. Some component-owned artwork uses those extension keys directly.
This record does not invalidate or require migration of that shipped behavior.

The target model adds one rule for future component roles: new semantic component
slots use `componentIcons`. Shipped extension keys remain supported until a
separate compatibility decision changes them. This record does not decide the
outcome for any existing component.

## System model

### Shared icon resolution

`IconName` is the closed set of shared semantic icon names, such as `check`,
`close`, and `chevronDown`. Existing extension keys coexist with that set.

The general resolver chooses artwork in this order:

1. the active theme's `icons[name]` entry;
2. a process-wide `registerIcons()` entry; and
3. a matching entry in `defaultIcons`.

This record does not change which shared or extension keys the general resolver
accepts. Theme application owns how the active theme is selected. Theme
authoring owns how `icons` is normalized and inherited. The Icon component
contract owns its public source modes, rendering, size, color, and accessibility.

### Typed component-owned slots

A component icon slot names a stable purpose inside one component. It does not
name artwork.

The public `@astryxdesign/core/Icon` subpath owns an augmentable map:

```ts
// Public @astryxdesign/core/Icon module
export interface ComponentIconSlotMap {
  'selector-selected-option': true;
}

export type ComponentIconSlotName = keyof ComponentIconSlotMap & string;

export type ComponentIconMap = Partial<
  Record<ComponentIconSlotName, IconName | null>
>;
```

The interface must be declared in the public module that consumers augment. An
interface declared only in an implementation file and re-exported from the
public subpath will not widen the type used by consumers. Runtime resolver code
imports the map from its public owner.

External component packages add their slots by augmenting the same public
`@astryxdesign/core/Icon` module. A slot uses
`<component-kebab>-<semantic-role>`. The role describes why the icon exists, not
its current shape or direction.

`defineTheme({componentIcons})` maps a component slot to a shared `IconName` or
to `null`. It is separate from `defineTheme({icons})`:

```ts
defineTheme({
  name: 'brand',
  componentIcons: {
    'selector-selected-option': 'success',
  },
  icons: {
    success: <BrandSuccessIcon />,
  },
});
```

The slot map says which shared meaning a component role uses. The icon map says
which artwork draws that shared meaning.

### Component slot precedence

A component resolves an icon-bearing role in this order:

1. a consumer-provided instance prop, when the component exposes one;
2. the nearest active theme's `componentIcons[slot]` entry; and
3. the component's declared fallback `IconName | null`.

`undefined` means “use the next fallback.” `null` means “render no icon.” A
mapped `IconName` continues through shared icon resolution.

Shared resolver modules apply this order consistently:

- `getComponentIconName(slot, fallback, source)` resolves the slot to a shared
  `IconName | null`;
- `getComponentIcon(slot, fallback, source)` resolves that shared name to
  artwork; and
- the client hook resolves the same slot and fallback from the active theme.

Components use these resolvers instead of reading `componentIcons` directly.
The resolver does not own a component's slot name, fallback, or rendering rules.

This record defines the meaning of an active theme's `componentIcons` map. The
theme-authoring architecture owns `DefineThemeInput`, normalization, and
`extends`. Theme compilation owns preserving the normalized map in built output.

## Known deviations

- **NumberInput stepper icon.** Current `NumberInput` resolves its stepper through
  a general extension key instead of `componentIcons`. This shipped behavior
  remains supported and is not precedent for new component slots.
- **Owner:** `component:NumberInput` once that component contract exists.
- **Exit condition:** a separately reviewed migration uses the component-slot
  model while preserving released callers and theme overrides, or follows an
  explicitly approved breaking-change path.

This record does not choose the migration design or timeline.

## Boundaries and invariants

- **INV1 — Shared names and component roles are separate.** `IconName` owns
  shared semantic meanings. `ComponentIconSlotName` owns component-specific
  purposes.
- **INV2 — Slots are typed and owner-declared.** Every Core slot is listed in
  `ComponentIconSlotMap`. External packages extend that map instead of adding
  unowned Core strings.
- **INV3 — Slots map to shared meanings.** A `componentIcons` value is an
  `IconName` or `null`, never concrete artwork.
- **INV4 — Null suppresses a slot.** `componentIcons[slot] = null` intentionally
  renders no icon. An absent mapping uses the component fallback.
- **INV5 — Every slot declares a fallback.** The owning component declares one
  `IconName | null`; themes do not need to repeat defaults.
- **INV6 — Resolution order is stable.** Instance content wins over theme slot
  mapping. Slot mapping chooses a shared name before the shared registry chooses
  artwork.
- **INV7 — Component slots do not grow the shared name set.** Adding a slot does
  not widen `IconName`.
- **INV8 — Component behavior stays with the component.** State changes,
  transforms, placement, size, color, and accessibility remain owned by the
  component that renders the slot.
- **INV9 — Theme lifecycle stays single-owned.** This record defines what a
  `componentIcons` entry means. Theme authoring owns its normalization and
  inheritance; theme application owns active-theme selection.
- **INV10 — Existing keys coexist.** Shipped slots and extension keys remain
  supported until a separate compatibility decision changes them. New Core slots
  use `ComponentIconSlotMap` and `componentIcons`.

This record does not own:

- `DefineThemeInput`, theme normalization, or `extends`; those belong to the
  theme-authoring architecture;
- active-theme selection; that belongs to the theme-application architecture;
- Icon's public source modes, rendering, visual anatomy, size, color, or
  accessible-name API; those belong to the Icon component contract;
- consumer-authored icon content passed through public component props;
- CSS theming targets for an icon's paint or state;
- stateful indicator renderer replacement; or
- the visual design of a component's chosen glyph.

## Component-local documentation

The architecture record owns the shared rules. The component owner documents
each slot locally.

A component `.doc.mjs` theming entry records:

- the slot name;
- its fallback `IconName | null`; and
- a short description of the role and whether `null` may hide it.

A component spec records behavior that a theme author must understand, such as
state-dependent rendering, placement, accessibility ownership, and compatibility
requirements. It does not copy the general resolution algorithm.

Consumer icon props remain documented as component API. They are not listed as
`componentIcons` slots unless the component also promises a separate stable
theme-level role.

## Change coupling

- Adding a Core slot updates `ComponentIconSlotMap` in the public
  `@astryxdesign/core/Icon` module, its owning component source, local docs,
  resolver tests, and component tests.
- Adding a package-owned slot augments the public `@astryxdesign/core/Icon`
  module from that package and adds the same owner-local docs and tests.
- Changing a slot fallback or precedence is a compatibility change because a
  theme may omit the slot and rely on the old result.
- Renaming, removing, or reinterpreting a shipped slot or extension key requires
  an explicit compatibility plan and an allowed breaking-change path when one
  cannot preserve existing behavior.
- Changing the `componentIcons` authoring shape, normalization, or inheritance
  updates the theme-authoring record and its tests. Changing built output updates
  the theme-compilation record and parity tests.
- Changing active-theme selection updates the theme-application record and its
  tests.
- Changing Icon's public source modes or rendering updates the Icon component
  contract and its tests.
- Adding a component-specific key directly to the general Icon API requires
  architecture review; it is not the default way to add a component slot.

## Owning code

- The public `@astryxdesign/core/Icon` subpath owns `ComponentIconSlotMap` and
  the public slot types that consumers augment.
- `packages/core/src/Icon/globalIconRegistry.tsx` owns `getIcon`,
  `getExtendedIcon`, `getComponentIconName`, and `getComponentIcon`.
- `packages/core/src/Icon/useIcon.ts` owns active-theme client resolution for
  shared names and component slots.
- The Icon component and its component contract own public source modes,
  rendering, size, color, and accessibility. Components resolve their semantic
  slots before passing the result to Icon.
- `architecture:theme-authoring-contract` owns `DefineThemeInput`, normalized
  theme data, and `extends`, including integration of the separate
  `componentIcons` map.
- `architecture:theme-application` owns active-theme selection and lookup.
- `architecture:theme-compilation` owns preserving normalized theme data in built
  output.
- Each component owns its slot meaning, fallback, state-dependent rendering,
  accessibility, and consumer override behavior.
- CLI and docsite tooling expose owner-declared slot metadata without inventing
  new slot semantics.

## Deciding specs

None. This record consolidates the existing shared registry behavior and the
approved typed component-slot model.

## Verification

| Invariant        | Evidence                                                        | Failure signal                                                                      |
| ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| INV1, INV3, INV6 | Registry resolver tests and one rendered component fixture      | A component slot resolves concrete artwork directly or skips shared icon resolution |
| INV2, INV5       | Type tests plus component `.doc.mjs` metadata checks            | A Core slot is an untyped string or has no owner/fallback                           |
| INV4             | Resolver and component tests with `componentIcons[slot] = null` | A null mapping falls through and still renders an icon                              |
| INV7             | Shared-name type and registry snapshot tests                    | Adding a component slot widens `IconName`                                           |
| INV8             | Representative Selector and component-owner tests               | A theme must know component rendering details to replace artwork                    |
| INV9             | Theme-authoring, application, and compilation owner tests       | This record invents a second normalization or active-theme path                     |
| INV10            | Shipped-key compatibility fixtures and new-slot negative tests  | A shipped key stops working without a separate compatibility decision               |
| Documentation    | Component metadata and generated CLI/docsite fixtures           | A themeable slot cannot be discovered with its owner, fallback, and purpose         |

---

### Architecture Interaction Modality (`docs/architecture/interaction-modality.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:interaction-modality
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-10
owners: [cixzhang]
applies_to:
  [
    packages/core/src/utils/interactionModality.ts,
    packages/core/src/utils/focusOutline.stylex.ts,
    packages/core/src/hooks/useFocusReturnVisibility.ts,
    packages/core/src/hooks/useIndicatorFocusRing.tsx,
    packages/core/src/Layer/useTouchTrigger.ts,
    packages/core/src/,
  ]
verified_by:
  [
    packages/core/src/CheckboxInput/CheckboxInput.test.tsx,
    packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
    packages/core/src/Selector/Selector.test.tsx,
    packages/core/src/Slider/Slider.test.tsx,
  ]
deciding_specs: []
---

# Interaction modality architecture



```json
{
  "scope": "global",
  "triggers": {
    "interaction": ["INV1", "INV2", "INV3", "INV4", "INV7", "INV8"]
  }
}
```

## Purpose

Astryx components behave predictably when people move between keyboard, pointer,
touch, pen, and programmatic focus. Essential actions remain perceivable and
reachable while their controlled state or task context is presented, and newer user
intent remains authoritative over stale work. This record defines those shared
interaction invariants, the shared modality state, the boundary between modality and
component focus ownership, and the evidence required when any of them changes.

It does not define focus-ring appearance, hover paint, press treatment, or new
component APIs. Those remain with Design Conventions, theme tokens, and the
owning component family.

## System model

Astryx tracks the last input modality as one shared state:

- a key press without Meta, Alt, or Control held sets `keyboard`;
- a pointer press, including mouse, touch, or pen, sets `pointer`;
- a key press with Meta, Alt, or Control held does not change modality; and
- before any input, the state is `keyboard` so initial or restored focus remains
  perceivable.

Programmatic focus does not create a new modality. Its focus indicator follows
the last input modality: it is hidden after pointer input and shown after
keyboard input. Components use the browser's `:focus-visible` behavior together
with the shared modality state where `:focus-visible` alone cannot distinguish
the initiating input.

Modality answers **whether the shared focus indicator should be visible**.
Component ownership answers **which element paints it**. The semantic focus
owner may paint on itself, delegate paint to an owning wrapper, or paint on a
visual proxy when the focusable element is intentionally hidden. That ownership
does not change the modality state.

Hover capability is separate from modality history. Hover behavior is available
only to hover-capable pointers and remains an enhancement over keyboard, click,
and touch paths. Touch and pen presses participate in pointer modality even when
a family gives them different gesture behavior.

Disabled, busy, read-only, intentionally hidden, and intentionally unavailable
semantics are separate from modality. Their current component, family, or system
contracts decide whether an action is present, focusable, or operable; modality only
controls the focus indicator on an eligible focus owner.

An action is **essential for a supported state and modality** when it is the only
supported control that can continue, reverse, dismiss, or change the component-owned
context at the point where that context is offered for interaction. Layout and
composition may move or proxy that action, but MUST NOT remove or separate every
supported path from that interaction point. This does not require an action to remain
continuously onscreen, sticky, or visually prominent. Ordinary scrolling is valid
unless the supported composition keeps the controlled context at the interaction
point while stranding its sole action.

Current component and family contracts define accepted intent, owner replacement,
pending presentation, and which close, cancellation, disablement, or unmount
transitions supersede the same interaction outcome. Once that owner defines
supersession, completion of older pending, deferred, or asynchronous work MUST NOT
change the current outcome's content, selection, status or announcement, visibility,
focus, or activation. This rule does not cancel or reassign unrelated background
work.

## Boundaries and invariants

- **INV1 — Last input owns programmatic focus visibility.** Programmatic focus
  preserves the current modality. After pointer input it does not show the shared
  focus indicator; after keyboard input it does.
- **INV2 — Modality and focus ownership stay separate.** Shared modality decides
  visibility. Each component family decides the semantic focus owner and the
  element that paints on its behalf.
- **INV3 — One focus move has one owning indicator.** A wrapper or visual proxy
  may paint for the semantic owner, but sibling controls paint their own focus
  and replacement content cannot silently remove the owner's indicator.
- **INV4 — Every modality has an operable path.** Keyboard, pointer, and touch or
  pen can reach each supported interaction. Hover is never the only discovery or
  activation path.
- **INV5 — Existing state semantics win.** Disabled controls do not become
  interactive because of modality. Read-only and busy controls preserve their
  documented focus and activation behavior.
- **INV6 — Modality remains internal.** Components derive modality from shared
  browser input state. A public focus-visibility or modality prop requires a
  separate public-API decision.
- **INV7 — Essential actions remain reachable.** At the interaction point where a
  component offers an essential action's controlled context, every supported
  modality has a perceivable and operable path to that action. Layout, scrolling,
  overflow, reflow, responsive composition, replacement content, or a
  custom-renderer seam MUST NOT separate every path from that context. This does not
  require continuous visibility, sticky behavior, or increased prominence. A
  current component, family, or system contract MAY intentionally hide, disable, or
  make the action unavailable in a named state.
- **INV8 — Newer owner-accepted intent owns the current outcome.** Current component
  and family contracts define intent acceptance, pending presentation, and
  supersession for the same interaction outcome. Once superseded, completion of
  older work MUST NOT change the current outcome's content, selection, status or
  announcement, visibility, focus, or activation. Unrelated background work keeps
  its existing owner and lifetime.
- **INV9 — Interaction findings require observable harm.** A shared interaction
  violation requires concrete user harm in a reproducible supported state and
  modality. Evidence names the lost or stale outcome and verifies representative
  normal or legacy paths remain unchanged by the remedy. Aesthetic preference,
  speculative unsupported states, and a claim that an action merely could be easier
  to discover do not establish an interaction violation. This does not bar a visual
  correction already settled by current component, family, design, theme, or
  objective accessibility authority; that correction uses rendered-pixel evidence
  and its direct owner rather than being recast as interaction reachability.

## Allowed variation

Component families keep their shipped interaction models:

- actions may paint the shared outline on the focusable action;
- bordered fields may paint their established field-focus treatment on an owning
  wrapper;
- hidden native inputs may have owner code paint on the visible indicator;
- menus may move focus with mouse hover so pointer and keyboard share one
  highlighted item; and
- spatial controls may move focus during a pointer gesture without showing the
  keyboard focus indicator.

These are ownership and behavior choices, not alternate modality definitions.
A new visual representation still requires design review; this record does not
authorize one.

## Restorative-change routing

`architecture:knowledge-contracts` alone defines change disposition. INV7 and INV8
may supply the current outcome a change restores, but they do not authorize another
observable public delta. New APIs, exceptions, defaults, interaction models, icon or
control sizing, sticky behavior, caller-owned layout, and new visual representations
remain with their direct current owner unless evidence proves that exact mechanism is
necessary to restore the invariant and representative unaffected paths remain
unchanged. A separable delta is removed or split before this interaction restoration
is asked to carry a new decision.

## Change coupling

- A change to modality tracking, focus-return visibility, shared focus styles, or
  visual-proxy ownership updates the relevant representative tests in the same
  pull request.
- Moving a component's semantic focus owner or paint owner verifies keyboard
  order, programmatic focus after both modalities, one-indicator ownership, and
  disabled, busy, and read-only behavior where applicable.
- Adding pointer-dependent behavior verifies mouse, touch, and pen as applicable,
  including a non-hover path.
- Moving, hiding, clipping, replacing, or asynchronously updating an essential
  action verifies each supported modality in the affected state, close and
  supersession where applicable, and a representative unaffected state.
- A change presented as an interaction restoration inventories every additional
  public behavior delta. Layout, sizing, API, default, and exception changes that
  are not proven necessary remain separately routed.
- Changing focus appearance remains coupled to Design Conventions and theme
  verification. Exposing modality through public API remains coupled to
  `architecture:public-component-api`.

## Owning code

- `utils/interactionModality.ts` — records and exposes the shared last-input
  modality.
- `utils/focusOutline.stylex.ts` and focus tokens — provide the shared focus
  indicator mechanics and theme seam without choosing component ownership.
- `hooks/useFocusReturnVisibility.ts` — applies last-input visibility when focus
  returns from adaptive surfaces.
- `hooks/useIndicatorFocusRing.tsx` — lets the semantic owner guarantee paint on
  a replaceable visual indicator.
- Current component and family records define focus destination, keyboard and pointer
  behavior, essential-action availability, accepted intent, supersession, pending
  lifetimes, and focus-paint ownership.
- `architecture:react-component-runtime` owns shared lifecycle and resource
  mechanisms. This record owns only the cross-cutting modality-path and
  no-stale-interaction-outcome requirements.

## Deciding specs

No separate specification changes this record. The last-input rule was approved as
a system architecture decision by `cixzhang` on 2026-08-30. Essential-action
reachability, newer-intent precedence, and the evidence/scope guard were approved by
`cixzhang` on 2026-09-10.

## Verification

| Invariant  | Evidence                                                                                                                     | Failure signal                                                                                                                                                               |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV2 | Programmatic-focus cases after keyboard and pointer input in representative component tests                                  | Programmatic focus invents a modality, or pointer history paints the keyboard indicator                                                                                      |
| INV3       | Direct-owner, wrapper-owner, replaceable-indicator, and nested-action cases                                                  | No indicator, two indicators, or replacement content can opt out                                                                                                             |
| INV4       | Keyboard, mouse, touch, and pen browser paths where applicable                                                               | Hover-only behavior, sticky touch hover, or unreachable action                                                                                                               |
| INV5       | Disabled, busy, and read-only DOM and activation assertions                                                                  | Inactive control reacts, eligible control loses focus, or state changes with modality                                                                                        |
| INV6       | Public API review and representative source inspection                                                                       | A component adds caller-controlled modality without a separate API decision                                                                                                  |
| INV7       | Rendered-browser affected-state checks across geometry, clipping, scrolling, hit testing, built-in and custom-renderer paths | At an offered interaction point one modality loses every essential path, or evidence substitutes class equality for reachability/perceptibility                              |
| INV8       | Owner-defined intent order, pending, close, cancellation, disablement, and unmount mutations                                 | Older work changes current content, selection, status or announcement, visibility, focus, or activation after owner-defined supersession                                     |
| INV9       | Concrete-harm reproduction, representative normal/legacy/no-new-prop paths, and coincident public-delta inventory            | Unsupported or aesthetic preference is treated as harm, unaffected behavior changes, or an API, layout, sizing, default, sticky, or exception delta escapes direct authority |

Real-browser evidence is required when the behavior depends on
`:focus-visible`, pointer capability, touch or pen synthesis, rendered focus
ownership, scrolling, clipping, hit testing, or whether an essential control remains
perceivable in the supported presentation. Unit tests prove event routing, DOM state,
and deterministic intent ordering but do not replace those browser outcomes.

A composed editor surface is the record's verification benchmark: it covers
keyboard-owned editor focus, pointer suppression, clearing an existing keyboard
indicator on pointer press, and keeping internal action focus owned by the
action. The benchmark also verifies programmatic editor focus after both
keyboard and pointer input before the behavior is treated as complete. A
benchmark demonstrates the invariants; it does not carry authority for them.

---

### Architecture Internationalization (`docs/architecture/internationalization.md`)

---
schema_version: 1
template_version: 3
kind: architecture
id: architecture:internationalization
authority: current
archive_reason: null
superseded_by: null
approved_by: nynexman4464
approved_at: 2026-10-15
owners: [nynexman4464, cixzhang]
applies_to:
  [
    packages/core/src/i18n/,
    packages/core/locales/,
    packages/core/src/i18n/generated-locales/,
    scripts/generate-i18n-runtime.mjs,
    packages/build/src/vite.ts,
    packages/core/src/utils/plainDate.ts,
    packages/core/src/utils/dateParser.ts,
    packages/core/src/PowerSearch/formatFilterValue.ts,
    packages/core/src/Timestamp/tooltipEntries.ts,
    packages/core/src/utils/characters.ts,
    packages/core/src/hooks/useStreamingText.ts,
    internal/eslint-plugin-astryx/,
    scripts/check-i18n-catalog.mjs,
  ]
verified_by:
  [
    packages/core/src/i18n/__tests__/resolve.test.ts,
    packages/core/src/i18n/__tests__/InternationalizationProvider.test.tsx,
    packages/core/src/i18n/__tests__/useLocale.test.tsx,
    packages/core/src/i18n/__tests__/useDirection.test.tsx,
    packages/core/src/i18n/__tests__/getLocaleDirection.test.ts,
    packages/core/src/i18n/__tests__/useCollator.test.tsx,
    packages/core/src/i18n/useTranslator.test.tsx,
    scripts/check-i18n-catalog.test.mjs,
    scripts/generate-i18n-runtime.test.mjs,
    packages/build/src/vite.test.ts,
    packages/build/src/vite.build.test.ts,
    internal/eslint-plugin-astryx/no-hardcoded-i18n-string.test.mjs,
    internal/eslint-plugin-astryx/no-raw-intl-locale.test.mjs,
    packages/core/src/PowerSearch/formatFilterValue.test.ts,
    packages/core/src/Timestamp/tooltipEntries.test.ts,
  ]
deciding_specs: []
---

# Internationalization architecture



```json
{
  "scope": "global",
  "triggers": {
    "accessibility": ["INV1", "INV1a", "INV7"],
    "behavior": [
      "INV1",
      "INV1a",
      "INV3",
      "INV4",
      "INV5",
      "INV7",
      "INV8",
      "INV9",
      "INV10"
    ],
    "compatibility": ["INV2", "INV9", "INV10"],
    "layout": ["INV11", "INV12"],
    "public-api": ["INV1", "INV1a", "INV2", "INV7", "INV9", "INV10"],
    "react-runtime": ["INV3", "INV4", "INV11", "INV12", "INV13"]
  }
}
```

## Contract at a glance

| Area                    | Contract                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Governing contract      | `component:InternationalizationProvider` owns provider behavior. This record defines the intended internationalization contract and the implemented runtime-catalog delivery boundary; it does not certify every existing consumer's conformance.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| System behavior         | Astryx-owned strings resolve through per-locale overrides, supplied catalogs, then shipped English, walking exact → parent locale. Locale-sensitive operations (date, time, number, list, collation) receive the provider locale directly, with no catalog or override step; legacy pure helpers with an optional locale default deterministically to English, except the known `currency()` gap recorded under INV9. Without a provider the result is deterministic English. Authored catalogs are rich (`defaultMessage` + `description`); shipped runtime catalogs are generated string maps derived from them. Messages are ICU MessageFormat 1 strings. `PlainDate` stays Gregorian. Rendered direction comes from the DOM.                                                                            |
| End-user impact         | People using a non-English locale see Astryx interface text, formats, and sort order in that locale wherever a translation exists and English otherwise; layout and mirroring follow the document direction they are reading in.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| Builder impact          | Render one `InternationalizationProvider` and keep DOM `dir` aligned with it. Import a shipped locale from `@astryxdesign/core/locales/<tag>.generated.js` (string map) or `<tag>.json` (rich); both are accepted. Pass the provider locale to Astryx-owned `Intl` calls. Add Astryx strings to `en.json` with a description; never edit generated modules. No separate locale packages and no second Astryx runtime.                                                                                                                                                                                                                                                                                                                                                                                       |
| Compatibility/readiness | Released provider, hooks, subpath, rich JSON exports, and the rich context shape are preserved; `RuntimeCatalog`, `ProviderMessagesByLocale`, and the `*.generated.js` exports are additive. A server/RSC translation runtime and an external-runtime adapter remain separate decisions (INV13, INV14).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| Review checks           | Reject Astryx-owned text or announcements built from literals, concatenation, or hand-rolled plurals instead of a catalog message (INV1a); locale-sensitive `Intl` or prototype calls outside the named shared helpers, or a public date helper called without its provider-locale argument (INV8); a parallel or replacement runtime without a migration decision (INV2); implicit host-locale reads (INV3); non-string message output (INV7); a translation catalog that adds stale keys or changes an ICU contract (INV6); a hand-edited, committed, or description-carrying generated module, or a changed rich export path (INV15); locale silently changing `PlainDate` calendar semantics (INV10); render-time provider direction driving geometry (INV11); the provider mutating DOM `dir` (INV12). |
| Governing rules         | No deciding system spec.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |

This table is a review projection; the body below is authoritative.

## Purpose

Astryx localizes the system-owned text, formatting, comparison, and text direction
of its components without becoming a general application internationalization
framework. Applications continue to own product copy and may use any application
i18n runtime alongside Astryx.

This record defines the library's catalog and locale boundaries. Consumer setup
and examples remain in `astryx docs internationalization`.

### Cross-cutting component contract

The global routes project shared i18n claims into component and module reviews
when their semantic triggers match. They do not require each component to repeat
these rules or add a backlink merely to receive them. An accessibility review
loads string ownership and string-output claims; a behavior review also loads
locale, fallback, formatting, and calendar claims. Catalog generation and provider
internals are not universal component requirements merely because they share this
record.

Component and module contracts own which content is Astryx-authored versus
caller-authored, the semantic role of a content prop, local formatting inputs,
state-dependent behavior, and the evidence that proves those local outcomes.
Shared string ownership, helper boundaries, deterministic locale defaults, and
Gregorian calendar requirements remain here. `component:InternationalizationProvider`
owns the provider's local API and precedence contract, not a second set of rules
for every component.

Review applies only the matched claims within their stated scope and resolves the
direct component, module, or family owner for the exact delta. A local exception
must name its semantic boundary and remain consistent with current shared
claims; a missing local spec or existing implementation does not create an
exemption. Draft routes do not supply governing authority.

## System model

```text
consumer content prop
  → wins when the component exposes semantic specialization

packages/core/locales/<tag>.json          (authored, rich: defaultMessage + description)
  → scripts/generate-i18n-runtime.mjs
  → packages/core/src/i18n/generated-locales/<tag>.generated.ts   (derived, ignored)
  → @astryxdesign/core/locales/<tag>.generated.js                  (published string map)
  → @astryxdesign/core/locales/<tag>.json                          (published rich, unchanged)

InternationalizationProvider
  → active locale
  → optional locale catalogs, rich or generated (ProviderMessagesByLocale)
      → normalized to the rich context shape (MessagesByLocale)
  → optional per-locale Astryx overrides
  → optional direction override

Astryx-owned string
  → provider-bound translator
  → exact locale
  → parent locale(s)
  → shipped English fallback (generated English runtime catalog)

Locale-sensitive formatting or comparison
  → active provider locale passed directly to the shared formatter
```

`@astryxdesign/core/i18n` is the canonical public entry point. The released
surface consists of `InternationalizationProvider`, `InternationalizationContext`,
their public value/prop types, `useTranslator`, `TranslatorFn`, `useLocale`,
`useCollator`, `useDirection`, `getLocaleDirection`, the types `Locale`,
`Catalog`, `MessageEntry`, `RuntimeCatalog`, `MessagesByLocale`,
`ProviderMessagesByLocale`, and `Overrides`, and the `Translator` interface.
Shipped catalogs are published under `@astryxdesign/core/locales/`: `<tag>.json`
is the rich authored catalog and `<tag>.generated.js` is its runtime string map,
for all 30 shipped locales plus `pseudo`.

## Boundaries and invariants

- **INV1 — Astryx owns Astryx strings, not application copy.** Component-owned
  labels, announcements, instructions, and assistive text use Astryx catalogs.
  Product content remains caller-owned. A content prop wins when Astryx exposes
  it for semantic specialization.
- **INV1a — Astryx-owned text is catalog-backed.** Every Astryx-owned visible
  or assistive string, including live-region announcements and `aria-*`/`title`
  values, MUST be an `en.json` key resolved by the canonical translator. Text
  that varies at runtime, such as counts, ranges, or joined labels, MUST be one
  ICU message with placeholders, plurals, or selects; it MUST NOT be assembled
  at the call site from literals, concatenation, or hand-rolled plural
  branches. The translator is the enforcement boundary for this rule: because
  owned text funnels through one recognized API, lint can reject a literal or an
  assembled string in a text-bearing JSX child, string-typed prop, attribute, or
  announcement without guessing intent. Caller-owned product copy supplied
  through a content prop is outside this rule.
- **INV2 — The released provider and hooks are canonical.**
  `InternationalizationProvider` plus the hooks under
  `@astryxdesign/core/i18n` remain the supported runtime. A replacement provider,
  hook, subpath, or catalog ownership model needs an explicit compatibility and
  migration decision; a parallel public runtime is not added by convention.
- **INV3 — No provider is deterministic English.** Components used outside a
  provider resolve Astryx strings from the shipped English catalog, expose locale
  `en`, direction `ltr`, and do not read the browser or host locale implicitly.
- **INV4 — Runtime locale changes are live.** Re-rendering the provider with a new
  locale, messages, overrides, or direction updates consumers in that subtree.
  Astryx does not persist or select the application's locale.
- **INV5 — Fallback is ordered and silent for expected gaps.** For a valid BCP
  47 locale, resolution checks per-locale overrides from exact to parent locale,
  then supplied catalogs from exact to parent locale, then shipped English. A
  missing non-English translation silently falls back. A key absent from every
  source including English is a defect: development warns once and renders the
  key visibly. Malformed locale tags are outside this fallback guarantee and may
  throw when platform formatters or collators are constructed. Known bug: the
  final step reads shipped English only, not a supplied `en` catalog, so
  non-Astryx keys have no cross-locale fallback today; see
  `component:InternationalizationProvider` OQ1.
- **INV6 — English is the source contract.** `packages/core/locales/en.json`
  defines Astryx's key set, default messages, descriptions, and ICU runtime
  contracts. Translation catalogs may omit keys and fall back to English; they
  may not add stale keys or change the argument, select, plural, ordinal, tag, or
  nesting contract of a translated entry. Translator `description` text lives in
  the authored JSON only; runtime catalogs never carry it.
- **INV7 — Messages produce strings.** Astryx catalogs use ICU MessageFormat 1
  and `intl-messageformat`. The supported translation result is a string suitable
  for visible text and attributes such as `aria-label` and `title`. Rich React
  nodes, functions, and application-owned message objects are outside this
  runtime unless a later accepted contract adds them.
- **INV8 — Shared helpers own locale-sensitive behavior.** Astryx-owned date,
  time, number, relative-time, list, collation, speech-recognition, and similar
  operations receive the active provider locale explicitly. Component code calls
  shared formatter, parser, or collation helpers rather than constructing raw
  `Intl` formatters or calling locale-sensitive prototype methods at arbitrary
  call sites. Supplying an argument named `locale` alone is not conformance:
  syntax-only lint cannot prove that it came from the provider. The named helper
  boundary makes that source traceable and reviewable.
  The formatter modules listed under Owning code own raw locale-sensitive `Intl`
  access. Lint guards module access and locale-argument presence; review guards
  the narrower purposes of utilities and test oracles. Review and regression tests
  still verify that callers pass the provider locale. An allowlist entry is not
  proof that every consumer complies. Astryx-owned locale-sensitive call sites
  MUST NOT read `navigator.language` or silently substitute a host default or
  hardcoded locale. INV9 preserves deterministic compatibility defaults for
  external callers of older pure helpers.
  Date helpers also enforce INV10 independently of language and numbering.
  Locale-tag inspection, locale-independent grapheme segmentation, time-zone
  identifier validation, and machine-field conversion are separate utility
  responsibilities. They may inspect tags or use a deterministic fixed locale
  when the operation's result does not depend on the display locale. Those uses
  do not authorize a second path for locale-sensitive display formatting or an
  omitted provider locale in date, number, list, or collation helpers. Independent
  test oracles may use platform `Intl` to verify production helpers.
  Localized words surrounding a formatted value remain catalog-backed under
  INV1a; a formatter is not an alternative translation runtime.
- **INV9 — Pure helpers have deterministic compatibility defaults.** A public pure
  formatter that predates provider threading may keep an optional locale for
  compatibility, but its omitted value resolves deterministically to English.
  Astryx-owned call sites pass the provider locale explicitly. New pure helpers
  require an explicit locale unless a current contract records another default.
  The charts `currency()` helper does not yet conform: an omitted locale follows
  the host default. That is a known conformance gap, not a recorded default;
  making it deterministic is a separate compatibility change.
- **INV10 — Default date and calendar semantics are Gregorian.** Locale changes
  language, numbering, and field order; it MUST NOT silently select a different
  calendar for built-in Astryx date displays or date-component behavior. Shared
  built-in date formatters pin the Gregorian calendar instead of inheriting the
  locale's default calendar. `PlainDate` parsing, arithmetic, constraints, grids,
  and navigation remain Gregorian. The existing low-level `plainDateFormat`
  display-calendar override remains a supported explicit caller choice; it does
  not establish alternative-calendar component semantics or permission to switch
  a built-in format merely because its locale has a different default calendar.
- **INV11 — Rendered direction comes from the DOM.** The provider exposes a
  semantic direction default, but Astryx layout, mirroring, and interaction SHOULD
  resolve from the rendered region through CSS logical properties,
  direction-conditioned CSS, or a lazy DOM read. `useDirection()` is a render-time
  last resort and MUST NOT replace the DOM as the ordinary source for component
  geometry or behavior, because provider direction can disagree with `<html dir>`
  and create first-paint or hydration errors.
- **INV12 — Locale and direction are related but distinct.** The provider derives
  a default direction from locale and accepts an explicit override. It does not
  mutate DOM `dir`. Applications own the page or region `dir`, and must keep it
  aligned with the provider when they want Astryx layout and browser text flow to
  share a direction.
- **INV13 — Client context is the current translation runtime.** The shipped
  translator and locale hooks are client-context APIs. Pure helpers such as
  `getLocaleDirection` remain server-safe. A server/RSC translation runtime is a
  separate API decision, not an implied capability of the current hooks.
- **INV14 — External-runtime integration is additive.** Applications may run
  another i18n provider alongside Astryx. A future adapter may delegate Astryx
  formatting to an application runtime while preserving Astryx key lookup,
  fallback, string results, and released provider compatibility. Astryx does not
  absorb application catalogs merely to offer a general-purpose i18n framework.
- **INV15 — Runtime catalogs are derived, never authored.** Every shipped locale
  has one rich authored catalog (`packages/core/locales/<tag>.json`, exported
  unchanged as `@astryxdesign/core/locales/<tag>.json`) and one generated runtime
  string map (`@astryxdesign/core/locales/<tag>.generated.js`) whose public shape
  is `RuntimeCatalog`, a key-to-message record. The generated sources live under
  `packages/core/src/i18n/generated-locales/`, are ignored by version control,
  and are regenerated before install, build, test, Storybook, docsite, and
  sandbox runs; a long-running development server regenerates them when an
  authored catalog changes, and an edit that lands during the initial generation
  queues another pass rather than being missed. A rich entry without a string
  `defaultMessage` fails generation; runtime maps carry no `description` or
  other metadata. The built-in English fallback imports the generated English
  module. Neither path, shape, nor export of the rich catalogs changes because
  generated modules exist.
- **INV16 — Standard build integration resolves both catalog forms.** The Astryx
  Vite plugin resolves `@astryxdesign/core/locales/<tag>.json` to the rich
  authored file and `@astryxdesign/core/locales/<tag>.generated.js` to the
  generated source module in source mode, so applications and the repository's
  own apps import either form the same way in development and production.

## Change coupling

Changes to the provider, context, public i18n barrel, resolver, catalog schema,
source catalog, runtime-catalog generator or its watcher, locale package exports,
build-plugin locale aliases, locale-aware helpers, catalog validation, or i18n
lint rules must review this record. A change preserves the architecture only when its fallback,
ownership, deterministic defaults, runtime-update behavior, and output type remain
true.

A new public provider or hook, application-catalog ownership, rich-message output,
server translation runtime, implicit host-locale behavior, or non-Gregorian
component semantics is a new human API or ownership decision. It cannot be
inferred from an implementation pull request.

## Owning code

- `packages/core/src/i18n/` — provider, context, public hooks, locale-direction
  derivation, catalog types, and lookup/formatting runtime.
- `packages/core/src/utils/plainDate.ts`, `packages/core/src/utils/dateParser.ts`,
  `packages/core/src/Timestamp/formatInstant.ts`,
  `packages/core/src/Timestamp/formatRelativeTime.ts`,
  `packages/core/src/PowerSearch/formatFilterValue.ts`,
  `packages/core/src/NumberInput/numberParser.ts`,
  `packages/core/src/i18n/useCollator.ts`, `packages/charts/src/formatters.ts` —
  shared locale-sensitive formatters, parsers, and collation. Component consumers
  pass the provider locale; naming a module here does not approve a duplicate
  formatting path or incomplete propagation in its callers.
- `packages/core/src/Timestamp/tooltipEntries.ts` — time-zone identifier
  validation, isolated from localized date rendering.
- `packages/core/src/utils/plainDate.ts` `getTimeZoneParts` — deterministic
  Gregorian machine-field conversion for instant/calendar-date operations.
- `packages/core/src/i18n/resolve.ts`, `getLocaleDirection.ts` — locale-tag
  inspection and direction derivation, not date/number display formatting.
- `packages/core/src/utils/characters.ts`,
  `packages/core/src/hooks/useStreamingText.ts` — locale-independent grapheme
  segmentation; this does not admit locale-sensitive word/sentence segmentation
  without the provider-locale helper contract.
- `packages/core/locales/en.json` — source key set, default English messages, and
  translator descriptions.
- `packages/core/locales/*.json` — partial translated catalogs, rich shape, the
  Crowdin round-trip surface.
- `scripts/generate-i18n-runtime.mjs` — derives the runtime string maps and the
  pseudo locale, verifies every authored catalog projects to a metadata-free map
  (`check:i18n-runtime` in `check:repo`), and watches authored catalogs during
  development.
- `packages/core/src/i18n/generated-locales/*.generated.ts` — derived runtime
  catalogs, ignored by version control, published as
  `@astryxdesign/core/locales/*.generated.js`.
- `packages/core/package.json` `exports` — publishes both `./locales/*.json` and
  `./locales/*.generated.js`.
- `packages/build/src/vite.ts` — resolves both catalog import forms in source
  mode.
- `scripts/check-i18n-catalog.mjs` — source/translation key, syntax, runtime
  contract, and plural validation.
- `internal/eslint-plugin-astryx/no-hardcoded-i18n-string.js` — rejects
  Astryx-owned user-facing literals and assembled text in JSX text, string-typed
  props, and `announce()` arguments so every owned string is a catalog key.
- `internal/eslint-plugin-astryx/i18n-key-format.js` — enforces the
  `@astryx.` namespace and camelCase catalog-key segments. This rule currently
  lacks its own focused test file; adding one is a verification gap.
- `internal/eslint-plugin-astryx/no-raw-intl-locale.js` — enforces the shared
  locale-sensitive helper boundary with syntax checks and explicit locale-argument
  checks. The separate utility and test-oracle scopes above do not widen that
  display-formatting boundary. A new formatter module requires focused boundary
  tests; passing lint alone does not prove provider-locale provenance.
- Component contracts — own which semantic content is Astryx-authored versus
  caller-authored and which content props specialize a component.

## Deciding specs

No deciding system spec. The provider-local behavior contract belongs to
`component:InternationalizationProvider`; this record owns the shared catalog,
formatter, and DOM-direction boundaries.

## Verification

The map names evidence for the intended contract, not proof that every existing
consumer or every edge case conforms. Helper admission defines responsibility;
review and regression tests verify caller behavior. Missing evidence or violating
callers remain conformance work and do not relax INV1a, INV8, INV9, or INV10.

| Invariant          | Evidence                                                                                                                                                   | Failure signal                                                                                                                                                                                                |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV1a, INV7  | hardcoded-string lint, catalog tests, component translation tests with a non-English provider                                                              | Astryx-owned text bypasses the catalog, an announcement or count is assembled from fragments or a hand-rolled plural, or non-string output reaches visible/AT attributes                                      |
| INV2, INV13, INV14 | export drift checks, public i18n tests, compatibility review                                                                                               | A parallel/replacement runtime lands without migration, or client hooks claim server behavior                                                                                                                 |
| INV3–INV5          | resolver and provider rerender tests                                                                                                                       | no-provider output depends on the host, locale swaps stay stale, fallback order changes, or malformed-locale behavior is described as graceful                                                                |
| INV6               | `check:i18n-catalog`, key-format lint, and focused mutation tests                                                                                          | stale/malformed keys, malformed ICU, or source/translation runtime-contract drift passes CI                                                                                                                   |
| INV15              | generator tests, `check:i18n-runtime`, package-export drift check, provider/resolver runtime-catalog tests                                                 | a generated module carries descriptions or is committed, a malformed entry projects instead of failing, a rich export path changes, or a `RuntimeCatalog` input is rejected or leaks a non-rich context shape |
| INV16              | build-plugin unit and real-build tests                                                                                                                     | a rich or generated locale import fails to resolve through the standard plugin                                                                                                                                |
| INV8, INV9         | raw-Intl-locale lint (module allowlist plus date-helper locale-argument check) and provider-locale regression tests, including a server/client render pair | Astryx output follows the host locale, raw `Intl` appears outside a named formatter module, an owned call omits the locale argument, or server and client output differ for the same provider locale          |
| INV10              | PlainDate and built-in date formatter tests, including locales with non-Gregorian defaults                                                                 | locale selects a non-Gregorian built-in date display, changes PlainDate calendar semantics, or loses the explicit low-level display override                                                                  |
| INV11, INV12       | direction helper/provider tests plus rendered RTL audit                                                                                                    | component layout reads provider direction instead of the region, provider mutates DOM, or overrides stop composing                                                                                            |

---

### Architecture Knowledge Contracts (`docs/architecture/knowledge-contracts.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:knowledge-contracts
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang]
applies_to:
  [
    AGENTS.md,
    .github/PULL_REQUEST_TEMPLATE/,
    docs/,
    packages/core/src/,
    packages/lab/src/,
  ]
verified_by:
  [
    scripts/check-knowledge.test.mjs,
    scripts/specs-for-file.test.mjs,
    .github/scripts/change-scope.test.mjs,
    .github/scripts/component-design-decisions.test.mjs,
    .github/scripts/lib/classify-visual.test.mjs,
    .github/scripts/review-clear-workflow.test.mjs,
    .github/scripts/review-signal-decision.test.mjs,
    .github/scripts/review-signal-workflow.test.mjs,
    .github/scripts/spec-owner-decision.test.mjs,
  ]
deciding_specs: []
---

# Knowledge contracts and decisions



```json
{
  "scope": "global",
  "triggers": {
    "specification": [
      "INV9",
      "INV11",
      "INV14",
      "INV16",
      "INV18",
      "INV20",
      "INV21",
      "INV25",
      "INV26",
      "DEC-9",
      "DEC-10",
      "DEC-12"
    ]
  }
}
```

## Purpose

A reviewer should be able to answer two questions without rereading old pull
requests:

1. What behavior has a human already decided?
2. Is this pull request making a new decision that needs a human?

## System model

Astryx keeps different facts in different places:

- Component contracts describe aggregate behavior one component promises.
- Module contracts describe an independently contractible public hook, plugin,
  utility, or subsystem owned by one component. Private implementation helpers do
  not require records.
- An optional `Design decisions` table inside a component or module contract
  records durable visual intent local to that owner. It does not hold exact pixel
  tuning or rules shared across components.
- Family contracts describe behavior sibling components share.
- Design specs record human-owned visual and interaction decisions, including the
  cross-theme accessibility and contrast methodology used to judge token/color
  pairings.
- Theme specs record one package theme's intent, inherited base, selected token
  and palette mappings, required pairings/states, theme-specific exceptions,
  measured receipts, known gaps, compatibility, and artifacts.
- System specs record decisions that cross components or themes or change architecture.
- Consumer docs explain props, examples, and usage.
- Audit records hold current evidence and findings. The operational store is the
  automated wiki `component-scores.json`, which supplies the current post-fix score
  and unresolved findings to the sandbox. The bootstrap reserved colocated
  `<PublicName>.audit.json` files, but no schema or records activated that path; it is
  not an audit datastore or current migration target.

The reviewer starts with the changed code and the nearest current component or
module contract. They follow only the links needed for the question:

```text
changed code or theme source
  → nearest current component, module, or theme contract
  → relevant family or design requirement
  → architecture or system decision only when referenced
  → mapped tests and audit evidence
```

A lookup can also start from any repository path. `pnpm specs:for <path>`
lists every current record linked to that path, the system specs that decided
those records, and the global claims the path's component triggers route. It
follows only links that records and files declare:

- placement: the record itself, the component, module, or theme record whose
  canonical location contains the path;
- path fields: `applies_to`, `verified_by`, and `path-applicability:v1` patterns;
- names: family `members`, design `components`, and `affects_consumer_docs` ids
  naming the path's component or consumer-doc topic;
- citations: a system-spec number such as `AST-030` written in the file;
- inheritance: the references a linked record relies on.

A current record may cite a previous human decision. When the new case is within
that decision's stated scope, the reviewer applies it without asking again.

Theme records sit between shared theming architecture and downstream records.
Cross-theme API, vocabulary, inheritance, validation, compiler behavior, and
artifact policy remain in architecture or system specs. Cross-theme human visual
and accessibility methodology for contrast belongs in a current design record;
shared measurement/tool implementation belongs to architecture or tooling. One
theme's selected token/palette mappings, required pairings and states,
exceptions, measured receipts, known gaps, migration, and artifacts belong in
its package-local theme record. Components and families continue to own
observable behavior; consumer docs continue to own supported syntax and examples.

Every record is either:

- `draft`: useful for review, but not a rule;
- `current`: explicitly approved and safe to rely on;
- `archived`: retained for history and linked to its replacement when one exists.

## Boundaries and invariants

- **INV1 — Current means approved.** Only `current` records guide implementation
  and review.
- **INV2 — One fact has one owner.** A component or theme record does not copy
  family, design, system, consumer, audit, or tooling content. Cross-theme API and
  compiler behavior stay in architecture/system records; human contrast
  methodology stays in design; shared measurement implementation stays in
  architecture/tooling; theme-specific mappings, states, exceptions, receipts,
  and gaps stay in the package-local theme record; aggregate observable component
  behavior stays in component/family records; independently contractible public
  component-module behavior stays in module records; consumer syntax stays in
  consumer docs.
- **INV3 — Existing decisions are reusable.** A reviewer cites an applicable
  decision and proceeds without asking the human again.
- **INV4 — New judgment is explicit.** A reviewer asks a human when no current
  decision applies, existing decisions conflict, or the choice is deliberately
  human-owned.
- **INV5 — Blank forms are not policy.** Templates only create drafts. Search
  and review never present template text as an approved Astryx decision.
- **INV6 — Required structure changes per kind.** Template wording may change
  without touching existing records. Adding a new kind does not migrate unrelated
  kinds. Adding or removing a required field or section from an existing kind
  creates a later schema definition for that kind and migrates every active record
  of that kind in the same pull request.
- **INV7 — Only pure spec changes use lightweight CI.** Every changed file must
  be a component, module, family, design, theme, or system spec. A change to code,
  architecture, guidance, templates, schemas, audits, or any unknown path runs
  normal CI.
- **INV8 — Private operations stay private.** Public records never name or link
  private release or automation systems.
- **INV9 — Current records have no implicit precedence.** A newer, narrower, or
  more local current record does not silently override another current record.
- **INV10 — Records describe ideal behavior, not pull-request verdicts.** A current
  record states durable requirements, prohibitions, compatibility, ownership, and
  evidence independently of any one implementation change. It never exists to
  approve a pull request and never approves, rejects, classifies, designates, or
  authorizes a specific one. Pull requests and issues are not standing content:
  a record states the fact a change established, not the change, and remains
  complete without any pull request or issue it once cited. Research that
  supports a decision — a survey, an options comparison, a benchmark, a test's
  design or scores — is an artifact linked from the pull request, not record
  content; the record keeps the conclusion inside the decision that rests on
  it. A fact owned elsewhere is delegated by linking the record that owns it.
  The reviewer owns each change's disposition against current authority.
- **INV11 — Every public delta has an authority result.** Every public API update
  and every public behavior change is matched to current committed authority
  before acceptance. Package-export shape is not the only trigger: reachable
  supporting types, context members, hook returns, defaults, and observable
  behavior participate too.
- **INV12 — Audit state has one automated datastore.** Wiki
  `component-scores.json` is the operational source for current scores and
  unresolved findings. The unactivated `<PublicName>.audit.json` convention is
  retired. A future storage change requires an explicit system decision, versioned
  data contract, automated migration and reconciliation, sandbox-reader cutover,
  and rollback evidence; it MUST NOT create per-component shadow ledgers by
  convention.
- **INV13 — Authors search before creating authority.** Before creating or
  materially expanding a record, search current records and open pull requests by
  proposed canonical owner/id, affected paths and exported symbols, and semantic
  behavior terms. Extend or project the existing canonical owner by default. A new
  record requires a distinct fact boundary and an explicit explanation of why no
  existing owner can contain it. Open pull requests coordinate overlapping work;
  they remain non-authoritative evidence.
- **INV14 — Current authority is claim-scoped.** `authority: current` approves the
  explicit claims inside a record's stated ownership boundary. It does not promise
  that every behavior of the named component, module, family, or code path has been
  specified. Review MUST NOT expand one narrow decision into adjacent uncontracted
  behavior unless the pull request's exact delta depends on that behavior.
- **INV15 — Review contracts toward one landing-ready intent.** Authors identify one
  primary intent and reviewers classify each observable delta separately. A
  separable delta that violates current authority or requires a new human decision
  is removed or split before review asks the primary change to carry new authority.
  A specification is the path for an intentional durable decision, not the default
  remedy for unrelated scope discovered during review.
- **INV16 — Specs govern; generated review context only informs.** Committed current
  component, module, family, design, theme, architecture, and system records own
  intended product behavior. Reviewer-generated summaries, sections, checklists,
  tests, screenshots, measurements, and recommendations are context or evidence
  only. They may prove conformance, contradiction, or an implementation defect;
  they MUST NOT invent direction, authorize a delta, reverse a human hold, or
  upgrade the authority result.
- **INV17 — Missing review context stays problem-bound.** Review reads the pull
  request and current authority first, then fills only missing context. It traces
  why the problem occurs, why it harms the affected task, and why the harm matters;
  maps the proposed solution and each primary or supporting delta back to that
  problem; and identifies independently removable tagalongs. Product impact remains
  owned by the applicable product record. Review may explain who is affected, in
  what supported state, and what the change enables or prevents, but that generated
  explanation remains context until a current canonical owner records the behavior.
- **INV18 — Product contracts preserve implementation freedom.** Component, module,
  family, design, theme, and system specifications own observable inputs, outputs,
  states, guarantees, failures, compatibility, and caller-visible ownership. A new
  or materially amended requirement MUST NOT prescribe internal modules, files,
  function names, algorithms, data structures, storage layouts, manifests,
  journals, locks, transaction protocols, or CI job/workflow topology unless that
  exact mechanism is itself an intentional public contract. Internal ownership,
  seams, and mechanisms belong in an architecture record. A verification map names
  evidence sufficient to prove a claim; it does not authorize the implementation or
  CI structure that produces it. Existing current records continue to govern their
  explicit claims until their human owner deliberately migrates them; this invariant
  does not silently invalidate or reinterpret those records.
- **INV19 — Automated spec review informs a human decision.** A pull request whose
  primary intent creates, amends, adopts, or supersedes a product contract receives
  written advisory analysis before the named human owner decides it. The analysis
  identifies the canonical knowledge owner, applicable current claims, overlapping
  current and open work, ownership collisions or missing owners, claim-scope gaps,
  behavior-versus-architecture leakage, and exact claims to keep, remove, move, or
  clarify. Automation may report contradictions, evidence gaps, and proposed edits;
  it MUST NOT approve, adopt, ratify, or make the contract current. A human approval
  remains required even when the advisory analysis finds no defect.
- **INV20 — Global review routing is explicit and claim-scoped.** A cross-cutting
  baseline participates without a component backlink only when its
  `review-applicability:v1` block declares `scope: "global"` and maps a semantic
  trigger to exact claim ids in that same record. Review loads matching current
  global claims before narrower records, then resolves the direct component,
  module, or family owner first when it governs the delta. The marker never makes
  the whole record, adjacent claims, drafts, or proposed-head authority govern.
  Every routed match records the base authority commit, record digest, record id,
  path, trigger, claim id, claim title, and deterministic match reason.
- **INV21 — Component-local visual intent stays local and durable.** The optional
  `### Design decisions` table under `## Design relationships` belongs only to
  component- or module-local appearance intent that a future redesign must
  reconsider. Authors cite, reuse, or amend stable `DD1`, `DD2`, … rows before
  adding one. Exact pixel tuning normally remains in code and visual evidence;
  shared or cross-component rules remain in `docs/design/`. Team design owners
  may commit a row with the pixels and evidence in one atomic appearance-only
  pull request. External contributors follow the normal contribution flow and
  MUST NOT be asked to author specifications; maintainers own any missing
  authority. A human request supplies intent but becomes committed current
  authority only through exact-head owner approval.

### Review evidence placement

- **INV22 — One-off evidence stays with the change.** Ad-hoc screenshots,
  recordings, measurements, sensor receipts, and run logs are attached or linked
  from the pull request that uses them, not committed to `main` as disposable
  assets under `docs/pr-assets/` or another source directory. Public GitHub
  attachments are the preferred home for manually published one-off evidence.
  Standing records keep durable conclusions and verification relationships, not
  individual run inventories. Accepted visual baselines, reusable stories and
  test fixtures, shipped documentation media, and repository-owned receipts
  explicitly permitted by their current owner remain versioned with the code.
- **INV23 — Cleanup preserves historical evidence.** Removing disposable assets
  from the current tree MUST NOT rewrite history or break existing commit-pinned
  evidence links. Before deletion, every removed file has a verified public
  retained copy. A linked commit outside retained `main` history needs a durable
  retention anchor at that exact commit; a commit hash or a temporary branch
  alone is insufficient. An immutable non-release `evidence/` tag may retain
  historical Git-hosted evidence when attachment publication is unavailable.
  Its name stays outside release-tag conventions and automated pruning, and it
  MUST NOT be moved or removed while the evidence is referenced. Cleanup verifies
  the remote anchor, exact bytes, and existing links before removing source files.
- **INV24 — Storage does not change proof or authority.** The pull request's text
  states the claim, tested build, relevant state, observed result, and evidence
  links; an image or expiring artifact is not the only record of the conclusion.
  Attachments remain evidence under INV16, not authority. Owner-specific receipt
  requirements, operational audit stores, and CI artifact lifetimes remain with
  their current owners. [AST-009](../specs/AST-009/spec.md) owns real-AT receipts;
  INV12 owns the operational audit datastore. [AST-029](../specs/AST-029/spec.md)
  FR5 and FR10 own the audit pull request's evidence receipt and exact-head
  eligibility report. [AST-030](../specs/AST-030/spec.md) FR12 owns shared
  visual-regression execution and baseline adoption. This placement rule does
  not add a capture owner or require permanent archiving of every CI run.

### Path discovery

- **INV25 — Every current record is reachable from a path.** Each current record
  is linked from at least one repository path that is not itself a knowledge
  record, through placement, a path field, a name, a citation, inheritance, or
  a deciding link. A path-first lookup reports a path with no linked record as
  unrouted. It never infers a link from a file name, extension, or directory
  name.
- **INV26 — Path applicability locates a record; claims still decide.** A record
  whose rules apply to paths that no other link reaches declares them in one
  `path-applicability:v1` block. A matching path lists the record. The match
  does not widen any claim beyond its stated scope (INV14), does not make the
  record outrank a narrower owner (INV9), and does not require behavior to live
  in the named files (INV18). Every pattern matches at least one tracked file.

## Path applicability

Use this optional block when a record governs paths that its placement, path
fields, names, and inherited links do not reach. A record that is already
reached from every path it governs stays unmarked.



```json
{
  "paths": ["apps/docsite/**", "packages/**/*.doc.mjs"]
}
```

In an opted-in record, replace the example marker with `path-applicability:v1`.
Patterns are repository-relative and case-sensitive. `**` is a whole segment
that spans any number of segments, `*` matches within one segment, and `?`
matches one character within a segment. Patterns use only letters, digits,
`_ . @ - /`, and those wildcards. A bare `**` is rejected. The checked-in
validator rejects malformed blocks, unsafe or repeated patterns, and patterns
that match no tracked file.

## Global review applicability

Use this optional block only when named claims apply across otherwise unrelated
component owners. Ordinary component, module, family, design, architecture, theme,
and system records remain unmarked.



```json
{
  "scope": "global",
  "triggers": {
    "public-api": ["FR1", "DEC-2"],
    "accessibility": ["AR1"]
  }
}
```

In an opted-in record, replace the example marker with
`review-applicability:v1`. Trigger names use this closed vocabulary:
`accessibility`, `behavior`, `compatibility`, `component-slots`, `docsite`,
`interaction`, `layering`, `layout`, `motion`, `navigation`, `platform`,
`public-api`, `react-runtime`, `responsive`, `scrolling`, `specification`,
`styling`, `testing`, `theming`, `tokens`, and `visual`. Every trigger has a
non-empty list of unique claim ids, and each id resolves to an explicit numbered
claim in that record. The checked-in validator rejects unsupported triggers,
malformed blocks, missing or duplicate claims, and empty routing.
`scripts/review-global-baselines.mjs` requires both the exact reviewed head and
its full base authority commit, verifies that commit is the head's `origin/main`
merge base, and emits deterministically sorted match rows. It therefore cannot
read a proposed head as its own authority. Consumers load those rows before narrow lookup, preserve their
provenance in a review receipt, and apply only the cited claim after the direct
owner has been resolved.

## Writing specifications and contracts

These rules preserve semantic fidelity while putting the decisions a reader needs
first. They are authoring guidance, not a quantified claim about human readability.
They apply prospectively to new or materially revised architecture, component, and
module specifications; they do not require historical rewrites.

Before writing:

1. Search current records for the behavior, public symbols, affected paths, and
   proposed canonical owner/id.
2. Search open pull requests for the same owner path, symbols, and semantic terms.
3. Update the canonical owner or coordinate with the overlapping work. Create a
   new record only when the fact has a distinct owner and explain that boundary in
   the pull-request summary.

Begin each component or module contract with this reader-first projection after its
title:

| Area                    | Contract                                                                                                                        |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Public contract         | State the exact public API, concept, or syntax delta; write `None` when none changes.                                           |
| Behavior                | State the observable governing behavior, including the decisive default, boundary, or precedence rule.                          |
| End-user impact         | Name who is affected, in what state, and what improves, worsens, or remains unchanged.                                          |
| Builder impact          | Name migration work and every new caller choice; write `None` when there is no new burden.                                      |
| Compatibility/readiness | State default compatibility, additive/breaking status, authority and implementation state, and material evidence still pending. |
| Review checks           | List the few concrete conditions that make an implementation or proposal rejectable under this contract.                        |
| Governing rules         | Link the canonical owner and only the current clauses needed to justify the projection.                                         |

Architecture records use the same position and footer with architecture-specific
rows:

| Area                    | Contract                                                                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Governing contract      | Name the observable current decisions this architecture serves; write `None` when no product contract changes.                 |
| System behavior         | State shipped system behavior, including the decisive invariant, boundary, or precedence rule.                                 |
| End-user impact         | Name who is affected, in what state, and what improves, worsens, or remains unchanged.                                         |
| Builder impact          | Name the implementation decisions, coordination work, or workarounds this architecture removes or adds.                        |
| Compatibility/readiness | State compatibility effect, authority and implementation state, and material evidence still pending.                           |
| Review checks           | List boundary violations, product-API mechanism leaks, or lost invariants/evidence distinctions that make a change rejectable. |
| Governing rules         | Link the deciding specs and only the current clauses needed to justify the projection.                                         |

The table is a review projection; the body remains authoritative. The projection
itself does not make a record shorter. Size falls only when authors remove true
duplication from the body without dropping contract semantics. `Review checks`
project settled requirements and boundaries from the body; they do not invent
policy or reopen a decision. When citing a list-item clause such as an AST-002 FR,
use its exact visible label and canonical file link. Do not invent a fragment URL
for a clause that has no stable heading or explicit anchor.

Then write the authoritative body:

- Describe the system in the present tense, not the project that produced it.
  `docs/contributing/spec-writing.md` turns this rule and INV10 into a rubric;
  a pull request that creates or changes a record reports that rubric's results
  in its description before review.
- Use familiar words and short, direct sentences.
- State each rule fully once, beside the conditions and exceptions that control it.
  Later sections cite its ID or canonical owner instead of restating the prose.
- Contract only the semantic slice being decided. Name adjacent behavior as a
  non-goal or uncontracted gap; do not fill it merely to make the record `current`.
- Do not duplicate consumer prop tables, usage recipes, shared-system mechanics,
  current audit dumps, or implementation steps. Link their canonical owners.
- Use readable tables for branches or state matrices when they improve scanning.
  Never remove contract content merely to shorten a record.

For routine local appearance intent, a component or module record may include the
optional versioned `Design decisions` table under `Design relationships`. Each row
uses a stable, unique `DD` id and states the decision, its intent or reason, where
it applies, and allowed variation. Add a row only when the intent is durable enough
that a future redesign must reconsider it. Reuse or amend an existing row first.
Do not copy exact pixel tuning out of code/evidence or shared rules out of
`docs/design/`. A header-only table is valid in a template; an authored block has
at least one complete row.

Plain language must preserve normative force, predicates, cardinality, defaults,
compatibility, authority, owners, IDs, evidence state, and decision status. Do not
delete, weaken, merge, or hide distinct normative content. Replace a local fact
with a link only when this record's ownership rules assign that fact to another
canonical owner.

Qualifiers and authority verbs are contract semantics, not polish. Words such as
`only`, `every`, `consistently`, `current`, `records`, `owns`, `delegates`, and
`inherits` MUST NOT be dropped, generalized, weakened, or upgraded during a
rewrite. When reshaping a rule, keep its action, activating conditions,
exceptions or non-goals, owner, and evidence state in the same row or bullet as
the claim they limit.

Do not optimize for a line, word, byte, or percentage-reduction target. First
remove true duplication, shorten prose without changing meaning, use readable
tables or lists, and link genuinely shared authority as INV2 requires. Keep every
load-bearing condition, qualifier, boundary, decision, and evidence distinction
that remains. Human scanability and semantic fidelity win over size.

### Same rule, plainer form

Dense:

> When `items` contains exactly one visible item, the component MUST announce that
> item exactly once; when `items` contains zero or more than one visible item, it
> MUST NOT announce an item, and consumers that omit `items` MUST retain the
> existing silent behavior.

Plain:

| Condition                           | Required behavior                       |
| ----------------------------------- | --------------------------------------- |
| `items` is omitted                  | MUST keep the existing silent behavior. |
| Exactly one visible item is present | MUST announce that item exactly once.   |
| Zero or more than one is present    | MUST NOT announce an item.              |

The plain version changes the shape, not the meaning.

### Before review

- [ ] Compare the edited text with its source and confirm that every contract
      property named above remains explicit.
- [ ] Confirm each rule's action, conditions, qualifiers, exceptions or non-goals,
      owner, authority verb, and evidence state remain explicit and adjacent to the
      claim they limit.
- [ ] Confirm the projection states the public delta, behavior, end-user and builder
      impact, compatibility/readiness, rejection checks, and governing rules without
      replacing or contradicting the authoritative body.
- [ ] Confirm tables and lists improve scanning without hiding content, and no
      content was removed merely to meet a size target.

### When current records disagree

1. A draft is context only and cannot conflict with current policy.
2. Identify the canonical owner from the fact's scope: aggregate component
   behavior, independent public module behavior, family behavior, design
   representation, one theme's semantics/mappings, cross-theme architecture,
   consumer usage, or audit evidence.
3. If two current records make different claims, review stops. Do not choose by
   recency, path proximity, or specificity; record a `novel-human` gap.
4. Resolve the gap by changing the canonical owner and removing the copied claim.
   A deliberate exception is recorded by that owner; affected records link to
   it instead of restating it.
5. A system-spec decision may authorize the change, but the current owning
   contract must change in the same pull request before reviewers rely on it.

## Change coupling

A document does not stay current by convention alone.

Before any component, module, family, design, theme, or architecture record becomes
`current`, the repository must support this flow:

1. The record names the code surface that can affect it and the checks that
   verify it.
2. A pull request touching that surface triggers a focused contract review.
3. Review starts from the pull request's declared primary intent and classifies
   each public delta independently as one of five results:
   - `preserves`: the exact delta restores or retains current authority without
     adding public API or behavior beyond it;
   - `settled`: an existing current human decision covers the exact delta and is
     cited;
   - `violates`: the exact delta contradicts current authority;
   - `novel-human`: no current authority settles the exact public API, behavior,
     ownership, compatibility, or design delta; or
   - `out-of-scope`: another component, module, family, system, or product owns it.
4. `preserves` and `settled` enter normal correctness review. They are eligible
   for approval only when the implementation and evidence also pass.
5. `violates` receives request-changes with the smallest conforming remedy:
   change or remove the violating delta. Review does not recommend a specification
   merely to rescue contradictory implementation; an owner may separately choose
   to propose a policy change.
6. A separable `novel-human` tagalong is removed or split from the primary intent.
   Review may request that contraction without deciding the tagalong's semantics,
   then continue reviewing the remaining change. A `novel-human` delta enters a
   private human hold only when it is the primary intent, is inseparable from it,
   or the author or owner explicitly chooses to pursue it.
7. A bug fix is `preserves` only when it restores existing current authority
   without changing public API or public behavior beyond that contract. Any
   separable API, visual, layout, or interaction tagalong follows step 5 or 6
   rather than making the restoration wait for a broader specification.
8. A visual correction may be `preserves` or `settled` when current component,
   family, design, theme, or objective accessibility authority covers its exact
   outcome. Real rendered evidence must prove the affected state and representative
   unchanged states. A new visual representation remains `novel-human`; calling it
   an improvement does not settle it.
9. Audit freshness is computed from the same code and test relationship, so a
   relevant code change cannot leave an audit looking current.

Audit-storage migration is a separate contract change. It requires a versioned
data contract, automated conversion and reconciliation of existing wiki rows, a
named source of truth during transition, sandbox-reader cutover, and rollback
evidence. Repository-local per-component files are not a migration plan by
themselves.

### Contract before escalation

Review and build should converge on the smallest ideal change that can land:

1. State the pull request's primary intent and partition its observable deltas.
2. Apply current authority to each delta. Existing design and objective standards
   can settle a visual correction even when a component record does not enumerate
   its exact paint.
3. For a contradiction, conform or remove it. Do not propose changing the
   specification unless changing policy is itself an intentional, separately
   reviewable goal.
4. For an unsettled but separable delta, remove or split it. Continue the primary
   change without asking the contributor to solve adjacent system design.
5. Escalate only a surviving, intentional `novel-human` delta. Record only the
   exact decision needed for that delta; adjacent module behavior may stay
   uncontracted.

The actionable review result names the acceptance criteria for that landing-ready
contraction. It does not merely report that authority is missing.

### Recording a new human decision

1. A contributor explains the intended behavior and primary intent in normal
   pull-request language and responds to review. External contributors keep the
   normal contribution flow: they are never asked to author or update a
   specification.
2. A reviewer or agent applies the contraction path above. Only a surviving,
   intentional `novel-human` question proceeds; the contributor does not invent
   the answer or specify unrelated adjacent behavior.
3. An authorized owner answers in the pull-request review.
4. A maintainer or agent records that ruling in the canonical owning record.
   - Prefer a commit in the same pull request when maintainers can update the
     branch.
   - If they cannot update the contributor branch, open a small linked spec pull
     request below it and rebase the implementation after that decision lands.
   - If the direction is accepted, the contributor updates the code when needed.
   - If the direction is rejected, the rejected implementation is removed or
     changed. Record the rejected alternative only when the boundary is
     consequential and likely to come up again.
5. The final commits invalidate prior approval. The owner approves the exact
   heads after the record and implementation agree.

If no implementation direction is accepted, close the contributor pull request.
The maintainer-owned spec pull request remains only when the ruling is useful
independently.

Review comments are evidence of the conversation; the checked-in record is the
canonical decision.

Record the durable outcome, not the review transcript. A ruling belongs in a
canonical record when at least one is true:

- it changes or clarifies an owning component, module, family, or system boundary;
- it establishes a requirement or prohibition future work must preserve; or
- it rejects an alternative that is consequential and likely to recur.

A prop name, implementation mechanism, or failed visual experiment from an
abandoned pull request stays in review history unless that detail itself passes
this test.

Use a separate lower spec pull request only when the ruling changes a shared
contract beyond the contributor change and should land or be reused
independently. The implementation pull request then rebases onto that decision.

Examples:

- NumberInput changes its stepping math. Its current contract says the final
  operation clamps to `min`/`max`, and the mapped tests still pass. Result:
  `preserves`; continue to normal correctness review with no human question.
- A new NumberInput path uses the same previously approved transformation order.
  Result: `settled`; cite that `DEC` and continue to normal correctness review.
- A package-exported context changes a required function parameter while its
  current contract preserves the earlier operation shape. Result: `violates`;
  request the smallest conforming change. A new compatibility policy is a separate
  proposal, not the default repair.
- A scroll-overflow fix also introduces a new hover disclosure. The overflow
  restoration proceeds under its current layout authority. If disclosure lacks
  authority, remove or split that separable visual tagalong; do not require the bug
  fix to specify the whole interaction module.
- Selector removes empty indicator space as its primary intent, but no current
  decision says whether option labels must stay aligned. Result: `novel-human`;
  hold privately while the owner decides the exact alignment contract.
- A product requests a one-off width prop for a family-owned input layout rule.
  Result: `out-of-scope`; route the change to the family contract rather than
  creating a component-specific API.

A draft record cannot clear a review gap. Only a verified `current` contract or
an applicable decision can produce `preserves` or `settled`.

The bootstrap does not mark any component, module, or family record current until
this flow passes the historical review benchmark and is enforced on pull requests.

## Owning code

- `AGENTS.md` points reviewers to the narrowest relevant record.
- `docs/templates/knowledge/` contains authoring forms.
- Component records are direct `<PublicName>.spec.md` children of a Core or Lab
  component root. The public name normally matches the root; a parent/member
  exception requires an exact top-level or full inline consumer-doc entry in
  that root. Public semantic module records are nested at least one directory
  beneath the same root as `<PublicName>.spec.md`; the parent component's
  `modules` list and the module's `parent_component` field must agree. Hidden,
  fixture, test, generated, build-output, coverage, dependency, and
  `*.generated.spec.md` paths are ignored consistently by discovery and PR
  routing.
- Wiki `component-scores.json` is the current operational audit datastore. The
  historical `<PublicName>.audit.json` reservation never received a schema or active
  records and is retired by INV12.
- `packages/themes/<theme>/<theme>.spec.md` contains that package theme's
  canonical record; `docs/themes/README.md` is guidance and an index only.
- `docs/schemas/knowledge/` defines required structure.
- `scripts/check-knowledge.mjs` validates templates, records, approval metadata,
  optional component/module design-decision tables, `path-applicability:v1`
  blocks, and INV25 reachability.
- `scripts/specs-for-file.mjs` (`pnpm specs:for`) resolves the records linked to
  a path. `--unrouted` lists tracked paths with no linked current record;
  `--unreached` lists current records no path reaches.
- `.github/scripts/component-design-decisions.cjs` classifies current
  component/module changes as no DD change, DD-only, or mixed from trusted base
  and head bytes. DD-only requires unchanged path, kind, authority, and every byte
  outside a valid optional block.
- `.github/scripts/change-scope.cjs` identifies pure spec-record changes.
- `.github/workflows/spec-owner-gate.yml` binds approval to the exact pull
  request head. Approval for every record kind derives from `.github/ENGOWNERS`;
  current design and theme records, normative design assets, and DD-only current
  component/module changes additionally accept `.github/DESIGNOWNERS`. A mixed
  DD plus other component/module edit requires both groups. Record metadata never
  self-authorizes. The workflow enables auto-merge only for pure spec changes.
- `.github/workflows/review-signal.yml` lets a DESIGNOWNER author bypass the
  engineering gate only when trusted-base code verifies every changed Core line
  against the fetched base/head source bytes as a conservative declaration inside
  a real `stylex.create` call, and no other code reason applies. The classifier
  also rejects direct interaction, operability, and scroll-control
  declarations—including hit testing, touch behavior, selection, resize,
  cursor, visibility, overflow, snapping, and scrollbar affordance—on either
  diff side. Contributors and mixed or behavioral changes remain gated.
  Safe-space classification considers both current and previous paths: only
  changes whose every path is safe are excluded, and any rename crossing that
  boundary is an engineering reason.
  Engineering and design reviews count only when their `commit_id` equals the
  current pull-request head. The direct reconciler clears only a current-head
  gate already owned by `review-signal`; an effective ENGOWNER
  `CHANGES_REQUESTED` or `DISMISSED` state first restores the hard status gate to
  pending, then restores its advisory label. A failed status write performs no
  preceding label mutation.
  Approval, withdrawal, and dismissal all recheck the head immediately before
  mutation, and an ungated head never acquires a gate from review state alone.
  For an author whose live repository permission is write or admin, the
  review app named in the `REVIEW_APP` repository variable also satisfies the
  engineering gate under DEC-11. Only that app's latest `APPROVED`,
  `CHANGES_REQUESTED`, or `DISMISSED` review whose `commit_id` equals the current
  head counts. Both workflows read the author's permission at every evaluation
  and publish the status with their own Actions token.

## Deciding specs

### DEC-1 — Current-record conflicts do not resolve by precedence

**Reference:** `architecture:knowledge-contracts/DEC-1`
**Decider:** `cixzhang`, `2026-08-30`

A current record does not override another by being newer, narrower, or closer
to the code. Review stops, identifies the canonical owner, and resolves the
conflict there. Other records link to the owning decision rather than copying
it.

Rejected: silently choosing the newest or most specific record, because that
turns documentation order into unreviewed system policy.

### DEC-2 — New rulings normally stay in the contributor pull request

**Reference:** `architecture:knowledge-contracts/DEC-2`
**Decider:** `cixzhang`, `2026-08-30`

A human ruling is normally discussed in the pull request that exposed the gap.
The contributor is responsible for explaining intent and changing their code;
maintainers and review agents are responsible for the spec system. They record
the ruling in the same branch when possible, or in a small linked lower spec PR
when the contributor branch cannot be updated. Rejected implementation is
removed or changed; a rejected alternative is recorded only when it protects a
consequential boundary from being debated again. Final exact-head approval
attests that the decision and implementation agree.

Rejected: requiring the owner to open a second pull request for every ruling,
because it separates the answer from the change and adds unnecessary review
work.

### DEC-3 — Search overlapping authority before writing

**Reference:** `architecture:knowledge-contracts/DEC-3`
**Decider:** `cixzhang`, `2026-09-07`

Before drafting a new record or materially expanding one, search current records
and open pull requests using more than the proposed title: canonical owner/id,
affected paths, exported symbols, and semantic behavior terms. Extend or project
the canonical owner when the fact already belongs there. Create a new record only
for a distinct fact boundary and explain why the existing owner cannot contain it.

Rejected: searching only filenames or landed records. Semantic overlap may use a
different title, and open work may already be changing the same owner before it
lands. Open pull requests coordinate work and provide evidence; they do not become
authority.

### DEC-4 — Contraction precedes new authority

**Reference:** `architecture:knowledge-contracts/DEC-4`
**Decider:** `cixzhang`, `2026-09-11`

When an otherwise reviewable change carries a separable contradiction or unsettled
decision, remove or split that delta and keep the primary intent moving. Recommend
a specification only when the surviving delta is intentionally pursued as durable
behavior, not because review discovered adjacent incompleteness.

Rejected: turning every uncontracted tagalong into a specification project, because
that makes a narrow repair absorb unrelated design work and leaves the contributor
without a landing-ready path.

### DEC-5 — Current approval is claim-scoped

**Reference:** `architecture:knowledge-contracts/DEC-5`
**Decider:** `cixzhang`, `2026-09-11`

A current record authorizes only its explicit claims within its stated ownership
boundary. It may deliberately leave adjacent behavior uncontracted. Reviewers do
not treat `current` as certification that a whole component or public module is
fully specified, and do not demand unrelated coverage before applying a narrow
approved decision.

Rejected: requiring a narrow visual or behavior decision to contract every API,
composition, accessibility, and implementation fact of the named module.

### DEC-6 — Product specs govern; generated review context informs

**Reference:** `architecture:knowledge-contracts/DEC-6`
**Decider:** `cixzhang`, `2026-09-12`

Committed current product records decide intended behavior. A review begins with
those claims and uses generated context, checklists, tests, screenshots,
measurements, and recommendations only to prove conformance, contradiction, or a
concrete defect. Generated completeness and green checks never create authority or
upgrade the authority result.

When the pull request or current record leaves context implicit, review may fill the
gap by tracing the problem through three why questions, mapping the solution back to
that problem, and naming tagalongs. It may also explain affected users, supported
states, enabled or prevented behavior, and caller burden. Those explanations remain
context unless the canonical product owner records them as current behavior.

Rejected: treating a complete review form, persuasive impact story, or passing
validation matrix as an alternative source of product direction.

### DEC-7 — Product specs contract behavior, not internal architecture

**Reference:** `architecture:knowledge-contracts/DEC-7`
**Decider:** `cixzhang`, `2026-09-12`

A new or materially amended product specification states the observable contract a
conforming implementation must satisfy and leaves equivalent internal implementations
free. It names an exact mechanism only when callers or interoperating systems
intentionally depend on that mechanism as public behavior. Otherwise, internal
ownership and seams belong in an architecture record, and verification remains
evidence rather than a prescribed CI or test topology.

This authoring rule is prospective. Existing current records retain their explicit
authority until the named human owner migrates them; adding this rule cannot silently
void a requirement such as a legacy implementation constraint. When an amendment
touches such a requirement, the owner either demonstrates that the mechanism is
publicly observable or moves it to the applicable architecture record.

Rejected: promoting a successful prototype's module graph, algorithm, manifest,
journal, lock, transaction design, filesystem layout, or CI job into product
requirements merely because that implementation supplied the decision evidence.

### DEC-8 — Spec review is advisory analysis for the human owner

**Reference:** `architecture:knowledge-contracts/DEC-8`
**Decider:** `cixzhang`, `2026-09-12`

Automated review of a specification does not stop at routing the change to a human.
It first produces written, evidence-backed feedback about the canonical owner,
existing and overlapping claims, ownership collisions, claim scope, misplaced
architecture, and exact contract edits. That analysis helps the human owner inspect
the decision; it never substitutes for the owner's approval or becomes authority.

Rejected: automatically approving a specification because its checks are green, or
returning only “needs human” without giving the owner the contract and ownership
analysis already available to the reviewer.

### DEC-9 — Global applicability routes exact claims, not whole records

**Reference:** `architecture:knowledge-contracts/DEC-9`
**Decider:** `cixzhang`, `2026-09-19`

A cross-cutting rule is discoverable without a component backlink only through a
validated `review-applicability:v1` block. Each semantic trigger names exact local
claim ids. Reviewers load matching current claims from the base authority commit
before narrower records, preserve match provenance, and then resolve the direct
owner first when it governs the delta.

Rejected: treating a `global` marker as authority for every statement in a file,
because that would erase claim boundaries and let unrelated or draft material
silently govern a change.

### DEC-10 — Routine visual intent lives with its component or module

**Reference:** `architecture:knowledge-contracts/DEC-10`
**Decider:** `cixzhang`, `2026-09-23`

A small structured `Design decisions` table in the owning component or module
spec records only durable local visual intent. One row may cover a coherent local
direction; tiny visual tuning does not create one decision record per tweak.
Existing rows are cited, reused, or amended before new rows are added. Exact
pixels remain in code and evidence, while shared and cross-component direction
remains in `docs/design/`.

A team DESIGNOWNER may include the local decision, implementation, and evidence
in one atomic appearance-only pull request. DD-only current-record changes route
to design approval; mixing a DD edit with another component/module contract edit
requires both design and engineering. A DESIGNOWNER's exact-head ready transition
may satisfy the design group. The Core code gate is bypassed only when trusted-base
classification proves an existing-file declaration-only appearance diff and no
other code reason applies. Any uncertainty, contributor-authored change, behavior,
JSX, API, package, barrel, new component, or unknown runtime path fails closed to
engineering review.

External contributors explain intent and supply normal evidence; maintainers own
specification work and never require contributors to author it. A request or
conversation is decision input, not committed authority. The edited current
record becomes reusable only after exact-head owner approval.

Rejected: sidecar records or one design record per pixel tweak, because they split
routine intent from its owner; treating a design request as authority before
exact-head approval, because reviewable intent is not yet committed policy.

### DEC-11 — A review-app approval clears the code gate for write-access authors

**Reference:** `architecture:knowledge-contracts/DEC-11`
**Decider:** `cixzhang`, `2026-10-15`

An author with write or admin permission on the repository is an internal
contributor, whether or not they are an engineering or design owner; designers
and partners with write access are included. For those authors, the latest
exact-head verdict of the configured review app decides the engineering gate: an
approval clears it, and a later request for changes, a dismissal, or a new
commit makes it pending again. An effective ENGOWNER request for changes on the
exact head keeps the gate pending over an app approval, and an ENGOWNER approval
still clears the gate for every author.

Authors without write permission, including read-only members and fork
contributors, keep ENGOWNER approval as the only way to clear the gate. The
permission comes from the repository's collaborator permission at evaluation
time, not from owner lists, organization membership, or author association. An
unset `REVIEW_APP`, an unreadable permission, or any lower permission leaves the
app's reviews without effect. The status is published by the workflow's own
identity, never by a person's account.

Rejected: counting any app approval on the pull request, because an approval of
an earlier head or one followed by a request for changes would keep the gate
clear.

### DEC-12 — Records declare the paths they govern in an optional overlay

**Reference:** `architecture:knowledge-contracts/DEC-12`
**Decider:** `cixzhang`, `2026-10-21`

A record that governs paths no other link reaches declares glob patterns in an
optional `path-applicability:v1` block, beside `review-applicability:v1`. The
overlay is independently versioned, so opting in does not change a record
kind's required shape or migrate other records. A path-first lookup lists a
matched record; claim scope, precedence, and implementation freedom are
unchanged.

Rejected: a required `applies_to` field on every kind, because it would migrate
every active record to declare paths most already reach through placement or
inheritance. Rejected: regular expressions, because a reviewer cannot read
their reach at a glance and they over-match silently.

## Verification

| Invariant                         | Evidence                                                                                                                                                                                                     | Failure signal                                                                                                                                                                                                                                                |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV6                        | `scripts/check-knowledge.test.mjs`                                                                                                                                                                           | An unapproved current record or unmigrated active record passes                                                                                                                                                                                               |
| INV5, INV7                        | `.github/scripts/change-scope.test.mjs`                                                                                                                                                                      | A template, schema, guidance, architecture, code change, unsafe rename, or truncated list qualifies as spec-only                                                                                                                                              |
| Approval follows the current head | `.github/scripts/spec-owner-decision.test.mjs`                                                                                                                                                               | An approval for another commit clears the gate, a self-declared owner becomes an approver, or the wrong owner group approves a current record                                                                                                                 |
| INV3, INV4, INV11                 | Blinded historical review benchmark                                                                                                                                                                          | Reviewer re-asks a settled decision, invents a new one, approves an unsettled public delta, or treats a contradiction as preserves                                                                                                                            |
| INV10                             | Record-content and review-disposition fixtures                                                                                                                                                               | A spec assigns a PR verdict, or a reviewer treats a PR link as authority                                                                                                                                                                                      |
| INV13                             | Blinded spec-authorship fixture plus overlap-search receipt                                                                                                                                                  | An author creates parallel authority, searches only landed records or filenames, misses open work on the canonical owner, or treats an open PR as authority                                                                                                   |
| INV14, INV15                      | Narrow-decision and mixed-intent review fixtures                                                                                                                                                             | A current visual slice is forced to contract its whole module, a separable tagalong blocks a repair, or review reports a gap without a landing-ready remedy                                                                                                   |
| INV16, INV17                      | Spec-first review and missing-context fixtures                                                                                                                                                               | Generated checklist completeness overrides authority, review invents product direction, restates supplied context, or fails to identify an unrelated tagalong or affected caller state                                                                        |
| INV18                             | Spec-review mechanism analysis plus owner migration receipt                                                                                                                                                  | A new/amended product spec requires a private mechanism without proving it is public, silently invalidates an existing current record, or leaves touched architecture wording in the product contract                                                         |
| INV19                             | Spec-review ownership-collision receipt and human decision                                                                                                                                                   | Automated review approves a product contract, omits current/open owner overlap, misses a collision or architecture leak, or returns only a human hold without actionable written analysis                                                                     |
| INV20, DEC-9                      | `scripts/check-knowledge.test.mjs` and `scripts/review-global-baselines.mjs`                                                                                                                                 | A matching review misses a current global claim, loads a draft/nonmatching claim, loses base-commit provenance, or treats the rest of a routed record as authority                                                                                            |
| INV21, DEC-10                     | `.github/scripts/component-design-decisions.test.mjs`, `.github/scripts/review-signal-decision.test.mjs`, `.github/scripts/spec-owner-decision.test.mjs`, and `.github/scripts/lib/classify-visual.test.mjs` | A malformed/local DD escapes validation, stale approval clears a new head, owner routing loses mixed-change gates, or executable/API/unknown runtime code is misclassified as appearance-only                                                                 |
| DEC-11                            | `.github/scripts/review-signal-decision.test.mjs`, `.github/scripts/review-clear-workflow.test.mjs`, and `.github/scripts/review-signal-workflow.test.mjs`                                                   | An app approval clears a read-only or fork author or an earlier head; a later app request for changes or dismissal leaves the gate clear; an app approval overrides an ENGOWNER request for changes; or an unset app or unreadable permission clears the gate |

| Invariant            | Evidence                                                                                                                                 | Failure signal                                                                                                                                                                                                   |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV22–INV24          | Contributor/reviewer evidence inventory; public-link and byte verification; retained remote refs; unchanged fixture/baseline/media paths | Disposable captures enter the source tree, cleanup loses a referenced image, a temporary branch is called durable, or storage changes overwrite an owner's proof or retention contract                           |
| INV25, INV26, DEC-12 | `scripts/specs-for-file.test.mjs`, `scripts/check-knowledge.test.mjs`, and `pnpm check:knowledge`                                        | A current record is reached by no tracked non-record path, a lookup infers a link from a name, a pattern matches nothing or the whole repository, or a pattern match widens a claim or outranks a narrower owner |

Evidence placement is enforced by contributors and reviewers. The local
`docs/pr-assets/` ignore prevents accidental staging; it is not a blocking CI
check and does not prevent force-added evidence elsewhere.

Current enforcement gap: no checked-in gate yet proves the open-pull-request
search. Until one exists, the pull-request summary records the search terms,
canonical owner/path, and overlapping open work inspected.

---

### Architecture Layer Runtime (`docs/architecture/layer-runtime.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:layer-runtime
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/Layer/,
    packages/core/src/Popover/,
    packages/core/src/Dialog/,
    packages/core/src/Drawer/,
    packages/core/src/DropdownMenu/,
    packages/core/src/ContextMenu/,
    packages/core/src/Tooltip/,
    packages/core/src/HoverCard/,
    packages/core/src/Toast/,
    packages/core/src/CommandPalette/,
    packages/core/src/hooks/useFocusTrap.ts,
    packages/core/src/hooks/useMenuHover.ts,
  ]
verified_by:
  [
    packages/core/src/Layer/useLayer.test.tsx,
    packages/core/src/Layer/layerViewportInset.test.ts,
    packages/core/src/Layer/LayerProvider.test.tsx,
    packages/core/src/Layer/layerHost.test.ts,
    packages/core/src/Layer/anchorName.test.ts,
    packages/core/src/Layer/useLayerDismissal.test.tsx,
    packages/core/src/Layer/layerDismissalInvariants.test.tsx,
    packages/core/src/Popover/Popover.test.tsx,
    packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
    packages/core/src/ContextMenu/ContextMenu.test.tsx,
    packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx,
    packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx,
    packages/core/src/CommandPalette/CommandPalette.test.tsx,
    packages/core/src/hooks/useFocusTrap.test.tsx,
    packages/core/src/hooks/useMenuHover.test.tsx,
    packages/core/src/Toast/ToastViewport.test.tsx,
    packages/core/src/Toast/ToastViewport.modalHost.test.tsx,
  ]
deciding_specs: [spec:AST-038, spec:AST-059]
---

# Layer runtime



```json
{
  "scope": "global",
  "triggers": {
    "layering": ["INV2", "INV5", "INV6", "INV7", "INV11", "INV12"],
    "layout": ["INV3", "INV11", "INV12"]
  }
}
```

This record describes the layer runtime shipped on current `main`. Accepted but
unimplemented changes live in `spec:AST-003`; they are not current architecture.

## Purpose

Layered UI must escape clipping, stay positioned against its trigger, preserve
nearby theme and writing context, and reconcile browser-owned visibility with
React state.

This record separates DOM hosting, browser top-layer promotion, positioning, and
current dismissal plumbing. A component does not inherit every behavior merely
because it uses one of those mechanisms.

## System model

### Browser hosts and corrective portals

A browser host determines whether and how a surface enters the browser's top
layer:

- `popover="auto"` provides native top-layer promotion, light dismissal, close
  requests, and the browser's auto-popover stack;
- `popover="manual"` provides top-layer promotion without native light dismissal
  or auto-popover exclusivity;
- `dialog.showModal()` provides a native modal boundary, backdrop, inert outside
  content, and platform close requests;
- `dialog.show()` provides a non-modal dialog in its containing context; and
- ordinary DOM rendering provides none of those browser guarantees.

A React portal changes DOM placement. It is not itself a top-layer host and
cannot use `z-index` to outrank a native modal dialog.

`useLayer` keeps a context layer at its JSX position when that position is safe.
When parsing, interactive-content, or inline-formatting constraints make that
position unsafe, it portals only beyond the outermost unsafe ancestor. A
persistent `<template>` marker preserves the intended position so the host can be
resolved again when the render call moves. The nearest safe host preserves live
CSS custom-property inheritance; direction and writing mode are copied only when
the portal would otherwise lose them.

A normal React portal retains React context. `architecture:theme-application`
owns Theme provider behavior; this record owns where a layer's DOM is hosted.

### Positioning

`useLayer` has three positioning modes:

- **context / anchor:** the trigger receives a unique CSS `anchor-name`; the
  surface receives `position-anchor`, logical `position-area`, and ordered
  fallback positions;
- **context / custom:** the trigger and `position-anchor` relationship remain,
  while the caller owns geometry, fallback, offset, and direction handling; and
- **fixed:** the caller supplies viewport `x` and `y` coordinates and receives no
  CSS-anchor geometry.

Anchor names form a list so several layers may share one trigger without
clobbering one another. Standard placement uses the `self-*` logical keyword
family so inherited direction controls RTL behavior. Every anchored placement
can flip across either axis. Centered placements also gain span fallbacks so the
surface can move along the alignment axis near viewport edges. Clearance is
applied to both edges of the placement axis so a flip retains the gap.

#### Viewport inset

Anchor mode owns the viewport inset (`spec:AST-059`). Every anchor-mode layer
keeps a gutter from each viewport edge equal to `max(--spacing-4,
env(safe-area-inset-<edge>))` plus the inset the app declares for that edge as
`inset` on `LayerProvider` (`blockStart`, `blockEnd`, `inlineStart`,
`inlineEnd`; zero by default; app configuration, not a theme value). The
provider publishes the declaration through its context; `useLayer` reads it
and writes it inline on the layer element as the custom properties
`--astryx-layer-inset-<logical-edge>` the gutter expressions read, and the
toast viewport writes the same properties on itself from the provider's prop.
Because the value travels by context and not by CSS inheritance, a corrective
portal cannot lose it, and a layer with no provider — nothing written, every
property reading `0px` — renders exactly as one under a default provider. A
hand-written property is an unsupported escape hatch. The inline gutter reads
the larger of the two physical safe-area insets so a flipped layer still clears
a notch. `Layer/layerViewportInset.stylex.ts` holds the one definition. The
gutter is a margin, so the margin box is what must fit a position option and
what the browser's overflow shift keeps inside the viewport: a layer that would
end inside the gutter or under a declared bar does not fit there and the next
option is tried. On the placement axis the gutter rides the far edge beside
the anchor clearance on the near edge; on the alignment axis it rides the far
edge of an aligned layer — one on the anchor-facing edge would push the layer
off its anchor — and both edges of a centered one. The flip tactics swap the
margins with the area, so the clearance stays on the anchor side and the gutter
on the far side, and the placement-axis gutter is one value for both edges —
the larger of the two block gutters — so a bar declared on one block edge keeps
a layer the same distance from the opposite edge.

The layer box is capped to the viewport minus both gutters on both axes, never
to the span of viewport beside the trigger, and never exceeds that cap. An
explicit size renders at its size up to the cap; a content-sized layer wraps to
the room beside its trigger when its content can wrap and keeps its size up to
the cap when it cannot. Content that cannot fit the cap overflows inside the
layer box, where the composing component scrolls or clips it (Popover scrolls
once it measures overflow); the box itself stays on screen. A consumer caps its
painted surface with the same definition and clamps any minimum of its own
(trigger matching, an explicit width or `menuWidth`) with the runtime's cap
expression, because a CSS minimum otherwise wins over a maximum.

The fallback order is the runtime's: preferred position; flip across the
placement axis; flip across the alignment axis; both; then, while the anchor is
in view, the slide: named `@position-try` options (`Layer/layerSlideRules.ts`)
whose area spans the whole alignment axis, same side of the trigger first,
opposite side second, each carrying the gutter on both alignment-axis edges and
the clearance-and-gutter pair of the side it lands on, which the browser's
overflow alignment shifts the least distance that brings the margin box inside
the viewport. Centered layers keep their one-sided spans ahead of the full
span. The slide is its own option because a layer wider than the room on either
side of its trigger overflows every flipped option on the alignment axis, and
the browser would otherwise keep the base option and never move the layer to
the side of the trigger with room on the placement axis. The rules are one
static style sheet the runtime installs in the layer's document head before
the layer's first paint — never inside the layer, where a role-bearing layer's
text would name them to assistive technology, and never beside it, where they
would change a parent's last child.
Content taller than the placement axis can hold on either side is capped to the
viewport minus both gutters and shifted into the viewport by the browser; its
margin box stays inside the viewport, so it keeps its anchor clearance from the
edge it meets.
Once the anchor has left the viewport the runtime pins the layer's
self-alignment `unsafe` toward the anchor, so the layer holds the position its
flips give it and holds its size rather than sliding to the edge. Components
pass no fallbacks of their own to an anchor-mode layer.

Every one of these inputs is resolved by the browser's style and layout in the
same frame as the render that produced it; the runtime measures nothing after
paint to apply them. Anchor visibility is read synchronously in a layout effect
before the layer's first paint and then observed while the layer is open, so a
layer opened with its anchor already out of view holds from its first frame. A
layer never paints at a geometry it is about to correct.

### Current browser support behavior

Native Popover API plus CSS Anchor Positioning provide the complete behavior
implemented by the main path: top-layer promotion, native light dismiss,
auto-popover stacking and association, logical anchor geometry, and browser
fallback placement.

When `showPopover` or `hidePopover` is unavailable, `useLayer` falls back to
changing `display`. Explicit component controls can still show and hide mounted
content, callbacks and React state still update, and fixed coordinates still
apply. This fallback does not reproduce top-layer promotion, native outside or
close-request behavior, auto-popover exclusivity or nesting, invoker focus order,
anchored geometry, or collision fallbacks.

Public docs do not yet state this reduced behavior as a support boundary. The
accepted support contract and documentation change are owned by `spec:AST-003`.

### Current Popover lifecycle and light dismissal

Public `useLayer` defaults to `popover="manual"`; Popover-family APIs normally
select `popover="auto"`. Browser-initiated closes are observed through the queued
`toggle` event. The browser may already have hidden the surface before `isOpen`,
`onHide`, or controlled-state synchronization catches up.

`gestureCounter` identifies the physical pointer or keyboard gesture in flight.
`useLayer` records the gesture associated with a browser close and
`wasJustDismissed()` prevents that same press from reopening a trigger.
DropdownMenu and Selector-family trigger paths consume this guard.

`useMenuHover` adds a real native invoker relationship for applicable auto
popovers. It separately suppresses the synthetic `mouseenter` produced when a
closing panel exposes a stationary trigger, while allowing a genuine later hover.

Controls that sit beside an open layer can temporarily gain `popovertarget`
during a press so native light dismissal does not mistake them for outside
interaction. Their click action is cancelled as an invoker toggle so the control
can perform its own action without closing the layer.

### Current dismissal plumbing

`family:overlay-dismissal` owns the shipped Escape/platform-close membership
contract. The current shared stack registers present layers with `close` or
`block` behavior and orders them by logical depth, DOM containment, then stable
active-cycle registration sequence. `useFocusTrap` adapts an active trap with `onEscape` into
that stack.

Tooltip, HoverCard, Dialog, Drawer, Popover, DropdownMenu, ContextMenu,
Lightbox, MobileNav, BottomSheet, and BottomSheetSwitcher all register with the shared
stack. Tooltip and HoverCard report current DOM presence; Popover and
DropdownMenu register through `useFocusTrap`; Dialog, Lightbox, MobileNav,
BottomSheet, BottomSheetSwitcher, and Drawer additionally ask
`shouldDismissOnCloseRequest()` before acting on native platform close
requests. Other family members still use local Escape handling as listed in
`family:overlay-dismissal`.

Outside interaction is not coordinated by the shared stack. Current paths are
independent:

- auto popovers use native light dismiss;
- native dialogs handle backdrop clicks locally;
- ContextMenu uses a local outside `mousedown` listener;
- Tooltip and HoverCard use hover/focus loss plus a touch-only outside listener;
- sheets own scrim and swipe behavior; and
- dropdown submenus encode their root-close relationship locally through
  `DropdownMenuContext`.

The shared stack exposes `isTopmostLayer()`, but current outside, backdrop, touch,
and swipe paths do not use it. There is no shared interaction-owner role,
association graph, branch registry, or outside-branch resolution operation on
current `main`.

### Layer content boundary — AST-038 implementation projection

[AST-038](../specs/AST-038-layer-text-boundary/spec.md) owns the reading baseline
and surface/group boundary. This implementation resets layer-root text, whole
React contexts carrying surface/group membership, and named structural custom
properties; structural CSS isolation covers only the channels it names.

The shared private text baseline is applied in both `useLayer` renderers and the
Dialog, Drawer, Lightbox, MobileNav, BottomSheetPanel, and ToastViewport content
roots. Component and caller styling remains stronger. Existing padding
normalization, hosting, theme inheritance, and writing context remain unchanged.

`createLayerScopedContext` creates surface-scoped contexts whose complete default
value is provided by `LayerContentBoundary`. Membership-owned disabled state,
selection, callbacks, and labels stop together with presentation defaults.
Existing context shapes and public hooks are unchanged. Content-local providers
remain owners; consumers requiring a group need its complete provider inside the
new surface. Menus already establish their own content-local owner and close
chain. Unrelated application state, collection protocols, semantic DOM selection,
and interaction coordination continue through ordinary React contexts.

Each boundary snapshots its provider chain at mount so later lazy imports cannot
remount live content or discard state/focus. Existing native depth-provider seams
retain their original depth values; raw Layer, sheet panels, and toast content
use the private content boundary directly. Toast page children stay outside it.

The private structural reset sits beside the text baseline on the same content
roots and stops each structural custom property it names by setting it to
`initial`. It names `--step-connector-gap`, so an inner Stepper in a layer opened
from `Step.children` content starts from the connector's 0px fallback; a value
set on that Stepper, through its `stepper` target, or by a caller style on the
layer root still applies. It never resets by prefix, so theme tokens and writing
context keep inheriting. A structural channel it does not name still inherits
into layer content.

Existing component behavior checks cover membership exit, explicit inner owners,
unrelated context continuity, and state/focus retention. Browser evidence covers
text inheritance and visual appearance.

### Current global and nonparticipating surfaces

LayerProvider supplies Toast configuration and mounts ToastViewport. It is not a
general portal host for all layers. Without a provider, `useToast` creates a
separate React root under `document.body` and mirrors root theme attributes onto
it.

ToastViewport uses `popover="manual"` for top-layer promotion. Each open modal
Dialog supplies its `<dialog>` as an outlet through the private
`Layer/modalOutlet.ts` registry. A top-layer viewport renders into a host element
it owns, moves that host into the latest open outlet, returns it to the
viewport's tree position when no Dialog is open, and enters the top layer again
after each move. Toasts therefore paint above the Dialog and stay operable inside
it. Moving the host does not remount the rows, so entries, timers, and focus
handoff survive. Toast state lives inside the viewport. A viewport rendered with
`isTopLayer={false}` stays in place.

CommandPalette composes native Dialog. Its normal launcher presentation therefore
uses `showModal()`; its documentation/showcase `isInline` path does not. The
modal path delegates Escape to Dialog's shared dismissal registration. Only the
inline preview closes from CommandPalette's local key handler because it is not a
registered layer.

Banner and FieldStatus are in-flow. `useAnnounce` is nonvisual. AlertDialog,
imperative Dialog, Lightbox, and ordinary Popover-family surfaces are
interaction-local. `useKeyboardHint`, Carousel's control overlay, and visual-only
layers use Layer rendering without joining Escape/platform dismissal.

## Boundaries and invariants

- **INV1 — Corrective portals preserve locality.** A context layer stays inline
  when safe and otherwise moves only outside the outermost unsafe ancestor.
- **INV2 — Hosting and promotion are separate.** A React portal moves DOM. Native
  Popover or modal Dialog APIs control top-layer participation and modal
  boundaries.
- **INV3 — Positioning modes do not leak.** Anchor mode owns logical placement,
  fallbacks, and the viewport inset; custom mode owns its geometry; fixed mode
  owns explicit coordinates.
- **INV4 — Shared anchors compose.** One layer adding or removing its anchor name
  does not overwrite names belonging to sibling layers.
- **INV5 — Logical placement remains direction-aware.** Standard anchored
  placement derives from the surface's inherited writing direction and keeps
  clearance after a fallback flip.
- **INV6 — Native close reconciliation is single-fired.** Programmatic hide and
  browser close do not double-fire state or `onHide` updates.
- **INV7 — Existing native-light-dismiss guards identify gestures.** The same
  physical press that closes a Layer cannot immediately reopen it; menu-hover also
  suppresses exposure-induced hover.
- **INV8 — Current Escape delivery follows registered layers.** The shared stack
  uses present `close` and `block` entries in its current depth, containment, and
  stable-registration order.
- **INV9 — Outside channels remain component-owned and uncoordinated.** Current
  backdrop, pointer, touch, hover/focus, and swipe handlers act through their
  owning component or browser mechanism rather than one shared branch operation.
- **INV10 — LayerProvider is configuration, not a universal layer host.** It
  carries Toast configuration and the app-declared viewport inset. Trigger-
  associated layers resolve near their JSX position independently of the
  provider; the inset reaches them as a context value each layer carries on
  itself, never by resolving placement through the provider, and a layer with
  no provider renders as one under a provider with the default inset.
- **INV11 — One viewport gutter.** The gutter, the viewport caps, and the
  app-declared inset have one definition in the runtime; every anchor-mode layer
  inherits them, the layer box never exceeds the caps, the toast viewport reads
  the same inset, no consumer defines its own, no size is capped to the span
  beside the trigger, and a consumer minimum is clamped by the runtime's cap.
- **INV12 — The first frame is the settled frame.** No input to a layer's
  viewport geometry is measured after paint and then applied; the one runtime
  observation, anchor visibility, is read before first paint. A layer never
  paints at a geometry it is about to correct.

This record does not make future eligible-owner, branch-association, global-host,
or browser-support requirements current. It does not own component focus entry or
return, modal semantics, channel admission policy, public controlled/uncontrolled
APIs, visual treatment, or theming anatomy.

## Current gaps and accepted change

Current gaps are observable facts, not current target behavior:

- Tooltip currently consumes Escape ahead of an associated Popover, menu, or
  Dialog instead of acting as passive information.
- There is no distinction between active, blocking, passive, and visual-only
  registration roles.
- Outside interaction cannot resolve a whole associated menu/submenu branch or a
  nested child branch through shared infrastructure.
- Backdrop, manual outside, touch, and swipe paths do not share the existing
  gesture claim or one association model.
- Toast's root and fallback hosts remain behind the other native modals:
  Lightbox, MobileNav, and a scrim BottomSheet or BottomSheetSwitcher.
- The reduced browser fallback is not documented as non-equivalent.
- Existing tests simulate Popover `toggle` and DOM nesting; they do not prove
  native pointer light dismissal, top-layer/modal ordering, real anchor geometry,
  SSR parser repair, or nested portal behavior in supported browsers.

`spec:AST-003` is the accepted, unimplemented change that owns requirements,
migration, verification, and completion criteria for these gaps. This record must
be updated only as that work ships.

## Change coupling

- A change to `useLayer` hosting or lifecycle verifies safe inline placement,
  corrective portals, host relocation, show/hide reconciliation, theme
  inheritance, writing context, and reduced-browser behavior.
- A positioning change verifies all placement/alignment combinations in LTR and
  RTL, viewport-edge fallbacks, offsets after flips, shared anchors, custom mode,
  and fixed mode; and, through the `Core/Layer` viewport-inset stories in real
  Chromium at a desktop and a phone viewport, an explicit size near an edge,
  content that fits and content that does not fit beside the trigger, a trigger
  where neither side fits, a layer wider than the viewport, an anchor that
  leaves the viewport or is already out of view, an app-declared inset arriving
  or changing, and the first painted frame against the settled one.
- A Popover lifecycle change verifies programmatic and browser closes, controlled
  state, temporary invoker association, and same-gesture trigger behavior.
- A dismissal-stack change updates `family:overlay-dismissal` when membership or
  Escape/platform delivery changes.
- A local outside, backdrop, touch, hover, or swipe change preserves the owning
  component's policy and verifies interactions with nested surfaces.
- A Toast host change preserves queued and visible toasts, timers, focus handoff,
  theme context, and ordering.
- A CommandPalette host change preserves native modality, search state, focus
  entry/return, and controlled close behavior.
- A browser fallback change updates public `useLayer` and Popover docs and tests
  for both the native path and actual fallback.

## Owning code

- `Layer/useLayer.tsx` owns Popover API lifecycle, state reconciliation,
  anchor/fixed/custom rendering, trigger source, and current same-gesture memory.
- `Layer/layerHost.ts` owns safe inline versus nearest corrective portal placement.
- `Layer/anchorName.ts` owns composition of anchor names on one trigger.
- `Layer/layerTextReset.stylex.ts` owns the text baseline and
  `Layer/layerStructureReset.stylex.ts` owns the named structural custom-property
  reset applied on layer content roots.
- `Layer/layerViewportInset.stylex.ts` owns the gutter and the viewport caps;
  `Layer/layerSlideRules.ts` owns the slide options' `@position-try` rules;
  `LayerProvider` publishes the declared inset through `LayerContext`, and
  `Layer/layerInset.ts` owns the `--astryx-layer-inset-*` properties a layer or
  the toast viewport writes on itself to carry it; `Layer/clampInlineSize.ts`
  owns the clamp a consumer applies to a size of its own.
- `Layer/gestureCounter.ts` owns physical pointer/key gesture identity.
- `Layer/layerStack.ts`, `Layer/useLayerDismissal.ts`, and
  `Layer/LayerDepthContext.tsx` own current registration, presence, ordering, and
  Escape/platform routing.
- `Popover/usePopover.tsx` owns focus-trap composition and default native light
  dismiss for Popover-family surfaces.
- `hooks/useFocusTrap.ts` adapts dismissible traps into current shared
  registration; it does not own component focus policy.
- `hooks/useMenuHover.ts` owns hover-open confirmation, native invoker wiring,
  menu focus, and exposure-induced re-hover suppression.
- `Layer/useTouchTrigger.ts` owns touch trigger classification and the current
  touch-only outside listener for Tooltip/HoverCard.
- `DropdownMenuContext` and `DropdownMenuSubMenu` own the current local
  menu-cascade parent-close chain.
- Dialog families own native modal/backdrop presentation and their local channel
  policies.
- Drawer owns modal `showModal()` and non-modal `showPopover()` hosting while the
  shared dismissal stack owns Escape and platform close routing.
- `LayerProvider`, `ToastContext`, `useToast`, and `ToastViewport` own current
  notification state, dispatch, and viewport rendering; the viewport sits at
  the provider's inset on each edge unless its own `toast.inset` overrides that
  edge.
- CommandPalette owns command search and selection; Dialog owns its native modal
  host.
- `family:overlay-dismissal` owns Escape/platform-close membership.
  `architecture:interaction-modality` owns last-input modality.
  `architecture:public-component-api` owns public component and hook contracts.
  `architecture:theme-application` owns provider and portal theme context.

## Deciding specs

`spec:AST-038` governs the layer content boundary projected above. It does not
change the hosting runtime described here.

`spec:AST-059` governs the viewport inset projected above: the gutter, the
caps, the fallback order and its anchor-visibility condition, and the
app-declared inset.

`spec:AST-003` is accepted but unimplemented. It defines the approved next
runtime and must move to `shipped` before its requirements are incorporated into
this current architecture record.

## Verification

| Invariant  | Evidence                                                                                                                                                    | Failure signal                                                                                                                                                                                                |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV2 | `useLayer.test.tsx` and `layerHost.test.ts`                                                                                                                 | Invalid DOM, stale host, lost theme/writing context, or portal mistaken for top-layer promotion                                                                                                               |
| INV3–INV5  | `useLayer.test.tsx` and `anchorName.test.ts`                                                                                                                | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost                                                                                                       |
| INV11      | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor |
| INV12      | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read                                          | A layer whose first painted rectangle differs from its settled one                                                                                                                                            |
| INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, and `useMenuHover.test.tsx`                                                               | Duplicate close callback or the same press/re-hover reopens a surface                                                                                                                                         |
| INV8       | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx`                                                              | Current top registered layer is skipped, two layers close, or a blocker leaks through                                                                                                                         |
| INV9       | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests                                                                         | A current local channel silently changes ownership or policy                                                                                                                                                  |
| INV10      | `LayerProvider.tsx`, `LayerProvider.test.tsx`, `useToast.tsx`, `ToastViewport.test.tsx`, and `ToastViewport.modalHost.test.tsx`                             | Provider begins relocating ordinary layers, Toast fallback loses its current lifecycle, or a default provider renders differently from no provider                                                            |

Current unit coverage proves emitted styles, reducers, state transitions, and DOM
placement. Native Popover, `<dialog>`, focus, top-layer ordering, and rendered
anchor geometry require real Chromium and WebKit evidence when changed. The
device safe-area term of the gutter is not reproducible in Chromium emulation;
it is proven on a device.

---

### Architecture Public Component Api (`docs/architecture/public-component-api.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:public-component-api
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/,
    packages/core/package.json,
    packages/core/src/BaseProps.ts,
    packages/cli/authoring/doctypes/component/,
    internal/eslint-plugin-astryx/,
  ]
verified_by:
  [
    internal/eslint-plugin-astryx/require-base-props.js,
    internal/eslint-plugin-astryx/require-baseprops-passthrough.js,
    packages/core/src/docPropLiterals.test.ts,
    packages/core/src/docPropReferences.test.ts,
  ]
deciding_specs:
  [
    spec:AST-002/DEC-1,
    spec:AST-005/DEC-1,
    spec:AST-012/DEC-1,
    spec:AST-012/DEC-2,
    spec:AST-071/DEC-1,
  ]
---

# Public component API



```json
{
  "scope": "global",
  "triggers": {
    "public-api": [
      "INV1",
      "INV2",
      "INV3",
      "INV4",
      "INV5",
      "INV6",
      "INV7",
      "INV8",
      "INV9",
      "INV12"
    ]
  }
}
```

This record defines the shared public API contract for stable Astryx components.

## Purpose

Consumers should be able to predict how an Astryx component is imported,
controlled, extended, and composed without learning its internal implementation.
New API should express caller-owned intent and preserve established behavior.

## System model

The installable package is the public contract. It includes:

- exported components and hooks;
- exported props and stable supporting types;
- published component subpaths;
- documented defaults; and
- behavior consumers can observe.

Consumer `.doc.mjs` files explain that contract. They do not create props or
policy that the exported code does not have.

Once this record is `current`, every current component contract lists
`architecture:public-component-api` in its `architecture` links. Draft component
contracts add that link only after this record becomes current, so candidate
relationships do not create authority early.

Component and family contracts own narrower behavior. Theme records own theme
targets, public theme properties, tokens, and generated styles. Contribution
guidance owns the process used to propose and test APIs.

## Boundaries and invariants

- **INV1 — Public exports are reachable.** A stable component, hook, prop type,
  and other promised public types are available from their published package
  entry point or component subpath.
- **INV2 — Names carry the same meaning.** Components, hooks, booleans,
  callbacks, Actions, directions, refs, and native HTML pass-throughs follow the
  shared naming grammar. Component-specific vocabulary stays local.
- **INV3 — One prop controls one independent concept.** A prop does not silently
  suppress an unrelated prop. A parent does not duplicate state already owned by
  a composed child.
- **INV4 — New props express caller-owned intent.** New public props follow
  `spec:AST-002/DEC-1`. A component keeps a decision internal when it can derive
  the correct result from state, content, layout, context, or platform behavior.
- **INV5 — Accepted consumer props are preserved.** For a DOM-owning component,
  supported DOM, data, ARIA, style, class, and event inputs reach the element that
  owns the contract. Component-owned accessibility and behavior cannot be
  accidentally overwritten.
- **INV6 — Styling inputs combine.** `xstyle`, `className`, and `style` compose in
  their documented order instead of replacing one another. A family record may
  declare an exception to root styling inputs for its members, stating the
  replacement part inputs and its reason in a decision; `family:input-fields`
  FR10 is one.
- **INV7 — Event handlers compose deliberately.** Consumer handlers and built-in
  behavior use the shared cancellation contract. A consumer can cancel built-in
  behavior only where that public API promises cancellation. A component that
  moves a handler to another element keeps the order and cancellation it
  released for that handler and documents it (`family:input-fields` DEC-8).
- **INV8 — Refs follow React 19.** A public DOM component accepts `ref` as a prop
  and connects it to the element promised by its contract.
- **INV9 — Released APIs change deliberately.** A released prop, type, export,
  default, or observable behavior is not removed, renamed, retyped, deprecated, or
  corrected incompatibly without the lifecycle, evidence, and migration required by
  [`spec:AST-017`](../specs/AST-017/spec.md).
- **INV10 — Shared subcontracts are linked, not copied.** Input Actions, layer
  behavior, theming, and family-specific rules stay with their owning records.
- **INV11 — Public theme seams pass API admission.** A public semantic CSS custom
  property is admitted only for caller-owned intent that the target's guaranteed
  CSS property set cannot express. Its theming record owns the exact purpose,
  stable default/fallback, scope, documentation, evidence, and compatibility
  contract; an internal styling gap alone does not justify public API.
- **INV12 — Reachability outranks internal labels.** A type, context member, hook
  return, utility signature, default, or operation reachable from a supported
  package path is public even when its source comment, filename, or current use
  calls it internal. Privacy requires removing the export or moving the capability
  behind a non-exported boundary.
- **INV13 — Public deltas route to their authority owner.** Every public API update
  and every public behavior change follows the result and disposition defined by
  `architecture:knowledge-contracts`. Tests, a Changeset, or a generated manifest
  are evidence; none can settle an absent or contradictory contract.
- **INV14 — Shared breakpoint names keep one meaning.** Components that accept a
  theme width point use `sm`/`md`/`lg`/`xl`/`2xl` from the active Theme. A
  `below` boundary is exclusive, so equality belongs to the wider side.
- **INV15 — A part's styling inputs come as a set.** Styling for an element other
  than the root is accepted as `<part>Xstyle`, `<part>ClassName`, and
  `<part>Style` together, reaching that element and composing as INV6 describes.
  No public prop accepts StyleX styles for an element that cannot also take a
  class name and inline styles, following `spec:AST-071/DEC-1`.

This record applies to stable public packages. Lab components are not stable
public promises until promotion.

## Change coupling

- Adding or changing an exported prop, supporting type, context member, hook
  return, operation signature, default, package entry point, ref target, or
  intentional observable contract triggers public-API review. A top-level export
  need not change for a reachable supporting contract to change.
- Each review compares declarations and reachable exports from merge-base to head
  and from current main to the synthetic merge. The generated surface manifest
  inventories evidence and maps each row to its canonical component, family,
  architecture, or system owner; it does not decide acceptance.
- After the generated surface inventory identifies the exact API and behavior
  delta and its canonical owner, route it under
  `architecture:knowledge-contracts`. This record does not redefine review results
  or PR disposition.
- Consumer docs change when consumer usage or a documented promise changes, or
  when the existing docs would otherwise become false. Fixing an implementation
  defect does not by itself require consumer-doc changes.
- A new prop includes the admission argument from `spec:AST-002/DEC-1`; it does
  not get accepted only because it solves one callsite.
- A new public semantic CSS custom property includes the same admission argument
  and shows why the target's guaranteed CSS property set cannot express the
  caller-owned need. Its detailed contract and evidence stay in the owning
  theming/component records.
- A released breaking change includes the compatibility decision and migration
  evidence required by the release process.
- A component exposing a named theme width point uses the fixed vocabulary and
  exclusive upper-edge behavior from `spec:AST-012`; it does not maintain a
  parallel hardcoded breakpoint table.
- Changes to a family-owned API update the family contract rather than copying
  its rule into this record or every component.
- A component that accepts or derives a navigation destination follows
  `family:navigation-destinations`. Adding a new sink or destination-bearing
  component updates that family rather than creating a component-local URL rule.

## Owning code

- Public package manifests and component barrels own reachable exports.
- Component prop/type definitions and runtime behavior own the actual API.
- `BaseProps` defines the shared DOM/styling surface for DOM-owning components.
- `.doc.mjs` files own consumer-facing prop descriptions, defaults, and examples.
- Astryx ESLint rules and API/doc tests check the shared contract.

## Deciding specs

- `spec:AST-002/DEC-1` — a new public prop requires caller-owned information the
  component cannot derive.
- `spec:AST-005/DEC-1` — every Astryx-owned navigation exit follows one
  destination-safety decision.
- `spec:AST-012/DEC-1` and `spec:AST-012/DEC-2` — components reuse the fixed
  theme width names and inclusive-`from`/exclusive-`below` edge meanings.
- `spec:AST-071/DEC-1` — every styling input works from every styling system,
  so a part's styling inputs come as a set.

Transition Action sequencing and pending behavior belong to their component or
family contract. This record links that owner once it is current; it does not
copy the component matrix.

## Verification

| Invariant               | Evidence                                                                     | Failure signal                                                                                                           |
| ----------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| INV1, INV8              | Export, public-subpath, prop, and ref checks                                 | A promised component/type cannot be imported or its ref cannot reach the contract element                                |
| INV2                    | Naming and logical-direction lint/tests                                      | Equivalent concepts use conflicting names or physical direction leaks into public API                                    |
| INV3, INV4              | Historical API review benchmark and `spec:AST-002` evidence                  | A prop combines unrelated axes or exposes a derivable implementation choice                                              |
| INV5, INV6, INV7        | BaseProps/passthrough lint and representative runtime tests                  | Consumer ARIA/data/style/events are dropped, clobber component semantics, or fail to compose                             |
| INV9                    | Published-surface, Changeset, migration, and public-type checks              | A released API changes without explicit compatibility evidence                                                           |
| INV11                   | Public-API admission review plus owning theming/component tests              | A public semantic custom property exposes derivable or unsupported implementation detail                                 |
| INV12, INV13            | Generated declaration/export/behavior inventory plus canonical-owner mapping | A reachable supporting type or behavior is called internal, or mechanical evidence is treated as permission to accept it |
| INV14                   | `AppShell.test.tsx`                                                          | A component hardcodes a divergent point or treats equality as below                                                      |
| INV15                   | Public-API review of the generated declaration inventory                     | A part or StyleX-typed styling prop ships without class-name and inline-style counterparts on the same element           |
| Consumer-doc projection | `docPropReferences.test.ts` and `docPropLiterals.test.ts`                    | Docs name a nonexistent prop or omit public literal choices                                                              |

Known verification gaps:

- The passthrough lint is warning-only while known violations remain.
- The current source-to-doc completeness matcher still looks for old prefixed
  props interfaces, so it does not yet prove full prop coverage.
- No checked-in generated manifest currently proves the complete reachable public
  delta across supporting types, context members, hook returns, utility signatures,
  defaults, CLI schemas, and observable behavior for both the branch and synthetic
  merge. Reviews must build that inventory explicitly until enforcement exists.

These gaps are named here rather than treating partial checks as complete proof.

---

### Architecture React Component Runtime (`docs/architecture/react-component-runtime.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:react-component-runtime
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-07
owners: [cixzhang]
applies_to: [packages/core/src/, packages/lab/src/]
verified_by:
  [
    packages/core/src/Layer/useLayer.test.tsx,
    packages/core/src/utils/sharedResizeObserver.test.ts,
  ]
deciding_specs: []
---

# React component runtime architecture



```json
{
  "scope": "global",
  "triggers": {
    "react-runtime": ["INV1", "INV2", "INV3", "INV4", "INV5", "INV6", "INV8"]
  }
}
```

## Purpose

Astryx components should preserve their documented behavior through render,
interaction, node replacement, lifecycle replay, and unmount. This record assigns
shared runtime roles to render, handlers, refs, Effects, external resources, and
private behavior seams.

It defines correctness and ownership boundaries. It does not ban React features or
require one implementation pattern whenever several preserve the same contract.

## System model

A component runtime has four distinct roles:

1. **Render derives output.** Props, state, and context determine current rendered
   semantics.
2. **Handlers process discrete intent.** Pointer, keyboard, handle, and other
   commands perform the work caused by one action.
3. **Refs retain non-rendering mutable data.** Refs may hold DOM nodes, resources,
   latest values, or other coordination data that does not itself drive output.
4. **Effects synchronize external systems.** Effects connect React state to
   browser APIs, subscriptions, timers, observers, network activity, and other
   systems whose lifetimes are outside render.

Component and family contracts continue to own public state semantics, controlled
and optimistic behavior, focus, announcements, gestures, and other user-facing
outcomes. This record owns only the shared React lifecycle boundary used to fulfill
those contracts.

## Boundaries and invariants

- **INV1 — Render starts from a valid state.** Rendered output MUST derive from
  current props, state, and context. A component MUST NOT render a known-wrong
  initial state and repair it in a follow-up Effect. State stores information that
  cannot be derived during render or directly from the current interaction. DOM
  nodes and whole DOM collections MUST NOT be stored in React state; store only the
  minimal rendering key or own the node through a ref.
- **INV2 — Discrete intent stays discrete.** Work caused by one user or imperative
  action MUST NOT be deferred to an Effect when the originating handler can perform
  it, including calling a consumer callback such as `onChange`, `onOpenChange`, or
  `onSave`: the callback fires from the handler that caused the change, never from
  an Effect reacting to the resulting state. Using the same state for rendered
  output does not make an Effect-owned copy of the action valid. Deferral MUST NOT
  delay, repeat, or couple the action to unrelated rendering.
- **INV3 — Effects synchronize external systems.** A new or changed Effect MUST
  name the external system it synchronizes. When setup acquires, installs, or
  starts a resource, cleanup MUST release that resource. Synchronization that
  acquires no resource MUST instead be idempotent and safe under dependency
  changes, node replacement, unmount, and StrictMode replay. Ordinary rerender
  synchronization MUST NOT duplicate consumer-visible callbacks or other one-time
  work.
- **INV4 — Mutable refs do not become hidden rendered state.** Refs MAY hold general
  non-rendering mutable data. A ref mutation MUST NOT be the sole source for a value
  later read to produce React-rendered output. A callback ref MAY synchronously
  update an owned DOM node when that DOM update is itself the observable operation
  and attach, detach, replacement, and consumer override behavior preserve the
  current contract. Ref-backed coordination MUST preserve the current component,
  family, accessibility, and API contracts.
- **INV5 — Resource ownership is complete.** Every listener, timer, animation frame,
  observer, request, subscription, and imperative browser resource has one declared
  lifecycle owner. Component- or node-owned resources are removed, aborted, or
  released when disabled, replaced, or unmounted. A document-owned singleton MAY
  persist across consumer gaps when current authority names that lifetime and
  tests prove one shared installation. A persistent singleton without current
  authority is an authority gap, not by itself proof of an implementation leak.
  Optional behavior SHOULD acquire local resources only while enabled.
- **INV6 — Node replacement is a lifecycle event.** A callback ref or other node
  owner handles attachment, `null` detachment, and replacement with another node.
  Cleanup for the old node completes before the new node becomes the active owner.
- **INV7 — Observation preserves one semantic owner.** When JavaScript behavior
  genuinely needs browser geometry, observation stores only the minimal React
  state needed for that behavior and follows INV5–INV6. A repeated row, item, or
  cell path MUST NOT create unbounded observer fan-out when one shared owner can
  provide the same browser fact.
- **INV8 — Behavior stays with its stable lifecycle owner.** Current component,
  family, interaction, and layer authority identifies the behavior owner and the
  documented replacement/composition seams; this record does not choose a new
  owner. A state machine, gesture, timing protocol, or interaction algorithm is
  owned by the component or primitive whose lifetime spans every documented
  replacement and composition seam. Replacing a render prop, slot, child, or
  visual subpart MUST NOT silently remove owner behavior. Internal hook or utility
  extraction is not required by this invariant.
- **INV9 — Broadcast identity follows the broadcast contract.** Context values and
  external-store snapshots MUST NOT change identity while the semantic broadcast
  payload is unchanged when that change would broadcast work to consumers. Public
  hook-result identity remains owned by `architecture:public-component-api` and
  the hook's current contract. Ordinary local objects and callbacks have no shared
  identity promise.

## Authoring guidance

The following are preferred techniques, not independent compatibility promises:

- Use an Effect Event when an external-system Effect needs a stable callback that
  reads the latest props or state without making those reads reactive. Do not use
  an Effect Event to hide a value whose change should resynchronize the system.
- Keep callback refs reference-stable when practical so React does not detach and
  reattach an unchanged node solely because the callback identity changed.
- Prefer CSS for presentational responsiveness. Style authoring owns when container
  queries, media queries, structural selectors, and other CSS mechanisms replace
  JavaScript observation.
- Reuse Astryx's shared observer owner when it supplies the required browser fact.
  Tooling owns the required import and enforcement; INV7 owns the runtime outcome.
- Give a separately nameable state machine, gesture, timing protocol, or
  interaction algorithm a private hook or pure utility when that creates a useful
  focused test seam. Small behavior inseparable from rendering may remain inline;
  do not extract a hook per callback.
- Do not add memoization ceremony without a measured broadcast boundary or a
  current identity contract.

Violating guidance becomes an architecture violation only when it crosses an
invariant above or another current owner.

## Controlled and optimistic state

`architecture:public-component-api`, component records, and family records own
controlled, uncontrolled, and optimistic semantics.

The runtime implementation MUST preserve that current owner:

- a controlled prop is not casually mirrored into a second source of truth;
- a separately named optimistic or pending value follows its current owner's
  applicable settlement and lifetime contract; undefined branches remain with that
  owner;
- callbacks distinguish requested intent from owner-accepted state; and
- whether controlled and uncontrolled ownership may switch after mount remains
  component-owned unless current shared authority says otherwise.

## DOM and imperative API boundary

React state owns rendered semantics. DOM reads are limited to browser-owned facts
such as geometry, focus, selection, event boundaries, native dialog or popover
state, and pointer capture, plus imperative browser operations on an owned node.
Component and API authority owns any narrower composition or introspection rule.

`architecture:public-component-api` also owns public ref admission and naming. A
component may expose both of these distinct surfaces when that authority admits
them:

- `ref` for the documented primary DOM element and its native capabilities; and
- `handleRef` for a component-defined semantic imperative API implemented with
  `useImperativeHandle`.

This runtime record requires only that either surface preserve lifecycle ownership
and current controlled-state semantics. It does not admit a handle, require handle
identity, or decide its members.

## Audit-row routing

This record supplies shared authority for:

- **C5, C6:** derived state and ref-backed hidden state through INV1 and INV4;
- **C19:** complete external-resource cleanup through INV5–INV6; and
- durable runtime outcomes from **C1, C9, C10, C16, C22** through INV1–INV3,
  INV5, and INV8.

It supplies a narrow runtime mechanism boundary informed by **C7–C8** through
INV7, while style authoring and tooling own CSS preference, the shared observer
API, and mechanical enforcement.

Other rows keep their canonical owners:

- **C2, C13:** public API, controlled semantics, public hook identity, and public
  ref/handle contracts belong to `architecture:public-component-api`, families,
  and component records;
- **C3, C17:** focus, announcements, analytics semantics, and focus-preserving
  visibility belong to accessibility, interaction, family, and component records;
- **C4, C11, C18, C21:** callback-ref preference, ref-read purity,
  presentational-component classification, and comment quality remain lint or
  contributor guidance unless a concrete invariant above is violated;
- **C12:** the applicable accessibility, layer, interaction, family, or component
  owner decides which existing primitive must be composed;
- **C14:** this record defines the React/DOM state boundary, while public
  composition and child introspection remain API/component-owned;
- **C15:** each registry or data source keeps its own canonical owner; and
- **C20:** numeric provenance belongs to style, token, component, or interaction
  authority according to the value's purpose.

## Change coupling

Focused runtime review triggers when a change adds or alters:

- an Effect, state mirror, ref mutation, or callback ref;
- a listener, timer, observer, request, subscription, or imperative browser
  resource;
- controlled or optimistic reconciliation mechanics;
- context or external-store broadcast identity; or
- ownership, lifetime, or observable behavior of a state machine, gesture, timing
  protocol, or interaction algorithm.

Review covers initial render, update, interruption, cleanup, node replacement,
unmount, and StrictMode replay where applicable. It also verifies representative
unchanged behavior through the current component, family, API, and accessibility
contracts.

## Owning code

- `packages/core/src/` — stable Core component and hook runtimes.
- `packages/lab/src/` — experimental component runtimes using the same lifecycle
  correctness boundary without receiving stable public API status.
- `packages/core/src/utils/sharedResizeObserver.ts` — current shared Core resize
  observation mechanism; tooling and utility tests own its concrete API.

## Deciding specs

None. Component, family, API, accessibility, layer, interaction, and style records
remain authoritative for the user-facing outcomes delegated above.

## Verification

Lint and focused tests are one-way evidence. A lint failure means the construct
violates the invariant that rule enforces. A passing lint run does not prove the
full runtime contract because many ownership, lifecycle, composition, and
observable-behavior predicates require review or runtime evidence. Likewise, a
focused test proves only its named behavior and mutation expectation; a green
component suite is not blanket conformance with every invariant.

| Invariant        | Evidence                                                                                                       | Failure signal                                                                                                                              |
| ---------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1–INV2        | Initial-render and interaction tests                                                                           | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect                                                  |
| INV3, INV5       | StrictMode setup/cleanup and dependency-change tests                                                           | Duplicate callback, leaked work, stale subscription, or missing resynchronization                                                           |
| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations          | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently      |
| INV7             | Real-browser geometry plus N-consumer observer-instance/subscription mutations                                 | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber |
| INV8             | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced                                                                  |
| INV9             | Provider/external-store identity and render-count tests                                                        | Unchanged semantic payload changes identity, or snapshot identity creates a render loop                                                     |

---

### Architecture Rtl Audit Evidence (`docs/architecture/rtl-audit-evidence.md`)

---
schema_version: 1
template_version: 3
kind: architecture
id: architecture:rtl-audit-evidence
authority: current
archive_reason: null
superseded_by: null
approved_by: nynexman4464
approved_at: 2026-10-15
owners: [nynexman4464, cixzhang, imdreamrunner]
applies_to:
  [
    apps/storybook/rtl-audit/,
    scripts/component-packages.cjs,
    .github/workflows/ci.yml,
    .github/workflows/rtl-weekly.yml,
    .github/scripts/rtl-audit-coverage.test.mjs,
    .github/scripts/weekly-rtl-summary.js,
    .github/scripts/rtl-join.mjs,
    .github/scripts/rtl-report-completion.mjs,
    .github/scripts/rtl-shard-scope.mjs,
    .github/scripts/rtl-scorecard-summary.mjs,
    internal/eslint-plugin-astryx/no-physical-properties.js,
  ]
verified_by:
  [
    .github/scripts/rtl-audit-coverage.test.mjs,
    .github/scripts/weekly-rtl-summary.test.mjs,
    .github/scripts/rtl-join.test.mjs,
    .github/scripts/rtl-report-completion.test.mjs,
    .github/scripts/rtl-shard-scope.test.mjs,
    internal/eslint-plugin-astryx/no-physical-properties.test.mjs,
  ]
deciding_specs: []
---

# RTL audit evidence architecture



```json
{
  "scope": "global",
  "triggers": {
    "testing": [
      "INV1",
      "INV2",
      "INV3",
      "INV4",
      "INV6",
      "INV9",
      "INV10",
      "INV12",
      "INV13",
      "INV14",
      "INV16"
    ]
  }
}
```

## Contract at a glance

| Area                    | Contract                                                                                                                                                                                                                                                                                                                                                                            |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Governing contract      | `design:bidirectionality` owns what correct RTL behavior means; component contracts own local semantics, exceptions, and verification. No product contract changes; this record owns evidence admission and freshness.                                                                                                                                                              |
| System behavior         | Every component in the live roster is accounted for; available in-scope stories are rendered LTR and RTL. Results distinguish measured, verified N/A, coverage gap, and behavior failure. Lint and rendered audit prove different claims.                                                                                                                                           |
| End-user impact         | People reading RTL interfaces are protected by evidence that actually observed mirroring, ordering, placement, geometry, and behavior, rather than by a green report that measured nothing.                                                                                                                                                                                         |
| Builder impact          | A new or changed component must either be measured by an applicable check or carry an explicit gap; behavior failures have no allowlist. Verified-N/A and pre-existing-debt admission await owner decisions (OQ1, OQ2).                                                                                                                                                             |
| Compatibility/readiness | INV12–INV16 specify D1 fixture metadata and stable selection, not shipped enforcement. Component-wide N/A admission, historical debt, D7 size enforcement, and required-check rollout remain unresolved.                                                                                                                                                                            |
| Review checks           | Reject lint standing in for rendered evidence (INV1); an allowlist that relabels a behavior failure (INV3); all-N/A credited as RTL-ready (INV4); a component dropping from the roster because its story moved (INV5); a new dimension without a red/green mutation receipt (INV9); a D1 exclusion on directional controls/cues or missing review-facing disclosure (INV12, INV16). |
| Governing rules         | No deciding system spec; `architecture:knowledge-contracts` evidence-freshness and human-decision rules apply.                                                                                                                                                                                                                                                                      |

This table is a review projection; the body below is authoritative.

## Purpose

Astryx needs review evidence that distinguishes three claims:

1. a component's direction-sensitive behavior was actually measured;
2. RTL genuinely does not apply to the component; or
3. RTL applicability remains unmeasured debt.

A green lint rule, an all-N/A browser report, or a checked-in exception must not be
credited with proving more than it observed. A reference fixture's dimension-level
non-applicability is distinct from a component-wide verified-N/A declaration.
The former describes one story's semantics; the latter requires evidence for the
whole component.

The global `testing` route supplies shared evidence and exclusion-review claims
when an RTL test, evidence claim, or exemption is relevant to the change. It does
not supply component behavior or visual intent: `design:bidirectionality` owns
those relationships, and component/module contracts own local states and their
verification maps. Matching this route neither requires every component to
implement the audit runner nor imports workflow topology, unresolved N/A/debt
admission, or gate-rollout decisions as universal component rules. Review applies
only the routed claims within their stated evidence scope; draft routes remain
non-authoritative.

## System model

```text
changed component or full-library sweep
  → discover the live component roster across every registered component package
  → render relevant stories LTR and RTL
  → run automatic relationship checks
  → run curated component checks
  → combine results per component
       measured
       verified N/A
       unexplained coverage gap
       optional known debt (unresolved proposal)
  → report behavior failures, stale evidence, and applicability state
```

Lint and rendered audit are complementary:

- lint rejects source patterns whose directional failure can be proved from the
  declarations alone;
- browser checks prove computed geometry, visibility, ordering, hit targets,
  scroll/drag behavior, and composed transforms;
- verified-N/A evidence, if admitted, proves only that reviewed source and stories
  currently expose no direction-sensitive relationship.

## Boundaries and invariants

- **INV1 — Evidence follows the claim.** Source lint MUST NOT stand in for
  computed geometry or interaction. Rendered screenshots MUST NOT stand in for
  keyboard, drag, scroll, or hit-test behavior. A browser relationship check MUST
  NOT claim that every component state was exercised when its stories did not
  render those states.
- **INV2 — RTL is graded as a relationship.** Directional evidence compares LTR
  and RTL outcomes from the same built source. Pixel identity is not the contract;
  semantic mirroring, order, side, geometry, and behavior are.
- **INV3 — Behavior failures have no allowlist.** When an applicable automatic or
  curated dimension shows incorrect mirroring, ordering, placement, geometry, or
  behavior, the result is a finding. Known history does not turn incorrect RTL
  behavior into pass or N/A.
- **INV4 — All-N/A is not measured.** A component for which every dimension is N/A
  has no positive RTL evidence. It is either verified not applicable under an
  admitted evidence policy or remains a coverage gap/debt.
- **INV5 — The live source roster owns coverage.** The roster is every public
  component of every package registered as a component package, not only Core
  and Lab. A component cannot disappear from review merely because it has no
  story or because its story title changed. Missing renderability is itself a
  coverage gap when the component is in scope.
- **INV6 — Automatic and curated coverage compose.** Generic checks cover broadly
  discoverable icon, decoration, and positional relationships. Component-specific
  selectors are used only for semantics generic discovery cannot infer, such as
  order, scroll direction, overlay side, or touch-target ownership.
- **INV7 — CI scope and weekly scope answer different questions.** PR CI checks
  components affected by the change; it may run per package, but the PR result is
  the join of every package scope and cannot pass with one scope missing. The
  scheduled unfiltered audit checks the full live roster and shared regressions.
  Neither silently replaces the other.
- **INV8 — Applicable evidence invalidates N/A.** A new applicable automatic or
  curated dimension MUST make an existing verified-N/A declaration stale. Source-
  and story-byte invalidation is a proposed additional closure requirement, not a
  capability of the current reason-only registry.
- **INV9 — Evidence mutations prove the gate.** A new audit rule or dimension MUST
  demonstrate that the relevant defect passes unnoticed without the rule and is
  detected after the rule. A passing happy path alone does not prove enforcement.
- **INV10 — Touch-target evidence includes size and alignment.** For a coarse-
  pointer target, proving that the target center matches the visible control is
  insufficient. Browser evidence MUST also measure the effective/native target
  against the owning component's stated minimum size. Current D7 enforcement
  checks centering and hit-testing only; size enforcement is an open gap.
- **INV11 — Audit state is not product intent.** This architecture owns evidence
  admission and freshness. The human meaning of correct bidirectional behavior
  belongs in `design:bidirectionality`; component contracts own local semantics,
  exceptions, and verification.
- **INV12 — Fixture applicability follows semantic role.** D1 icon mirroring
  applies to artwork used as a directional control or cue. A reference fixture
  that displays caller-supplied left/right glyph geometry as data may declare D1
  non-applicable. A glyph's shape alone does not make the sample a directional
  control, and an exclusion MUST NOT hide an applicable behavior failure. The
  human review and disclosure requirements in INV16 apply to explicit exclusions.
- **INV13 — Story exclusions are typed, resolved, and fail closed.** The admitted
  story metadata is `rtlAudit.D1` with `applicable: false` and a nonempty `reason`
  explaining INV12. The audit reads resolved Storybook parameters, including normal
  story-over-meta precedence. Missing rendered metadata, malformed parameters,
  unsupported dimension keys, and an exclusion without a rationale MUST produce
  an error rather than silently skip a check. An empty resolved `rtlAudit` object
  means no exclusion; the reason is not a component-wide approval.
- **INV14 — A D1 exclusion changes only D1.** D5 and D6 MUST still scan every
  in-scope story, including D1-excluded reference fixtures. Other component stories
  remain D1 candidates. If every D1 candidate is excluded, D1 reports N/A with the
  excluded stories and reasons; that is not measured evidence and does not close
  a component coverage gap or grant verified N/A.
- **INV15 — D1 selection and completion identities are stable.** The audit orders
  candidates by story source path, source export order, then story id, and selects
  the first candidate not explicitly excluded from D1. Generated-index insertion
  order MUST NOT choose the representative. The planned identity stays constant
  when another candidate runs or every candidate is excluded; the selected story
  is retained separately so the report names what actually ran. Package/shard
  joins MUST reconcile exact planned/completed identities, not merely equal counts.
- **INV16 — Deliberate D1 exemptions are reviewed and visible.** Adding or widening
  an explicit D1 exclusion, or changing an excluded story's semantic role, MUST
  receive human review through the normal pull-request review. Adding a story
  that inherits a meta-level exclusion counts as widening that exclusion. The reviewer
  checks every affected story, including stories inheriting meta-level parameters,
  and rejects an exclusion whose artwork acts as a directional control or cue.
  A nonempty reason proves only valid metadata, not valid non-applicability.
  Review-facing audit output MUST list every declared D1 exclusion in the audited
  scope with its canonical component, story id, dimension, and rationale, including stories not
  selected as the D1 representative. Reports MUST NOT call the absence of a D1
  measurement proof that directional behavior is correct. Automatic N/A because
  no directional icons were found requires no extra human approval; an unchanged
  exemption is not reapproved on every audit run. This rule does not admit
  component-wide verified N/A or historical coverage debt.

## Change coupling

Changes to audit dimensions, component discovery, curated targets, applicability
registries, proposed evidence hashes, workflow scope, summaries, or RTL lint must
review this record.

A new direction-sensitive component contract must map its requirement to executable
evidence or remain an explicit gap. If owners admit verified N/A or known debt with
byte-bound closure, the implementation MUST bind that evidence to the canonical
source, directly owned helpers/styles, and owned stories so relevant changes fail
stale rather than silently retaining their old classification.

Component-wide verified-N/A admission, a historical coverage-debt baseline, and
required-check rollout remain outside the settled claims until an owner decides
those boundaries. They do not prevent claim-scoped approval of the dimension-level
fixture policy. No decision about those broader states is implied by a D1
exclusion.

### Audit dimensions

The audit's `D` identifiers name repository-specific checks, not an external
internationalization standard. Automatic discovery covers D1, D5, and D6; curated
story targets supply the other dimensions.

| ID  | Observed relationship                                                        |
| --- | ---------------------------------------------------------------------------- |
| D1  | Directional icons mirror or swap once.                                       |
| D2  | Previous/next controls exchange horizontal order.                            |
| D3  | Directional interaction or scroll behavior follows the active direction.     |
| D4  | An overlay or positioned affordance follows its logical side.                |
| D5  | Positioned elements mirror relative to their parent.                         |
| D6  | Contextual directional decorations receive exactly one bidi treatment.       |
| D7  | Coarse-pointer input centers and hit-testing align with the visible wrapper. |
| D8  | Logical inline padding resolves to mirrored physical edges.                  |
| D9  | Grouped corners follow the declared logical inline side.                     |

D7's current check does not establish target-size conformance. INV10 owns the
required evidence distinction; the component/composition contract owns the minimum.

## Owning code

- `apps/storybook/rtl-audit/rtl-audit.mjs` — renders and compares LTR/RTL behavior.
- `apps/storybook/rtl-audit/rtl-audit-coverage.mjs` — discovers the live roster and
  combines measured, N/A, and gap states.
- `scripts/component-packages.cjs` — the registry of component packages whose
  public components form the roster.
- `apps/storybook/rtl-audit/targets.json` — curated D2/D3/D4/D7/D8/D9 targets.
- `apps/storybook/rtl-audit/story-parameters.ts` — planned typed D1 fixture
  applicability surface, absent from the base implementation.
- `apps/storybook/.storybook/preview.tsx` — planned resolved story metadata on the
  existing Storybook root, without a wrapper or sibling around the rendered story.
- `apps/storybook/stories/SVGIconRegistry.stories.tsx` — reference glyph data;
  association with the SVGIcon owner and D1 metadata await the fixture-policy
  implementation.
- `apps/storybook/rtl-audit/verified-not-applicable.json` — candidate verified-N/A
  registry; its admission policy is unresolved (OQ1).
- Historical coverage-debt admission is unresolved. No additional debt-baseline
  file forms part of this contract; absent measurements remain visible gaps.
- `.github/workflows/ci.yml` — affected-component PR audit, sharded per component
  package and joined into one result.
- `.github/scripts/rtl-join.mjs`, `rtl-report-completion.mjs`, and
  `rtl-shard-scope.mjs` — exact package/shard scope and completion reconciliation.
- `.github/scripts/rtl-scorecard-summary.mjs` — review-facing scorecard projection;
  report completeness and exclusion visibility remain requirements, not authority
  to suppress findings.
- `.github/workflows/rtl-weekly.yml` — full-roster scheduled audit.
- `internal/eslint-plugin-astryx/no-physical-properties.js` — source-provable
  physical/logical declaration constraints.
- `design:bidirectionality` — human product intent, owned separately.

The shipped RTL mechanics themselves still lack one canonical architecture owner.
A follow-up `architecture:bidirectional-layout` record should own CSS logical
properties, `rtlStyles`, lazy DOM direction reads, centering helpers, and
first-paint/hydration invariants. This evidence record MUST NOT be treated as that
missing implementation contract.

## Deciding specs

No current system spec settles verified-N/A or known-debt admission. The current
knowledge architecture requires evidence freshness and explicit human decisions;
this record narrows the remaining decision to the options below.

## Verification

| Invariant        | Evidence                                                                                              | Failure signal                                                                                                                                                                                                                                         |
| ---------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| INV1, INV2, INV6 | focused audit fixtures plus real built Storybook mutations                                            | A source-only rule claims rendered correctness, or a relationship defect stays green                                                                                                                                                                   |
| INV3             | automatic/curated failure tests and summary tests                                                     | A known bad RTL outcome is suppressed as expected or N/A                                                                                                                                                                                               |
| INV4, INV5       | coverage fixtures with missing/no-applicable stories                                                  | A component disappears or all-N/A reports as RTL-ready                                                                                                                                                                                                 |
| INV7             | PR workflow scope tests and weekly-summary tests                                                      | A changed component is skipped or full-roster debt is mistaken for PR-only evidence                                                                                                                                                                    |
| INV8             | automatic/curated applicability tests                                                                 | A newly applicable dimension leaves a verified-N/A declaration current                                                                                                                                                                                 |
| INV9             | red/green mutation receipts for each new dimension                                                    | A new dimension has only passing self-confirming tests                                                                                                                                                                                                 |
| INV10            | none for size today; D7 covers center/hit in LTR/RTL, sm/md                                           | Removing the component's minimum native hit area remains green because D7 never checks size                                                                                                                                                            |
| INV12–INV16      | candidate fixture-policy tests and built Storybook mutations; not enforced by the base implementation | reordering the generated story index changes D1; malformed/missing metadata silently skips; a fixture opt-out skips D5/D6 or closes a component gap; an exclusion on directional controls/cues survives semantic review or is omitted from the summary |

### Implementation state

The base audit implements the existing automatic/curated checks and component
coverage rollup. INV12–INV16 define the dimension-level fixture policy and stable
selection contract; their metadata transport and focused mutation tests are not
part of the base implementation. They require separate implementation validation.
An owner may approve that intended contract before implementation without calling
its new checks shipped or enforced. Complete exclusion-summary coverage under
INV16 remains implementation work; a passing audit alone is not the required
human review of an added or changed exemption.

### Candidate evidence states

| State                   | Minimum evidence                                                                                   | May clear a new/changed component? | Current decision state            |
| ----------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------- | --------------------------------- |
| Measured                | At least one applicable relationship or behavior check passes; every applicable check passes       | yes                                | settled by current audit behavior |
| Verified N/A            | Today: human-reviewed reason; proposed: source/helper/story evidence with fail-closed invalidation | unresolved                         | owner decision required           |
| Coverage gap            | No applicable measurement and no admitted current N/A evidence                                     | no                                 | settled                           |
| Known pre-existing debt | Proposed removal-only baseline entry that remains visible and non-failing while unchanged          | unresolved                         | owner decision required           |
| Behavior failure        | An applicable dimension fails                                                                      | no                                 | settled; no suppression           |
| Stale evidence          | Today: an applicable dimension appears; proposed: recorded evidence bytes change                   | no                                 | partially implemented             |

### Current enforcement gaps

- D7 measures hit-testing and center alignment in LTR/RTL at sm/md under a coarse
  pointer, but does not fail when the native target falls below the owning
  component's minimum size. Removing the coarse native-input expansion leaves
  that center/hit check passing. Size evidence must include the native input box.
- The reason-only verified-N/A registry becomes stale when an automatic/curated
  dimension becomes applicable, but does not yet bind evidence to source/helper/
  story bytes.
- CI summary code still contains an unused `knownNotRtl` label path even though the
  report emits no such allowlist. Remove it so the implementation cannot be read as
  permitting known behavior failures.
- Component/composition records and AST-020 own coarse-pointer minimum target
  sizes. This audit record does not assign a universal pixel minimum.

## Open decisions

### OQ1 — Is verified N/A an admitted closure state?

- **Option A:** Admit verified N/A when one declaration carries a specific reason.
  Before this option can become current, add evidence over the canonical source,
  directly owned helpers/styles and owned stories, plus fail-closed invalidation.
  This recognizes truly direction-neutral components without manufacturing
  meaningless browser tests.
- **Option B:** Require every component to have executable rendered measurement.
  This removes judgment-based N/A but forces artificial checks for components with
  no directional relationship.

### OQ2 — May pre-existing unexplained all-N/A enter removal-only known debt?

- **Option A:** Snapshot existing gaps as visible, non-failing debt while new gaps,
  stale entries, behavior failures, and removals that do not become measured/N/A
  continue to fail. This allows gradual rollout without hiding the inventory.
- **Option B:** Keep every unexplained gap failing. This is simpler and stricter,
  but leaves the audit permanently red until the full historical roster is
  measured or reviewed.

### OQ3 — When does RTL become a required merge check?

The current PR audit is soft-gated. Owners must choose the prerequisite clean
states and stability window before making it required. Regardless of gate status,
reports must remain truthful and behavior failures must not be relabeled.

---

### Architecture Template Authoring (`docs/architecture/template-authoring.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:template-authoring
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [josephfarina, cixzhang]
applies_to:
  [
    packages/cli/assets/templates/,
    packages/cli/authoring/doctypes/template/,
    packages/cli/foundation/discovery/template-adapter.mjs,
    packages/cli/foundation/config/project.mjs,
    packages/cli/api/template/,
    apps/docsite/src/components/templateComponents.ts,
    apps/docsite/scripts/generate-data.mjs,
    scripts/sync-templates.js,
  ]
verified_by:
  [
    packages/cli/authoring/doctypes/template/parse.test.mjs,
    packages/cli/foundation/discovery/template-adapter.test.mjs,
    packages/cli/foundation/config/project.test.mjs,
    packages/cli/api/init/init.test.mjs,
    packages/cli/api/template/template.test.mjs,
    packages/cli/api/template/template-integration.test.mjs,
    packages/cli/api/template/template-suffix.test.mjs,
    packages/cli/clients/cli/e2e-smoke.test.mjs,
    apps/docsite/src/__tests__/data-extraction.test.ts,
  ]
deciding_specs: [spec:AST-017/DEC-2]
---

# Template authoring architecture

## Purpose

Astryx templates are copyable starting points. Page templates scaffold complete
content surfaces. Block templates provide focused patterns and component examples.
This record owns their source shapes, catalog identity, validation boundary,
consumer projections, and copy semantics.

It does not copy contributor instructions or internal audit-publication workflow.
The shipped template-quality guide owns the public grading rubric, and the
public contributor guide links to that source.

## System model

### Shipped state

Astryx ships two template kinds. A showcase is a role held by a block, not a third
kind.

| Kind or role         | Current source shape                                                                                           | Current identity and use                                                              |
| -------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Built-in page        | `pages/<slug>/page.tsx` plus `template.doc.mjs`                                                                | The directory slug is the CLI id. The page is a complete route starting point.        |
| Built-in block       | Paired `<Name>.tsx` and `<Name>.doc.mjs` under `blocks/`                                                       | The file stem is the CLI id. The block is a focused example or reusable pattern.      |
| External block       | The same paired files under a package declared through `astryx.blocks`                                         | The package, type, and file stem identify the entry.                                  |
| Integration template | Paired `<id>.tsx` and `<id>.template.*` or legacy `<id>.doc.*` under a configured integration `templates` root | The package, type, and root-relative id identify the entry. Nested ids are supported. |
| Primary showcase     | A block with `isShowcase: true`                                                                                | Supplies the hero example for its `exampleFor` target.                                |
| Secondary showcase   | A block naming targets in `alsoShowcaseFor`                                                                    | Reuses the same source as the hero example for additional component or hook pages.    |
| Additional example   | A block naming targets in `alsoExampleFor`                                                                     | Reuses the same source in additional Examples sections.                               |

`name` and `displayName` are human-facing labels, not catalog identity. They may
repeat. The resolvable identity is `(package, type, id)`; callers narrow an id that
exists across packages or kinds.

The shipped loaders do not yet share one validation boundary:

- Built-in pages load metadata directly and default some absent values.
- Built-in and external blocks require a paired source file but otherwise load
  metadata directly and default some absent values.
- Integration templates pass through `parseTemplate()` and report missing source,
  load, or envelope errors.

The shipped consumers also collect different sets:

- `discoverAll()` supplies the template API, search, and layout.
- `Project.templates()` independently re-collects integration templates for project
  diagnostics and deduplication.
- `listTemplates()` returns every built-in page directory without reading metadata;
  programmatic `init` uses that list when applying a named starter page.
- The sandbox and docsite generate metadata/source registries independently.
- The docsite overview reads generated metadata, while live page previews depend on
  the separate hand-maintained lazy map in `templateComponents.ts`.

Copying emits source into the adopting project. It does not keep a live relationship
to the catalog, and later template releases do not rewrite copied projects.

### Normalized catalog contract

Before this record can become `current`, every source must normalize into one catalog
entry and pass one validation boundary. That boundary uses these requirements:

| Scope                | Required                                                                                                                 | Optional or conditional                                                               |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| Every entry          | `id`, `package`, `type`, `name`, `displayName`, non-empty `description`, explicit `isReady`, and an existing source file | `keywords`; source-specific provenance and diagnostics                                |
| Page                 | Everything above; `category` when eligible for the overview                                                              | `scaffold`, `isHiddenFromOverview`; a hidden special-purpose page may omit `category` |
| Block                | Everything above plus `exampleFor`, positive `aspectRatio`, and complete `componentsUsed`                                | `scale`, `isShowcase`, `alsoExampleFor`, `alsoShowcaseFor`                            |
| Primary showcase     | Block requirements plus `isShowcase: true`; at most one resolved primary showcase per `(package, target)`                | The same block may serve additional targets through `alsoShowcaseFor`                 |
| Additional placement | A valid block and a valid target named by `alsoExampleFor` or `alsoShowcaseFor`                                          | Placement does not create another template identity                                   |

Page and block metadata may be authored with richer first-party types or a narrower
integration envelope. Source adapters translate those source-specific shapes into
the common entry. A **projection** is a filtered view of that catalog for one
consumer; it does not discover another membership list.

The converged projections are:

- CLI template and search surfaces select all eligible pages and blocks.
- Layout selects blocks only.
- `init` selects every named built-in page.
- The docsite overview selects ready pages not marked `scaffold` or
  `isHiddenFromOverview`.
- Docsite live previews select the same eligible page entries and import their
  catalog source.
- Component and hook docs select ordinary examples, primary showcases, and explicit
  secondary placements from block metadata.
- Project diagnostics consume the same catalog errors instead of re-collecting
  integration membership.

Invalid required data produces a diagnostic. It is not repaired with an implicit
default or skipped silently. TypeScript checking remains an additional first-party
authoring check; it does not replace runtime catalog validation.

## Boundaries and invariants

- **INV1 — Pages and blocks are the only template kinds.** Showcase and additional
  example placements are block roles, not separate source kinds or identities.
- **INV2 — Identity is separate from labels.** `(package, type, id)` resolves a
  template. `name` and `displayName` may change or repeat without becoming identity.
- **INV3 — Role requirements are explicit.** Every accepted entry satisfies the
  normalized field matrix above. Source pairing and projection metadata are
  validated before a consumer receives the entry.
- **INV4 — Source-specific authoring converges at one boundary.** Built-in, external,
  and integration sources may have different adapters, but none bypasses normalized
  validation.
- **INV5 — One catalog owns membership.** CLI, search, layout, project, `init`,
  sandbox, and docsite differences come from declared projections, not independent
  filesystem scans or hand-maintained membership lists.
- **INV6 — Preview source is shipped source.** A page preview imports the same
  `page.tsx` the catalog serves. A generated lazy-loading map may implement the
  projection, but it does not decide membership.
- **INV7 — Pages remain host-composable.** A normal page owns page content, not the
  host application's global chrome. A page in the explicit `Shell -` category may
  own `AppShell` and global navigation because demonstrating that chrome is its
  purpose. The contributor guide owns the exact root-component recipe.
- **INV8 — Copying is a self-contained snapshot.** The emitted source belongs to the
  adopting project. Repository-only demo assets are replaced during copy so the
  scaffold does not depend on Astryx preview hosting. Later catalog updates do not
  mutate copied projects.
- **INV9 — Catalog values may evolve.** Template ids, labels, metadata, categories,
  example data, and starter source are mutable catalog data under
  `spec:AST-017/DEC-2`.
- **INV10 — The operation surrounding the catalog remains contractual.** The
  `template` command, supported options, exit behavior, and machine-readable
  response schema remain governed by the CLI contract.
- **INV11 — Shared behavior requires a shared contract.** Similar templates do not
  implicitly form a family. Shared states, interactions, responsive behavior, and
  constraints require an approved family, design, or system record.
- **INV12 — Evidence is revision-specific.** A rubric score identifies the exact
  template commit and rubric version it measured. It can support review but cannot
  grant Design approval, implementation approval, or current-record authority.

## Delegated authority

This record does not absorb adjacent facts:

- The shipped `authoring/building-blocks/templates/write-good-templates` guides own the public
  grading rules, rubric version, grade bands, categories, weights, and agent
  workflow. The public
  [Contributing Templates](https://github.com/facebook/astryx/wiki/Contributing-Templates)
  guide owns contributor workflow and the score-ledger publication procedure,
  and links to the shipped rubric instead of copying it. Its current statement
  that renaming is breaking is superseded by `spec:AST-017/DEC-2`.
- `design:template-composition` owns shared human visual, hierarchy, responsive, and
  interaction intent. It remains a candidate record while its authority is `draft`.
- Component, module, and family records own the behavior of components composed by a
  template. A good rubric score does not override those contracts.
- `spec:AST-017/DEC-2` owns the compatibility split between mutable catalog values
  and the stable surrounding CLI operation.
- `TemplateDoc` types and `parseTemplate()` implement the authoring envelope. They
  must converge on this record's field requirements; neither silently becomes
  policy when the two disagree.

## Change coupling

- A template source or metadata change re-runs its source adapter, normalized
  validation, and every affected projection check.
- A change to required metadata or a template role updates the TypeScript type,
  runtime parser, contributor field tables, generators, API response, and active
  records together. A material shape change follows the repository's schema-version
  and migration rule.
- A page addition, removal, or projection-flag change updates generated sandbox and
  docsite data and proves live-preview coverage for every admitted page.
- A showcase or example-placement change validates one resolved primary showcase per
  target and regenerates the showcase/example registries.
- A visual or interaction change follows applicable current design, family, and
  component records. One review does not substitute for another.
- A rubric scoring change updates the shipped template-quality guide's version
  and score-ledger contract together. It changes this architecture only when it
  moves a system boundary.
- A proposal to share behavior across several templates updates the relevant family,
  design, or system record rather than inferring a contract from similarity.

## Owning code

- `packages/cli/assets/docs/tree/template-quality.doc.mjs` and its child guides —
  the canonical public rubric and agent grading workflow.
- `scripts/template-score-ledger.mjs` — rubric score persistence; imports the
  canonical version, grade bands, categories, and weights from the guide.
- `packages/cli/assets/templates/` — built-in page and block source plus metadata.
- `packages/cli/authoring/doctypes/template/` — first-party authoring types and the
  runtime template envelope.
- `packages/cli/foundation/discovery/template-adapter.mjs` — current source loaders,
  normalization, projections, and shared template IO.
- `packages/cli/foundation/config/project.mjs` — project diagnostics and current
  integration-template re-collection.
- `packages/cli/api/template/` — list, show, skeleton, and copy operations.
- `scripts/sync-templates.js` — current sandbox registries and preview routes.
- `apps/docsite/scripts/generate-data.mjs` — current docsite page, block, example,
  and showcase registries.
- `apps/docsite/src/components/templateComponents.ts` — current hand-maintained page
  preview loader; its independent membership is a migration gap.

## Deciding specs

- `spec:AST-017/DEC-2` — template identity, metadata, and starter source are mutable
  catalog data; the surrounding CLI operation remains contractual.

## Verification

| Scope                                 | Current evidence                                                              | Missing before `current`                                                                        |
| ------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Source shapes and integration parsing | `parse.test.mjs`, `template-suffix.test.mjs`, `template-integration.test.mjs` | One corpus check that runs every shipped page and block through the normalized boundary         |
| Catalog membership and diagnostics    | `template-adapter.test.mjs`, `project.test.mjs`                               | One catalog implementation; no duplicate collection; every invalid source reported consistently |
| `init` projection                     | `init.test.mjs`                                                               | Selection of every named built-in page from the catalog instead of a directory scan             |
| Docsite and sandbox projections       | `data-extraction.test.ts`, generated registry checks                          | Membership parity and live-preview coverage derived from catalog metadata                       |
| Copy boundary                         | `template.test.mjs`, `template-suffix.test.mjs`                               | Mutation coverage for preview-only asset removal and snapshot independence                      |
| Rubric evidence                       | Versioned scorecards carrying `commit` and `rubricVersion`                    | None for architecture; scores remain audit evidence rather than authority                       |

## Shipped-catalog audit

An exact-source audit at `067b176` found:

- 53 page source/metadata pairs. All pass the current `parseTemplate()` boundary.
  Forty are ready, non-scaffold pages visible in the overview projection. One of
  those, `work-item-detail`, lacks a live-preview loader.
- All forty overview-visible pages preserve the host-chrome boundary. The only
  source-shape exception is the unfinished `messaging-shell`; `blank` is an
  intentional minimal scaffold rather than a gallery page.
- 644 block source/metadata pairs with no orphaned source or metadata files. Every
  block declares `type`, `name`, `displayName`, `isReady`, `exampleFor`, positive
  `aspectRatio`, and non-empty `componentsUsed`. The audit did not prove that every
  array names every component imported by its source; that completeness still needs
  an executable check.
- 158 primary showcase blocks resolve to 158 distinct primary targets. Fourteen
  blocks add secondary showcase targets and twenty add secondary example targets;
  the 174 expanded showcase targets have no collisions.
- Thirty-five primary showcases omit `description`. Current built-in discovery turns
  that into an empty string, while `parseTemplate()` rejects it. This is a metadata
  backfill gap, not an intentional showcase exception.
- Core block file-stem ids are unique. Three pairs share human-facing `name` values;
  that is allowed by INV2 and creates no CLI ambiguity.
- The TypeScript type, runtime parser, and contributor field tables disagree about
  whether `displayName`, `description`, `isReady`, `exampleFor`, `aspectRatio`, and
  `componentsUsed` are required. The parser also accepts `preview`, which the
  TypeScript type does not declare. They must be aligned before this record becomes
  `current`.

## Decision log

### Catalog convergence direction — 2026-09-05

One validated catalog owns template membership. Source-specific authoring shapes may
remain different, but each source normalizes through the same validation boundary.
CLI, `init`, project, sandbox, and docsite behavior become explicit metadata-driven
projections. Parallel collectors and hand-maintained membership are migration gaps.

### Corpus audit — 2026-09-09

Every shipped page and block was enumerated at `067b176`. The audit added the role
matrix above and identified enforcement, metadata, and preview-registration gaps.
Those gaps block promotion to `current`; they do not make the inventory disappear or
turn existing audit scores into architecture authority.

## Open questions

- **OQ1 — Approval routing.** Which repository owners must approve a change that
  crosses both template visual intent and authoring/runtime behavior before this
  record can become `current`?
- **OQ2 — Rubric home.** Should the versioned grading rubric move into its own
  repository record while the wiki becomes a contributor-facing reference, or
  should the wiki remain its authority?

---

### Architecture Theme Application (`docs/architecture/theme-application.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:theme-application
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/theme/Theme.tsx,
    packages/core/src/theme/MediaTheme.tsx,
    packages/core/src/theme/useTheme.ts,
    packages/core/src/theme/themeRegistry.ts,
    packages/core/src/AppShell/AppShell.tsx,
  ]
verified_by:
  [
    packages/core/src/theme/Theme.test.tsx,
    packages/core/src/theme/MediaTheme.dom.test.tsx,
    packages/core/src/theme/useTheme.test.tsx,
    packages/core/src/theme/themeRegistry.test.ts,
    packages/core/src/AppShell/AppShell.test.tsx,
  ]
deciding_specs: [spec:AST-012/DEC-1]
---

# Theme application



```json
{
  "scope": "global",
  "triggers": {
    "theming": ["INV1", "INV2", "INV3", "INV7", "INV8", "INV9"]
  }
}
```

This record defines how a compiled theme becomes active for a React tree and the
browser document.

## Purpose

People should see the same theme in normal content, nested theme regions,
portals, and non-CSS consumers. Mounting more than one provider must not create
duplicate styles or let one provider remove styles another provider still uses.

## System model

`Theme` receives a `DefinedTheme`, a color mode, and children.

- It registers the theme by name.
- For a theme that was not built ahead of time, it asks the web compiler for CSS
  and mounts that CSS in the document.
- For a built theme, it assumes the consumer loaded the stylesheet and does not
  compile or inject it again.
- It wraps children with the theme name and color mode.
- The root provider also writes the active theme name and explicit light/dark mode
  to `<html>`. Nested providers do not change `<html>`.

`useTheme` normally reads the nearest provider through React context.

When there is no provider context—such as content rendered into a separate React
root or a fallback viewport—it can follow only the root theme through one shared
fallback path:

1. The root `Theme` writes its theme name and explicit light/dark mode to
   `<html>`.
2. `useTheme` reads those attributes and shares one observer per document.
3. It looks up the theme object by name in the shared registry.
4. Every no-provider consumer reuses that path instead of creating its own
   observer.

A normal React portal keeps provider context and does not need this fallback.
The fallback cannot recover a nested provider for a separate React root. Content
that needs a nested theme must keep that provider context in the same React tree.

`MediaTheme` marks a local surface as dark, light, automatically detected, or
off. Compiled theme CSS uses that marker to switch surface tokens while keeping
the parent theme's component rules.

`AppShell` uses the same provider/root-fallback identity path to resolve named
mobile-navigation width points. The nearest Theme wins; without provider context
it follows the registered root theme, and without an active theme it uses the
standard width map.

## Boundaries and invariants

- **INV1 — The nearest provider wins.** Children read the closest Theme context
  and CSS scope.
- **INV2 — Only the root provider changes `<html>`.** Nested providers stay local
  and cannot change browser chrome or portal-wide theme identity.
- **INV3 — Detached consumers share one root-theme store.** A separate React
  root or fallback viewport reads root attributes and the theme registry through
  one internal store with one observer per document. A normal React portal keeps
  provider context. The fallback cannot recover a nested provider.
- **INV4 — Built themes are not compiled again.** A theme marked as built uses its
  loaded stylesheet and skips runtime style generation and injection.
- **INV5 — Runtime styles are shared safely.** Multiple providers using the same
  unbuilt theme share one document-level style set. Styles are removed only when
  no mounted provider still needs them.
- **INV6 — Provider consumers avoid document observation.** `useTheme` under a
  provider reads context directly. Shared document observers exist only for
  no-provider consumers and are disconnected when unused.
- **INV7 — System mode follows the platform.** `system` resolves to the active
  light/dark preference without writing a forced mode to `<html>`.
- **INV8 — Media surfaces do not replace the theme.** `MediaTheme` changes local
  surface tokens and optional surface-specific component rules. It keeps the
  active theme and does not remount children when switched off.
- **INV9 — Application does not change compiled rules.** The provider may mount,
  share, and remove compiler output. It must not create different theme-to-CSS
  behavior from the build path.
- **INV10 — Named responsive consumers share Theme metadata.** AppShell resolves
  `sm`/`md`/`lg`/`xl`/`2xl` from the nearest active theme through the same root
  fallback as other no-provider consumers. Its mobile side is strictly below the
  point; equality belongs to the wider layout.

This record does not own theme authoring, token definitions, compiler output, or
which component parts are public theme targets.

## Change coupling

- A provider nesting change tests root, nested, portal, and cleanup behavior.
- A style-lifetime change tests two providers using the same theme and confirms
  that unmounting one does not break the other.
- A root-attribute change tests browser mode, portal reach, and no-provider hooks
  together.
- A `useTheme` fallback change tests provider and no-provider paths and confirms
  that provider consumers do not create document observers.
- A MediaTheme change tests dark, light, auto, off, fallback, and unchanged child
  identity.
- A change to runtime compilation belongs first in the shared compiler and is
  checked against the static build path.

## Owning code

- `Theme.tsx` owns provider scope, runtime style lifetime, and root document sync.
- `themeRegistry.ts` owns server-safe lookup by theme name.
- `useTheme.ts` owns provider access and the shared root fallback used by
  detached consumers. Its observation and subscription logic stays
  single-owned; extracting another module is not part of this contract.
- `MediaTheme.tsx` owns local light/dark surface context.
- `AppShell.tsx` consumes the nearest effective width map for mobile navigation.
- `architecture:theme-compilation` owns the CSS that Theme mounts.

## Deciding specs

- `spec:AST-012/DEC-1` owns the fixed width-point vocabulary AppShell consumes.

## Verification

| Invariant        | Evidence                                  | Failure signal                                                                                     |
| ---------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
| INV1, INV2, INV3 | `Theme.test.tsx` and portal/root fixtures | A nested provider changes `<html>`, or portal content cannot find the root theme                   |
| INV4, INV5       | Runtime injection and cleanup tests       | A built theme injects CSS, duplicate providers duplicate CSS, or one unmount removes shared styles |
| INV6, INV7       | `useTheme.test.tsx`                       | Provider consumers observe the DOM, fallback observers leak, or system mode resolves incorrectly   |
| INV8             | `MediaTheme.dom.test.tsx`                 | Surface mode replaces the theme, loses parent component rules, or remounts children                |
| INV9             | Runtime/build compiler comparison         | Provider-mounted CSS differs from built CSS for the same theme                                     |
| INV10            | `AppShell.test.tsx`                       | A named point ignores the nearest theme or treats equality as mobile                               |

---

### Architecture Theme Authoring Contract (`docs/architecture/theme-authoring-contract.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:theme-authoring-contract
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/theme/defineTheme.ts,
    packages/core/src/theme/themeAdaptations.ts,
    packages/core/src/theme/syntax/defineSyntaxTheme.ts,
    packages/core/src/theme/expandColorScale.ts,
    packages/core/src/theme/expandTypeScale.ts,
    packages/core/src/theme/expandRadiusScale.ts,
    packages/core/src/theme/expandMotionScale.ts,
    packages/core/src/theme/mergeComponents.ts,
    packages/core/src/theme/onMediaTokens.ts,
    packages/core/src/theme/localTokens.ts,
    packages/themes/,
    packages/cli/assets/theme.template.ts,
  ]
verified_by:
  [
    packages/core/src/theme/defineTheme.test.ts,
    packages/core/src/theme/themeAdaptations.test.ts,
    packages/core/src/theme/syntax/serverSafeSyntax.test.ts,
    packages/core/src/theme/expandColorScale.test.ts,
    packages/core/src/theme/expandTypeScale.test.ts,
    packages/core/src/theme/expandRadiusScale.test.ts,
    packages/core/src/theme/expandMotionScale.test.ts,
    packages/core/src/theme/onMediaTokens.test.ts,
    scripts/check-theme-template.test.mjs,
  ]
deciding_specs:
  [
    spec:AST-006/DEC-1,
    spec:AST-006/DEC-2,
    spec:AST-006/DEC-3,
    spec:AST-006/DEC-4,
    spec:AST-006/DEC-6,
    spec:AST-012/DEC-1,
    spec:AST-012/DEC-2,
    spec:AST-012/DEC-3,
    spec:AST-012/DEC-4,
    spec:AST-017/DEC-1,
  ]
---

# Theme authoring contract



```json
{
  "scope": "global",
  "triggers": {
    "theming": ["INV1", "INV3", "INV8", "INV9", "INV11", "INV12"]
  }
}
```

This record defines what a theme author may express and how `defineTheme`
normalizes that input into one self-contained theme representation.

## Purpose

A theme author should describe design intent once. Runtime mounting and static
builds should consume the same normalized result instead of reinterpreting the
source configuration independently.

## System model

`DefineThemeInput` accepts:

- a name and optional normalized base theme;
- higher-order color, typography, radius, and motion configuration;
- explicit semantic token overrides;
- optional theme-family-local token declarations;
- component target/style-key overrides;
- icon and indicator registries;
- syntax tokens;
- `onDark` / `onLight` surface overrides; and
- ordered environmental `adaptations` with a fixed named width map.

`defineTheme` resolves that input into a flat `DefinedTheme`. An extended theme
contains the resolved values it inherits, so its normalized representation is
complete. A standalone build does not need the base stylesheet.

Normalization follows one precedence order:

1. the resolved base theme;
2. values generated from color, typography, radius, and motion configuration;
3. explicit token overrides;
4. generated component typography followed by explicit component overrides;
5. inherited and explicit media-surface overrides;
6. inherited and explicit theme-local declarations, validated against the
   resolved token, component, and media surfaces; and
7. inherited and explicit icon/indicator registry entries.

Explicit values win within their surface. Component maps merge by component,
style key, and CSS property rather than replacing the entire inherited target.

## Boundaries and invariants

- **INV1 — One input produces one normalized theme.** Runtime and build consumers
  receive the same `DefinedTheme`; they do not implement separate authoring
  semantics.
- **INV2 — Precedence is deterministic.** A value's winner follows the documented
  order and does not depend on object traversal outside that order.
- **INV3 — Extension is semantically flattened.** `extends` accepts a real
  `DefinedTheme` and carries forward its resolved tokens, local-token owner and
  lineage metadata, component rules, media surfaces, adaptations and axes, icons,
  and indicators. The normalized child is complete.
- **INV4 — Invalid bases fail loudly.** An undefined, namespace, or plain object
  passed to `extends` cannot produce a plausible partial theme.
- **INV5 — Explicit tokens override generated scales.** Generated values provide
  coherent defaults; an explicit semantic token wins token by token.
- **INV6 — Component overrides merge deeply.** Restating one CSS property keeps
  inherited properties on the same component/style key.
- **INV7 — Surface overrides inherit coherently.** `onDark` and `onLight` combine
  system defaults, inherited surface values, and local overrides in that order.
- **INV8 — Local-token enrollment is explicit and owner-tracked.** Supplying
  `localTokens`, or extending an exact enrolled base, produces a flattened local
  declaration map plus exact ownership and lineage metadata. Every declared key is a
  valid CSS custom-property name and no key collides with `tokens`; ownership is never
  inferred from a prefix. Exact `var()` matches to an effective enrolled declaration
  are owned edges on the validated local-token, component, media-surface, and
  adaptation surfaces. Non-exact references remain external. Enrolled themes retain
  exact owner, lineage, collision, and cycle validation; unenrolled themes retain
  their legacy behavior.
- **INV9 — Authoring and output are separate systems.** This record owns the
  normalized theme definition. The shared compiler owns turning it into styles;
  runtime and build own using or saving that output.
- **INV10 — Adaptations are ordered normalized intent.** Every effective theme
  retains its complete `sm`/`md`/`lg`/`xl`/`2xl` width map, generative-axis
  metadata, and inherited-then-local `{when, value}` rule order. Rules re-resolve
  against a child theme's effective root metadata; they do not mutate root token
  reads or introduce identity, registries, media surfaces, or new local names.
- **INV11 — `defineTheme` validates only what it constructs.** It owns theme
  creation and normalization. It may reject malformed input that affects normalized
  theme behavior or generated values as a construction precondition. Authoring or
  reference data used only for validation stays outside `DefineThemeInput` and
  `DefinedTheme`.
- **INV12 — Theme `define*` helpers construct theme values.** They transform,
  derive, or normalize input into a durable typed theme value used by a current
  supported consumer; validating and returning the exact input unchanged is
  insufficient.

This record does not own:

- semantic token names/defaults;
- turning `DefinedTheme` into style rules, including local-token emission,
  private-variable expansion, and cascade/layer behavior;
- runtime mounting, scope lifetime, root synchronization, or DOM observation;
- CLI file generation and packaging; or
- which component targets, states, and public properties participate.

`architecture:theme-compilation` owns platform output. For web, runtime and
static build use one CSS compiler and the same maintained stylesheet rules. A
future native compiler may turn the same theme definition into native style
objects without making CSS concepts part of shared authoring. Runtime application
and CLI packaging use the compiled output without changing it. The participating
public surface belongs to
`architecture:component-theming-surface`.

## Change coupling

- Admission asks: **What normalized theme behavior or generated value does this
  field or `define*` helper construct, and how do runtime and static build consume
  it?** No concrete answer keeps it out of Core runtime authoring. An admitted
  field's normalization, precedence, inheritance, validation, template exposure,
  and negative tests change together.
- Validation-only theme data stays in theme-package tests, internal test utilities,
  or an explicit CLI checker/doctor workflow used by a current supported consumer.
  It does not enter Core runtime merely to share a validator.
- `spec:AST-017` owns breaking classification and migration. A maintained theme
  release enters that path when its source or build stops working unchanged with a
  Core version inside the theme's currently supported peer/dependency range, even
  if its normalized tokens and CSS are unchanged. A validation-only Core field,
  helper, or export that causes that failure is a real compatibility cost, not
  harmless metadata. A targeted, coordinated break may proceed only through that
  compatibility owner; this record does not define its remedy.
- A local-token change preserves explicit enrollment, valid exact custom-property
  names, exact owner metadata, source/built lineage parity, and the separation from
  portable `tokens`. Prefixes are owner conventions only: they do not grant, reserve,
  or restrict ownership. One name cannot appear in both maps because CSS output and
  portable token helpers would otherwise resolve different values.
- Changing precedence is a compatibility decision because existing themes may
  contain both generated and explicit values.
- Changing `ComponentStyleMap` merge behavior verifies base, generated, explicit,
  and on-media composition; no layer invents its own merge rule.
- A new generated scale states which semantic tokens it may produce and confirms
  explicit token overrides still win.
- An adaptation change preserves the fixed condition vocabulary, inclusive
  `from`/exclusive `below` width edges, authored rule order, root-only local-name
  enrollment, and source/built extension parity.
- Authoring fields unavailable to the runtime-only path remain prohibited.
  Build/CLI metadata without a normalized-theme effect may exist only for a named
  current supported reader, stays outside `DefineThemeInput` and `DefinedTheme`,
  and must not impose Core/runtime compatibility. A hypothetical sidecar is
  insufficient.

## Owning code

- `packages/core/src/theme/defineTheme.ts` owns the public input, normalized IR,
  orchestration, and extension behavior.
- `themeAdaptations.ts` owns the fixed breakpoint/condition vocabulary,
  validation, inherited rule ordering, axis completion, and concrete rule writes.
- `syntax/defineSyntaxTheme.ts` constructs syntax themes before `defineTheme`
  adopts their tokens.
- `expandColorScale.ts`, `expandTypeScale.ts`, `expandRadiusScale.ts`, and
  `expandMotionScale.ts` own their derived token/component values.
- `mergeComponents.ts` owns the shared deep-merge rule.
- `onMediaTokens.ts` owns normalization of inverted-surface overrides.
- `localTokens.ts` owns opt-in enrollment, valid CSS custom-property names, exact
  owner metadata, reference closure, cycle detection, cross-map collision rejection,
  and flattened lineage. It does not infer ownership from prefixes.
- `packages/cli/assets/theme.template.ts` is an author-facing projection and must
  remain aligned; it does not define additional semantics.

## Deciding specs

AST-006 decisions 1–4 and 6 establish the shipped theme-local authoring shape,
enrollment, exact-name preservation, inheritance, shared validation, and value contract.
Its 2026-09-12 amendment makes key validity and ownership prefix-independent while
retaining exact owner metadata, lineage, collisions, cycles, and legacy unenrolled
behavior. AST-012 decisions 1–4 establish the fixed width map, named closed conditions,
ordered rule cascade, and source/built adaptation metadata parity. AST-017 decision 1
owns released compatibility classification and migration.

## Known conformance and verification gaps

Prefix-independent `localTokens` key acceptance is accepted but unshipped. The current
validator still requires the original theme-derived prefix and uses that prefix to
classify local references. Until the implementation lands, INV8's prefix-independent
clauses are current authority but not enforcement. Existing explicit enrollment,
owner, lineage, collision, cycle, and legacy-unenrolled behavior remains shipped.

## Verification

| Invariant                | Evidence                                                                                                | Failure signal                                                                                                            |
| ------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV2, INV5         | `defineTheme.test.ts` plus representative runtime/build parity                                          | Runtime and build interpret one input differently, or generated values beat explicit tokens                               |
| INV3, INV4               | extension and invalid-base tests                                                                        | Child themes require a base stylesheet or silently accept a non-theme base                                                |
| INV6                     | component merge tests across base/generated/explicit rules                                              | Restating one property drops inherited component styles                                                                   |
| INV7                     | `onMediaTokens.test.ts` and generated surface-rule tests                                                | A child loses inherited surface customization or surface precedence changes                                               |
| INV8                     | AST-006 runtime/static validator and source/built inheritance tests                                     | A valid name fails because of its prefix, ownership follows spelling, or lineage/reference/collision/cycle checks diverge |
| INV10                    | `themeAdaptations.test.ts` and CLI build fixtures                                                       | Width metadata, rule order, or child re-resolution diverges across source and built themes                                |
| INV11                    | `DefineThemeInput`/output diff plus runtime/build fixtures                                              | Validation-only data enters the normalized theme, or productive input loses construction validation                       |
| INV12                    | Core theme export diff, constructed-value evidence, and current-consumer callsite                       | A theme `define*` helper only checks input and returns that exact input unchanged                                         |
| Theme/Core compatibility | Maintained theme source/build against the minimum Core in its currently supported peer/dependency range | A theme update silently requires newer in-range Core or bypasses the `spec:AST-017` path                                  |
| Authoring projection     | `scripts/check-theme-template.test.mjs`                                                                 | A supported authoring concept is missing or misstated in the template                                                     |

---

### Architecture Theme Compilation (`docs/architecture/theme-compilation.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:theme-compilation
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-23
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/theme/generateThemeRules.ts,
    packages/core/src/theme/themeAdaptations.ts,
    packages/core/src/theme/derivedVarRegistry.ts,
    packages/core/src/theme/Theme.tsx,
    packages/cli/api/theme/build/build.mjs,
    packages/cli/api/theme/build/core-interception.mjs,
    packages/build/,
  ]
verified_by:
  [
    packages/core/src/theme/generateThemeRules.test.ts,
    packages/core/src/theme/themeAdaptations.test.ts,
    packages/core/src/theme/derivedVarRegistry.test.ts,
    packages/cli/api/theme/build/build.test.mjs,
    packages/cli/api/theme/build/build.public-component-vars.test.mjs,
    packages/cli/api/theme/build/build.adaptation-core-compat.test.mjs,
    packages/cli/api/theme/build/build.packed-old-core.test.mjs,
  ]
deciding_specs:
  [
    spec:AST-006/DEC-2,
    spec:AST-006/DEC-4,
    spec:AST-012/DEC-3,
    spec:AST-012/DEC-4,
  ]
---

# Theme compilation



```json
{
  "scope": "global",
  "triggers": {
    "theming": ["INV2", "INV3", "INV4", "INV6", "INV9", "INV10"]
  }
}
```

This record defines how one theme definition becomes usable styles.

## Purpose

A theme author defines a theme once. On the web, using the theme at runtime and
building it ahead of time must produce the same CSS behavior.

Other platforms may need different output. For example, a future native compiler
may produce style objects instead of CSS. It should still start from the same
theme definition.

## System model

The compiler receives a `DefinedTheme` from the theme-authoring system.

The current web compiler works like this:

1. `generateThemeRules` turns portable tokens, theme-local tokens, and component
   overrides into CSS rules.
2. Ordered adaptation rules compile to separate media blocks after root rules.
   Blocks preserve author order and are never merged or value-diffed; media-surface
   overrides compile after them.
3. The same code adds state rules, media-surface rules, scopes, and layers.
4. The `Theme` provider mounts those rules when a theme was not built ahead of
   time.
5. The CLI saves those rules into CSS and packages the related JavaScript and
   types.
6. A built theme preserves local-token ownership, effective width points,
   generative-axis metadata, and normalized ordered rules, and is marked so the
   provider does not compile or inject it again.

For top-level declarations in base component target rules, the compiler preserves
generic CSS properties as written. When a guaranteed property needs to reach
internal painters, the checked registry additionally translates it into one or
more private variables and may replace the source declaration when applying it to
the target element would be wrong. Reviewed public semantic custom properties
pass through as direct author input. Direct private `--_*` input is prohibited by
the contract; current runtime, build, pseudo-rule, and media-surface conformance
gaps are listed below.

A future platform compiler may turn the same theme into a different output type.
Platform-specific details stay inside that compiler.

## Boundaries and invariants

- **INV1 — One definition can have many outputs.** Authors do not maintain
  separate definitions for the same shared theme intent.
- **INV2 — Web has one compiler.** Runtime and static builds call the same code to
  turn a theme into CSS.
- **INV3 — Mounting and saving do not change the rules.** The provider mounts
  compiled CSS. The CLI saves and packages it. Neither creates different theme
  behavior.
- **INV4 — Web uses one cascade contract.** Runtime and built CSS use the same
  scopes, `astryx-theme` layer, component overrides, and consumer precedence.
- **INV5 — Guaranteed properties remain observable.** The compiler preserves a
  guaranteed target property directly when that produces the promised effect. If
  internal structure prevents that, one checked registry entry may route,
  transform, or fan the public property out to one or more private component
  variables; the implementation choice cannot change the property's promised
  meaning.
- **INV6 — Private variables stay private.** Private `--_*` variables are
  compiler/component implementation details. Theme authors use the guaranteed
  property that owns them. Runtime and built authoring paths must reject direct
  private values rather than emitting them as plausible output.
- **INV7 — Public semantic variables pass through deliberately.** A reviewed
  public custom property is direct author input only when the component-theming
  contract admits it because no guaranteed CSS property expresses the need. The
  compiler must preserve it without turning private machinery into public API.
- **INV8 — Generic properties do not become promises by compiling.** The generic
  styling pipeline may emit properties outside a target's guaranteed set. Output
  alone does not make their effect or compatibility part of the public contract.
- **INV9 — Platform details stay out of shared authoring.** CSS selectors, layers,
  scopes, and custom properties belong to the web compiler. A native compiler
  may use native style objects.
- **INV10 — Shared intent keeps the same meaning.** A token or supported component
  override means the same thing across outputs. Platform-only features have an
  explicit support boundary.
- **INV11 — Theme-local names remain exact and prefix-independent.** For an enrolled
  theme, the compiler emits the normalized `localTokens` map beside portable
  declarations without rewriting names or values. A prefix neither grants nor
  restricts ownership. Exact references to effective enrolled declarations retain
  owner, lineage, collision, and cycle validation; non-exact references remain
  external. Runtime and static output use the same rules, and invalid enrolled input
  is rejected before either path writes partial CSS.
- **INV12 — Adaptation order is observable.** Root declarations emit first,
  adaptation blocks remain separate in authored order, and media-surface overrides
  emit last. Duplicate conditions and later root-restoring writes are preserved
  exactly; runtime and static output use the same blocks.

- **INV13 — A declaration stays inside its boundary.** The web compiler rejects
  only an authored property name or value that would escape or invalidate its CSS
  declaration/rule boundary. It drops that declaration and continues generating the
  rest of the theme, including inherited and conditional surfaces. The boundary
  check preserves valid authored CSS bytes, including quoted semicolons/braces,
  data URLs, escapes, closed comments, nested functions, vendor-prefixed names, and
  `!important`. It is not a property-value grammar validator. Legacy unenrolled
  token values retain their historical string interpolation; this check must not
  introduce a crash for values such as `null`. Existing construction validation,
  including atomic enrolled local-token validation, remains separate and unchanged.
- **INV14 — Reporting does not change compilation.** Dropped declarations produce
  runtime warnings and appear in the existing static-build receipt's `warnings`.
  The existing `generateThemeRules`, `generateThemeRulesSplit`, `generateThemeCSS`,
  `generateOnMediaCSS`, and `generateAdaptationCSS` operations may accept one
  optional `warnings?: string[]` collector. Supplying it only appends warning text
  instead of writing that text to the console; it does not change generated CSS,
  clear existing array entries, change validation, or mutate the theme. Omission
  retains normal runtime warning behavior. This transport does not introduce a
  callback API, exported diagnostics/options types, theme metadata, or a structured
  diagnostics schema. A CLI paired with an older core retains that core's existing
  compilation behavior; it does not pretend to detect drops the older core cannot
  report.

This record does not own:

- token names and defaults, including the meanings of theme-local names;
- `DefineThemeInput`, local-token enrollment and lineage, inheritance, or
  authoring precedence;
- which component parts and properties are public theme APIs; or
- provider nesting, root synchronization, and DOM observation after compilation.

## Change coupling

- Any change to how a web theme becomes CSS belongs in the shared compiler and
  is tested through both runtime mounting and CLI-built output. The Theme
  provider and CLI may mount or package compiled rules; they must not implement
  their own theme-to-CSS transformations.
- Adding or changing a guaranteed property path uses the target's exact
  `guaranteedProperties` declaration and adds representative compiler and runtime
  evidence that the property produces its promised effect on that target's
  anatomy part. Catalog membership alone is not a guarantee. If private expansion
  is needed, update the checked registry and component metadata in the same
  change.
- Adding or changing a public semantic custom property verifies direct runtime
  and built output, default/fallback behavior, and rejection of any private alias.
- Generic-property pass-through remains best effort and must not be documented or
  tested as a compatibility guarantee unless the component-theming record admits
  that property into the guaranteed set.
- Changing scope or layer output tests both source and distribution builds.
- Changing local-token emission or packaging verifies exact-name runtime/static
  parity, atomic failure, and preservation of built-theme lineage metadata.
- Changing adaptation output verifies authored block order, duplicate conditions,
  root-restoring writes, media-surface precedence, derived component lowering,
  and source/built extension parity.
- The CLI and core version independently, so a CLI that compiles adaptations
  against a core that does not is a supported pairing rather than a broken
  install. That pairing is bounded to the adaptation capability, not a general
  compatibility scheme for arbitrary older cores: a theme with no adaptation
  intent builds and emits the same CSS it always did, while valid rules, a custom
  width map, and present-but-malformed adaptation metadata all count as intent
  and fail with `ERR_CORE_INCOMPATIBLE` before any output is generated. A
  complete default width map with no rules is a no-op on both paths, because that
  is what core writes onto every resolved theme and every shipped built theme.
  Neither path may emit CSS with adaptation rules missing. Intent is read from
  resolved metadata when available; because a core that predates adaptations
  erases it, the build also records each raw `defineTheme()` input and associates
  it with the theme it produced. Only the selected theme's lineage decides, so an
  unused adaptive theme elsewhere in the graph does not fail a plain build. If
  that selected lineage contains an unobserved source package — whether the
  async fallback bypassed interception or CommonJS reached a core namespace that
  could not be wrapped — the build fails closed. Built package artifacts remain
  observable through their retained metadata. Captured raw typography, color,
  radius, and motion metadata is also retained when the old core omits `__axes`,
  so a current-core child extending the artifact resolves partial adaptation
  axes exactly as it would from source. Generated CSS is identical across the
  two cores for a theme with no adaptation intent; generated JavaScript and
  `@generated` provenance may differ because they record resolved metadata and
  the core version, so `--check` can legitimately report drift after an upgrade.
- Adding a platform compiler names the shared concepts it supports and tests
  that they keep the same meaning. Unsupported concepts fail clearly instead of
  disappearing.
- Declaration-boundary changes verify positive and negative scanner cases, local
  dropping with surviving neighbors, legacy token generation, and runtime/build
  parity across tokens, local tokens, variants, pseudo states, media surfaces,
  adaptations, inheritance, and Heading fallback rules. Collector tests verify
  append-only text, unchanged CSS with/without a collector, and build receipts.
- Build packaging may change without changing compiled theme behavior.

## Owning code

- `generateThemeRules.ts` owns the web compiler and canonical CSS output,
  including portable and theme-local declarations in the same scoped block.
- `Theme.tsx` mounts compiled CSS for themes that were not built ahead of time.
- `derivedVarRegistry.ts` owns checked mappings from guaranteed public properties
  to private component variables.
- Component `.doc.mjs` `theming.derived[]` mirrors those mappings;
  `theming.vars[]` distinguishes reviewed public semantic variables from private
  implementation variables.
- `packages/cli/api/theme/build/build.mjs` saves and packages compiled CSS. Its
  private-variable diagnostic is currently non-blocking; rejecting that input is
  a named conformance gap, not existing enforcement. It also owns the
  adaptation-capability check for the installed core: baseline generation is
  required, and `generateAdaptationCSS` is demanded by valid or malformed
  adaptation intent in authored, resolved, or built input.
  `core-interception.mjs` owns capturing authored input and its generative axes:
  it hands the loading theme a core whose `defineTheme` is wrapped to record each
  raw input against the theme it returned, and reports loader paths it could not
  cover so erased intent never passes as clean.
- Future platform compilers consume the same `DefinedTheme` behind this boundary.

## Deciding specs

AST-006 decisions 2 and 4, as amended on 2026-09-12, establish
prefix-independent local-token names and atomic shared validation for enrolled themes.
AST-012 decisions 3 and 4 establish ordered adaptation blocks and source/built metadata
parity. The system owner separately selected one definition with platform-specific
outputs and the guaranteed, best-effort, public-semantic, and private implementation
tiers.

## Verification

| Invariant        | Evidence                                                                                                        | Failure signal                                                                                                          |
| ---------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| INV1, INV2, INV3 | Compiler imports and runtime/build comparison fixtures                                                          | Runtime and build use different theme-to-CSS logic or produce different web rules                                       |
| INV4             | `generateThemeRules.test.ts` and source/distribution cascade tests                                              | Scope or layer order differs by output path                                                                             |
| INV5             | Existing per-property fixtures (partial; gap below)                                                             | A guaranteed property compiles but does not produce its promised observable effect                                      |
| INV6, INV7       | Existing registry and CLI public-variable tests (partial)                                                       | Private variables become authorable, or a reviewed public semantic variable fails build/runtime                         |
| INV8             | Component target metadata and compatibility review                                                              | Successful generic emission is treated as a guaranteed public behavior                                                  |
| INV9, INV10      | Platform compiler tests when another compiler ships                                                             | CSS details enter shared authoring, or shared theme intent silently disappears                                          |
| INV11            | `defineTheme.test.ts` and `build.test.mjs` local-token fixtures                                                 | Runtime/static output rewrites a local name, disagrees, or leaves partial output after failure                          |
| INV12            | `themeAdaptations.test.ts` and CLI adaptation build fixtures                                                    | Rule blocks merge/reorder/drop, surfaces lose precedence, or runtime/static CSS diverges                                |
| INV13, INV14     | Declaration scanner, generator, Theme mounting, CLI receipt tests, and the existing theme-family Chromium guard | One unsafe declaration corrupts neighbors, valid CSS changes, legacy token generation crashes, or reporting changes CSS |
| Built themes     | Theme and CLI build tests                                                                                       | Runtime recompiles a built theme, or built output omits canonical rules                                                 |

## Known conformance and verification gaps

Declaration-boundary enforcement and the warnings-array collector in INV13–INV14
are accepted but not yet shipped. Their verification obligations above describe
implementation acceptance, not existing enforcement.

Prefix-independent `localTokens` key acceptance is accepted but unshipped. The current
compiler still requires the original theme-derived prefix and uses that prefix to
classify local references. Until implementation lands, INV11's prefix-independent
clauses are current authority but not enforcement; exact-name emission and the existing
enrollment, owner, lineage, collision, cycle, and legacy-unenrolled behavior remain
shipped.

The remaining invariants above describe the approved current contract. The following
shipped behavior does not yet conform and must not be treated as enforcement:

- **Private author input is not rejected end to end.** `themeBuild` reports direct
  `--_*` values as errors in its receipt/log, but continues compiling and emits
  them. Its validation checks only the top-level declarations under
  `components`; nested pseudo declarations and media-surface components bypass
  it. Runtime `defineTheme` has no equivalent validation and accepts them. The
  follow-up must reject direct private variables recursively before CSS
  generation across both paths and prove runtime/static parity.
- **Nested pseudo component declarations bypass derived expansion.** Top-level
  component declarations at the root, in adaptations, and under
  `onDark.components` / `onLight.components` use `derivedVarRegistry`, including
  `replaces` and container expansion. Nested pseudo declarations on those
  surfaces still serialize properties directly. The follow-up must route nested
  pseudos through the same component-declaration lowering path, with parity
  fixtures for a normal mapping, `replaces`, and container padding.
- **Guaranteed-property coverage is not machine-complete.** The shared catalog is
  normative, but the component-doc schema has no per-target
  `guaranteedProperties` declarations. CI therefore cannot prove that a target
  rationally supports its selected catalog subset or reviewed additions. The
  follow-up must add and migrate that metadata, then fail when a declared
  target/property pair lacks both output-path and observable runtime evidence.

These invariants are the approved rules governing implementation and review. The
nonconformities above are shipped defects against that contract, not proposed
behavior.

---

### Architecture Theme Tokens (`docs/architecture/theme-tokens.md`)

---
schema_version: 1
template_version: 1
kind: architecture
id: architecture:theme-tokens
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
applies_to:
  [
    packages/core/src/theme/tokens.stylex.ts,
    packages/core/src/theme/tokens.ts,
    packages/core/src/theme/localTokens.ts,
    packages/core/src/theme/domainTokens/,
    packages/core/src/theme/syntax/,
    packages/cli/assets/theme.template.ts,
  ]
verified_by:
  [
    packages/core/src/theme/tokens.test.ts,
    packages/core/src/theme/defineTheme.test.ts,
    scripts/check-theme-template.test.mjs,
    scripts/generate-token-docs.mjs,
  ]
deciding_specs: [spec:AST-006/DEC-1, spec:AST-006/DEC-5]
---

# Theme token architecture



```json
{
  "scope": "global",
  "triggers": {
    "tokens": ["INV1", "INV3", "INV4", "INV6", "INV8", "INV9"]
  }
}
```

This record defines the semantic token vocabulary that components, themes,
documentation, and non-CSS consumers share.

## Purpose

A theme author and a component author should refer to the same stable semantic
names. Changing a default theme, resolving a token in JavaScript, or generating
consumer documentation must not create a second token vocabulary.

## System model

Core tokens are declared as paired exports in `tokens.stylex.ts`:

- `*Defaults` objects hold default CSS values.
- `*Vars` objects are created from those defaults with `stylex.defineVars` and
  are what component styles consume.
- `defineTheme` combines the core defaults into the complete theme token type.
- `tokenVar`, `tokenVars`, `resolveThemeToken`, and `resolveThemeTokens` expose
  the same vocabulary to canvas, SVG, charts, tests, build tools, and other
  non-StyleX consumers.

Syntax-highlighting and data-visualization tokens are domain layers. They remain
separately importable so applications that do not use those domains do not need
their implementation modules, while `domainTokenDefaults` makes their names
available to theme validation and resolution.

The CLI theme template and generated token documentation are projections of
this vocabulary. They do not define additional tokens.

Theme-family-local tokens are a separate enrolled map. They may reuse the
existing token value shape, but they do not enter `TokenName`, defaults,
`tokenVar`, `tokenVars`, `resolveThemeToken`, `resolveThemeTokens`, generated
portable documentation, or Core component source. A name cannot be present in
both portable `tokens` and `localTokens` because CSS and non-CSS resolution would
then have contradictory winners.

## Boundaries and invariants

- **INV1 — `defineVars` declarations are canonical.** A core token name and its
  default originate in `tokens.stylex.ts`; projections do not introduce names.
- **INV2 — Defaults and typed variables move together.** Each core token family
  has one `*Defaults` object and matching `*Vars` export.
- **INV3 — Token names describe semantic roles.** Components consume purpose-led
  names such as text, surface, spacing, and motion roles rather than embedding a
  theme's raw palette choices.
- **INV4 — Mode-relative defaults remain one token.** A semantic role that varies
  by color mode uses a supported `light-dark()` or tuple value instead of
  creating unrelated light and dark token names.
- **INV5 — Domain layers are explicit.** Syntax and data tokens are available to
  theme authoring and resolution without being folded into the core component
  token module.
- **INV6 — Non-CSS consumers resolve the same graph.** JavaScript helpers follow
  token references and supported color expressions instead of maintaining a
  second set of resolved values.
- **INV7 — Projections cannot drift silently.** Theme-template inventory and
  generated token docs change with the canonical family or fail validation.
- **INV8 — Theme-local names stay non-portable.** Enrolled local declarations
  remain outside the canonical vocabulary, typed helpers, generated portable
  documentation, and Core component dependencies.
- **INV9 — Spacing tokens are rem.** The core `--spacing-*` defaults are `rem`
  values (`--spacing-N` is `N × 0.25rem`; `--spacing-4` is `1rem`, 16px at the
  default root), so gaps, padding, and margins built from them follow the
  reader's root font size the way the type scale does. One rule covers every
  surface: a spacing step is `rem` unless a theme documents that step as a
  `px` exception. A theme that overrides steps writes `rem`; a generator of
  spacing steps (the CLI theme templates, which copy a first-party theme
  verbatim, and the docsite playground theme editor's ramp) emits `rem`, and
  reproduces a theme's documented `px` exception only because the theme
  declares it. Values an author writes in a custom theme are honoured as
  written, never converted. Non-CSS consumers (`useTheme().token()`,
  `resolveThemeToken()`, `tokenDefaults`) return the token's value with its
  unit; code that needs pixels resolves the value against the root font size
  instead of parsing a number.

This record does not own:

- the `defineTheme` input shape, generated scales, or precedence;
- local-token names, meanings, enrollment, ownership, lineage, and emission;
- mounting, nesting, color-mode selection, or runtime theme observation;
- turning a normalized theme into scoped CSS and build files;
- component-specific targets, visual states, or private derived variables.

Those concerns belong to separate theming architecture records so a token change
does not silently change authoring, runtime, compiler, or component contracts.

## Change coupling

- Adding, removing, or renaming a core `*Defaults` family updates its `*Vars`
  export, the combined `TokenName`/defaults surface, the CLI template inventory,
  generated token docs, and drift tests together.
- Adding or changing a domain token updates that domain's defaults and exported
  name type; the aggregate domain map remains the validation bridge.
- Renaming or removing a released token is a compatibility decision, not a
  mechanical cleanup. Consumers may use its CSS custom-property name directly.
- Changing a default value is a visual behavior change and requires real-browser
  evidence for representative light/dark, status, and high-contrast surfaces.
- Changes to JavaScript resolution preserve CSS cascade semantics for supported
  references and color expressions; unsupported expressions remain intact
  rather than being guessed.
- Adding a theme-local name does not update portable defaults, name types,
  helpers, or generated token docs. Authoring validation rejects a duplicate
  across `tokens` and `localTokens` before the compiler or helpers can disagree.

## Owning code

- `packages/core/src/theme/tokens.stylex.ts` owns core token names, defaults, and
  StyleX variables.
- `packages/core/src/theme/domainTokens/` and
  `packages/core/src/theme/syntax/tokens.ts` own domain vocabularies. The
  syntax vocabulary is nineteen slots: thirteen token colours, the code
  background, the three diff row paints, and the two diff marker colours.
  `--color-syntax-added` and `--color-syntax-removed` default to
  `--color-success-muted` and `--color-error-muted` at 25% strength
  (`color-mix` with transparent); `--color-syntax-diff-header` defaults to
  `--color-background-muted` in the default syntax theme and, in a defined
  syntax theme that leaves it unset, to `transparent` (the header row sits on
  that theme's own syntax background with its `punctuation` text); `--color-syntax-added-marker` and
  `--color-syntax-removed-marker` (the line number and the glyph of a
  changed row) default to `--color-text-green` and `--color-text-red` in the
  default syntax theme, and in a defined syntax theme that leaves them unset
  they take that theme's `--color-syntax-punctuation`. The added and removed
  row paints keep their defaults in every theme that does not set them.
- `packages/core/src/theme/defineTheme.ts` aggregates token types/defaults for
  validation and authoring; it does not become a second token source.
- `packages/core/src/theme/tokens.ts` owns server-safe references and resolution.
- `packages/core/src/theme/localTokens.ts` enforces the boundary between the
  portable and enrolled local maps; it does not add local names to this
  vocabulary.
- `packages/cli/assets/theme.template.ts` and generated token docs are checked
  projections for theme authors and consumers.

## Deciding specs

AST-006 decisions 1 and 5 establish that theme-local names are additive,
theme-family contracts rather than portable Astryx tokens. The existing portable
vocabulary and helper architecture remain unchanged.

### DEC-1 — Spacing follows the root font size

**Decider:** `cixzhang`, `2026-10-12`

Spacing tokens are `rem`. A reader who raises the browser's root font size gets
proportionally larger gaps and padding, so text does not tighten inside boxes
that stay in pixels; at the default 16px root each step is `4px × step`.
`defineTheme` has no spacing base unit: it takes spacing only as per-step token
overrides (`architecture:theme-authoring-contract` owns that input). Two
surfaces generate spacing steps and both emit `rem` under INV9's one rule: the
CLI's theme templates, which copy a first-party theme's steps verbatim, and the
docsite playground's theme editor, which derives a ramp from a base step and
exports it as `defineTheme` source. The visual gate's probe theme pins
`--spacing-4` in `px` on purpose, to prove a token value flows through; it is a
test fixture, not a generator, and keeps its pin. Theme authors keep control: a
theme may pin a step in `px` as its documented exception, and author-written
values are never rewritten. Costs accepted with the decision: fixed-width table columns lose
room to larger padding at very large roots; a theme whose pinned `px` steps are
its documented exception does not follow the root for those steps; and code
that parses a spacing token as a pixel number reads the wrong value, which no
codemod can find.

Rejected: keeping spacing in `px` while type is `rem` (text grows into fixed
boxes); converting author-written `px` in custom themes (a theme's own
decision).

## Verification

| Invariant                    | Evidence                                                                                                                                                                                                                                                                                                   | Failure signal                                                                                                                              |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| INV1, INV2, INV7             | `scripts/check-theme-template.test.mjs` and token-doc generation check                                                                                                                                                                                                                                     | A token family exists without a matching author-facing inventory or generated documentation                                                 |
| INV5                         | Domain-token exports and theme-generation tests                                                                                                                                                                                                                                                            | Domain names disappear from validation or require importing unused domain implementation                                                    |
| INV6                         | `packages/core/src/theme/tokens.test.ts`                                                                                                                                                                                                                                                                   | JavaScript resolution disagrees with defaults, mode selection, references, or supported color expressions                                   |
| INV8                         | `defineTheme.test.ts`, template checks, and token-doc generation                                                                                                                                                                                                                                           | A local name enters portable helpers/docs or can conflict with a portable declaration                                                       |
| INV9                         | `tokens.test.ts` (core defaults), `SpacingRootFontSize.a11y.chromium.spec.ts` (real-Chromium root font size), each overriding theme's `*Theme.spacing.test.ts`, `check-cli-theme-bundle.test.mjs` (bundled copies byte-equal to the packages), `theme-concentricity.test.ts` (playground ramp read in rem) | A spacing step in `px` that no theme documents as its exception: a core default, a theme override, a bundled copy, or a generated ramp step |
| Released token compatibility | Type checking plus representative package/source builds                                                                                                                                                                                                                                                    | A released CSS variable or typed token name disappears without an explicit migration decision                                               |
| Visual defaults              | Real Chromium examples in light, dark, status, and high-contrast contexts                                                                                                                                                                                                                                  | A changed default produces unreadable or semantically inconsistent output                                                                   |

---

### Contributing Api Conventions (`docs/contributing/api-conventions.md`)

# API conventions for contributors

Use this guide to shape a public component API before review. This page is a
practical projection. It does not create policy. If it conflicts with a current
owner record, follow the owner record and fix this guide.

This repository guide is the maintained contributor reference. The public wiki
may provide an overview and rationale, but it should link here instead of keeping
a second copy of these conventions.

## Start with the owner

Use only records whose front matter says `authority: current`. Read the narrowest
owner first when one is linked or easy to find. Human contributors do not need to
understand or edit the spec system to contribute; reviewers and maintainers route
new decisions and record final rulings.

1. The component's `<Name>.spec.md`, when one exists beside its code in
   [`packages/core/src/`](../../packages/core/src/), owns component behavior.
2. A current contract under [`docs/families/`](../families/) owns behavior shared
   by sibling components.
3. Current architecture and accepted system decisions own cross-component rules.
   Start with the [public component API architecture](../architecture/public-component-api.md),
   the [knowledge contract](../architecture/knowledge-contracts.md), and
   [AST-002: public API admission and operation shape](../specs/AST-002/spec.md).

The `owners` field names who can settle a missing decision. Component source and
its public `index.ts` own the shipped API. The component's `.doc.mjs` owns exact
consumer syntax and reference material. The component spec owns local semantic
API meaning and guarantees, including public hooks or utilities it explicitly
co-owns.

The wiki gives broader background: [API Conventions](https://github.com/facebook/astryx/wiki/API-Conventions)
and [API Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration).
Do not copy detailed decisions from those pages into a proposal. Link the current
owner instead.

## Separate semantic contract from syntax reference

Every public-facing API addition or semantic change updates its canonical owning
component, family, architecture, or system record. Update the component spec only
when it owns the changed component-local semantics or explicitly co-owns the
public hook or utility. The owning record describes what callers mean, observe,
and may rely on; `.doc.mjs` remains the authority for signatures, prop/reference
tables, and consumer usage. Do not turn the semantic contract into a second
syntax catalog.

A component spec inherits every applicable rule from its current family
contracts. Record only component-local concepts, additions, and explicit
exceptions. Link an exception to its approving decision instead of copying or
rewriting the family rule.

A component spec may co-own a colocated public hook or utility when that ownership
is explicit. Its local semantic contract covers:

- inputs and options, including meaning, defaults, invalid values, and unsupported
  combinations;
- outputs and operations, including observable results and guarantees; and
- side effects, identity, lifetime, resource ownership, and cleanup where relevant.

Link a different canonical owner for any public surface the component spec does
not own.

## Name one concept at a time

| Surface                      | Convention                              | Example                       |
| ---------------------------- | --------------------------------------- | ----------------------------- |
| Component                    | Unprefixed PascalCase                   | `Button`, `TextInput`         |
| Props type                   | `<Component>Props`                      | `ButtonProps`                 |
| Public hook                  | `use<Name>`                             | `usePopover`                  |
| Boolean state                | `is<Name>`                              | `isDisabled`                  |
| Boolean capability           | `has<Name>`                             | `hasClear`                    |
| Uncontrolled boolean default | `defaultIs<Name>` or `defaultHas<Name>` | `defaultIsOpen`               |
| Synchronous callback         | `on<Verb>`                              | `onChange`                    |
| Disambiguated callback       | `on<Verb><Scope>`                       | `onSidebarCollapsedChange`    |
| Transition Action            | `<verb>Action`                          | `changeAction`, `clickAction` |
| Logical direction            | `start` or `end`                        | `startIcon`, `paddingEnd`     |

Use callback scope only when the verb could refer to more than one part. Use an
`html` prefix only for a native attribute that passes through unchanged, such as
`htmlName`. Keep the clearer semantic name when the component owns the concept.

String values use `camelCase`. A public input has one stable semantic
responsibility across its full value domain and every accepted input shape. Its
name and type disclose the caller-owned meaning.

One semantic input may derive several visual details when they form one cohesive,
named outcome. For example, a semantic `status` or `variant` may own both tone and
a signifier. This is not an overloaded input.

Reject a property when its value or input shape changes which axis it controls, or
when consumers need implementation knowledge to predict which axes it controls.
If two axes are independently caller-owned, represent them with separate inputs
and prevent conflicting combinations. If the system owns their coordination,
expose the semantic concept and derive the details instead of naming the input
after one mechanism such as `color`.

Parallel inputs must not create hidden conditional precedence. An override is
valid only when its name, type, and behavior across every combination form an
explicit coherent contract, with invalid or conflicting states prevented under
[FR15](../specs/AST-002/spec.md#requirements).

## Name public module and utility functions by their result

Choose a function's verb from its primary caller-observable result and side
effects. Distinguish construction, inspection, lookup, conversion, registration,
and guaranteed state. Do not name the public function after one internal step.
Keep one callable role per public capability.

This section covers exported standalone functions that form module or utility
APIs. It does not reinterpret component names, callback `on<Verb>` names, or
transition Action names. CLI command verbs and their programmatic command twins
have separate ownership in the
[CLI surface architecture](../architecture/cli-surface.md) and CLI conventions.

The repository-wide export audit supports these roles:

| Verb                  | Public module or utility role                                                                      | Boundary                                                                                          |
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `define*`             | Construct or normalize and return a durable typed value used by a supported consumer.              | Validation may be a precondition; inspection or unchanged identity alone is not definition.       |
| `validate*`, `check*` | Inspect input and return structured results.                                                       | State which failures are returned and which conditions throw.                                     |
| `create*`             | Construct or initialize a caller-used runtime value, state object, source, configuration, or view. | The result may be normalized; inspection alone is not creation.                                   |
| `build*`              | Assemble a composite configuration or artifact from parts.                                         | This row covers module utilities, not CLI command naming.                                         |
| `generate*`           | Derive a new aggregate or serialized output from supplied input.                                   | Name the generated result; do not hide unrelated mutation.                                        |
| `resolve*`            | Choose and return a concrete value from inputs, options, context, or a registry.                   | Document fallback and missing-value behavior.                                                     |
| `parse*`              | Convert an external or string representation into a typed or structured representation.            | The public contract states whether invalid input returns `null`, returns a result, or throws.     |
| `format*`             | Serialize a value or produce display text without changing the source value.                       | Include locale, mode, or fallback behavior when it affects output.                                |
| `get*`                | Read or project a requested value without mutating its source.                                     | Current APIs include both registry lookup and deterministic projection; do not imply persistence. |
| `is*`, `has*`         | Return a boolean predicate or type guard.                                                          | Use a structured check when callers need reasons, warnings, or multiple findings.                 |
| `register*`           | Add or replace shared registry state.                                                              | Registration is an explicit side effect.                                                          |
| `ensure*`             | Idempotently establish required state when it is absent.                                           | The name must disclose the state or resource the function may create or mutate.                   |
| `use*`                | Expose a React hook that may read context/state and own React lifecycle or effects.                | Follow the Rules of Hooks; a non-hook utility must not use `use*`.                                |
| `reset*`              | Clear or restore shared state to its documented baseline.                                          | The affected state and intended consumer scope must be explicit.                                  |
| `expand*`             | Convert a compact configuration or representation into its fuller derived form.                    | Expansion does not imply persistence.                                                             |
| `merge*`              | Combine compatible inputs into one returned value or composed behavior.                            | State precedence and conflict behavior.                                                           |

If construction, validation, registration, or another capability is public,
expose each capability through its own callable function. A constructor may reuse
the same internal validation primitive, but it still returns the constructed
value and does not absorb a separately public role.

These are roles supported by current repository evidence, not permission to pick
a familiar verb first and make the implementation fit later. Check the exported
signature, implementation, tests, consumer docs, supported callsites, and release
history. Scope a documented exception to the owning module instead of weakening a
verb across the repository.

Do not silently rename a released mismatch. Preserve compatibility through an
explicit deprecation and migration, then remove or change the old contract only at
the approved compatibility boundary.

## Callbacks and Actions

A callback reports an event synchronously. An Action starts transition-aware
work. The Action name never starts with `on`.

`changeAction` does not replace `onChange`. A component may support either or
both. When both are present, run the callback first. Run the Action afterward
unless the component's public event contract lets the consumer cancel it.

```tsx
interface SearchInputProps {
  value: string;
  onChange?: (
    value: string,
    event: React.ChangeEvent<HTMLInputElement>,
  ) => void;
  changeAction?: (
    value: string,
    event: React.ChangeEvent<HTMLInputElement>,
  ) => void | Promise<void>;
}

const [, startTransition] = React.useTransition();

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  const nextValue = event.currentTarget.value;
  onChange?.(nextValue, event);

  if (changeAction && !event.defaultPrevented) {
    startTransition(() => changeAction(nextValue, event));
  }
}
```

Do not name the Action `onChangeAction`. Do not require every input callback by
category. Requiredness follows the component's usable-state, control, and
accessibility contract.

When a component and a consumer both handle the same React event, compose the
handlers deliberately. Put the consumer first only when the public contract
promises that `preventDefault()` cancels built-in behavior.

```tsx
const handleClick = composeEventHandlers(onClickProp, selectItem);
```

If cancellation is not promised, preserve the component's required behavior and
state that order in its contract and tests.

## DOM props, styling, and refs

Extend [`BaseProps`](../../packages/core/src/BaseProps.ts) only when the component
owns a stable DOM element. A component that only coordinates children or returns
multiple unrelated roots should expose the smaller contract it actually owns.

For a DOM-owning component:

- type `BaseProps` with the contract element and accept `ref` as a React 19 prop;
- remove native names that collide with component concepts by using `Omit`;
- forward neutral `data-*`, ARIA, DOM, and event props to the contract element;
- keep component-owned role, accessibility, and behavior props from being
  overwritten; and
- merge `xstyle`, `className`, and `style` instead of choosing one.

```tsx
export interface PanelProps extends BaseProps<HTMLDivElement> {
  ref?: React.Ref<HTMLDivElement>;
  children: React.ReactNode;
}

export function Panel({
  children,
  ref,
  xstyle,
  className,
  style,
  ...rest
}: PanelProps) {
  return (
    <div
      ref={ref}
      {...mergeProps(
        themeProps('panel'),
        stylex.props(styles.root, xstyle),
        className,
        style,
      )}
      {...rest}>
      {children}
    </div>
  );
}
```

Destructure styling and owned handlers before spreading `rest`. Use
`composeEventHandlers` when both sides need the same event. Set component-owned
contract props after `rest` so spread order cannot change semantics.

## Open visual vocabularies and closed axes

The [component theming surface](../architecture/component-theming-surface.md#boundaries-and-invariants)
admits a theme-extensible prop axis only when it is visual and an unavailable
custom value has one safe, deterministic baseline independent of the active
theme. `Heading.type` qualifies because required `Heading.level` supplies that
baseline. `Icon.size` does not: choosing a fallback size would silently change
geometry, alignment, or composition.

Behavioral, structural, placement, directional, and state-machine axes stay
closed. A theme may redefine an existing value on a closed axis, but it may not
add one.

An admitted theme-extensible vocabulary uses a public `*Map` interface in the
component subpath barrel. Derive the prop type from its keys.

```ts
// packages/core/src/Button/index.ts
export interface ButtonVariantMap {
  primary: true;
  secondary: true;
  ghost: true;
  destructive: true;
}

// packages/core/src/Button/Button.tsx
export type ButtonVariant = keyof ButtonVariantMap;
```

A theme can add an admitted visual value through module augmentation of
`@astryxdesign/core/Button`. The component contract or governing system spec and
focused test must show the fallback when no matching theme rule is active; the
shared structural guard checks only the public map, `themeProps()` reflection,
and theming metadata.

Do not assume a nested `theme.components.button.variants` shape. Follow the current
[theme authoring contract](../architecture/theme-authoring-contract.md) for
component target and style-key overrides.

## Slots and composition

Prefer composition when content or child behavior has its own contract. A named
slot accepts the complete child and renders it directly.

```tsx
<AppShell
  topNav={<TopNav items={items} />}
  sideNav={<SideNav sections={sections} />}
/>
```

Do not hoist `SideNav` state or callbacks onto `AppShell`. Keep them on the child
that owns them. Use a render function only when the parent must provide item data
or context. Use a prop for a finite independent axis, not for a one-off product
recipe.

High-level compositions have a higher bar for new props than utility components.
Before adding a prop, check whether a child, slot, theme target, styling escape
hatch, parent layout, or existing context already owns the distinction.

## API proposal gate

Classify the change after identifying current authority. The
[knowledge contract](../architecture/knowledge-contracts.md#change-coupling) owns
the five results and their disposition: `preserves`, `settled`, `violates`,
`novel-human`, and `out-of-scope`. This guide applies those results; it does not
redefine them.

A defect fix is `preserves` only when it restores a current contract or standard
without adding public API or public behavior beyond that authority. Supply focused
regression evidence for the broken state and representative unchanged states. Any
additional public delta is classified independently through the knowledge
contract: existing current authority may settle it, and only absent authority uses
the unsettled owner path. The bug-fix label never bypasses API design.

For a claimed API addition or semantic behavior change, review has four stages:

1. **Inventory the public delta.** State the exact semantic before → after,
   identify the canonical owner by scope, and include supporting declarations,
   types, context fields, hook returns, defaults, and observable behavior reachable
   from supported package paths.
2. **Apply API admission.** Reject a public choice the component can derive, a
   public input whose controlled axis changes by value or input shape, hidden
   conditional precedence between parallel inputs, or a parallel public/package-
   internal operation for the same semantic action. A cohesive semantic status or
   variant may derive several visual details when its public meaning stays stable
   and disclosed. Within one module, keep one canonical operation name; another
   requires genuinely distinct caller-owned intent and contract.
3. **Apply current authority.** Follow the result and disposition owned by the
   knowledge contract. A draft is useful review context but is not policy and
   cannot clear the gate. Exact-head owner discussion or approval is decision
   evidence; an accepted decision must be committed in the canonical record as
   `current` before implementation acceptance.
4. **Review implementation correctness.** Once current authority settles the
   public contract, verify the exact implementation head, regression evidence,
   compatibility, migration, docs, and representative unchanged states.

Mechanical manifests and receipts may inventory the delta and prove which
current record was read. They are evidence only; they do not choose semantics or
assign pull-request disposition.

## API proposal checklist

Every public-facing API pull request has this minimum readable summary:

- **Owner:** link the canonical owning record and state its authority. Use the
  component spec only for component-local semantics; use the family, architecture,
  or system record when it owns the changed semantics. If the owner is draft or
  missing before this pull request, name the intended owner.
- **Semantic before → after:** state the caller-visible meaning and guarantee in
  one sentence.
- **Classification:** name `preserves`, `settled`, `violates`, `novel-human`, or
  `out-of-scope` as defined by the knowledge contract.
- **Representative syntax:** include it only when public syntax changes; the
  component `.doc.mjs` remains the complete syntax/reference authority.

Before requesting review:

- **Prove caller ownership.** Show two otherwise identical cases that need
  different outcomes, why the caller knows the difference, and why the component
  cannot derive it. This is the admission rule in
  [AST-002](../specs/AST-002/spec.md#dec-1--public-props-require-a-non-derivable-caller-distinction).
- **Keep each input's responsibility stable.** Walk the full value domain, every
  accepted input shape, and every combination with parallel inputs. The name and
  type must disclose one caller-owned meaning. A semantic status or variant may
  derive a cohesive set of visual details; reject values or shapes that switch
  the controlled axis, and reject hidden conditional precedence. Represent
  independently caller-owned axes separately and prevent invalid or conflicting
  combinations under FR15.
- **Update the semantic owner.** Update or add the canonical owning record.
  Component-local concepts belong in the component spec; shared family,
  architecture, or system concepts belong in that owner. Record inputs, options,
  defaults, invalid values and unsupported combinations, outputs, operations,
  guarantees, and relevant lifetime or resource obligations without copying
  inherited rules.
- **Prevent broken states.** Make statically knowable invalid combinations
  unrepresentable when practical. Otherwise document validation or warnings, or
  a safe fallback; do not silently render a broken state or block legitimate
  composition.
- **Check operation uniqueness.** Within one module, use the same canonical name
  for public and package-internal forms of one semantic action. The internal form
  may accept wider options; another operation requires distinct caller-owned
  intent.
- **Check the shared grammar.** Cover names, optionality, callback/Action order,
  cancellation, ref target, DOM pass-through, and whether each string axis is
  open or closed. An open axis must name and test its safe theme-independent
  fallback when no matching theme rule is active.
- **Protect compatibility.** State defaults and observable behavior. Include a
  migration for a released breaking change.
- **Ship API evidence together.** When consumer usage or a documented promise
  changes, update consumer docs, public exports, focused runtime and type tests,
  and representative integration coverage in the same pull request.

If two or three viable shapes remain after applying current conventions, use the
[API Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration)
process. Compare realistic consumer code across the relevant cases and put the
evidence in the pull request. Ask the owner to decide. A maintainer or review
agent records the final ruling in the owning spec. Keep the research procedure
out of architecture records.

## Common review smells

- A public API change has no semantic delta in its canonical owning record, so
  reviewers must infer meaning from implementation or syntax.
- One module gives the same semantic action separate public and package-internal
  operation names because an internal caller needs wider options.
- A public input controls one axis for some values or shapes and additional axes
  for others, so consumers must know implementation branches to predict output.
- Parallel inputs create a conditional override without an explicit contract for
  every combination or prevention of conflicting states.
- A prop exposes a value the component can derive from state, content, layout,
  context, or the platform.
- A high-level component accumulates tuning props or duplicates a child's state.
- Equivalent concepts use different names, such as `onChangeAction` instead of
  `changeAction`.
- An Action suppresses its callback, or handler order accidentally removes a
  promised consumer cancellation path.
- A callback is required only because all inputs were assumed to require it.
- An eligible theme-extensible visual value is a closed union, an axis opens
  without a safe theme-independent fallback, or a behavioral, structural,
  placement, directional, or state-machine axis is opened to augmentation.
- `BaseProps` is applied to a component without one stable contract element, or
  accepted DOM props never reach that element.
- `xstyle`, `className`, `style`, a ref, or an event handler is dropped or
  clobbered by spread order.
- A parent wraps slot content or mirrors props that belong to the slotted child.
- A proposal invents a nested `theme.components.button.variants` layer instead of
  using the current component target and style-key contract.
- A green docs parser is treated as full API proof. Parsing and selected drift
  checks do not prove export reachability, runtime behavior, ref targets,
  pass-through, handler composition, compatibility, or complete doc coverage.

When a current record conflicts with another current record, stop. Do not choose
by recency or specificity. Route the conflict to the canonical owner described in
the [knowledge contract](../architecture/knowledge-contracts.md#when-current-records-disagree).

---

### Contributing Cli Conventions (`docs/contributing/cli-conventions.md`)

# CLI conventions for contributors

This guide turns the CLI surface architecture (`docs/architecture/cli-surface.md`)
and the admission rules (`docs/specs/AST-042-cli-command-admission/spec.md`) into
the steps you follow when you change `packages/cli`. It does not create policy.
Where it disagrees with either record, the record wins.

## Who the CLI is for

The caller is an agent. It runs the CLI in a subprocess, reads `--json`, and
acts on the result without a person watching. A person reading the text output
is a supported reader, never the caller the design serves.

Two things follow, and they settle most arguments before they start:

- **The CLI must not impede the agent's flow.** No prompt, no confirmation, no
  question. A command that cannot finish returns an error with a code the agent
  can branch on and a suggestion it can act on.
- **The output is data first.** `--json` is the source of truth. The text output
  is a projection of the same values, produced by the formatters.

## What the CLI is for

Give an agent access to everything that helps it build with Astryx: what
components and templates exist, what they do, how they are used, what is wrong
with the code in front of it, and the utilities that change that code.

The measure of the CLI is coverage and depth of that surface, not the number of
commands on it. Most valuable work makes an existing command answer better.

## Adding a command

The bar depends on the tier (`spec:AST-042` FR4):

- **A new top-level command** needs a current system spec that authorizes it,
  approved by an approval owner (an owner listed in the knowledge schema's
  `approvalOwners`). Write the spec first.
- **A new subcommand** stays inside its parent's one job. State its case in the
  pull request, and get approval from a code owner of `packages/cli`.
  `.github/CODEOWNERS` is the source of truth for who that is.
- **A new flag** follows the flag rules below and needs no extra approval.

Propose a command before you write it: a command is a permanent concept — it
appears in help, in the manifest, in the README, and in every agent's cheat
sheet, and removing one is a breaking change.

Every command is a thin layer over one exported `api/` function: parse the
arguments, call the function, render the result. The function does the work, so
an agent that scripts the API gets the same result as one that runs the command.

A command earns its place when all four hold:

1. **It answers a question an agent actually has** while building with Astryx —
   not a function the CLI happens to be able to expose.
2. **No existing command can be deepened to answer it.** Deepening is the
   default. Reach for a new command only after naming the command you would
   have extended and saying why extending it is wrong.
3. **It is one job.** If the summary needs an "and", it is two commands.
4. **Its result is worth returning as data.** If the useful output is prose for
   a person, it is a docs topic, not a command.

A command that fails any of these is usually a flag, a subcommand, or a docs
topic.

## Adding a flag

Flags are more forgiving than commands, and they do not need a proposal, but
`spec:AST-042` FR5 makes the rules below binding. They are not free: every flag
is a branch an agent has to know about, and a combination somebody has to keep
working.

A good flag:

- **Narrows or redirects work the command already does.** It never gives the
  command a second job.
- **Has a default that is the right answer most of the time.** A flag exists to
  escape the default, not to reach the useful behaviour. If callers must pass it
  to get a sensible result, the default is wrong.
- **Is boolean-off or has a value.** A boolean flag that defaults to true is a
  mis-named opt-out; name the opt-out instead.
- **Changes what the command produces.** A flag that only changes how the same
  result is presented belongs to the global set (`--json`, `--detail`,
  `--lang`), not to your command.
- **Is not a workaround.** If the flag exists so callers can avoid a defect, fix
  the defect.
- **Has a closed composition matrix.** See below — this is the check people
  skip.

If the flag changes what the command _is_, it is a subcommand.

## The composition matrix

Before you land a flag, pair it with every flag already on that command and
decide each cell. There are only three legal answers, and every cell needs one:

1. **They compose** — and a test proves it.
2. **They are refused together** — with a clear message and a code.
3. **They cannot co-occur** — because another rule already refuses the
   combination that would reach them.

An undecided cell is the defect. It ships as behaviour nobody chose, and an
agent finds it before a person does.

## Flags with the same name

A name is a promise across the whole CLI. Two rules:

- **The same flag name means the same thing everywhere, and is spelled the same
  way.** Nothing else may take a flag name for another idea.
- **No command is forced to carry a flag because a sibling has it.** Alignment is on
  meaning, not on presence.

## Where the code goes

Copy `api/blog` and `clients/cli/commands/blog.mjs`. They are the reference
layout (`cli-surface` INV20–INV22).

- The behavior lives in `api/<subject>/`: an entry `<subject>.mjs`, one leaf
  folder per operation, a `FunctionDoc` and typedefs for each exported function,
  and tests beside the code they cover.
- Anything that touches the environment — files, the network, subprocesses,
  loading the project or running discovery — goes in the subject's
  `_adapter.mjs`. The leaves only shape the result.
- The handler in `clients/cli/commands/` only parses, calls, and renders.

## Output: use the shared functions

Never call `console.log`. Every path is provided:

| You want                    | Use                                                     |
| --------------------------- | ------------------------------------------------------- |
| A machine result            | `jsonOut({type, data, meta?})`                          |
| An error                    | `jsonError(message, suggestions, code)` / `AstryxError` |
| A heading, prose, a list    | `section()`, `text()`, `list()`                         |
| One record, or many         | `record(obj, opts)`, `records(arr, opts)`               |
| A code sample               | `code(source)`                                          |
| To print any of the above   | `emit(...blocks)`                                       |
| Chatter that JSON must hide | `humanLog()`, `humanWarn()`                             |

`emit` accepts only a renderer-produced `Block`, so a bare string will not
compile. Keep text field names identical to the JSON keys — the text output is
a view of the envelope, not a separate design. Do not pad strings, align
columns, or draw tables yourself; map rows onto `records()`. A new block kind
needs the evidence that `spec:AST-042` FR3 asks for.

## Errors: every failure carries a code

Add a code to `foundation/response/error-codes.mjs` when no existing one fits.
Codes are `ERR_<SUBJECT>[_<QUALIFIER>]`, grouped by subject, and **append-only**:
once shipped, a code is never removed and never re-meant. Reword the message
whenever it helps a reader; never make a caller match on it.

Attach `suggestions` where the agent has an obvious next move — a near-miss
name, the command that lists valid values.

## Every command ships a doc

A command is not done without its `CommandDoc` in `<name>.doc.mjs`. Fill in
`summary`, `description`, `args`, `options` (each with a description),
`examples`, `exitCodes`, `related`, and `outsideProject`: what the command does
outside a project. Help text, the README tables, and the
manifest are generated from it, so an undocumented flag is an invisible flag.

Give at least one example that an agent would actually run, including a `--json`
one.

## Global mode: every command works outside a project

People install the CLI once for their computer and run it in any folder
(`spec:AST-081`). So every command also has to work, or explain itself, in a
folder that is not a project, and its docs have to say which.

1. **Declare `outsideProject`** in the command doc: `same` (it answers as it
   would in a new project), `refuse` (it writes nothing and fails before doing
   any work, naming what it needs), or `own` (it does something else). A
   command that only groups subcommands declares nothing.
   `clients/cli/commands/outside-project.test.mjs` runs every command in an
   empty folder and checks what it does against this value, and fails on a
   command without one; an `own` command needs a test of its own there.
2. **Add the note, for readers.** Every `refuse` and every `own` command
   carries one prose note that starts `In global mode:`, says exactly what it
   does outside a project (the error code and message it stops with, or what
   it does instead), and links `{@link namespace:global}`.
   `test/global-mode-notes.test.mjs` fails on a missing note, and
   `test/global-mode-docs.test.mjs` checks that the error code a note names is
   the one the command stops with. An API function whose result depends on
   `cwd` being in a project carries the same sentence in its description.
3. **Add a who-answers row** when a change touches which copy of the CLI
   answers (an install layout, a door, a hand-off rule): one row in the global
   and project table of `spec:AST-081` and its test in
   `packages/cli/test/global-local.test.mjs`, named with the row's number
   (`G12 …`); a test there fails on a gap in the row numbers.
4. **Quote, never paraphrase, what the CLI prints.** A terminal line shown on a
   global-mode page must be one the CLI prints; `test/global-mode-docs.test.mjs`
   builds those lines from the CLI's own strings.

The user-facing explanation lives in `astryx docs global`.

## Every integration item ships a typed doc

A discoverable contribution is not done without a strongly typed
`<source-stem>.doc.mjs` beside its source or payload. Export the descriptor type
from `@astryxdesign/cli/authoring`, annotate the descriptor with that type, and
use the kind's canonical typed export. New descriptor kinds use the stamped
default export. The descriptor is the only place for item metadata. The root
integration manifest points at directories; it does not enumerate the items
inside them.

When an item spans several files, define one confined item-local ownership
boundary and make discovery, materialization, and pack verification enumerate the
same complete set. Never repeat source identity in the descriptor when the
same-stem pair proves it.

Never catalog items centrally, for any kind: no file under a root that lists its
items (like `themes/manifest.json`), and no per-item map or list in the
integration manifest (like a map of template replacements). Per-item options
such as `replaces` go in the item's own `.doc.mjs`. Adding one item changes no
file its siblings share, apart from declaring a new root. 0.6 shipped a theme
catalog and had to remove it as a breaking change; do not repeat it.

Released alternate readers may remain for compatibility, but new writers,
examples, and contribution kinds use `.doc.mjs`. See `spec:AST-039` for the
compatibility transition and the mandatory theme descriptor contract.

## Marking work in progress

Some of the surface is not finished, and today nothing on it says so. Callers
cannot tell a settled command from one still being shaped.

When you land a command or subcommand that is not ready to be depended on, mark
it, and say in the doc what is still expected to change. Treat an unmarked
command as stable: changing its output shape or its flags is then a breaking
change.

## Checklist before you open the pull request

- [ ] For a new top-level command: a current spec authorizes it. For a new
      subcommand: a code owner approved its case.
- [ ] The handler only parses, calls one exported `api/` function, and renders;
      the subject's adapter does any file, network, or project access.
- [ ] One file per command, with its sibling doc file.
- [ ] Every new integration item has a typed, same-stem `.doc.mjs`; the root
      manifest only locates its directory. No catalog file, no per-item map.
- [ ] `--json` returns one envelope; the `type` matches the API function.
- [ ] Every failure path carries a code; new codes are appended, never edited.
- [ ] No `console.log`; all human output goes through the formatters, with no
      hand-padded columns.
- [ ] Text field names match the JSON keys.
- [ ] Exit code is the same with and without `--json`.
- [ ] The composition matrix is closed: every pair composes with a test, is
      refused with a message, or cannot co-occur.
- [ ] Any path you write passes through `assertWithin`.
- [ ] The doc lists the new flag, an example, and the exit codes.
- [ ] The doc declares `outsideProject` (`same`, `refuse`, or `own`), and a test
      run outside a project matches it.
- [ ] Marked as work in progress if it is not ready to be depended on.
- [ ] The command doc declares `outsideProject`; a value other than `same`
      has its `In global mode:` note and a test of what it does outside a
      project.

## Common review smells

- **An item catalog below an integration root, or a per-item map in the
  manifest.** Put metadata and options in the typed, same-stem `.doc.mjs`; the
  root manifest points at the directory.
- **A flag that only a maintainer would pass.** It is a debugging affordance;
  keep it out of the surface.
- **A new command whose summary contains "and".** Two commands.
- **A handler that reads files or loads the project.** That work belongs in the
  API subject's adapter.
- **A flag added because another command has one.** Presence does not have to
  align; meaning does.
- **A composition cell nobody decided.** The most common defect in a flag PR.
- **A code invented at the call site.** Codes live in one frozen table.
- **Text output built with string concatenation.** It will drift from the JSON
  within one release.
- **`--json` output that omits what the text output shows.** The text is the
  projection; it cannot be the richer of the two.
- **An error message that tells the agent to "check your configuration".** Name
  the file, the key, and the expected value, or give a suggestion.
- **A flag whose default is the unhelpful answer.** Agents will not discover it.

---

### Contributing Pull Requests (`docs/contributing/pull-requests.md`)

# Pull requests: one primary intent

A pull request should make one reviewable change for one reason. Choose the template that matches its primary intent; supporting tests and docs belong with that intent, but unrelated API, visual, layout, behavior, or policy changes do not.

GitHub uses the default template automatically. To choose another, add `?template=<file>.md` to the new-pull-request URL, or copy the matching file from `.github/PULL_REQUEST_TEMPLATE/` into the description.

Any pull request that creates or changes a knowledge record — a `docs/specs/<id>/spec.md`, a family, design, theme, or architecture record, or a colocated `<Name>.spec.md` — reports the results of the rubric in [Writing specification records](spec-writing.md) in its description, whichever template it uses. `specification.md` carries the `## Writing rubric results` section; copy it into another template when that pull request also touches a record.

| Primary intent                                       | Template           | Minimum evidence                                                                                                                 |
| ---------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| Restore broken behavior                              | `bug-fix.md`       | reproduction, expected authority, before/after result, unchanged representative path                                             |
| Correct or intentionally change appearance           | `visual-update.md` | current visual authority or owner decision, real-browser before/after pixels, relevant state matrix                              |
| Add a capability                                     | `new-feature.md`   | user need, current specification, complete public delta, behavior and compatibility evidence                                     |
| Correct contributor or consumer documentation        | `documentation.md` | reader impact, source of truth, rendered or generated result                                                                     |
| Record a durable decision                            | `specification.md` | exact unresolved claim, existing authority searched, decision, rejected alternatives, explicit non-goals, writing rubric results |
| Change tooling, tests, CI, or repository maintenance | `maintenance.md`   | operational problem, failure proof, success proof, unchanged product behavior                                                    |
| Anything else                                        | `other.md`         | primary intent, user or maintainer impact, authority, evidence                                                                   |

## Explain only what authority does not already say

The pull request and current records are the first source for context. Do not copy
them into another summary. Fill only what is missing:

- Trace the problem through three why questions: why it occurs, why it harms the
  affected task, and why that harm matters.
- Map the proposed solution and every primary or supporting delta back to that
  problem. Remove or split a tagalong that does not clearly contribute.
- State who is affected, in what supported state, and what behavior the change
  enables or prevents.
- When public API changes, show representative before and after callsites,
  defaults and compatibility, and every additional decision the caller must make.

These explanations help reviewers apply current authority; they do not create it.
Product behavior remains owned by the applicable current component, module,
family, design, theme, architecture, or system record. API caller burden follows
`spec:AST-002`.

Reviews also load any `current` cross-cutting claims whose machine-readable global
route matches the change's semantic triggers. The direct component, module, or
family owner still resolves the exact delta first; a global route contributes only
its listed claim and never grants authority to the rest of that record. Review
receipts identify the matched record, claim, trigger, and reason.

## Keep the intent atomic

Before requesting review, partition every observable delta:

- **Primary:** the reason this pull request exists.
- **Supporting:** evidence or documentation required to make the primary change complete.
- **Tagalong:** independently removable behavior, visual, API, layout, policy, or cleanup.

Remove or split tagalongs. A bug fix does not become a feature because a nearby affordance could also improve. A feature does not absorb unrelated cleanup because the same file is open.

## Reviews contract scope; they do not expand it

The opening primary intent sets the scope ceiling. The first submitted
implementation is a baseline for detecting later review-driven growth, not approval
for its tagalongs. A review request may make that intent correct and safe, require
evidence it needs, or remove/split a delta already in the pull request. It must not make a narrow
change absorb adjacent policy, migrations, sibling conformance, new API, or cleanup
merely because review discovered them.

If an earlier review caused the pull request to grow, the next pass should unwind or
split those additions rather than treating them as the new primary intent. Broader
work stays only when the original change cannot be correct or safe without it; then
request the smallest safe correction or a narrower mechanism.

## Use specifications last, not first

Review should produce the smallest ideal change that can land:

1. Apply current component, module, family, design, theme, architecture, and system authority to each delta.
2. If a delta violates current authority, conform or remove it. Do not propose changing the specification merely to rescue the implementation.
3. If a delta is unsettled but separable, remove or split it and keep reviewing the primary intent.
4. Propose a specification only for a surviving, intentional durable decision the team wants to pursue.

A current record governs only its explicit claims inside its ownership boundary. It does not imply that the whole named component or module is fully specified.

## Visual corrections

A visual change does not automatically need a new specification. It may proceed as a correction when current component, family, design, theme, or objective accessibility authority already settles the exact outcome. Show that authority and verify the affected state with real-browser pixels, plus representative unchanged states.

For routine component-local or module-local intent, cite and reuse an existing
`DD` row when one applies. A team DESIGNOWNER may amend or add the optional
`Design decisions` table in the component/module spec in the same atomic
appearance-only pull request as the pixels and evidence. Add a row only for
stable intent a future redesign must reconsider; exact pixel tuning stays in
code and evidence, and shared or cross-component rules stay in `docs/design/`.

External contributors keep the normal contribution flow and are never asked to
author specifications. If authority is missing, a maintainer records the owner
ruling. A human request supplies intent, but it becomes current committed
authority only after exact-head owner approval.

A new visual representation, subjective direction, or interaction model remains a design decision. Put a shared decision in its own design specification; keep a durable component-local decision in the owning component/module table.

## Review evidence

[Knowledge contracts: review evidence placement](../architecture/knowledge-contracts.md#review-evidence-placement)
owns where one-off evidence lives and how cleanup preserves historical links.
Attach manual screenshots and their receipts to the pull request, or link a
public retained copy outside the implementation branch. Keep reusable fixtures,
accepted baselines, and media used by public documentation in their owning source
paths. Evidence storage does not relax the matching template's evidence bar.

## Public repository boundary

Descriptions, screenshots, commits, tests, and specification records are public. Do not include internal links, identifiers, hostnames, tools, or operational context.

---

### Contributing Spec Writing (`docs/contributing/spec-writing.md`)

# Writing specification records

A specification record describes the system as it is meant to be. It is read by
someone deciding whether an implementation conforms, long after the pull
requests that shaped it are closed. Every sentence in a record has to survive
that reader.

This guide applies to every knowledge record that states what the system does:
`docs/specs/<id>/spec.md`, family, design, theme, and architecture records, and
colocated `<Name>.spec.md` component and module contracts. An implementation
plan (`plan.md`) tracks work and is outside it. It projects
`architecture:knowledge-contracts` (INV2 and INV10) and the templates under
`docs/templates/knowledge/` into a pass an author runs and a reviewer checks. It
does not create policy; when it disagrees with a current record, the record wins
and the guide needs fixing.

## The test

> In six months, when every pull request mentioned in this record is closed and
> forgotten, does this sentence still say something true about the system?

A sentence that passes describes behavior, a boundary, a default, an owner, or a
piece of evidence. A sentence that fails describes the project: what a change
proposed, what the team considered, what the owner ruled against, what used to
be the case. That is narration. It was true the day it was written and it ages
into noise that a future reader has to step around to find the rule.

## The rubric

Run the items in order. The order is how often each one goes wrong, not where it
sits in the template. Each item is a question with a yes-or-no answer that a
reviewer can check by reading the record; a `no` is a pass. Report what the pass
found, not a tick.

| ID  | Question                                                                                                                                                                                              | When the answer is yes                                                                                                                                                        |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1  | Does any sentence describe what a change proposed, decided, replaced, ported, or used to do, rather than what the system does? (Scan for `proposes`, `we decided`, `previously`, `a port of`, `now`.) | Rewrite it in the present tense as a statement about the system, or delete it.                                                                                                |
| R2  | Does the record contain research: a survey of other systems, an options comparison, a benchmark run, or a test's design and scores?                                                                   | Delete it from the record. Publish it as an artifact linked from the pull request. Keep the conclusion it supports as one or two sentences inside the decision resting on it. |
| R3  | Does any evidence, basis, or verification cell name a pull request, issue, or commit instead of the fact it established?                                                                              | Replace the reference with the fact: `real-device touch feedback`, `current source`, `WAI-ARIA 1.2 combobox pattern`.                                                         |
| R4  | Is any rejected alternative longer than one line, or recorded anywhere other than inside the decision that rejects it?                                                                                | Collapse it to one `Rejected:` line in that decision. Keep the line only when naming the alternative prevents the mistake recurring; otherwise delete it.                     |
| R5  | Searching for `#` followed by digits, `/pull/`, and `/issues/`: does any pull request or issue remain as standing content?                                                                            | Delete it, or replace it with a link to the record that owns the fact. Links to other records are encouraged; they are canonical owners.                                      |
| R6  | Does any open question whose answer is already recorded still list its options?                                                                                                                       | Collapse the options into the decision. Only an unanswered question keeps its options side by side.                                                                           |

Report one line per item in the pull request description, under
`## Writing rubric results`. State what was found and what was done with it:

```text
R1 narration: Intent opened with the proposal history; rewritten as two present-tense sentences.
R2 research: none in the record; the comparison lives in a linked artifact.
R3 evidence: FR4 basis cited a pull request; now "real-device touch feedback".
R4 rejected alternatives: one, DEC-2, one line.
R5 change references: none.
R6 open questions: OQ1 is unanswered and keeps its two options.
```

A finding is not a defect in the author. "Two found, moved into DEC-3 as one
line" is a clean result. A record whose results all read `none` on the first
pass is rarer than one that found something, and a reviewer reads it with that
in mind.

## Why each item

### R1 — State the system, not the project

A record is a description of behavior that happens to have a history, not a
history that happens to describe behavior. Present tense, declarative, what the
thing does. "The pull request proposes", "we decided to change", "previously
this was", and "a port of" all describe the project. They tell the reader how
the sentence came to exist and nothing about what the system does.

Narration is the most common failure because it is the natural way to write
while the decision is fresh. The fix is mechanical: find the behavior the
sentence is circling and state it.

Narration:

> An open pull request proposed revealing row actions on hover. The owner ruled
> against it, so this record settles that actions are always visible.

The same fact as a record sentence:

> Row actions are always visible. They do not depend on hover or focus.

The second version is shorter, needs no context, and is still true when the pull
request is forgotten.

### R2 — Research is an artifact

A competitive survey, an options comparison, a benchmark, or the design and
scores of a test are how a decision was reached. They are valuable and they
belong somewhere durable, which is an artifact linked from the pull request. A
record states the conclusion the research supports, in a sentence or two inside
the decision that rests on it, with a citation only where a reader might
reasonably doubt the claim.

Research in a record fails the six-month test twice. The comparison stops being
true as the surveyed systems change, and the reader has to find the decision
inside a table of alternatives the decision already closed. A record also reads
as though the decision was always the decision; a survey reads as a debate.

The template's own line about audit data is the same rule applied to
measurements: "Current measurements belong in the audit record; this subsection
owns only durable constraints and their verification target."

### R3 — Evidence cites facts, not changes

An evidence cell answers "how do we know?" A pull request number does not
answer it; it points at a place where the answer once lived. "#NNNN's device
test" tells a future reader nothing once the pull request is closed, and tells
the present reader only that someone tested something somewhere. "Real-device
touch feedback" says what the evidence is.

The same applies to a basis column in a draft requirement table. `proposal` is a
valid basis while a row is unsettled; a pull request number in its place is not,
because the basis of a rule is a reason, and a change is not a reason.

### R4 — Rejected alternatives: one line, inside the decision

The record describes the specific behavior wanted, not every option considered.
A rejected alternative earns one line, inside the decision that rejects it, when
naming it prevents the same mistake from being proposed again. That is a
guardrail, not a history.

A rejected alternative that takes a paragraph is research (R2). A rejected
alternative in the Intent section is narration (R1). A rejected alternative that
no one would propose again is noise; delete it.

> **DEC-2 — Actions are caller-rendered nodes.** The caller supplies the control
> and owns its handler; the component places it and never inspects it.
> Rejected: a declared `{label, icon, onClick}` object, because the component
> would then render a control it does not own.

### R5 — No pull request or issue as standing content

A record outlives every change that touched it. A pull request referenced in
the body is a dependency on something that will close, merge, or be superseded,
and the sentence that references it fails the six-month test by construction.
`architecture:knowledge-contracts` INV10 states the rule: pull requests and
issues are not standing content, a record states the fact a change established
rather than the change, and it never exists to approve, reject, classify, or
authorize one.

Links to other records are the opposite case and are encouraged. A record is a
canonical owner; linking `component:Selector/FR3` or `spec:AST-002/DEC-1`
delegates a fact to the place that owns it, which is exactly what INV2 asks for.

Checking this item is a text search, which is why it is phrased as one.

### R6 — Open questions are the exception

An unanswered question genuinely has options, and the owner needs them side by
side to rule. An open question may therefore list its alternatives, with the
consequence of each. Once ruled, the alternatives collapse: the chosen one
becomes the behavior, and the rejected ones become at most one line inside the
decision (R4). A ruled question that still carries its options is a record
describing a debate that is over.

## Where the pass happens

The author runs the rubric before requesting review and reports the results in
the pull request description. The reviewer reads the record against the
results: an item reported `none` with a finding still in the record is a review
comment, and a record pull request with no results section is incomplete before
review starts. This applies to any pull request that creates or changes a
record, under whichever pull request template it uses; `specification.md`
carries the results section, and other templates add it when they touch a
record.

The templates under `docs/templates/knowledge/` carry a one-line reminder at the
sections where these failures usually land (Intent, evidence, decision log, open
questions) and point here. The reminder reaches an author editing the file; the
results section reaches the reviewer. Neither replaces the other.

## What is checked mechanically

`pnpm check:knowledge` validates record structure: frontmatter, required
sections, schema and template versions, relationship links, and metadata blocks.
It does not check any rubric item, by design:

- Whether a sentence is narration (R1), research (R2), or a ruled question (R6)
  is a judgement about meaning, and a check that guesses at it would be argued
  with and then ignored.
- A pull request reference (R3, R5) is detectable, but records approved before
  this rule carry references that were permitted when they were written, and
  the file cannot tell which of those a future edit is responsible for. A hard
  check would fail every one of them; a grandfather list would drift, which is
  the failure this guide exists to stop. The reviewer's text search is the
  honest check, and the rubric applies to the records a pull request touches.

Rubric results in the pull request description are a convention backed by
review, not a status check.

---

### Design Agentic States (`docs/design/agentic-states.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:agentic-states
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, accessibility]
verified_by: []
architecture: [architecture:theme-tokens]
components: []
families: []
deciding_specs: []
---

# Agentic states design specification

## User intent

People working with an agent should understand whether it is progressing,
waiting, inspecting, synchronizing, or presenting a result without learning a
second unrelated state language. Agent feedback should communicate actionable
system status, not expose private or hidden reasoning.

This seed record names the unresolved design surface. It intentionally does not
approve visual treatments for individual agent states.

## Design principles

- **DR1 — Extend established state language first.** Agentic states SHOULD reuse
  representations from `design:user-states` and `design:system-states` when the
  underlying intention is the same.

No additional agentic-state design requirement is approved in this seed draft.
The questions below identify areas that still require human decisions.

## Anatomy and hierarchy

The wiki does not define agentic-state anatomy or relationships. Those remain open
for a future proposal rather than being inferred by this seed draft.

## State representation

No agentic-state visual representation is approved in this seed draft.
Candidate treatments must first show why existing loading, processing, status,
selection, or temporal-overlay language is insufficient.

## Responsive and input behavior

No agentic-state responsive or input requirement is approved. Candidate
proposals must address attribution under reflow, incremental output, interruption,
and reduced motion as part of the relevant open question.

## Accessibility intent

Accessibility requirements remain unresolved with the visual treatments. Each
proposal should evaluate non-motion and non-color alternatives, announcement
frequency, focus stability, reading position, and input operability without
assuming an answer in this draft.

This record does not require disclosure of hidden chain-of-thought. Any
user-visible rationale is product content and must follow its own privacy,
safety, and content contracts.

## Representative examples

No representation is normative yet. Candidate examples should compare reuse of
existing system-state feedback with any proposed agent-specific treatment and explain
what user need the new treatment serves.

## Visual references

No normative visual assets are included. Candidate evidence should be added only
after an open question below receives a proposed treatment.

## Component contract links

No component contract links are asserted. Agent-facing components should not
claim adoption until individual state representations are decided and approved.

## Decision log

No agentic-state visual decisions have been approved. The public Design
Conventions wiki explicitly identified this area as mostly undefined; this draft
preserves that uncertainty rather than converting labels into policy.

## Open questions

- **OQ1 — Thinking or processing.** When does agent work need a representation
  distinct from ordinary system processing?
- **OQ2 — User-visible rationale.** What treatment, if any, distinguishes an
  intentionally authored explanation from ordinary output without implying
  access to hidden reasoning?
- **OQ3 — Streaming.** How should incremental text remain visibly incomplete
  without distracting from text already available?
- **OQ4 — Tool execution.** What information about backend work is useful to the
  person, and when should it remain collapsed?
- **OQ5 — Awaiting input.** How should a required human response outrank passive
  progress while preserving the surrounding task context?
- **OQ6 — Synchronizing and synchronized.** How should pending and completed
  synchronization differ from generic processing and success?
- **OQ7 — Inspecting.** Does agent inspection need a distinct state, or is
  ordinary progress plus scoped context sufficient?
- **OQ8 — Rendering.** How should generated UI communicate partial mounting and
  completion without exposing implementation churn?

## Content boundary

This file frames unresolved human-facing agentic states. It does not define agent
protocols, hidden reasoning disclosure, tool telemetry, progress-event schemas,
product copy, implementation mechanics, or approved visual treatments.

---

### Design Bidirectionality (`docs/design/bidirectionality.md`)

---
schema_version: 1
template_version: 2
kind: design
id: design:bidirectionality
authority: current
archive_reason: null
superseded_by: null
approved_by: nynexman4464
approved_at: 2026-10-15
owners: [nynexman4464, cixzhang, rubyycheung]
review_triggers: [visual, interaction, accessibility]
verified_by:
  [
    apps/storybook/rtl-audit/rtl-audit.mjs,
    apps/storybook/rtl-audit/targets.json,
    internal/eslint-plugin-astryx/no-physical-properties.test.mjs,
  ]
architecture:
  [architecture:internationalization, architecture:rtl-audit-evidence]
components: []
families: []
deciding_specs: [spec:AST-020]
---

# Bidirectionality design specification



```json
{
  "scope": "global",
  "triggers": {
    "accessibility": ["DR1", "DR2", "DR8", "DR10", "DR11", "DR12"],
    "interaction": ["DR1", "DR6", "DR8", "DR10", "DR11", "DR12", "DR13"],
    "layering": ["DR7", "DR10"],
    "layout": ["DR1", "DR2", "DR3", "DR7", "DR9", "DR10"],
    "navigation": ["DR1", "DR3", "DR4", "DR5", "DR6", "DR10", "DR11"],
    "react-runtime": ["DR9"],
    "responsive": ["DR1", "DR3", "DR10", "DR14"],
    "scrolling": ["DR1", "DR6", "DR10", "DR13"],
    "styling": ["DR3", "DR4", "DR5", "DR9", "DR10"],
    "visual": ["DR1", "DR4", "DR5", "DR10"]
  }
}
```

## User intent

People should understand and operate the same Astryx interface whether their
page, region, or content flows left-to-right or right-to-left. RTL support is not
a mechanical reflection of every pixel. It preserves meaning, reading order,
spatial relationships, target size, and interaction behavior for the active
writing direction.

## Design principles

- **DR1 — Preserve meaning, not coordinates.** An RTL presentation MUST preserve
  the same task meaning and affordance relationships as LTR. A physical left/right
  implementation detail is not a design requirement unless the content itself is
  physical.
- **DR2 — Content direction and layout direction are distinct.** A page or region
  has an ambient direction, while text, code, URLs, identifiers, and user content
  may carry their own direction. Mixed-direction content MUST retain readable
  character order and punctuation without moving to a different semantic slot.
- **DR3 — Reading-order relationships follow inline start and end.** Navigation,
  ordered progression, primary/supporting lanes, leading/trailing content, and
  start/end alignment MUST follow the containing region's direction.
- **DR4 — Directional artwork is handled exactly once.** A horizontal icon or
  decorative glyph whose meaning is previous, next, back, forward, start, end, or
  progression MUST mirror or swap once. Artwork that the Unicode bidi algorithm
  already mirrors MUST NOT receive a second manual mirror.
- **DR5 — Direction-neutral and physical content stays stable.** Vertical,
  symmetric, brand, media, chart, map, clock, hardware, and other physically
  meaningful content MUST remain unchanged unless its specific semantic role is
  directional. A reference sample that exposes caller-supplied left/right glyph
  geometry as data preserves that geometry; the same glyph used as a previous,
  next, or progression cue follows DR4 instead.
- **DR6 — Interaction follows the visual relationship.** Horizontal keyboard
  movement, drag/resize math, carousel or scroll progression, range navigation,
  and side placement MUST produce the action indicated by the RTL visual state.
- **DR7 — Overlays stay attached to their logical owner.** Menus, dialogs,
  tooltips, popovers, drawers, and other layered surfaces MUST preserve their
  start/end alignment and relationship to the trigger in the trigger's region.
- **DR8 — Hit targets follow visible controls.** A pointer or touch target MUST
  remain centered on the visible control in both directions and MUST preserve the
  component's required target size. Mirroring MUST NOT displace, shrink, or make
  only part of the visible control clickable.
- **DR9 — Direction changes must not flash the wrong relationship.** Server and
  first-paint output SHOULD agree with the document or region direction. A
  direction-dependent relationship MUST NOT wait for a post-render JavaScript
  correction when platform layout can express it.
- **DR10 — Exceptions require semantics, not convenience.** A component may keep a
  physical orientation only when that orientation carries stable user meaning.
  Implementation difficulty, current LTR behavior, or an all-N/A audit does not
  establish an exception.

## Anatomy and hierarchy

Bidirectionality applies to relationships rather than one shared DOM anatomy.

| Role                      | Purpose                                                            | Required relationship                                                                     |
| ------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| Ambient region            | Establishes layout and browser text-flow direction                 | Descendants follow its `dir` unless a nested content region establishes another direction |
| Ordered sequence          | Communicates progression or reading order                          | First/previous/start and last/next/end exchange physical sides under RTL                  |
| Directional control       | Moves, reveals, or points along the inline axis                    | Artwork and behavior agree after exactly one directional treatment                        |
| Directional decoration    | Communicates hierarchy or progression without interactive behavior | Mirrors exactly once when its UI role is directional                                      |
| Direction-neutral content | Carries visual meaning independent of inline direction             | Retains its orientation                                                                   |
| Interactive target        | Receives pointer, touch, or keyboard input                         | Remains aligned with the visible affordance and preserves required size                   |
| Nested bidi content       | Carries text whose direction differs from its surrounding region   | Preserves its own character order, punctuation, and readable isolation                    |

## State representation

| State                                           | Required representation                                                                      | Allowed variation                                          |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| LTR region                                      | Inline progression begins at the left and ends at the right                                  | Component-specific spacing and artwork                     |
| RTL region                                      | Inline progression begins at the right and ends at the left                                  | Component-specific spacing and artwork                     |
| Explicit direction override                     | The chosen direction governs the subtree even when locale normally implies another direction | Test and product reasons for the override                  |
| Nested opposite-direction region                | Local content flow changes without moving the region to another semantic slot                | Alignment chosen by the owning layout contract             |
| Mixed text, code, URL, or identifier            | Character order and ending punctuation remain readable                                       | Browser bidi behavior plus explicit isolation where needed |
| Manual-mirror glyph or icon                     | One horizontal mirror or one paired-glyph swap                                               | CSS transform or semantic glyph pair                       |
| Unicode bidi-mirrored glyph                     | Browser mirrors the unchanged glyph                                                          | Exact glyph choice                                         |
| Vertical, symmetric, brand, or physical artwork | Orientation remains unchanged                                                                | Scale, color, and theme treatment                          |
| Coarse-pointer control                          | Target stays centered and meets the component's minimum target size                          | Invisible hit-area shape beyond that minimum               |

## Responsive and input behavior

- **DR11 — Keyboard direction is resolved at interaction time.** Left/right
  behavior MUST follow the current rendered region, including nested regions and
  runtime direction changes. Labels remain semantic (`previous`, `next`, `start`,
  `end`) rather than becoming physical left/right instructions.
- **DR12 — Pointer and touch geometry is direction-invariant in quality.** The
  visible control, native input, and effective hit area MUST share a center within
  the component's stated tolerance in LTR and RTL, and MUST preserve the owning
  component's stated minimum target size. Proving centering alone is insufficient.
  WCAG 2.5.8 target-size ownership and exceptions remain with AST-020 and the
  component/composition contract.
- **DR13 — Drag, resize, and scroll outcomes mirror.** A gesture toward logical
  next/end MUST move toward logical next/end in both directions. Physical deltas
  may invert while the user-visible action remains equivalent.
- **DR14 — Responsive reflow preserves logical ownership.** Moving content into a
  drawer, sheet, overflow menu, or alternate layout MUST preserve which content is
  leading/trailing and how focus returns to its logical trigger.

## Accessibility intent

- Accessible names describe semantic actions, not physical icon direction. Use
  "Previous month" and "Next month," not "Left arrow" and "Right arrow."
- DOM `dir` must describe the browser's actual region direction so text flow,
  punctuation, native control behavior, focus/navigation order, and accessibility
  APIs agree with the visual presentation.
- Mixed-direction user content needs an explicit local direction or isolation when
  browser inference cannot keep weak punctuation, numbers, or URLs readable.
- RTL support must preserve focus visibility, keyboard reachability, target size,
  and announcement meaning. A mirrored picture with displaced focus or touch
  geometry is not equivalent behavior.
- Automated all-N/A output is not evidence that a component is RTL-ready. The
  evidence policy is owned separately by RTL audit architecture.

## Representative examples

| Example                                         | Required outcome                                                                                                                   |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Breadcrumb slash between hierarchy items        | Mirrors as progression decoration; `/` in a date, path, fraction, or prose does not                                                |
| Calendar previous/next buttons                  | Buttons exchange sides, chevrons point toward their action, and semantic labels stay previous/next                                 |
| Checkbox, Radio, and Switch on a coarse pointer | Native input remains centered over the visible control and preserves the owning component's minimum target size in both directions |
| Carousel next action                            | Control placement, icon, and scroll delta agree on logical next                                                                    |
| Arabic sentence containing a URL                | Arabic text flows RTL while the URL remains readable LTR and punctuation stays with the intended content                           |
| Raw glyph registry reference sample             | Caller-supplied left/right geometry remains unchanged as data; no directional-control behavior is implied                          |
| Brand logo or photograph                        | Does not mirror merely because the surrounding layout is RTL                                                                       |

## Visual references

No normative screenshots are required for this record. Repo-owned component
stories and relationship-based LTR/RTL audit output provide implementation
evidence; they do not replace the human intent in this record.

## Component contract links

No component record currently links this record. The explicit global routes
supply the matched RTL relationships to component and module reviews without
requiring a backlink from each component. A visual change loads
artwork meaning and exceptions; navigation, interaction, and scrolling load their
own directional outcomes together with the semantic-exception boundary. The routes
do not make every glyph directional, require a
particular implementation, or settle the open questions below.

Component, module, and family contracts own their local semantic relationships:
which artwork is a navigation cue versus data, which parts are leading/trailing,
which keyboard/scroll actions they expose, supported states, target-size
requirements, and the tests that prove those outcomes. They cite a specific shared
DR when a local requirement or exception needs that relationship; they do not
copy a universal RTL checklist or redefine the shared rules.

Review resolves the direct owner for the exact delta and applies only the matched
shared claims within their stated scope. Local exceptions require a consistent
semantic reason under DR5/DR10, not an implicit override of current authority.
Runtime/helper responsibility remains in `architecture:internationalization`;
RTL audit evidence and deliberate D1 exemption review remain in
`architecture:rtl-audit-evidence`.

## Decision log

### DEC-1 — Adopt DR1–DR14 as the shared bidirectional relationships

**Reference:** `design:bidirectionality/DEC-1`
**Decider:** `nynexman4464`, `2026-10-15`

People reading in either direction get the same task meaning, reading order,
and interaction outcomes when components follow DR1–DR14. Audit admission and
implementation evidence remain with their separate owners, and the open
questions below stay undecided.

## Open questions

- **OQ1 — Physical-orientation exception vocabulary.** Do components need a
  shared explicit way to document physical/world-oriented artwork exceptions, or
  are component-local decisions sufficient?
- **OQ3 — Mixed-content fixtures.** Which public-safe mixed Arabic/Hebrew plus
  URL/number/punctuation fixtures should become required representative stories?
- **OQ4 — Direction-change transitions.** Should runtime `dir` changes have an
  explicit no-animation rule, or may components animate when the resulting
  intermediate relationships remain understandable?

## Content boundary

This file does not define provider APIs, CSS or hook implementation, lint rules,
current audit scores, verified-N/A admission, or consumer examples. It links to
those owners.

---

### Design Color Emphasis (`docs/design/color-emphasis.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:color-emphasis
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, theming, accessibility]
verified_by: []
architecture:
  [
    architecture:theme-tokens,
    architecture:theme-authoring-contract,
    architecture:component-theming-surface,
  ]
components: []
families: []
deciding_specs: []
---

# Color emphasis design specification

## User intent

People should find the primary action and understand surface and status roles
without decoding arbitrary colors. Color should preserve legibility and hierarchy
across supported modes and themes.

## Design principles

- **DR1 — Foreground and background are one decision.** A color choice MUST be
  evaluated in the context of the surface it sits on.
- **DR2 — Neutral color follows semantic role.** Interaction overlays, tracks,
  content containers, and self-contained elements MUST remain distinguishable
  even when their neutral values look similar.
- **DR3 — Emphasis remains scarce.** A local action group SHOULD expose one clear
  primary emphasis rather than making every action compete equally.
- **DR4 — Status follows its canonical feedback contract.** Color emphasis MUST
  preserve the non-color cues and prominence defined by
  `design:system-states` rather than inventing another status treatment.
- **DR5 — Interaction overlays preserve context.** Hover and press treatment
  SHOULD visually combine with the underlying surface rather than replace it with
  an unrelated opaque block.

## Anatomy and hierarchy

| Role             | Purpose                                              | Required relationship                                       |
| ---------------- | ---------------------------------------------------- | ----------------------------------------------------------- |
| base surface     | Establishes page, container, or element context      | Determines the meaning and legibility of foreground choices |
| foreground       | Carries text, icon, or boundary information          | Remains legible against its paired surface                  |
| interaction tint | Communicates transient response                      | Preserves the identity of the underlying surface            |
| primary accent   | Directs attention to the most important local action | Remains visually stronger than peer actions                 |

## State representation

Rest, hover, press, focus, selected, and disabled states MUST remain perceptually
distinct in every supported mode. Status representation is owned by
`design:system-states`. Supporting and disabled text MUST not become
indistinguishable merely because both are quieter than body text.

## Responsive and input behavior

- **DR6 — Emphasis survives density.** When actions collapse or move, the primary
  action MUST remain identifiable without amplifying every remaining control.
- **DR7 — Hover color is optional feedback.** Touch and keyboard paths MUST retain
  state meaning without depending on hover tint.

## Accessibility intent

Foreground/background pairs must remain legible across supported modes. Color
emphasis must not weaken the non-color status cues owned by
`design:system-states`. Exact thresholds and mechanical contrast
verification remain accessibility and audit responsibilities.

## Representative examples

- A toolbar exposes one primary action while secondary and destructive actions
  remain clear without sharing the same emphasis.

## Visual references

No normative assets are included. Action hierarchy and semantic-neutral examples
should be added under `docs/design/assets/color-emphasis/` before promotion.
Status examples remain with `design:system-states`.

## Component contract links

No component links are asserted in this seed draft.

## Decision log

No repository design decision has approved this record yet. It distills color
role and emphasis intent from the public Design Conventions wiki while delegating
token vocabulary and theme mechanics to architecture.

## Open questions

- **OQ1 — Primary-action scope.** Which containers reset the local primary-action
  hierarchy?
- **OQ2 — Representative themes.** Which themes and modes form the minimum
  evidence set for role preservation?

## Content boundary

This file defines color role and emphasis. It does not define token names, raw
values, contrast thresholds, theme APIs, component props, or audit checks.

---

### Design Control Rhythm (`docs/design/control-rhythm.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:control-rhythm
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, layout, accessibility]
verified_by: []
architecture: [architecture:theme-tokens]
components: []
families: [family:input-fields]
deciding_specs: []
---

# Control rhythm design specification

## User intent

Mixed controls should feel intentionally composed rather than assembled from
unrelated sizing systems. Visual compactness should not make an interaction
unreasonably difficult to target.

## Design principles

- **DR1 — Mixed controls align.** Fixed-height and content-sized controls used in
  one row MUST share an intentional baseline and apparent height.
- **DR2 — Size and density are tuned together.** Authors MUST evaluate outer size
  and internal padding as one visual rhythm rather than independent settings.
- **DR3 — Content has breathing room.** Text and icons MUST retain enough internal
  space to remain legible and visually centered.
- **DR4 — Visual size and target size serve different needs.** A control MAY look
  compact while preserving an operable target appropriate to its input context.

## Anatomy and hierarchy

| Role                  | Purpose                            | Required relationship                                       |
| --------------------- | ---------------------------------- | ----------------------------------------------------------- |
| fixed control         | Provides a predictable silhouette  | Aligns with peer controls at the selected size              |
| content-sized control | Accommodates variable text or rows | Tunes density to the surrounding rhythm                     |
| content lane          | Holds text and icons               | Remains centered and free from edge crowding                |
| target area           | Receives interaction               | May exceed the visible silhouette without disrupting layout |

## State representation

Rest, focus, loading, value, and status states MUST preserve the intended row
height and alignment unless the component contract explicitly defines expansion.

## Responsive and input behavior

- **DR5 — Rows remain coherent under constraint.** Controls MAY wrap or stack, but
  each resulting row MUST retain deliberate alignment.
- **DR6 — Input context informs targets.** Compact pointer layouts MAY differ
  visually from touch-oriented layouts while preserving the same control identity.

## Accessibility intent

Compact treatment must not compromise readability, focus visibility, or
operability. Target-size mechanics and minimum thresholds remain with component,
accessibility, and audit contracts.

## Representative examples

- A medium action and default-density field in one toolbar land on the same
  apparent height and baseline.
- A compact icon control retains a larger operable target than its glyph suggests.

## Visual references

No normative assets are included. Mixed field/action and pointer/touch examples
should be added under `docs/design/assets/control-rhythm/` before promotion.

## Component contract links

No component links are asserted. `family:input-fields` is a candidate relationship
pending adoption review.

## Decision log

No repository design decision has approved this record yet. It distills the
size-and-density intent from the public Design Conventions wiki.

## Open questions

- **OQ1 — Target intent.** The source material describes a generous touch target
  but a much smaller audit failure threshold. The design target and mechanical
  floor need separate definitions.
- **OQ2 — Reference row.** Which control combination should provide normative
  visual evidence for each supported density?

## Content boundary

This file defines perceived rhythm and target intent. It does not define size
values, density props, padding tokens, hit-area mechanics, or audit thresholds.

---

### Design Elevation Hierarchy (`docs/design/elevation-hierarchy.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:elevation-hierarchy
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, theming]
verified_by: []
architecture: [architecture:layer-runtime, architecture:theme-tokens]
components: []
families: [family:overlay-dismissal]
deciding_specs: []
---

# Elevation hierarchy design specification

## User intent

People should understand which surface is above another without noticing an
overbuilt shadow system. A surface that looks higher should also cover lower
content and remain unclipped.

## Design principles

- **DR1 — Perceived and actual order agree.** A surface that appears higher MUST
  behave as the higher layer.
- **DR2 — Depth remains quiet.** Shadows and edges SHOULD communicate separation
  without becoming the dominant visual feature.
- **DR3 — One edge language leads.** A surface SHOULD use either a defined edge or
  soft elevation as its primary boundary rather than combining both at full
  strength.
- **DR4 — State rings are not elevation.** Input and focus rings MUST remain
  visually distinct from shadows that communicate layer depth.

## Anatomy and hierarchy

| Role             | Purpose                                      | Required relationship                            |
| ---------------- | -------------------------------------------- | ------------------------------------------------ |
| base content     | Establishes the reference plane              | Remains beneath temporary and floating surfaces  |
| floating surface | Presents content above the base              | Uses depth appropriate to its interaction role   |
| boundary cue     | Separates the surface from what is behind it | Supports rather than competes with elevation     |
| escape path      | Allows a floating surface to remain visible  | Prevents unintended clipping by lower containers |

## State representation

Resting content, floating menus, persistent overlays, and transient feedback MUST
form a coherent depth sequence. Focus or validation rings MUST NOT imply that a
control moved to another layer.

## Responsive and input behavior

- **DR5 — Depth survives repositioning.** A surface MAY move or resize to fit the
  viewport, but its perceived place in the hierarchy MUST remain stable.
- **DR6 — Floating content escapes containers.** Responsive clipping or scrolling
  MUST NOT make a surface appear underneath content it is meant to cover.

## Accessibility intent

Layer order must remain understandable through focus movement, semantics, and
content relationship, not shadow alone. High-contrast presentation should retain
clear boundaries even when soft shadows are unavailable.

## Representative examples

- A menu appears above its trigger and nearby sticky content without an exaggerated
  shadow.
- A modal and transient notification preserve their relative hierarchy when the
  viewport becomes constrained.

## Visual references

No normative assets are included. Representative layer combinations should be
added under `docs/design/assets/elevation-hierarchy/` before promotion.

## Component contract links

No component links are asserted. `family:overlay-dismissal` is a candidate
relationship for review, not an adoption claim.

## Decision log

No repository design decision has approved this record yet. It distills the
perceptual elevation intent from the public Design Conventions wiki and delegates
stacking mechanics to `architecture:layer-runtime`.

## Open questions

- **OQ1 — Boundary balance.** Which surface archetypes intentionally combine a
  subtle edge and shadow?
- **OQ2 — Evidence set.** Which nested layer sequence should be the normative
  visual and behavioral example?

## Content boundary

This file defines perceived depth. It does not define z-index values, portals,
layer allocation, overflow mechanics, shadow tokens, or dismissal behavior.

---

### Design Iconography (`docs/design/iconography.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:iconography
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, theming, accessibility]
verified_by: []
architecture:
  [
    architecture:component-theming-surface,
    architecture:icon-resolution-and-component-slots,
    architecture:theme-authoring-contract,
  ]
components: [component:Icon, component:Table]
families: []
deciding_specs: []
---

# Iconography design specification

## User intent

Icons are simple graphic interface elements that represent common actions,
concepts, and states. They should help people understand a surface, complete a
task, and find their way without adding visual noise or forcing them to decode
inconsistent symbols.

## Design principles

- **DR1 — Clear.** Icons MUST use simple, recognizable forms that communicate
  their intended meaning and aid comprehension.
- **DR2 — Intentional.** Every icon MUST have a purposeful role in its local
  context. Designers SHOULD evaluate the whole container and page before adding
  another icon so decoration does not increase cognitive load.
- **DR3 — Consistent.** Repeated concepts, actions, geometry, size roles, and
  artwork styles SHOULD remain coherent across the product. Components SHOULD
  reuse system semantic icons instead of introducing bespoke symbols for an
  existing meaning.
- **DR4 — Size follows semantic role.** A glyph MUST be sized for what it does,
  not merely for the nearest available space or the intrinsic proportions of its
  artwork.
- **DR5 — Outlined artwork is the default.** When an icon family offers outlined
  and filled versions of the same symbol, components SHOULD start with the
  outlined version unless a permitted filled use applies.
- **DR6 — Color supports meaning.** Icon color MUST follow an approved foreground,
  semantic, or constrained non-semantic role. Color alone MUST NOT communicate a
  concept or state.
- **DR7 — Glyph and target are separate.** Icon artwork MUST NOT be used as a
  substitute for an interactive container, operable hit target, focus treatment,
  or accessible name.

## Anatomy and hierarchy

### Meaning roles

| Role                | Purpose                                                    | Required relationship                                                                                      |
| ------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| informative icon    | Adds information or meaning beyond an adjacent label       | May stand alone only with an accessible name and enough explanation to understand it                       |
| decorative icon     | Reinforces nearby text without adding independent meaning  | MUST accompany content that already communicates the meaning; normally uses outlined artwork               |
| interactive icon    | Represents an action inside an interactive control         | The containing Button, IconButton, link, or other control owns interaction, state, focus, name, and target |
| semantic icon       | Reinforces information, success, warning, or error meaning | Uses the matching semantic icon and color together; the meaning remains available independently of color   |
| object or logo icon | Identifies a specific object, product, or brand            | Used only when recognition of that object is relevant; not as generic decoration for a category or section |

### Size roles

Astryx exposes four Icon sizes. Values are px-equivalents at a 16px root; Icon
continues to scale with the document's root font size.

| Role              | Current Icon size | Purpose and current relationship                                                  |
| ----------------- | ----------------- | --------------------------------------------------------------------------------- |
| compact indicator | 12px (`xsm`)      | Supporting indicators, metadata, and dense affordances                            |
| standard control  | 16px (`sm`)       | Most controls and disclosure affordances; current Button `sm` and `md` icon boxes |
| prominent control | 20px (`md`)       | Large controls and visually important icon-only actions; current Button `lg` box  |
| display or object | 24px (`lg`)       | Large standalone symbols, object identity, or exceptional display-scale treatment |

These roles describe visual intent, not new public Icon size names. Component
contracts own their role assignment. Bare Icon currently defaults to `md` (20px),
while many composing controls explicitly constrain their icon slot to 16px or
20px. An open contextual-sizing change may make an omitted Icon size inherit from
its owning control; this record does not depend on that implementation landing.

### Outlined and filled artwork

Outlined artwork is the starting point. Filled artwork MAY be used when one or
more of these conditions applies:

1. The icon also conveys an active or on state, such as a toggle or selected
   navigation item.
2. The icon is 12px-equivalent or smaller and fill demonstrably improves
   recognition.
3. An informational icon carries a persistent semantic color, such as information,
   success, warning, or error status.
4. The icon intentionally needs higher emphasis, such as a primary action or
   object identity.
5. The icon appears on media or another visually busy background and fill improves
   contrast or recognition.
6. The icon is a logo or brand mark whose canonical artwork is filled.

The shape of a symbol or a secondary foreground color is not enough by itself to
require fill. Astryx's current fallback chevrons, close mark, search icon, and sort
arrows are outlined at every size, while status icons are filled and intrinsically
solid marks such as more and stop remain solid. Peer controls SHOULD use a cohesive
artwork language, but cohesion does not override their semantic roles.

A filled exception MUST remain purposeful and local. It does not make filled
artwork the global default. Intrinsically solid primitives with no meaningful
outlined counterpart are not treated as filled variants merely because they
contain a solid shape.

## State representation

| State or context         | Required representation                                                                    | Allowed variation                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| rest                     | Uses the role's normal size, color, and outlined artwork unless a filled exception applies | Theme artwork and optical adjustment may vary                                           |
| active or on             | May change from outlined to filled while preserving one recognizable symbol                | Another established non-fill state treatment may reinforce the state                    |
| compact indicator        | Remains subordinate and recognizable; fill is allowed at 12px or smaller when it helps     | Size-specific artwork may improve legibility                                            |
| semantic status          | Pairs the status icon with its matching semantic color and accessible meaning              | Filled artwork may provide persistent emphasis                                          |
| disabled                 | Uses the approved disabled foreground without losing recognition                           | Artwork stays consistent with the role                                                  |
| on media or busy surface | Retains contrast and recognition against variable content                                  | Filled artwork and an inherited high-contrast foreground may be used                    |
| alternate theme          | Meaning, role hierarchy, and outlined/filled intent remain recognizable                    | Icon library and exact artwork may differ; role-specific sizing needs an exposed target |

An outlined-to-filled change MUST NOT be the only cue for a state whose
distinction must remain perceivable. Programmatic state, accessible naming,
position, color, or another established treatment must continue to carry it.

## Color roles

Astryx Icon currently exposes `primary`, `secondary`, `tertiary`, `disabled`,
`accent`, `success`, `warning`, `error`, and `inherit`, plus named palette colors.
The design intent is:

- **Primary** carries ordinary high-emphasis icon content.
- **Secondary and tertiary** carry supporting icon content. Their current token
  mapping may coincide, but the semantic role remains distinct.
- **Disabled** communicates unavailable icon content without implying status.
- **Accent** communicates selected emphasis or information when the owning
  component assigns that meaning.
- **Success, warning, and error** are reserved for matching semantic outcomes.
- **Inherit** lets the owning control or surface provide the correct foreground,
  including media and other busy surfaces.
- **Named palette colors** do not acquire semantic meaning from Icon. Use them only
  when an owning component or product defines and consistently applies a
  non-semantic category mapping.

`placeholder` and `onMedia` are not current Icon color values. A component may
create those visual roles through inheritance or a qualified theme target, but
this design record does not invent new Icon API values.

## Responsive and input behavior

- **DR8 — Density does not erase meaning.** A dense layout MAY choose the compact
  role for supporting indicators, but primary controls MUST remain recognizable
  as controls.
- **DR9 — Input adaptation belongs to the target.** Touch or coarse-pointer modes
  MAY increase the containing target or spacing without changing the glyph when
  its semantic role is unchanged.
- **DR10 — Role changes are explicit.** Responsive composition MUST NOT silently
  move an icon among compact, standard-control, prominent-control, and display
  roles solely to make it fit; the component or family contract must establish
  that variation.
- **DR11 — Compact artwork preserves recognition.** At 12px-equivalent or smaller,
  a theme MAY choose filled or otherwise optically simplified artwork when an
  outlined asset loses clarity. A 16px icon does not become filled merely because
  it is small; one of the filled-use criteria must still apply. The symbol's
  meaning and surrounding layout must remain unchanged.

## Composition guidance

### Icons in controls

- An interactive icon MUST be placed in an IconButton, Button, link, or another
  control that supplies interaction states, focus, an accessible name, and an
  adequate target.
- A bare Icon MUST NOT become interactive by attaching a click handler without an
  owning control.

### Icons in tabs

- A leading tab icon MAY identify a destination or reinforce the visible label.
  Decorative icons SHOULD be omitted when they add distraction or width without
  useful recognition.
- A selected icon MAY change to reinforce the selected state when the label and
  tab semantics continue to carry that state.
- Status icons, badges, and status dots belong in the tab's end-content or status
  position rather than replacing the tab label.

### Icons in tokens and badges

- Informational status treatment pairs the semantic icon and color so the
  combination communicates status without arbitrarily recoloring the container.
- A token or badge MAY coordinate its surface and icon through a component-owned
  semantic variant; a nested icon alone does not redefine the container's meaning.

### Icons in links

- Links SHOULD primarily use text. An icon MAY supplement that text when it adds
  a useful visual cue.
- An icon-only link MAY be used when the destination is recognizable in context
  and the Link receives a non-empty accessible `label`. Prefer Button with `href`
  when the interaction should read as a control rather than inline navigation.

### Icons in lists

- Non-selectable description and menu lists SHOULD normally use outlined icons at
  the standard 16px-equivalent size and a supported foreground role.
- Filled artwork, compact sizing, or named palette color MAY be used when another
  rule in this record applies; decoration alone is not sufficient justification
  for added emphasis.

### Icons in headers

- A header MAY include object media when the heading identifies a specific object
  and the icon materially aids recognition.
- Most category and section headings SHOULD NOT include an icon; generic media
  adds noise without clarifying the section.

### Icons in navigation

- Navigation icons MUST be chosen intentionally: use a clear, simple symbol that
  represents the destination rather than a generic decorative mark.
- Selected navigation MAY use a component-owned outlined-to-filled pair when text,
  `aria-current`, or another established treatment continues to identify selection.

## Accessibility intent

- Informative icons need an accessible image name when the information is not
  already available in text. Explanatory text or a tooltip should be available
  when the symbol alone may be ambiguous.
- Decorative icons expose no duplicate name and remain paired with visible text
  that communicates the meaning.
- Icon artwork MUST remain recognizable at its rendered size. Astryx does not
  require every icon at 16px or below to be filled: current fallbacks are mostly
  outlined, with filled status icons and intrinsically solid marks. Prefer
  size-specific or optically adjusted artwork when available; use fill only when
  a filled-use criterion above applies.
- Icon foregrounds MUST maintain applicable contrast against their backgrounds.
- Interactive icons need adequate surrounding space for recognition and an
  operable target owned by their control.

## Representative examples

- A grouped-row disclosure chevron is an interactive control for an entire
  section, so its intended standard role is 16px-equivalent rather than a 12px
  supporting indicator. Astryx's current fallback chevron remains outlined.
- A table sort-direction arrow is a compact indicator associated with a column
  heading, so it uses the 12px-equivalent role while the button and `aria-sort`
  carry interaction and state. It does not require filled artwork.
- A persistent semantic information cue may use filled artwork and the accent
  color when nearby content or an accessible name explains it.
- An on/off control may pair outlined artwork for off with the corresponding
  filled artwork for on while its semantic state remains available independently.
- A large icon-only Button uses a 20px-equivalent prominent-control glyph when the
  containing target and spacing support it; icon-only does not automatically mean
  prominent.
- A 24px-equivalent icon is reserved for display-scale or object treatment rather
  than the default icon-button glyph.
- A non-selectable list item normally uses a standard-size outlined icon in a
  primary or secondary foreground.

## Visual references

No normative assets are included. Public-safe examples of meaning roles, the
12/16/20/24 size ladder, outlined/filled exceptions, color roles, and
representative compositions should be added under
`docs/design/assets/iconography/` before promotion.

## Component contract links

- `component:Icon` owns the shared 12/16/20/24 size vocabulary, bare-Icon
  `md` default, scaling behavior, target, color API, and rendering semantics; this
  record assigns design intent to role, color, and artwork selection.
- Interactive components own their target, state, focus, accessible name, and icon
  slot geometry; Icon remains the glyph inside that owner.
- `component:Table` owns which generated Table affordances are compact indicators
  or standard controls. Grouped-row disclosure is the first candidate for an
  explicit standard-control link after this record becomes current.
- `architecture:icon-resolution-and-component-slots` owns semantic-name and
  artwork resolution. A component selects placement, size, color, and state before
  the registry supplies artwork; the registry is not the owner of those roles.
- `architecture:component-theming-surface` owns theming guarantees. The shared
  Icon target reflects `size` and `color`, so themes may coherently style existing
  values. A local component-role override still requires a qualified owning target
  with the relevant property guarantee; this record does not create one.

## Decision log

### DEC-1 — Outlined artwork is the default with deliberate filled exceptions

**Reference:** `design:iconography/DEC-1`
**Decider:** pending design-owner approval

Start with outlined artwork when a meaningful pair exists. Use the filled
counterpart only for active state, verified compact recognition at 12px or smaller,
persistent semantic status, deliberate emphasis, busy backgrounds, or canonical
logos. Maintain cohesion among peer controls without treating a symbol's shape or
secondary color as automatic permission to fill it.

Rejected: using filled artwork as the universal default, filling every icon at
16px or below, filling chevrons and close marks categorically, using fill as the
sole state cue, or selecting fill simply because the asset exists.

### DEC-2 — Astryx uses a 12 / 16 / 20 / 24 role ladder

**Reference:** `design:iconography/DEC-2`
**Decider:** pending design-owner approval

Use 12px-equivalent for compact supporting indicators, 16px-equivalent for
standard controls and disclosures, 20px-equivalent for prominent controls such as
large icon buttons, and 24px-equivalent for display-scale or object treatment.
Bare Icon currently defaults to 20px-equivalent `md`; composing components may
constrain their icon slots independently.

Rejected: importing the older three-size 12/16/24 profile unchanged, calling 16px
the primitive Icon default, using one size for every icon, or selecting size from
available space without regard to meaning.

### DEC-3 — Informative and decorative icons have different content obligations

**Reference:** `design:iconography/DEC-3`
**Decider:** pending design-owner approval

An informative icon may carry meaning beyond its label and therefore needs an
accessible name and enough explanation when used independently. A decorative icon
adds no independent meaning, stays paired with text, and is hidden from assistive
technology to avoid duplicate announcements.

Rejected: treating every icon as informative, or allowing a decorative icon to
stand alone without a label.

### DEC-4 — Themes tune shared and component roles at their owning surface

**Reference:** `design:iconography/DEC-4`
**Decider:** pending design-owner approval

A theme may replace artwork for a shared semantic icon name and may coherently
tune an existing `Icon.size` value through the shared Icon target. A local role
such as one component's disclosure control should instead use a qualified
component-owned target when that target exists and guarantees the relevant
property. Components continue to own placement, state, and accessible semantics;
this record creates no new target or guarantee.

Rejected: adding ad hoc size names, changing a shared size solely to affect one
component, using the artwork registry as the owner of component behavior or state,
or assuming every icon-bearing role is independently themeable today.

## Open questions

- **OQ1 — Theme-target rollout.** Which existing component roles need dedicated
  qualified targets for theme authors to tune size, color, or artwork
  independently?
- **OQ2 — Current conformance.** Which shipped callsites differ from the current
  12/16/20/24 role ladder, outlined default, or composition guidance and need an
  explicit component-owned exception?
- **OQ3 — Normative evidence.** Which public-safe examples best demonstrate the
  meaning roles, size and artwork rules, colors, and composition across the
  reference system theme and a deliberately different theme personality?

## Content boundary

This file defines public visual, composition, and accessibility intent for
iconography. It does not prescribe a particular icon library, asset-management
workflow, request process, implementation framework, prop syntax, target selector,
registry descriptor, or migration tooling. It does not change Icon's released
size vocabulary or default, define hit-target thresholds, choose individual SVG
geometry, or implement theme overrides.

---

### Design Mobile Typography (`docs/design/mobile-typography.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:mobile-typography
authority: current
archive_reason: null
superseded_by: null
approved_by: rubyycheung
approved_at: 2026-09-30
owners: [imdreamrunner, rubyycheung]
review_triggers: [visual, responsive, theming]
verified_by: [scripts/check-knowledge.mjs]
architecture: [architecture:theme-tokens, architecture:theme-authoring-contract]
components: []
families: []
deciding_specs: []
---

# Mobile typography design specification

## User intent

Balance the rest of the text styles with inputs' automatic 16px-reference
minimum on coarse-pointer iOS devices.

## Design principles

- **DR1 — Reading base.** The mobile base MUST be `M = max(B, 16)`; 16px-reference is `1rem`.
- **DR2 — Pin target.** The selected role MUST retain its desktop size; other roles follow the derived scale.

## Anatomy and hierarchy

`B` is the desktop base, `r` its ratio, `k` a raw step, and `a` the selected pin step.

| Desktop ratio r    | Pin target; step a | Design intent                                                                         |
| ------------------ | ------------------ | ------------------------------------------------------------------------------------- |
| `r <= 1.25`        | Display 1; `+6`    | Retain the compact scale's display size as the reading base increases.                |
| `1.25 < r < 1.414` | Heading 2; `+2`    | Preserve Heading 2 while reducing larger display roles relative to body text.         |
| `r >= 1.414`       | Heading 3; `+1`    | Preserve Heading 3 so the display tier does not dominate body text on narrow screens. |

The required size relationships are:

```text
M = max(B, 16)
r_pin = r * (B / M)^(1/a)
desktop(k) = round(B * r^k)
mobile(k) = round(M * r_pin^k)
```

Round to the nearest whole reference pixel, with halfway ties rounded up;
divide by 16 for rem. Above the pin, roles shrink; below it, they grow
(`B < 16`, before rounding).

## State representation

Outside the mobile condition, retain desktop sizes.
When `B >= 16`, the mobile profile retains the desktop base and ratio.

## Responsive and input behavior

- **DR3 — Activation.** By default, mobile MUST match `(width < 1024px) and (pointer: coarse)`.

Width is the layout viewport; coarse refers to the primary pointer.
Use the effective `lg` breakpoint governed by `spec:AST-012`; the stock value is
1024 CSS px and the upper edge is exclusive.
Browser support and fallback follow `spec:AST-013`; browser-specific behavior
requires evidence from that browser.

## Accessibility intent

- **DR4 — Root sizing.** The mobile profile MUST NOT change the document-root font size.

## Representative examples

```text
B=14; r=1.333; a=2; M=16; r_pin=1.246907324142416
heading-2: 25→25px; body: 14→16px; display-1: 79→60px
```

### Verification

| Contract | Verification                       | Representative states                     | Failure signal                  |
| -------- | ---------------------------------- | ----------------------------------------- | ------------------------------- |
| DR2      | Review the ratio-to-target mapping | Below, at, and above 1.25/1.414           | Wrong target or boundary        |
| DR1, DR2 | Compare the required sizes         | Worked example; B>=16                     | Changed anchor or base          |
| DR3      | Check activation                   | Narrow/wide × coarse/fine; exactly 1024px | Wrong conjunction or boundary   |
| DR4      | Check root sizing                  | Mobile profile active/inactive            | Changed document-root font size |

## Visual references

No normative assets are included.

## Component contract links

No component-specific contract links are introduced.

## Decision log

### DEC-1 — Mobile size relationships

**Reference:** `design:mobile-typography/DEC-1`
**Decider:** `rubyycheung`, `2026-09-30`
**Decision:** Apply DR1–DR4 to balance mobile text styles with the input minimum.

## Open questions

- **OQ1 — Which desktop base sizes and ratios must this policy support?**

## Content boundary

This record defines mobile text-size relationships, not prop syntax,
implementation structure, current audit scores, or consumer usage examples.

---

### Design Motion (`docs/design/motion.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:motion
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, accessibility, motion]
verified_by: []
architecture: [architecture:theme-tokens]
components: []
families: []
deciding_specs: []
---

# Motion design specification

## User intent

Movement should clarify what changed and where content came from without making a
surface feel theatrical or sluggish. Every state change should remain understandable
when motion is reduced.

## Design principles

- **DR1 — Motion carries meaning.** Animation MUST explain response, continuity,
  entry, exit, or spatial change rather than decorate otherwise static content.
- **DR2 — Weight determines timing.** Small local feedback SHOULD feel faster
  than large entrances, exits, or continuous movement.
- **DR3 — Movement settles naturally.** Easing SHOULD communicate controlled
  deceleration rather than ornamental bounce or elasticity.
- **DR4 — Stable content stays stable.** Motion SHOULD avoid unnecessary layout
  disruption and preserve reading and interaction context.
- **DR5 — Reduced motion preserves meaning.** Every animated transition MUST have
  an immediate or minimally moving form that communicates the same state change.

## Anatomy and hierarchy

| Role                | Purpose                                     | Required relationship                               |
| ------------------- | ------------------------------------------- | --------------------------------------------------- |
| origin state        | Establishes what is changing                | Remains identifiable until continuity is clear      |
| transition cue      | Connects origin and destination             | Uses only the movement needed to explain the change |
| destination state   | Shows the completed result                  | Settles without residual decorative activity        |
| reduced-motion path | Communicates the same result without travel | Preserves timing and status meaning where needed    |

## State representation

Hover and press use the lightest useful response; entrances and exits may use a
more visible transition; large or continuous movement requires stronger user
justification. Loading and status MUST remain understandable without animation.

## Responsive and input behavior

The source wiki does not establish responsive-path, interruption, or
input-specific motion rules beyond the principles above. Those concerns remain
open for separately evidenced decisions.

## Accessibility intent

Motion must not be required to perceive status, hierarchy, or completion. Reduced
motion should suppress unnecessary travel while preserving feedback. Animated
content should not repeatedly steal attention from reading or input.

## Representative examples

- A pressed control responds immediately without launching decorative movement.
- A surface entrance explains where new content belongs, then settles quietly.
- Reduced-motion mode shows the same completed state without spatial travel.

## Visual references

No normative assets are included. Micro-interaction, entrance/exit, and
reduced-motion pairs should be added under `docs/design/assets/motion/` before
promotion.

## Component contract links

No component links are asserted in this seed draft.

## Decision log

No repository design decision has approved this record yet. It distills motion
intent from the public Design Conventions wiki without copying duration values,
CSS properties, or audit mechanics.

## Open questions

- **OQ1 — Reference transitions.** Which component interactions should be the
  normative examples for local, entrance/exit, and large movement?
- **OQ2 — Interruption and reflow.** What requirements should govern reversing,
  cancelling, replacing, or responsively reflowing an active transition?
- **OQ3 — Allowed expressive motion.** Which product contexts, if any, justify a
  deliberately expressive treatment beyond functional continuity?

## Content boundary

This file defines motion intent. It does not define duration or easing tokens,
CSS properties, animation APIs, component behavior, or audit checks.

---

### Design Ordered Collection Reordering (`docs/design/ordered-collection-reordering.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:ordered-collection-reordering
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, accessibility, motion]
verified_by: []
architecture: [architecture:interaction-modality, architecture:theme-tokens]
components: []
families: []
deciding_specs: []
---

# Ordered collection reordering design specification

## User intent

A person reordering a list or row collection should understand what is moving,
where it will land, and when the change is committed. Pointer and keyboard paths
should communicate the same candidate position without making surrounding items
jump prematurely.

This treatment is for changing order within a collection. Freeform canvas
placement and file-drop targets require separate design specifications.

## Design principles

- **DR1 — Reordering begins from an explicit handle.** The interaction MUST start
  from a dedicated affordance so activation is intentional and other item
  actions remain available.
- **DR2 — The moving item remains recognizable without appearing elevated.** The
  source and pointer-following preview MUST read as the same item in a temporary
  moving state and MUST NOT imply a raised card.
- **DR3 — Placement is previewed before the collection changes.** A clear
  insertion cue MUST mark the candidate position while surrounding items remain
  stable until commit.
- **DR4 — Commit happens once.** The collection MUST adopt the new order on drop
  or release rather than repeatedly committing as the pointer crosses items.
- **DR5 — Completion restores normal hierarchy.** Temporary drag treatment MUST
  disappear immediately on drop or cancel, and settled items MUST return to
  their normal contrast and depth.

## Anatomy and hierarchy

| Role               | Purpose                                         | Required relationship                                                         |
| ------------------ | ----------------------------------------------- | ----------------------------------------------------------------------------- |
| reorder handle     | Starts and retains ownership of the interaction | Belongs to one item and remains distinguishable from item activation          |
| stationary source  | Preserves the item's origin during active drag  | Remains in place until commit and shares temporary treatment with the preview |
| moving preview     | Follows pointer movement                        | Duplicates the source identity without adding raised-card elevation           |
| insertion cue      | Marks the candidate ordered position            | Appears between items on the collection's ordering axis                       |
| surrounding items  | Provide stable spatial context                  | Do not shift until the reorder is committed                                   |
| settled collection | Shows the resulting order                       | Returns every item to its normal state after completion                       |

## State representation

| State              | Required representation                                                     | Allowed variation                                                |
| ------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| rest               | The handle is discoverable without overpowering item content                | Handle placement may follow the collection family                |
| dragging           | Source and moving preview use the same temporary reduced-emphasis treatment | Pointer preview may move freely on both axes                     |
| candidate position | A clear accent insertion line marks where the item will land                | Line length and inset may follow row geometry                    |
| dropped            | Items settle into the committed order and temporary drag treatment clears   | Settling motion may use the system's fast movement language      |
| cancelled          | Original order and normal contrast return immediately                       | Cancellation may be immediate even when normal motion is enabled |

## Responsive and input behavior

- **DR6 — Placement follows the collection axis.** Pointer previews MAY move
  freely, while candidate order MUST be calculated on the collection's ordering
  axis.
- **DR7 — Keyboard and pointer share the insertion cue.** Keyboard reordering
  MUST expose the same candidate-position representation as pointer reordering.
- **DR8 — Reduced motion updates directly.** When motion is reduced, the final
  order MUST update without animated travel.

## Accessibility intent

Focus should remain with the reorder handle throughout the operation. Position
changes, cancellation, and completion should be announced in terms a person can
understand without seeing the preview. The temporary reduced-emphasis treatment
is acceptable only during active reordering and must not reduce the persistent
legibility of content.

Focus mechanics, keyboard commands, live-region wording, and collection mutation
belong to component and family contracts.

## Representative examples

- A row starts moving from its handle; the original row and pointer preview
  become temporarily quieter while a line marks the candidate gap.
- Keyboard movement changes the same insertion cue one position at a time while
  focus remains on the handle.
- Dropping commits once, then the collection settles into the new order.

## Visual references

No normative assets are included in this seed draft. Pointer and keyboard
sequences should be added under
`docs/design/assets/ordered-collection-reordering/` before promotion.

## Component contract links

No component contract links are asserted in this seed draft. A collection
component should link these requirements only after its input and accessibility
contracts define reordering semantics.

## Decision log

No repository design decision has approved this record yet. The draft isolates
the reordering treatment from the broader user-state vocabulary so its distinct
interaction and accessibility requirements can be reviewed independently.

## Open questions

- **OQ1 — Preview treatment.** Does reduced emphasis for both stationary source
  and moving preview remain appropriate across light, dark, and high-contrast
  themes?
- **OQ2 — Insertion geometry.** Which collection families need a deliberate
  exception to the default between-item insertion line?
- **OQ3 — Representative owner.** Which component or family should provide the
  canonical pointer and keyboard evidence?

## Content boundary

This file defines the visual and interaction intent of ordered reordering. It
does not define event handling, keyboard commands, focus implementation,
announcement strings, list mutation, token names, canvas placement, or file-drop
behavior.

---

### Design Shape Relationships (`docs/design/shape-relationships.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:shape-relationships
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, theming, layout]
verified_by: []
architecture: [architecture:theme-tokens, architecture:theme-authoring-contract]
components: []
families: []
deciding_specs: []
---

# Shape relationships design specification

## User intent

Nested surfaces should read as parts of one intentional shape. Inner corners
should not visually fight their container, and decorative accents should not
break the geometry of rounded surfaces.

## Design principles

- **DR1 — Radius follows role.** Corner character MUST reflect whether an element
  is inner content, a control, a container, a page region, or a fully rounded form.
- **DR2 — Nested curves remain concentric.** Inner and outer corners MUST read as
  parallel shapes after accounting for the space between them.
- **DR3 — Shape character is systemic.** Themes SHOULD tune overall sharpness or
  roundness coherently rather than override isolated elements.
- **DR4 — Accents respect geometry.** Borders and edge accents MUST integrate with
  the corner shape instead of visibly colliding with it.

## Anatomy and hierarchy

| Role              | Purpose                           | Required relationship                                               |
| ----------------- | --------------------------------- | ------------------------------------------------------------------- |
| outer surface     | Establishes the containing shape  | Determines the curve available to nested content                    |
| intervening space | Separates nested boundaries       | Visually accounts for the difference between outer and inner curves |
| inner surface     | Continues the containing geometry | Reads as concentric with the outer surface                          |
| accent edge       | Adds emphasis or status           | Follows rather than interrupts the surface shape                    |

## State representation

Hover, focus, selection, and status borders MUST preserve the base geometry.
Changing state must not introduce a conflicting corner system.

## Responsive and input behavior

- **DR5 — Shape survives resizing.** Responsive changes MAY alter dimensions, but
  nested corner relationships MUST remain coherent.
- **DR6 — State rings follow the owner.** Focus or selection treatments MUST trace
  the visible owning shape without producing doubled or mismatched curves.

## Accessibility intent

Shape should reinforce boundaries and ownership but must not be the only signal
for state or grouping. Focus and status remain perceivable independent of corner
style.

## Representative examples

- A nested panel's inner curve follows its container and intervening space.
- A selected rounded card receives an integrated boundary rather than a thick
  side stripe that collides with its corners.

## Visual references

No normative assets are included. Nested and stateful examples should be added
under `docs/design/assets/shape-relationships/` before promotion.

## Component contract links

No component links are asserted in this seed draft.

## Decision log

No repository design decision has approved this record yet. It distills the
concentric-shape intent from the public Design Conventions wiki without copying
radius values or calculations.

## Open questions

- **OQ1 — Allowed exceptions.** Which components intentionally break concentric
  geometry because their content or clipping model requires it?
- **OQ2 — Evidence set.** Which nested surfaces and themes best demonstrate the
  relationship across sharp and rounded theme personalities?

## Content boundary

This file defines perceptual shape relationships. It does not define radius token
values, formulas, CSS, theme APIs, border widths, or audit checks.

---

### Design Spatial Hierarchy (`docs/design/spatial-hierarchy.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:spatial-hierarchy
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, layout, responsive]
verified_by: []
architecture: [architecture:container-padding, architecture:theme-tokens]
components: []
families: [family:layout-primitives, family:layout-regions]
deciding_specs: []
---

# Spatial hierarchy design specification

## User intent

People should understand what belongs together before reading individual labels.
A dense surface should remain organized because proximity expresses relationship,
not because every group is enclosed by another container.

## Design principles

- **DR1 — Proximity communicates relationship.** Closely related elements MUST
  sit closer than separate concerns.
- **DR2 — Separation grows with grouping level.** Gaps MUST increase from local
  content to groups and from groups to sections.
- **DR3 — Space precedes containment.** Authors SHOULD use spacing and alignment
  to establish groups before adding another card or boundary.
- **DR4 — Variation is intentional.** A composition MUST use enough spatial
  contrast to reveal its hierarchy rather than applying one gap everywhere.

## Anatomy and hierarchy

| Role           | Purpose                                                     | Required relationship                                      |
| -------------- | ----------------------------------------------------------- | ---------------------------------------------------------- |
| local gap      | Joins a label, value, icon, or control to immediate content | Smaller than its containing group gap                      |
| group gap      | Separates peer controls or content groups                   | Larger than local gaps and smaller than section separation |
| section gap    | Separates distinct concerns                                 | Strong enough to survive the squint test                   |
| alignment edge | Connects related content across rows or regions             | Repeats consistently within the same grouping level        |

## State representation

Spatial relationships MUST remain perceptible in populated, sparse, empty, and
error states. Conditional content must not collapse or invert the grouping order.

## Responsive and input behavior

- **DR5 — Reflow preserves grouping.** At constrained widths, content MAY stack or
  move, but local, group, and section relationships MUST remain distinguishable.
- **DR6 — Dynamic content preserves rhythm.** Validation, supporting text, and
  optional actions MUST remain associated with the content they serve.

## Accessibility intent

Visual grouping should agree with semantic reading order and programmatic
relationships. Space must not be the only signal when a group requires a label,
heading, or other semantic boundary.

## Representative examples

- A form uses its tightest relationship between label and field, a larger gap
  between fields, and the largest gap between sections.
- A dashboard separates regions through spacing and alignment before adding
  nested cards.

## Visual references

No normative assets are included. Wide and constrained examples should be added
under `docs/design/assets/spatial-hierarchy/` before promotion.

## Component contract links

No component links are asserted. The listed layout families are candidate
relationships pending adoption review.

## Decision log

No repository design decision has approved this record yet. It distills the
spacing intent from the public Design Conventions wiki without copying token
values or audit thresholds.

## Open questions

- **OQ1 — Evidence set.** Which page, form, and collection examples best verify
  each grouping level?
- **OQ2 — Containment exception.** Which component families require visible
  containment in addition to spatial grouping?

## Content boundary

This file defines perceptual grouping. It does not define spacing token values,
padding algorithms, component props, DOM structure, or audit scoring.

---

### Design System States (`docs/design/system-states.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:system-states
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, accessibility]
verified_by: []
architecture: [architecture:theme-tokens]
components: []
families: [family:input-fields, family:overlay-dismissal]
deciding_specs: []
---

# System states design specification

## User intent

A person should distinguish unavailable, waiting, processing, successful,
warning, informational, and error conditions without relying on color alone.
Feedback should use prominence appropriate to its persistence and urgency while
preserving surrounding layout.

## Design principles

- **DR1 — System state preserves context.** Loading, processing, and status
  treatments MUST keep enough of the original component's geometry and identity
  for the person to understand what is affected.
- **DR2 — Status meaning survives color.** Every semantic status MUST pair color
  with an icon, label, or other non-color signal.
- **DR3 — Prominence follows persistence and urgency.** Persistent in-flow
  feedback SHOULD remain quiet, compact or urgent feedback MAY use a solid
  treatment, and brief transient feedback MAY use an inverted overlay.
- **DR4 — One meaning remains recognizable across tiers.** Changing prominence
  MUST NOT change the underlying success, information, warning, or error meaning.
- **DR5 — Busy states do not cause layout shift.** Placeholder and processing
  representations MUST preserve the affected content's expected dimensions.

## Anatomy and hierarchy

| Role                    | Purpose                                              | Required relationship                                    |
| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
| affected surface        | Shows what is disabled, waiting, or reporting status | Retains enough geometry to preserve context              |
| progress representation | Shows that content or an action is unresolved        | Fits the scope and duration of the wait                  |
| semantic indicator      | Communicates feedback category                       | Pairs color with icon, label, or equivalent cue          |
| supporting message      | Explains reason, consequence, or next action         | Remains associated with the affected surface             |
| prominence container    | Scales feedback from in-flow to transient            | Matches persistence and urgency without changing meaning |

## State representation

| State                | Required representation                                                                 | Allowed variation                                                      |
| -------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| disabled             | Muted, clearly unavailable, and non-interactive; a reason remains available when needed | Components may place the reason according to their family contract     |
| loading, placeholder | A stable skeleton or structural placeholder stands in for content not yet available     | Shape follows the expected content                                     |
| processing, in place | A progress indicator appears without changing the control's dimensions                  | Indicator may replace or accompany content when identity remains clear |
| status, muted        | A quiet semantic surface combines status color with icon or text                        | Used for persistent banners, fields, and in-flow feedback              |
| status, solid        | A high-prominence semantic fill combines status color with contrasting content          | Used for compact labels or urgent feedback                             |
| temporal overlay     | A brief inverted surface appears above current content and dismisses                    | Used for transient messages and explanatory overlays                   |

## Responsive and input behavior

- **DR6 — Feedback remains attached under reflow.** Supporting messages and
  progress representations MUST remain associated with the affected content at
  narrow widths and across orientation changes.
- **DR7 — Transient feedback remains operable.** Responsive placement MUST NOT
  hide dismissal, pause, or follow-up actions where those actions exist.
- **DR8 — Busy feedback does not depend on motion.** Reduced-motion mode MUST
  still communicate that work is pending.

## Accessibility intent

A person should be able to identify status category, affected scope, and any
available next action without color perception or animation. Disabled reasons
should remain reachable without enabling the blocked action. Busy content should
communicate that it is unresolved without repeatedly disrupting reading or focus.

ARIA mapping, disabled semantics, busy behavior, dismissal, timing, and live
announcements remain component and family responsibilities.

## Representative examples

- A field processing a value keeps its width and label while a progress indicator
  occupies an established end lane.
- A persistent warning uses a quiet in-flow surface with an icon and message.
- An urgent transient error uses stronger prominence but preserves the same error
  meaning and icon language.

## Visual references

No normative assets are included in this seed draft. Representative disabled,
loading, muted, solid, and transient states should be added under
`docs/design/assets/system-states/` before promotion.

## Component contract links

No component contract links are asserted in this seed draft. The listed family
relationships are candidates for review, not claims that those draft or current
contracts have adopted these requirements.

## Decision log

No repository design decision has approved this record yet. It distills the
system-driven state and prominence vocabulary from the public Design Conventions
wiki while leaving semantics and mechanics with components and families.

## Open questions

- **OQ1 — Tier selection.** Which status surfaces have fixed prominence, and
  which may vary based on product context or severity?
- **OQ2 — Processing distinction.** When should an unresolved surface use a
  structural placeholder versus an in-place progress indicator?
- **OQ3 — Temporal boundary.** Which transient feedback belongs in this shared
  vocabulary versus a dedicated overlay or notification design record?

## Content boundary

This file defines visual distinctions and prominence for system-driven feedback.
It does not define prop names, ARIA attributes, loading semantics, dismissal or
timing behavior, token names, audit checks, or consumer usage.

---

### Design Template Composition (`docs/design/template-composition.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:template-composition
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, layout, theming, responsive]
verified_by: []
architecture:
  [
    architecture:component-theming-surface,
    architecture:container-padding,
    architecture:theme-tokens,
  ]
components: []
families: [family:layout-primitives, family:layout-regions]
deciding_specs: []
---

# Template composition design specification

## User intent

A page template or reusable block should look like an intentional product
surface, not merely a valid collection of components. It should establish a
clear reading order, coherent regions, and theme-safe emphasis while leaving
product content and application chrome to the adopting product.

## Design principles

- **DR1 — Layout communicates purpose.** Structural regions, grid, and stacking
  MUST make the page's primary task apparent before individual content is read.
- **DR2 — Visual hierarchy directs the eye.** Size, weight, and placement MUST
  establish an intentional path from page context to primary content and action.
- **DR3 — Spacing and alignment express relationships.** Repeated edges, gaps,
  and grouping tiers MUST remain coherent across the whole composition.
- **DR4 — Components preserve their affordances.** A template MUST choose
  components and variants whose visual language matches the intended action,
  navigation, data, or status role.
- **DR5 — Color and theming preserve hierarchy.** Surface and accent choices MUST
  retain meaning and emphasis in every supported theme and color mode.

## Anatomy and hierarchy

| Role               | Purpose                                                         | Required relationship                                              |
| ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------ |
| page context       | Establishes where the person is and what the surface is for     | Leads the visual hierarchy without competing with the primary task |
| structural region  | Groups navigation, controls, content, or supporting information | Uses layout boundaries and spacing that match its role             |
| primary content    | Carries the page's main task or information                     | Receives the strongest sustained hierarchy                         |
| primary action     | Offers the most important local next step                       | Remains singular and easy to identify within its action group      |
| supporting content | Adds explanation, metadata, or secondary action                 | Remains available without flattening hierarchy                     |
| repeated item      | Forms a list, grid, table, or card collection                   | Preserves alignment and rhythm across realistic content variation  |

## State representation

| State              | Required representation                                                     | Allowed variation                                                                   |
| ------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| populated          | Regions and hierarchy remain clear with realistic content                   | Content length and collection size may vary within the template's purpose           |
| sparse             | Empty space preserves intentional grouping rather than collapsing structure | Optional supporting regions may disappear                                           |
| constrained        | Regions reflow without losing reading order or primary action               | Navigation and secondary content may move or collapse according to family contracts |
| light or dark mode | Surface hierarchy, contrast, and accent meaning remain intact               | Theme controls palette and visual personality                                       |
| interactive        | Component states remain recognizable inside the larger composition          | Components retain their family-owned treatments                                     |

## Responsive and input behavior

- **DR6 — Reflow preserves reading order.** Responsive changes MUST retain the
  relationship among page context, primary content, supporting regions, and
  actions.
- **DR7 — Collections adapt without arbitrary clipping.** Repeated content MUST
  use a layout strategy that remains coherent across supported widths and
  realistic item lengths.
- **DR8 — Input modes keep the same task hierarchy.** Keyboard, pointer, and
  touch paths MUST reach the same primary task and actions without relying on
  hover-only discovery.

## Accessibility intent

Visual hierarchy should agree with semantic reading and focus order. Reflow must
not separate labels, explanations, errors, or actions from the content they
serve. Theme variation must preserve legibility and state meaning, and realistic
content must not expose inaccessible overflow or truncation.

Semantic markup, component APIs, focus management, and responsive implementation
belong to components, layout families, and consumer guidance.

## Representative examples

- A dense data page still exposes one clear title, control region, primary data
  region, and action hierarchy when viewed at a glance.
- A card collection retains alignment and grouping with realistic titles,
  metadata, and missing optional content.
- A narrow layout moves supporting regions without changing reading order or
  hiding the primary action.

## Visual references

No normative assets are included in this seed draft. Representative full-page
and block examples should be added under
`docs/design/assets/template-composition/` in light, dark, wide, and constrained
states before promotion.

## Component contract links

No component contract links are asserted. The listed layout-family relationships
are candidates for review; consumer templates remain responsible for using the
public contracts those families expose.

## Decision log

No repository design decision has approved this record yet. This draft migrates
only the human visual-quality axes from the public Design Conventions wiki; it
deliberately excludes authoring mechanics and audit grading.

## Open questions

- **OQ1 — Representative templates.** Which page templates and blocks should be
  normative examples for each composition requirement?
- **OQ2 — Product chrome boundary.** Which structural regions belong to a
  reusable page template versus the host application?
- **OQ3 — Evidence.** How should screenshot review demonstrate hierarchy and
  composition without turning subjective judgment into a misleading score?

## Content boundary

This file defines human visual intent for template composition. It does not
define required React components, raw-HTML policy, icon plumbing, styling
mechanics, mock-data sources, documentation metadata, line-count targets, audit
grades, or consumer instructions.

---

### Design Typography Hierarchy (`docs/design/typography-hierarchy.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:typography-hierarchy
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
owners: [ernestt, cixzhang]
review_triggers: [visual, theming, accessibility]
verified_by: []
architecture: [architecture:theme-tokens, architecture:theme-authoring-contract]
components: []
families: []
deciding_specs: []
---

# Typography hierarchy design specification

## User intent

People should be able to triage a surface quickly because headings, body copy,
labels, code, and supporting text are unmistakably different. Reading should
remain comfortable across dense and editorial theme personalities.

## Design principles

- **DR1 — Type roles communicate purpose.** Text MUST use a role whose hierarchy
  matches its function rather than an arbitrary nearby size.
- **DR2 — Adjacent roles remain distinguishable.** Meaningful hierarchy MUST come
  from visible contrast in size, weight, placement, or a deliberate combination.
- **DR3 — Multi-line text has room to read.** Body and supporting content MUST
  preserve comfortable leading and line measure.
- **DR4 — Theme personality preserves semantics.** Themes MAY tune scale and
  density, but headings, body, labels, and supporting roles MUST retain their
  relative meaning.

## Anatomy and hierarchy

| Role                    | Purpose                                    | Required relationship                                             |
| ----------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| display or page heading | Establishes the strongest reading landmark | Clearly outranks section and body content                         |
| section heading         | Organizes content within a surface         | Distinct from both page context and body copy                     |
| body                    | Carries primary reading content            | Optimized for sustained legibility                                |
| label                   | Identifies a control or compact datum      | Remains associated with its value or control                      |
| supporting text         | Adds secondary context                     | Quieter but still readable and distinguishable from disabled text |
| code or data text       | Preserves technical or tabular structure   | Remains legible without borrowing heading emphasis                |

## State representation

Loading, disabled, status, selected, and truncated states MUST preserve the text
role's identity. State styling must not flatten heading and body hierarchy or make
supporting text indistinguishable from unavailable content.

## Responsive and input behavior

- **DR5 — Measure remains readable.** Responsive layouts MUST constrain long-form
  text rather than allowing lines to expand without bound.
- **DR6 — Wrapping preserves hierarchy.** Labels and headings MAY wrap, but their
  relationship to controlled or grouped content MUST remain clear.

## Accessibility intent

Text should remain legible at supported zoom levels, modes, and theme settings.
Hierarchy should not rely on color alone, and all-caps or unusual tracking should
not undermine sustained reading.

## Representative examples

- A page title, section heading, body paragraph, and supporting note remain
  distinguishable when viewed quickly and when zoomed.
- A dense dashboard and an editorial surface use different personalities while
  preserving the same semantic role order.

## Visual references

No normative assets are included. Dense, editorial, zoomed, and constrained
examples should be added under `docs/design/assets/typography-hierarchy/` before
promotion.

## Component contract links

No component links are asserted in this seed draft.

## Decision log

No repository design decision has approved this record yet. It distills the
legibility and hierarchy intent from the public Design Conventions wiki without
copying type scales or numeric checks.

## Open questions

- **OQ1 — Scale reconciliation.** The source material's default scale and its
  adjacent-step hierarchy smell use different ratios. The intent and audit check
  need reconciliation before promotion.
- **OQ2 — Reference content.** Which realistic content set should verify every
  role across themes and widths?

## Content boundary

This file defines typographic intent. It does not define font sizes, weights,
line-height values, token names, text component APIs, or audit thresholds.

---

### Design User States (`docs/design/user-states.md`)

---
schema_version: 1
template_version: 1
kind: design
id: design:user-states
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-19
owners: [ernestt, cixzhang]
review_triggers: [visual, interaction, accessibility]
verified_by:
  [
    packages/core/src/__tests__/PressedState.a11y.chromium.spec.ts,
    packages/core/src/CheckboxInput/CheckboxInput.test.tsx,
    packages/core/src/Collapsible/Collapsible.test.tsx,
    packages/core/src/Link/Link.test.tsx,
    packages/core/src/RadioList/RadioList.test.tsx,
    packages/core/src/SegmentedControl/SegmentedControl.test.tsx,
    packages/core/src/Slider/Slider.test.tsx,
    packages/core/src/Switch/Switch.test.tsx,
    packages/core/src/TabList/TabList.test.tsx,
  ]
architecture: [architecture:interaction-modality, architecture:theme-tokens]
components:
  [
    component:CheckboxInput,
    component:Collapsible,
    component:Link,
    component:Slider,
    component:Switch,
    component:TabList,
    component:RadioList,
    component:SegmentedControl,
  ]
families: []
deciding_specs: []
---

# User states design specification

## User intent

A pointer user holding or dragging an enabled control should receive immediate,
consistent feedback on the surface being manipulated. The feedback must not imply
that a disabled control responded, and it must not erase a persistent selected
surface.

## Design principles

- **DR1 — Press the manipulated surface.** During pointer hold or drag, each
  enabled surface in the matrix below MUST paint `--color-overlay-pressed` on
  the named visual part. The overlay is transient and MUST clear when the press
  or drag ends.
- **DR2 — Disabled means no response.** A disabled control in the matrix MUST
  remain inoperable and MUST NOT gain a pressed visual treatment.
- **DR3 — Persistent selection wins.** A selected SegmentedControl item MUST
  keep its raised selected surface during pointer hold; only an unselected item
  receives the pressed overlay.

## Anatomy and hierarchy

| Role                        | Purpose                                  | Required relationship                                                         |
| --------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------- |
| interaction target          | Receives the pointer hold or drag        | Owns or controls the paint surface named in the matrix                        |
| pressed overlay             | Confirms the active manipulation         | Composes over the existing fill without replacing content or persistent state |
| persistent selected surface | Communicates an already-selected segment | Remains visually unchanged during pointer hold                                |

## State representation

| Component        | Enabled pointer hold or drag                                                                              | Disabled pointer hold                                          |
| ---------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| CheckboxInput    | Indicator paints `--color-overlay-pressed`                                                                | Indicator remains unchanged                                    |
| Collapsible      | Trigger row paints `--color-overlay-pressed`                                                              | Trigger row remains unchanged and does not expand or collapse  |
| Link             | Text surface paints `--color-overlay-pressed`                                                             | Text surface remains unchanged and does not activate           |
| Slider           | Only the dragged thumb paints `--color-overlay-pressed`                                                   | Thumb remains unchanged and the value does not change          |
| Switch           | Track and thumb both paint `--color-overlay-pressed`                                                      | Track and thumb remain unchanged and the value does not change |
| TabList Tab      | Tab surface paints `--color-overlay-pressed`                                                              | Tab surface remains unchanged and selection does not change    |
| RadioList        | Indicator paints `--color-overlay-pressed`                                                                | Indicator remains unchanged and selection does not change      |
| SegmentedControl | An unselected item paints `--color-overlay-pressed`; the selected item keeps its raised surface unchanged | Item surface remains unchanged and selection does not change   |

This table is the complete current claim. It does not approve pressed treatment
for other components or settle their state design.

## Responsive and input behavior

- **DR4 — Follow the real interaction.** Hold-driven controls keep the overlay
  only while the pointer is down. Slider follows its existing drag state so the
  overlay stays on the thumb being dragged, including when the drag began on the
  track.
- **DR5 — Preserve the resting result.** After release or cancellation, the
  component MUST return to the same visual state it would otherwise have had;
  pressed feedback does not create a persistent state.

## Accessibility intent

Pressed feedback supplements, and never replaces, the control's semantic state,
label, focus indication, or disabled behavior. A control that is exposed as
disabled must remain inoperable even when its implementation keeps it focusable
to explain why it is unavailable.

## Representative examples

- Holding an enabled CheckboxInput row darkens its indicator; releasing outside
  without activation restores the original indicator pixels.
- Dragging a range Slider paints only the thumb currently being moved.
- Holding the selected SegmentedControl item leaves its raised surface unchanged;
  holding an unselected item paints the pressed overlay.

## Visual references

The real-Chromium evidence generated by
`packages/core/src/__tests__/PressedState.a11y.chromium.spec.ts` captures matched
rest, held-pointer, released, disabled, and selected-exception surfaces for the
eight components. CI publishes those PNGs, pixel diffs, Chromium version,
viewport, story IDs, hashes, and exact PR-head SHA in the
`pressed-state-evidence` artifact. Generated evidence validates this record but
is not a normative design asset.

## Component contract links

The `components` frontmatter list is the machine-readable adoption matrix for
DR1–DR5. Component records do not duplicate this cross-component decision.

## Decision log

### DEC-1 — Use the system pressed overlay on the bounded eight-component matrix

**Reference:** `design:user-states/DEC-1`
**Decider:** `cixzhang`, 2026-09-19

Consistent feedback helps pointer users understand which enabled surface is
actively responding. The decision is intentionally limited to the surfaces in
the matrix, preserves disabled behavior, and keeps an already-selected segment's
raised surface stable.

## Open questions

None for this bounded pressed-state decision. Hover, focus, selection treatments
outside the selected-segment exception, and adoption by other components remain
outside this record's current claim.

## Content boundary

This file defines the approved cross-component pressed-state intent and its
bounded adoption matrix. Component implementations own selectors and event
mechanics; architecture records own interaction modality and token behavior;
tests and CI artifacts own current conformance evidence.

---

### Families Buttons (`docs/families/buttons.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:buttons
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-04
owners: [cixzhang]
review_triggers: [behavior, layout, theming, accessibility, public-api]
verified_by:
  [
    packages/core/src/Button/Button.test.tsx,
    packages/core/src/IconButton/IconButton.test.tsx,
    packages/core/src/ButtonGroup/ButtonGroup.test.tsx,
    packages/core/src/ToggleButton/ToggleButton.test.tsx,
  ]
members:
  [
    component:Button,
    component:IconButton,
    component:ToggleButton,
    component:ButtonGroup,
    component:ToggleButtonGroup,
  ]
architecture:
  [
    architecture:component-size-cascade,
    architecture:component-theming-surface,
    architecture:public-component-api,
  ]
contributing: []
deciding_specs: [spec:AST-002/DEC-1]
---

# Button family contract

## Intent

People should encounter one coherent button system whether an action is labeled,
icon-only, persistent, standalone, or grouped. Members share the same control
geometry, accessible-name requirement, interaction feedback, asynchronous-action
model, and surface ownership. Each member still owns the semantics specific to a
momentary action, navigation destination, persistent pressed state, or group.

ToggleButton renders Button's elevation state but cannot currently select the
family's resting elevation axis. The family admits that axis for ToggleButton.
Its transparent ghost surface remains a known visual question: elevation may
provide the floating boundary instead of an opaque fill or outline, and this
contract does not require a new background treatment.

## Membership rule

A component belongs when its primary public purpose is to activate a button-like
action, retain a button's pressed action state, or compose those controls into a
button-specific group while preserving their shared surface and interaction
contract.

- **Members:** Button, IconButton, ToggleButton, ButtonGroup, and
  ToggleButtonGroup.
- **Collaborators:** Spinner supplies pending feedback; Tooltip supplies visible
  explanation; LinkProvider supplies Button's navigation renderer; SizeContext
  supplies inherited control size; DropdownMenu may contribute a button trigger
  to ButtonGroup. These collaborators do not become Button-family members.
- **Excluded:** Link's primary purpose is navigation rather than a button surface.
  Switch, CheckboxInput, and RadioList represent settings or form values rather
  than button actions. SegmentedControl and TabList switch views or destinations
  under their own selection/navigation contracts. Components that merely use a
  Button as a trigger or action slot retain their own family membership.

Membership follows public responsibility, not an import of Button or a rendered
`<button>` element.

## Shared owner

- Button owns the common action surface: native button semantics, focus and press
  feedback, size, visual variant, loading presentation, link mode, and resting
  elevation.
- IconButton is the explicit icon-only projection of Button and inherits that
  surface contract without creating another action model.
- ToggleButton reuses the Button surface and adds a persistent controlled pressed
  state, pressed-state visuals, and an interruptible pressed Action.
- ButtonGroup owns connected action-group geometry, roving focus, inherited size
  and disabled state, and one shared connected elevation.
- ToggleButtonGroup owns single- or multiple-selection state and the layout of
  distinct ToggleButton surfaces. It does not become a connected visual surface
  merely because it groups selection.
- Shared primitives retain their own contracts. Spinner, Tooltip, Icon, Link, and
  DropdownMenu behavior does not become Button-specific when composed into a
  member.

## Canonical concepts

| Concept          | Values or states                                                        | Default semantics                                                                   | Stability                                           |
| ---------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------- |
| activation model | momentary action, navigation, persistent press                          | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction                                 |
| content mode     | visible label, custom visible content, icon-only                        | every control has a required accessible `label`; icon-only hides it visually        | shipped family rule                                 |
| size             | `sm`, `md`, `lg`                                                        | `md`; explicit member size wins over an inherited group size                        | shipped family axis                                 |
| visual state     | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose                             | shipped family rule                                 |
| async action     | absent, fire-once, interruptible persistent action                      | ordinary actions deduplicate while pending; persistent toggles remain reversible    | shipped family distinction                          |
| elevation        | `none`, `low`, `med`, `high`                                            | `none`; the element painting the visible surface owns the shadow                    | shipped family axis; ToggleButton adoption approved |
| grouping         | standalone, spaced set, connected surface                               | semantics and painted containment decide ownership; grouping alone does not         | family rule                                         |

## Cross-component invariants

- **FR1 — Every control has an accessible name.** A Button-family control MUST
  receive a non-empty accessible `label`. A text control renders that label or
  caller-provided visible content. An icon-only control exposes the label through
  `aria-label`; its icon does not substitute for a programmatic name.
- **FR2 — Native action semantics are the default.** A momentary or persistent
  action renders an operable button with keyboard activation, focus-visible
  feedback, and `type="button"` unless the component's documented form mode says
  otherwise. Button's `href` mode is the explicit navigation variation and also
  follows `family:navigation-destinations`.
- **FR3 — Disabled means non-operable.** A disabled member MUST NOT invoke its
  callback or Action. Disabled members stay focusable and follow
  `spec:AST-085`. A group disabled state overrides member availability.
- **FR4 — Pending feedback preserves purpose and geometry.** Where a member
  exposes loading or an Action, pending work MUST set `aria-busy`, keep the
  control's dimensions stable, and present a Spinner without changing the
  accessible purpose. Explicit disabled styling and pending styling remain
  distinct.
- **FR5 — Callback and Action order is consistent.** Where a member exposes both
  a synchronous callback and an Action, the callback runs first. Preventing that
  event prevents the Action. Standalone ToggleButton runs `onPressedChange`
  synchronously before `pressedChangeAction` and skips the Action when the callback
  calls `preventDefault()`. The Action also runs when no callback is supplied.
  A caller `onClick` on a standalone ToggleButton runs before `onPressedChange`
  and `pressedChangeAction`. When that `onClick` calls `preventDefault()`, the
  activation requests no next state and neither runs; otherwise the toggle
  proceeds. A caller `onClick` never replaces the toggle request.
  Without `pressedChangeAction`, callback-only activation MUST remain synchronous
  and report no Action-pending feedback. A fire-once action deduplicates activation
  while pending; an explicitly interruptible persistent action may accept a new
  activation and replace the in-flight intent.
- **FR6 — Persistent state is explicit and reversible.** A ToggleButton MUST
  expose its effective state with `aria-pressed` and request the next controlled
  value on activation. Pending state may be optimistic, but a new activation MUST
  derive from the effective in-flight value rather than a stale committed value.
- **FR7 — Shared size preserves family geometry.** Members using the family size
  axis MUST map `sm`, `md`, and `lg` to the same control-height contract. An
  icon-only member is square at the resolved size. Button and IconButton MUST
  default an Astryx Icon in their owned icon slot to `sm` for `sm` and `md`
  controls and to `md` for `lg` controls; an explicit Icon size MUST win. Label
  weight, pressed state, loading, or icon replacement MUST NOT change the outer
  control dimensions.
- **FR8 — Elevation belongs to the painted surface.** A standalone member that
  paints its visible surface owns its resting elevation. A connected group that
  paints one continuous surface owns one shared elevation and its members paint
  effective elevation `none`. A semantic or layout group that paints no shared
  surface MUST NOT acquire elevation solely because it contains buttons.
- **FR9 — Elevation is independent from interaction state.** Pressed, hover,
  focus, active, loading, disabled, and icon states MUST NOT change the owner or
  tier of elevation. They may change the treatment painted on that surface.
- **FR10 — Public, rendered, and theming states agree.** When a member exposes a
  family visual axis, its public prop, effective rendered `data-*` state, and
  documented theme visual prop MUST describe the value it actually paints. A
  wrapper MUST NOT report an ignored child value as effective output.
- **FR11 — A button-specific group has one accessible owner.** ButtonGroup and
  ToggleButtonGroup MUST expose an accessible group label and propagate their
  documented size and disabled defaults without removing a member's accessible
  name. The group owns only the behavior and surface declared by its group mode.
  A ToggleButtonGroup member identified by `value` MUST derive pressed state and
  selection requests from the group, not its standalone `isPressed`,
  `onPressedChange`, or `pressedChangeAction`. Member `onPressedChange` and
  `pressedChangeAction` are ignored. Member activation MUST NOT start a member
  Action or report member Action-pending feedback.
- **FR12 — Connected and spaced groups stay distinct.** ButtonGroup's connected
  presentation removes inter-member gaps, shares outer edges, owns one elevation,
  and uses its documented roving-focus keyboard model. The current
  ToggleButtonGroup keeps distinct child surfaces separated by a gap; each child
  may own its own elevation, and the wrapper owns no shadow. A future connected
  toggle presentation MUST move elevation to the group and make members flat.

## Allowed component variation

- **AV1 — Momentary versus persistent action.** Button and IconButton do not
  retain pressed state. ToggleButton owns `isPressed`, `onPressedChange`,
  `pressedChangeAction`, and pressed-state presentation.
- **AV2 — Visible versus icon-only content.** Button may render a visible label,
  custom visible content, a leading icon, and end content. IconButton always
  renders one required icon with no visible label. ToggleButton may use either
  visible or icon-only content and may replace its icon when pressed.
- **AV3 — Visual emphasis.** Button and IconButton expose the Button variant map.
  ToggleButton owns its selected/depressed treatment instead of inventing a
  momentary-action hierarchy. Themes may vary appearance without changing these
  semantics.
- **AV4 — Navigation.** Button and IconButton may render as links when `href` is
  supplied. ToggleButton remains an action with `aria-pressed`; navigation backed
  by its Action does not turn the control into a destination link.
- **AV5 — Group keyboard model.** ButtonGroup may use roving focus for a connected
  action cluster. ToggleButtonGroup retains the keyboard and selection behavior
  documented for its pressed-button set. Visual similarity alone does not require
  both groups to use the same composite-widget model.
- **AV6 — Tooltip ownership.** A component may require an explicit tooltip or
  provide a documented automatic tooltip for icon-only use. The accessible label
  remains required either way.

## Representative matrix

| Member and state                                   | Shared invariant                                                   | Deliberate variation                                                    |
| -------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `component:Button` / ordinary action               | named, sized, focusable action surface; fire-once pending behavior | visible content, variants, form type, and optional link mode            |
| `component:IconButton` / compact action or FAB     | Button behavior, square geometry, family elevation                 | required icon; label is programmatic rather than visible                |
| `component:ToggleButton` / pressed and unpressed   | Button geometry, naming, focus, pending, and surface ownership     | controlled `aria-pressed`, pressed icon/treatment, interruptible Action |
| `component:ButtonGroup` / horizontal or vertical   | labeled group; inherited size/disabled state                       | connected edges, one shared elevation, roving focus                     |
| `component:ToggleButtonGroup` / single or multiple | labeled group; inherited size/disabled state                       | selection owner; spaced child surfaces; no wrapper elevation            |
| future connected toggle group                      | labeled selection group and pressed semantics                      | one painted group surface owns elevation; members paint none            |

## Adoption and exceptions

| Component                     | Current adoption                                                                       | Gap or exception                                                                              |
| ----------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `component:Button`            | owns the shared surface, size, state, Action, link, and elevation mechanisms           | link mode additionally follows `family:navigation-destinations`                               |
| `component:IconButton`        | inherits Button props and always selects icon-only presentation                        | tooltip remains explicit consumer guidance                                                    |
| `component:ToggleButton`      | inherits Button geometry, focus, pending presentation, and rendered elevation state    | approved adoption gap: add the existing optional `elevation` prop and matching theme metadata |
| `component:ButtonGroup`       | connected surface, inherited size/disabled state, shared elevation, and roving focus   | member elevation is intentionally suppressed while connected                                  |
| `component:ToggleButtonGroup` | controlled single/multiple selection, inherited size/disabled state, and spaced layout | wrapper is not a painted surface and therefore exposes no family elevation                    |

ToggleButton elevation is an approved implementation gap. Adding the existing
optional `Elevation` axis restores family parity without changing no-prop
rendering. Its current transparent surface is accepted without claiming the
transparency question is solved; the shadow may provide the floating boundary,
and any later fill or outline treatment requires separate visual review.

## Verification map

| Contract  | Verification                                                                                                                                          | Representative members and states                                                                              | Mutation or failure expectation                                                                  |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| FR1–FR3   | role/name, keyboard, callback, disabled, and disabled-reason tests; native-disabled assertions pending conformance (`spec:AST-085`)                   | text Button, IconButton, ToggleButton, link mode, member/group disabled                                        | a member loses its name, keyboard path, or invokes while disabled                                |
| FR4–FR6   | `ToggleButton.test.tsx`: callback/Action order and cancellation, callback-only no-pending path, Action-only settlement, and optimistic re-click tests | standalone ToggleButton with either/both callbacks; Button fire-once Action                                    | callback is dropped, an empty Action reports busy, cancellation is bypassed, or stale state wins |
| FR7       | unit plus real-browser geometry checks                                                                                                                | all sizes; text/icon-only; pressed/unpressed; loading                                                          | family heights diverge, icon-only stops being square, or state shifts outer size                 |
| FR8–FR10  | data attribute, theme metadata, and computed-shadow tests                                                                                             | standalone Button/IconButton/ToggleButton; connected and spaced groups; every elevation tier                   | shadow lands on the wrong box, state changes depth, or public/theme/rendered values disagree     |
| FR11–FR12 | group semantics, propagation, DOM, keyboard, and rendered-surface tests; `ToggleButton.test.tsx` group ownership cases                                | single/multiple selection with member callbacks/Actions and delayed parent acceptance; connected/spaced groups | member callback or Action competes with group state, or surface ownership is conflated           |

## Decision links

- `spec:AST-002/DEC-1` — public API admission is explicit and evidence-backed.
- `family:navigation-destinations` — Button link mode retains the shared
  navigation safety contract.

## Open questions

None.

## Content boundary

This file owns only cross-component Button-family behavior. Component-specific
prop tables, callback payload types, selection algorithms, variants, tooltip
policy, group layouts, implementation mechanisms, current audit results, and
product-specific action hierarchy remain with their component, architecture,
design, audit, or callsite owners.

---

### Families Input Fields (`docs/families/input-fields.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:input-fields
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-20
owners: [cixzhang, imdreamrunner]
review_triggers: [behavior, layout, theming, accessibility]
verified_by:
  [
    packages/core/src/Field/Field.test.tsx,
    packages/core/src/InputGroup/InputGroup.test.tsx,
    packages/core/src/TextInput/TextInput.test.tsx,
    packages/core/src/Selector/Selector.test.tsx,
    packages/core/src/Typeahead/Typeahead.test.tsx,
    packages/core/src/Tokenizer/Tokenizer.test.tsx,
    packages/core/src/inputFontFloor.test.ts,
  ]
members:
  [
    component:TextInput,
    component:TextArea,
    component:NumberInput,
    component:DateInput,
    component:DateRangeInput,
    component:DateTimeInput,
    component:TimeInput,
    component:FileInput,
    component:Selector,
    component:MultiSelector,
    component:ComplexSelector,
    component:Typeahead,
    component:Tokenizer,
  ]
architecture:
  [
    architecture:component-theming-surface,
    architecture:interaction-modality,
    architecture:public-component-api,
  ]
contributing: []
deciding_specs: [spec:AST-001/DEC-1, spec:AST-001/DEC-2, spec:AST-002/DEC-1]
---

# Input field family contract

## Intent

A person should encounter one coherent input system: state display, behavior,
appearance, and size use consistent treatment and API contracts across members.
Each component still owns the interaction model specific to the value it edits.

This record separates normative family requirements from shipped facts. The
frontmatter `verified_by` list names representative evidence anchors rather than
every member suite. The membership list and adoption table are exhaustive for
the current family; changes to any member still require its focused evidence.

## Membership rule

A component belongs when its primary public purpose is collecting or editing a
form value through a labeled field surface with a content lane. Members use
`Field` semantics or own an equivalent field shell and can participate in
`FormLayout`.

- **Members:** TextInput, TextArea, NumberInput, DateInput, DateRangeInput,
  DateTimeInput, TimeInput, FileInput, Selector, MultiSelector,
  ComplexSelector, Typeahead, and Tokenizer.
- **Collaborators:** Field and FieldStatus provide shared field chrome;
  FormLayout arranges fields; InputGroup groups members that explicitly adopt its
  capability contract; InputClearButton, Spinner, Tooltip, and BaseTypeahead provide
  shared affordance or interaction behavior. These collaborators are not members.
- **Excluded:** CheckboxInput, RadioList, Switch, and Slider use labeled-control
  models without this content/end-lane geometry. PowerSearch and ChatComposer
  are higher-level compositions that consume member behavior rather than define
  another field primitive. BaseTypeahead is a combobox engine without a field
  surface. RichTextEditor stays outside this family until a family adoption is
  recorded.

FR10 and DEC-7 also bind CheckboxList, RadioList, and Slider. They stay
outside this family's geometry rules, but they compose Field, and every
component that composes Field targets base props and styling the same way.

Membership follows public responsibility, not use of `inputWrapperStyles` or
another implementation helper.

## Shared owner

- This family owns the consistent cross-component treatment and API contract for
  input state display, behavior, appearance, and size. Sizing and end-control
  geometry are invariants within that broader purpose, not the purpose itself.
- `Field` owns the standalone label, description, status placement, and explicit
  `width` seam, and is the member's `container*` styling target under FR10 when
  it renders a box. A member omits its nested Field when a supported InputGroup
  owns the group label and supporting text.
- `FormLayout` owns outer arrangement, spacing, direction, and form-level
  optionality. It does not redefine a member's internal control geometry.
- `InputGroup` owns the fixed row height, connected outer surface, edge alignment,
  and group-level focus presentation for admitted children. Admission follows the
  capability contract in FR3, not a permanent component allowlist; input-family
  membership alone does not imply support.
- Each admitted member owns the grouped adaptation that satisfies FR3. Ungrouped
  presentation and behavior remain component-owned and unchanged by admission.
  End controls may be ordinary flex items or a component-local lane; this family
  does not require one shared wrapper, measurement hook, target, custom property,
  or new public prop.
- Shared primitives retain their own visual and accessibility ownership. In
  particular, InputClearButton, Spinner, Tooltip, FieldStatus, and Icon do not
  become member-specific APIs when composed into a field.
- FieldStatus owns its direct `attached` and `detached` message-box
  presentations. Field owns composition with an input: it consumes `tooltip`
  before rendering FieldStatus and applies the member's supported placement
  contract.

## Canonical concepts

| Concept           | Values or states                                               | Default semantics                                                                                                                    | Stability                                            |
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| field shell       | standalone Field or supported InputGroup path                  | One label/status owner surrounds the control                                                                                         | shipped pattern                                      |
| field size        | `sm`, `md`, `lg` where supported                               | Same-size single-line controls align without one state changing the row                                                              | approved family rule                                 |
| inline size       | intrinsic, explicit `Field.width`, or containing layout        | A member keeps its available inline size across value and busy-state changes                                                         | approved rule; Selector exception below              |
| end controls      | absent, clear, busy, status, disclosure, or component content  | Every rendered control has non-overlapping space; absent controls leave no unexplained reserve                                       | approved family rule                                 |
| grouped row       | standalone or admitted InputGroup child                        | InputGroup owns fixed height, connected edges, and group focus; the child owns its grouped adaptation                                | approved capability contract; shipped adoption below |
| disabled reason   | absent or component-supported `disabledMessage`                | The reason remains keyboard- and assistive-technology-reachable while mutation stays blocked                                         | approved family rule where exposed                   |
| input busy        | explicit `isLoading` or pending `changeAction` where exposed   | The field value is resolving or being saved                                                                                          | approved by DEC-2 and AST-001                        |
| focus-zoom floor  | iOS or non-iOS text entry                                      | iOS text-entry controls floor rendered text at 16 CSS px to prevent focus zoom; other platforms retain theme sizing                  | approved family rule                                 |
| source busy       | component-owned async search or option loading                 | Supporting data work is separate from input `isLoading`                                                                              | component/system-spec owned                          |
| Transition Action | absent or component-supported `changeAction`                   | Callback first, optimistic value next, Action in a transition, one busy presentation                                                 | approved family rule where exposed                   |
| status placement  | `attached`, `detached`, or `tooltip` at the input-family layer | Attached is the default where the member supports safe overlap; detached and tooltip remain available to every input                 | approved family rule                                 |
| prop targets      | base props, `container*`, `control*`                           | Base props reach the primary semantic control; `container*` reaches Field; `control*` reaches the control box; no root styling input | approved by DEC-7                                    |

## Cross-component invariants

- **FR1 — Inline size is stable across ordinary field states.** In the same
  containing layout, a member MUST NOT change its outer available inline size
  merely because placeholder content becomes a value, or because a busy,
  status, or clear control appears. Explicit `width`, parent layout, and
  responsive constraints still apply. Standalone Selector is the DEC-1
  exception and may follow its displayed content.
- **FR2 — Rendered end controls own non-overlapping space.** Text, tokens, the
  caret, and selected content MUST NOT paint or receive pointer input underneath
  a clear action, Spinner, status control, disclosure, or component-owned end
  content. A lane that is absent MUST NOT leave stale or unexplained space.
  This is an observable requirement, not a mandate for measurement or a shared
  lane primitive.
- **FR3 — InputGroup admission requires a complete grouped adaptation.** Any input
  member MAY participate when its owning component explicitly adopts grouped mode
  and proves all of the following: it resolves a compatible control height and size
  from the group; removes or suppresses competing outer Field, border, radius, and
  surface geometry; delegates the connected outer border, radius, and group-level
  focus presentation coherently; keeps content within the single-row group geometry
  through component-owned truncation, folding, clipping, or overflow; and preserves
  the input's accessible name and description, value semantics, keyboard behavior,
  focus behavior, and editing or selection model. Admission follows these
  capabilities rather than a permanent allowlist. Family membership or context
  consumption alone is insufficient.
- **FR4 — Disabled reasons remain reachable where supported.** When a member
  exposes `disabledMessage`, the inactive field remains focusable
  (`spec:AST-085`) and exposes the reason while editing, selection, and
  activation stay blocked.
  Components without that public concept are not required to add it.
- **FR5 — Input loading describes the value, not supporting data.** Where a
  member exposes `isLoading`, it means the field value is resolving or being
  saved. It MUST NOT make independently supplied options unavailable or change
  a data-source prop's contract. Initial option-source pending is the separate
  explicit state required by AST-001.
- **FR6 — Transition Actions preserve immediate feedback.** Where a member
  exposes `changeAction`, every documented value-change path runs `onChange`
  first, presents the proposed controlled value optimistically, runs
  `changeAction` in a React transition, and contributes to the same busy
  presentation until the controlled value accepts or replaces it.
- **FR7 — Component-owned source work keeps family geometry and accessibility.**
  Typeahead and Tokenizer own asynchronous search state through BaseTypeahead,
  not through the family `isLoading` meaning. Their visible source-busy feedback
  still MUST be named, expose busy semantics on the combobox, and satisfy FR1,
  FR2, and FR3 where grouped.
- **FR8 — Status placement follows member capability.** Every input MUST offer
  `detached` and `tooltip`. A member supports `attached` only when its direct
  control is opaque, bordered, fixed-height, and its owning root reliably
  reflects the resolved size used for overlap. Where supported, `attached` is
  the default. Wrapped, custom, tall, or translucent surfaces normally use
  `detached` or a component-owned placement. Direct FieldStatus continues to
  support only `attached` and `detached`; Field consumes `tooltip` before
  rendering it.
- **FR9 — The focus-zoom floor is iOS-specific.** A text-entry control that uses
  the family focus-zoom guard MUST resolve its editable text to at least 16 CSS
  px on the supported iOS touch path. Android, desktop touch, and other non-iOS
  coarse-pointer environments MUST keep the active theme's type scale rather
  than inheriting the iOS floor. The platform-detection mechanism remains an
  implementation detail, but it MUST preserve those observable outcomes.
- **FR10 — Base props reach the control; styling reaches named parts only.**
  This is the input family's exception to base styling: where
  `architecture:public-component-api` and `spec:AST-071` give a component a root
  `xstyle`, `className`, and `style`, a Field-composed component has none. In
  every component that composes Field:
  1. `ref`, `id`, `data-*` (including `data-testid`), `aria-*`, `hidden`,
     `inert`, `dir`, and DOM event handlers MUST reach the primary semantic
     control: the native input, textarea, or combobox/trigger element that
     carries the field's accessible name and value. For a group input
     (RadioList, CheckboxList) the primary semantic control is the group element
     (`role="radiogroup"` or `role="group"`); per-option props stay on the option
     API.
  2. The component's props type MUST NOT accept root `xstyle`, `className`, or
     `style`; passing one is a type error. A development build warns when an
     untyped caller passes one at runtime, and the value is not applied.
  3. `containerXstyle`, `containerClassName`, and `containerStyle` MUST reach the
     Field box when one renders. Where none renders (inside InputGroup, and in
     horizontal-label FormLayout, where Field lays out its children directly in
     the form's columns), they have no effect and a development build warns;
     inside InputGroup the caller styles the group.
  4. `controlXstyle`, `controlClassName`, and `controlStyle` MUST reach the
     visual control box: the element that paints the field border and
     background and contains its icons and end controls. They never target the
     native input. Each set is one `spec:AST-071` part set.
  5. Styling never reroutes: no layout moves `container*` to the control box or
     `control*` to Field.

  Component-owned value callbacks (`onChange(value)`) are typed out of base
  props and keep their value signature. Retargeting a base prop changes the
  element a consumer DOM handler reaches, not its order or cancellation behavior:
  a component keeps the order and cancellation it released and states any order
  that differs from component-first composition in its docs and JSDoc. A handler
  a component forwards for the first time composes component-first through the
  shared composition helper. No component and no layout may vary the prop
  targets.

- **FR11 — The clear control's tooltip names the action, not the field.**
  Every member's clear control MUST show the localized generic tooltip "Clear"
  on hover and keyboard focus, whatever the field's label or the scope of the
  clear. Its accessible name stays contextual and member-owned, such as "Clear
  Search" or "Clear all Countries", and contains the visible word.

## Allowed component variation

- **AV1 — Native versus composed control.** Text fields may use native inputs;
  selection controls may use button/listbox or dialog composition.
- **AV2 — End-control mechanism and meaning.** A member may use in-flow flex
  items or a component-local out-of-flow lane and may render clear, busy,
  status, disclosure, stepper, or documented custom content. The mechanism does
  not weaken FR1 or FR2.
- **AV3 — Block-axis growth and grouped adaptation.** TextArea and Tokenizer may
  grow in the block axis for multiline or multi-token content while ungrouped.
  InputGroup admission does not change those standalone defaults. In grouped mode,
  an admitted component owns a presentation that satisfies FR3. Tokenizer is
  admitted on that basis and, when the caller does not choose another supported
  overflow mode, MUST default to a component-owned overflow treatment that preserves
  the group's single-line appearance. This grouped default does not require a new
  public prop or theme target.
- **AV4 — Action callback shape.** Text-editing members may pass their change
  event to `changeAction`; structured-value members may pass only the proposed
  value. Component docs own the exact callback type.
- **AV5 — Supported concepts vary.** A member need not add `size`, `isLoading`,
  `changeAction`, `disabledMessage`, clear, or InputGroup support merely to join
  the family. When it exposes one of those concepts, the matching family rule
  applies.
- **AV6 — Attached-status support.** A member that cannot satisfy FR8 keeps
  attached unavailable rather than approximating the overlap. It still offers
  detached and tooltip through the family status contract.
- **AV7 — Platform detection.** Implementations may use any supported capability
  test that identifies the iOS touch path without applying the floor to non-iOS
  coarse pointers. This contract does not require a particular CSS query,
  browser string, or shared helper.

## Representative matrix

| Member and state                                                    | Shared invariant                                                                                                                                         | Deliberate variation                                                                                          |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| TextInput / empty, valued, or input-busy                            | Stable inline size; in-flow end controls do not overlap text                                                                                             | Native input and optional clear/status controls                                                               |
| Selector / placeholder or selected                                  | End controls and grouped row remain bounded                                                                                                              | Standalone inline size may follow displayed content under DEC-1                                               |
| Typeahead / empty, selected, or source-busy                         | Stable inline size and one non-overlapping end area, standalone and grouped                                                                              | Editable input becomes a selected Token; source busy is BaseTypeahead-owned                                   |
| Tokenizer / standalone tokens, source-busy, and endContent          | Stable inline size and clear content/end-control separation                                                                                              | Standalone tokens may wrap and grow the field in the block axis                                               |
| Tokenizer / admitted InputGroup mode                                | Group height, connected surface, semantics, and keyboard behavior remain coherent                                                                        | Grouped default uses a single-line overflow treatment; caller-selected supported overflow may vary within FR3 |
| ComplexSelector / placeholder, selected, or input-busy              | Stable field surface and non-overlapping Spinner/disclosure                                                                                              | Caller owns rich popup content; no shipped InputGroup support                                                 |
| Admitted member / InputGroup                                        | Compatible size/height, one outer surface/focus owner, one-row geometry, and preserved semantics                                                         | Component owns its internal grouped truncation, folding, clipping, or overflow                                |
| Member with `disabledMessage`                                       | Reason is reachable; mutation remains blocked                                                                                                            | Component owns the focus target and Tooltip composition                                                       |
| Member / validation status                                          | Detached and tooltip are available; attached is default only when supported                                                                              | Component owns whether its control safely satisfies attached eligibility                                      |
| Floored text entry / iOS touch                                      | Editable text resolves to at least 16 CSS px and focus does not trigger browser zoom                                                                     | Theme size may already satisfy the floor                                                                      |
| Floored text entry / non-iOS coarse pointer                         | The active theme's type scale remains in force                                                                                                           | Platform and input hardware may vary                                                                          |
| Field-composed component / standalone, horizontal-label, InputGroup | Base props on the primary semantic control; `container*` on the Field box where one renders; `control*` on the control box; root styling is a type error | `container*` has no effect where no Field box renders                                                         |

## Adoption and exceptions

### Current shipped facts

| Component       | Current adoption                                                                                                           | Recorded exception                                                                                                                              |
| --------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| TextInput       | Field, FormLayout, InputGroup, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                | none                                                                                                                                            |
| TextArea        | Field, FormLayout, size, width, `isLoading`, `changeAction`, status, and disabled reason; block-axis growth                | not InputGroup-compatible                                                                                                                       |
| NumberInput     | Field, FormLayout, InputGroup, size, width, clear, status, and disabled reason                                             | no shipped `isLoading` or `changeAction`                                                                                                        |
| DateInput       | Field, FormLayout, InputGroup, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                | none                                                                                                                                            |
| DateRangeInput  | Field, FormLayout, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                            | not InputGroup-compatible                                                                                                                       |
| DateTimeInput   | Field, FormLayout, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                            | not InputGroup-compatible                                                                                                                       |
| TimeInput       | Field, FormLayout, InputGroup, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                | none                                                                                                                                            |
| FileInput       | Field, FormLayout, width, `isLoading`, `changeAction`, clear, status, and disabled reason                                  | no public `size`; compact and dropzone modes are component-owned                                                                                |
| Selector        | Field, FormLayout, InputGroup, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                | DEC-1 permits standalone content-sized width                                                                                                    |
| MultiSelector   | Field, FormLayout, InputGroup, size, width, `isLoading`, `changeAction`, clear, status, and disabled reason                | trigger-display modes remain component-owned                                                                                                    |
| ComplexSelector | Field, FormLayout, size, width, `isLoading`, `changeAction`, and status                                                    | no InputGroup or disabled-reason API                                                                                                            |
| Typeahead       | Field, FormLayout, InputGroup, size, width, clear, status, disabled reason, and BaseTypeahead source loading               | no family `isLoading` or `changeAction`; search lifecycle is component-owned                                                                    |
| Tokenizer       | Field, FormLayout, size, width, clear, status, disabled reason, BaseTypeahead source loading, and multi-token block growth | Not InputGroup-compatible on current main; DEC-5 approves admission after the grouped adaptation lands; no family `isLoading` or `changeAction` |

The current text-entry controls that already carry a 16px anti-zoom floor apply it
only on iOS. This includes input-family members and the existing shared or
higher-level text-entry collaborators covered by the family-wide source guard;
DEC-6 does not require unrelated components to add a new floor.

### Implementation gaps

- **Typeahead state geometry:** current main removes the intrinsic-width input
  from layout when a selected Token is shown, so a content-sized parent can
  collapse the field onto the value. Its clear control is separately positioned,
  and current source-busy feedback is an in-flow static clock without
  `aria-busy`; selected, source-busy, and grouped combinations do not have the
  focused geometry coverage required by FR1–FR3 and FR7. PRs
  [#5555](https://github.com/facebook/astryx/pull/5555) and
  [#5682](https://github.com/facebook/astryx/pull/5682) document attempted
  implementations and are non-authoritative evidence. Any implementation keeps its
  DOM/CSS mechanism component-owned and must be reviewed against this contract at
  its exact head.
- **Tokenizer state geometry and grouped admission:** current main positions
  `endContent` and clear controls out of flow without reserving their rendered
  width, so narrow input content can pass underneath them. Token count can also
  change intrinsic inline size. PR #5555 is non-authoritative evidence of an
  attempted component-local reserve. Tokenizer is not InputGroup-compatible on
  current main; DEC-5 admits it once its grouped path satisfies FR3 and defaults to
  a single-line overflow treatment without changing ungrouped behavior. [#4405](https://github.com/facebook/astryx/pull/4405)
  is implementation evidence to review against that authority, not a Tokenizer-only
  exception or authority by itself.
- **Selector source-state semantics:** Selector and MultiSelector keep provided
  options available while input `isLoading` is true, but current empty/no-result
  presentation still treats that flag as option-source pending and neither
  component has the explicit source-pending API required by AST-001.
- **Transition Action coverage:** current TextInput and FileInput clear
  affordances call `onChange` without `changeAction`, and FileInput does not
  explicitly hold the proposed controlled files as an optimistic value while
  its Action is pending. Other exposed clear paths inspected on current main
  route through their ordinary Action pipeline. These are conformance gaps
  under FR6, not alternate family semantics.
- **Status placement conformance:** current Field defaults attached for arbitrary
  children without proving the control satisfies FR8. TextArea, Tokenizer, and
  FileInput's dropzone are representative tall, wrapped, or custom surfaces that
  cannot inherit attached solely from the shared default. Some member docs also
  omit tooltip or the whole status-placement projection even though Field
  consumes the family sentinel. Follow-up work must make attached capability and
  the three input-level choices accurate in each member's API and docs.
- **Attached FieldStatus overlap:** attached placement is limited to eligible
  members whose direct control and owning root reliably expose resolved size.
  Eligible members preserve attached placement; unsupported wrapped, custom, tall,
  and translucent surfaces retain component-owned placement, normally detached.
  [PR #5769](https://github.com/facebook/astryx/pull/5769) is non-authoritative
  evidence of an attempted implementation that derives overlap from a descendant
  `data-size`; that mechanism does not by itself establish eligibility or root-size
  ownership.
- **Prop targets:** current components do not yet meet FR10. Every component
  below accepts root `xstyle`/`className`/`style`, and none accepts `container*`
  or `control*`. Today's targets:

  | Component                          | Root styling reaches | Base props reach                       | Primary semantic control    | Root styling migrates to |
  | ---------------------------------- | -------------------- | -------------------------------------- | --------------------------- | ------------------------ |
  | TextInput                          | control box          | native input                           | native input                | `control*`               |
  | TextArea                           | control box          | native textarea                        | native textarea             | `control*`               |
  | NumberInput                        | control box          | native input                           | native input                | `control*`               |
  | FileInput                          | control box          | native file input                      | native file input           | `control*`               |
  | DateInput (pointer, native, touch) | control box          | control box                            | date input                  | `control*`               |
  | DateRangeInput                     | control box          | control box; `ref` on a trigger button | range trigger               | `control*`               |
  | DateTimeInput (pointer, touch)     | control box          | control box                            | date-time input             | `control*`               |
  | TimeInput (typed, native, touch)   | control box          | not forwarded                          | time input                  | `control*`               |
  | Selector                           | control box          | trigger button                         | trigger button              | `control*`               |
  | MultiSelector                      | control box          | not forwarded                          | trigger button              | `control*`               |
  | ComplexSelector                    | control box          | control box                            | trigger button              | `control*`               |
  | Typeahead                          | Field                | Field                                  | combobox input              | `container*`             |
  | Tokenizer                          | Field                | Field                                  | combobox input              | `container*`             |
  | CheckboxList                       | Field                | Field                                  | `role="group"` element      | `container*`             |
  | RadioList                          | Field                | not forwarded; `ref` on Field          | `role="radiogroup"` element | `container*`             |
  | Slider                             | Field                | not forwarded; `ref` on the track      | slider control              | `container*`             |

  Conformance is an approved removal of root
  styling; an `astryx upgrade` codemod rewrites each component's root styling to
  the part it reaches today, per the last column. A codemod cannot follow
  styling passed through a product wrapper's own props or spread; FR10's type
  error surfaces those callsites.

- **Verification coverage:** current unit tests prove composition, semantics, and
  individual state behavior, but do not yet provide the complete real-browser
  inline-size/end-overlap matrix below. This current record names those
  implementation gaps rather than claiming they are already fixed.

The verification map names representative evidence, not every member suite.
The exhaustive coverage obligation follows the membership and adoption tables.

## Verification map

| Contract | Verification                                                                                                                                                                                                                                  | Representative members and states                                                                                                 | Mutation or failure expectation                                                                                                                                                                                          |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR1      | Real-Chromium before/after inline-size measurements in block, flex-item, inline-block, grid auto-track, and explicit-width containers                                                                                                         | TextInput baseline; Selector exception; Typeahead empty/selected/source-busy; Tokenizer zero/one/many tokens                      | Value or busy state changes the field's outer inline size outside a recorded exception                                                                                                                                   |
| FR2      | Real-Chromium content-box/control-box overlap matrix at narrow supported widths and LTR/RTL                                                                                                                                                   | clear, Spinner, status, disclosure, Tokenizer `endContent`; alone and combined                                                    | Content, caret, or pointer hit area intersects a rendered end control, or stale reserve remains after it unmounts                                                                                                        |
| FR3      | InputGroup unit tests plus rendered size/height, surface/focus-ownership, overflow, and interaction checks                                                                                                                                    | every admitted member; Tokenizer zero/one/many tokens, focused/unfocused, explicit/default overflow; mismatched child/group sizes | A child keeps competing outer geometry, expands or adds a row, loses connected focus/border ownership, or changes naming, keyboard, focus, editing, or selection semantics                                               |
| FR4      | Keyboard, pointer, and accessibility-tree tests                                                                                                                                                                                               | each member that exposes `disabledMessage`                                                                                        | Reason becomes unreachable, or the disabled control mutates/activates                                                                                                                                                    |
| FR5      | Selector and MultiSelector interaction and announcement tests from AST-001                                                                                                                                                                    | populated options while input-busy; explicit source-pending; completed empty                                                      | Input busy suppresses supplied options or substitutes for source state                                                                                                                                                   |
| FR6      | Focused callback/order/optimistic/busy tests for every Action-capable mutation path                                                                                                                                                           | typing, selection, calendar/preset, file selection, and clear where exposed                                                       | Callback/Action order changes, optimistic feedback disappears, or a clear path bypasses the Action contract                                                                                                              |
| FR7      | Typeahead/Tokenizer source-busy tests and real-browser geometry checks                                                                                                                                                                        | direct BaseTypeahead, standalone wrappers, grouped Typeahead, selected/tokenized values                                           | Busy feedback is unnamed/duplicated, `aria-busy` is absent, or source loading changes row/inline geometry                                                                                                                |
| FR8      | Representative member tests plus real-browser overlap/opacity/height checks                                                                                                                                                                   | eligible single-line direct controls; wrapped, custom, tall, and translucent controls; all three placements                       | Attached is missing where supported, appears where unsafe, or tooltip reaches direct FieldStatus                                                                                                                         |
| FR9      | `inputFontFloor.test.ts`, focused computed-style checks, and real iOS Safari focus evidence                                                                                                                                                   | iOS touch, non-iOS coarse pointer, and desktop fine pointer                                                                       | iOS renders below 16 CSS px or focus zooms, or a non-iOS coarse pointer receives the iOS-only floor                                                                                                                      |
| FR10     | A type test over every Field-composed component's props that fails if one accepts root `xstyle`, `className`, or `style`; per-component render tests for every base prop, `container*`, and `control*` prop and for both development warnings | every Field-composed component standalone, in horizontal-label FormLayout, and inside InputGroup where admitted                   | A component type-checks with root styling, a base prop lands off the primary semantic control, `container*` lands off the Field box or on the control box, `control*` lands off the control box, or a warning is missing |
| FR11     | `InputClearButton.test.tsx` tooltip and accessible-name assertions, plus real-Chromium hover captures                                                                                                                                         | a long field label; a locale override; light and dark                                                                             | The tooltip repeats the field label, or the accessible name loses the field label                                                                                                                                        |

## Decision links

### DEC-1 — Selector is the standalone inline-size exception

**Decider:** `cixzhang`, `2026-08-30`

Selector belongs to the input-field family, but a standalone Selector may size
with its displayed placeholder or selected value. This exception does not remove
constraints imposed by FormLayout, a supported InputGroup, or explicit product
layout.

### DEC-2 — Input loading describes value resolution or saving

**Decider:** `cixzhang`, `2026-08-30`

For members that expose `isLoading`, it describes the field value resolving or
being saved. It does not mean option or supporting data is loading. Data-source
props keep their normal contract while the value is busy.

`spec:AST-001/DEC-1` and `spec:AST-001/DEC-2` apply that distinction to Selector
and MultiSelector: provided options stay available, zero options is a no-choice
state, and initial option-source pending must be explicit.

### DEC-3 — Transition Actions are an input-family contract

**Decider:** `cixzhang`, `2026-08-30`

The optional `changeAction` API and its optimistic/pending behavior belong to the
input family. Component docs own each value and event type, but members do not
independently redefine callback ordering, optimistic feedback, or busy-state
semantics.

Public API admission remains owned by `spec:AST-002/DEC-1`; this family adds no
separate invalid-state or API-guardrail policy.

### DEC-4 — Attached status is conditional capability, not universal geometry

**Decider:** `cixzhang`, `2026-08-31`

Every input offers detached and tooltip status placement. A member offers
attached only when its direct control is opaque, bordered, fixed-height, and its
owning root reliably reflects the resolved size used for overlap. Attached is the
default whenever it is supported. Wrapped, custom, tall, or translucent surfaces
normally use detached or retain component-owned placement.

Direct FieldStatus remains limited to attached and detached presentation. Tooltip
is an input-family composition that Field consumes before rendering FieldStatus.

Rejected: deriving universal attached overlap from any descendant `data-size`,
because descendant metadata does not prove the direct surface is opaque,
bordered, fixed-height, or the owner of that resolved size.

### DEC-5 — InputGroup admission is capability-based

**Decider:** `cixzhang`, `2026-09-09`

InputGroup compatibility is not a closed component allowlist. Any input-family
member may participate when its component contract and implementation explicitly
adopt grouped mode and satisfy FR3: compatible resolved size and control height, no
competing outer Field or surface geometry, coherent connected border/radius/focus
ownership, single-row group geometry, and unchanged input semantics and keyboard
behavior.

Tokenizer is admitted under this rule. Its ungrouped wrapping and overflow defaults
remain unchanged. In grouped mode, when no supported overflow mode is selected by
the caller, Tokenizer defaults to a component-owned treatment that preserves the
group's single-line appearance. The implementation may reuse its existing overflow
surface; this decision adds no public prop, callback, theme target, or generic
InputGroup branch.

Rejected: a permanent hard-coded allowlist, a Tokenizer-only exception, allowing
context consumption without a complete grouped adaptation, or forcing standalone
multiline/multi-token inputs into single-line presentation.

### DEC-6 — The 16px focus-zoom floor applies only on iOS

**Decider:** `cixzhang`, `2026-09-20`

Text-entry controls that carry the anti-zoom safeguard resolve editable text to at
least 16 CSS px on the supported iOS touch path. Other coarse-pointer platforms
keep the active theme's type scale because they do not share the iOS focus-zoom
behavior this safeguard addresses.

The contract owns the observable platform outcomes, not a particular feature
query or source pattern. Existing shared and higher-level text-entry surfaces may
reuse the family rule without becoming input-family members, and this decision
does not require unrelated controls to adopt a new floor.

Rejected: applying the 16px floor to every coarse pointer. Pointer precision does
not establish the iOS browser behavior and inflates text on Android and
input-capable touch laptops without preventing a corresponding zoom failure.

### DEC-7 — Field-composed components forbid root styling

**Decider:** `cixzhang`, `2026-10-11`

A caller passing a base prop to a field wants the control a person operates, so
`ref`, ids, test ids, ARIA, and handlers reach the primary semantic control. A
field has two styleable surfaces, the whole field and its visual control box,
and a root `xstyle` would have to pick one silently. So the family declares an
exception to base styling: no root styling input, only `container*` for Field
and `control*` for the control box, each one AST-071 part set. A product wrapper
that spreads props into an input then gets a type error at the callsite, rather
than a visual move a codemod cannot see. Each part keeps one target in every
member and layout; where its element does not render, the part has no effect
rather than moving to another element.

Rejected: keeping root `xstyle` and routing it to Field; spreads and wrappers would move styling silently.

### DEC-8 — Prop retargeting keeps each component's handler order

**Decider:** `cixzhang`, `2026-10-11`

When a consumer handler runs relative to the component's own, and whether it can
cancel built-in behavior, are released interaction behavior that
`architecture:public-component-api` INV9 keeps stable. Moving base props to the
semantic control changes which element a consumer handler reaches, not that
behavior, so a component keeps the order and cancellation it released and
documents any order that differs from component-first composition.
Component-first cancelling composition applies only to handlers a component
forwards for the first time.

Rejected: composing every retargeted handler component-first; a consumer that
cancels built-in behavior, or that observes events the component cancels, would
change behavior with no API change to signal it.

### DEC-9 — The clear tooltip names only the action

**Decider:** `cixzhang`, `2026-10-13`

The field's label already sits beside the clear glyph, so a sighted person
needs only the action. Repeating the label makes long or noun-phrase labels
read as awkward copy. A screen-reader user does not see the field around the
button, so the accessible name keeps the label.

Rejected: a tooltip that repeats the accessible name; it doubles the label on screen.

## Open questions

None.

## Content boundary

This record owns only cross-component field behavior. Component props,
selection/search algorithms, visual target maps, implementation mechanisms,
current audit results, and product-specific compositions remain with their
component, architecture, design, audit, or callsite owners.

---

### Families Layout Primitives (`docs/families/layout-primitives.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:layout-primitives
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
review_triggers: [public-api, layout, theming]
verified_by:
  [
    packages/core/src/Stack/Stack.test.tsx,
    packages/core/src/Stack/StackItem.test.tsx,
    packages/core/src/Grid/Grid.test.tsx,
    packages/core/src/Center/Center.test.tsx,
  ]
members:
  [
    component:Stack,
    component:HStack,
    component:VStack,
    component:StackItem,
    component:Grid,
    component:GridSpan,
    component:Center,
  ]
architecture:
  [
    architecture:container-padding,
    architecture:public-component-api,
    architecture:component-theming-surface,
  ]
contributing: []
deciding_specs: []
---

# Layout primitives contract

## Intent

Builders should be able to arrange arbitrary content in one or two dimensions,
center it, size its layout box, and express spatial relationships with the same
small vocabulary. Choosing Stack, Grid, or Center changes the arrangement model;
it does not create a second spacing scale or a new meaning for shared prop names.

## Membership rule

A component belongs when its primary public purpose is arranging arbitrary child
content through the shared layout-primitive vocabulary. A modifier belongs with
its parent when it changes one child's participation in that arrangement.

- **Members:** Stack, its HStack and VStack convenience forms, StackItem, Grid,
  GridSpan, and Center.
- **Collaborators:** shared `SpacingStep` and `SizeValue` types; Stack and
  StackItem style utilities; `padding.stylex.ts`; the container-padding
  architecture; component `.doc.mjs` theming metadata and runtime
  `themeProps()` emission.
- **Excluded:** Section, Layout, its named regions, and Toolbar own structural
  region contracts rather than this family's arbitrary-child flow, grid, and
  centering vocabulary. Layout is a general-purpose primitive, but its public
  model is five named slots rather than unrestricted child arrangement.
  FormLayout owns field arrangement and form-level optionality. AspectRatio
  constrains one child's box rather than arranging arbitrary children. Card and
  Dialog are surfaces. AppShell owns the page shell and application navigation
  composition.

Membership follows public responsibility, not implementation mechanism. A
component does not join merely because its source uses flexbox or grid.

## Shared owner

- `SpacingStep` owns the public numeric spacing vocabulary used by member gap
  and padding props.
- `SizeValue` owns the number-as-pixels/string-as-CSS-value contract for member
  box dimensions.
- Stack owns one-dimensional direction, wrapping, and main/cross-axis
  alignment. HStack and VStack are fixed-direction forms of that contract.
- Grid owns fixed and intrinsic/fluid track construction. GridSpan owns one
  child's row and column participation.
- Center owns centering along one or both axes.
- `architecture:container-padding` owns bleed geometry. Applying local padding
  does not by itself publish that protocol.

## Canonical concepts

| Concept            | Values or states                                           | Default semantics                                             | Stability       |
| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------------- | --------------- |
| spacing step       | `0`, `0.5`, `1`, `1.5`, `2`, `3`, `4`, `5`, `6`, `8`, `10` | A token-backed distance for supported gap or padding props    | current         |
| box size           | number or CSS value string                                 | Numbers are pixels; strings pass through as CSS values        | current         |
| flow direction     | horizontal or vertical                                     | Stack defaults to vertical; HStack/VStack fix the direction   | current         |
| alignment          | main/cross axis or physical horizontal/vertical aliases    | The active arrangement model resolves the axis                | current         |
| gap                | uniform, row, or column where supported                    | Space between arranged items, not container inset             | current         |
| padding            | uniform, axis, or logical edge where supported             | Space inside the member's own box                             | current         |
| item participation | intrinsic, fill, self-aligned, column span, or row span    | A modifier changes one child's role in its parent arrangement | current         |
| responsiveness     | intrinsic, wrapping, or consumer-authored                  | Each member documents its own available mechanism             | component-owned |

## Cross-component invariants

- **FR1 — Shared names keep shared value semantics.** A member prop typed as
  `SpacingStep` or `SizeValue` follows the shared value and coercion contract.
  Members expose only the capabilities their arrangement model supports.
- **FR2 — Logical spacing follows writing direction.** Inline start and end are
  logical edges. A member does not reinterpret them as fixed left and right.
- **FR3 — Padding precedence is per edge.** On members with the full padding
  ladder, an explicit edge value wins over its axis value, which wins over
  uniform `padding`; an override changes only that edge.
- **FR4 — Gap and padding stay distinct.** Gap separates arranged items. Padding
  insets content inside a member. Grid's `rowGap` and `columnGap` override its
  uniform gap only on their axis.
- **FR5 — Alignment follows the arrangement model.** Stack resolves main and
  cross axes from direction. Grid aligns items in tracks. Center controls which
  axis or axes are centered. One member does not copy another's props when their
  meanings would differ.
- **FR6 — Modifier components require their parent model.** StackItem controls
  participation in Stack; GridSpan controls participation in Grid. Their
  component contracts own behavior outside the expected parent.
- **FR7 — Responsive behavior is explicit.** Grid may reflow from intrinsic
  track math; Stack wraps only when configured; Center does not create a
  breakpoint. The family does not promise a shared breakpoint or automatic
  region swap.
- **FR8 — Local padding is not a bleed signal.** Stack and Center currently
  apply padding without publishing container inset geometry. Descendants may
  rely on bleed compensation only under a publisher named by
  `architecture:container-padding`.
- **FR9 — The public contract does not prescribe source structure.** Components
  may use member utilities or direct platform layout when their observable API
  and behavior remain correct.

## Allowed component variation

- **AV1 — Arrangement model.** Stack uses one-dimensional flow, Grid uses
  two-dimensional tracks, and Center uses one- or two-axis alignment.
- **AV2 — Available props.** Stack, HStack, VStack, and Center expose the full
  logical-edge padding ladder. Grid exposes axis-specific gaps. Modifier
  components expose parent-specific participation instead of box-level layout
  controls.
- **AV3 — Element ownership.** Stack, HStack, VStack, and StackItem are
  polymorphic. Grid, GridSpan, and Center currently own fixed div elements.
- **AV4 — Overflow.** Stack, HStack, VStack, and StackItem expose their current
  scroll behavior; Grid and Center do not gain it through family membership.
- **AV5 — Responsiveness.** Grid's intrinsic columns, Stack wrapping, and
  consumer-authored responsive styles remain component-specific.
- **AV6 — Implementation.** Raw flex/grid and shared utilities are both allowed.
  Family membership does not create migration debt for implementation-only
  choices.
- **AV7 — Theming.** Current component `.doc.mjs` metadata documents shipped
  targets and capabilities, and runtime `themeProps()` calls emit them.
  `architecture:component-theming-surface` owns the cross-component rules.
  A component spec may add optional anatomy-mapping metadata when one exists;
  this family does not assume or replace such a spec.

## Representative matrix

| Member and state               | Shared invariant                                                    | Deliberate variation                                              |
| ------------------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Stack / vertical or horizontal | Shared spacing and sizing values; direction resolves alignment axes | May wrap, scroll, or render a chosen element                      |
| HStack or VStack               | Same Stack contract with a fixed direction                          | Narrower alignment types match the fixed axes                     |
| StackItem / `size="fill"`      | One child may consume remaining Stack space                         | `isScrollable` pairs overflow with StackItem's flex minimum reset |
| Grid / fixed columns           | Shared size and gap values                                          | Explicit equal-width track count (`columns={N}`); never reflows   |
| Grid / intrinsic columns       | Shared size and gap values                                          | `{minWidth, max?, repeat?}` constructs fluid tracks that reflow   |
| GridSpan / columns or rows     | Modifier participates in Grid                                       | Spans tracks rather than controlling parent geometry              |
| Center / one or both axes      | Shared size and padding values                                      | Needs available size on an axis before centering is observable    |

## Adoption and exceptions

| Component or concern  | Adoption                             | Current gap or exception                                                             |
| --------------------- | ------------------------------------ | ------------------------------------------------------------------------------------ |
| Stack, HStack, VStack | Shared one-dimensional contract      | Gap token mapping is implemented separately from Grid's mapping                      |
| StackItem             | Shared modifier contract             | No family-wide flex-item grow/shrink/basis vocabulary exists                         |
| Grid, GridSpan        | Shared two-dimensional contract      | No cross-component test proves Grid and Stack resolve equal gap steps equally        |
| Center                | Shared sizing and padding vocabulary | Like Stack, its padding is local and does not publish bleed geometry                 |
| Shared verification   | Per-component unit coverage          | No computed-style matrix proves every shared spacing and sizing value across members |

These are shipped coverage or adoption gaps, not authorization to add props or
change layout behavior in a documentation pull request.

## Change coupling

- Adding or changing a shared spatial prop checks whether its name, value type,
  coercion, logical direction, and precedence still match this family.
- Changing `SpacingStep` or `SizeValue` reviews every member that exposes the
  affected type and updates representative cross-component evidence.
- Changing Stack direction/alignment updates HStack and VStack in the same
  reviewed change.
- Changing intrinsic Grid track construction preserves its documented fixed,
  fill, fit, and capped states with focused tests.
- Enrolling a member in container bleed is a separate
  `architecture:container-padding` change with rendered compatibility evidence.
- A new component joins only when its public responsibility satisfies the
  membership rule; use of flexbox, grid, or a shared utility is insufficient.

## Verification map

| Contract | Verification                                               | What the evidence proves                                                                                                                                | Missing evidence                                                                                                                       |
| -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR3 | Stack and Center source plus their padding class-set tests | The current merge order selects edge over axis over uniform padding for the exercised values, and equivalent spellings emit equal functional class sets | No browser matrix compares computed values across members or both writing directions                                                   |
| FR2      | Logical-property source declarations in Stack and Center   | The implementation uses inline-start/end properties rather than physical left/right properties                                                          | Current tests do not render the padding ladder under both LTR and RTL                                                                  |
| FR4      | Stack/Grid source and their local tests                    | The APIs keep gap separate from padding; Grid accepts uniform and axis gap props                                                                        | Grid's gap/rowGap/columnGap tests assert render success, not computed spacing, and no test compares a step with Stack                  |
| FR5      | Stack, Grid, and Center source plus local render tests     | Each member currently routes alignment through its own arrangement model                                                                                | Stack/Grid tests do not assert computed alignment for the accepted values                                                              |
| FR6      | StackItem and GridSpan source plus local tests             | Source maps `size="fill"` into StackItem's fill style; scrolling changes class output; GridSpan asserts exact inline row/column spans                   | StackItem's fill test asserts only rendered content, not fill behavior; no integration test proves modifiers across every parent state |
| FR7      | Exact Grid track-output tests plus Stack/Center source     | Grid's fixed and intrinsic track strings are pinned; Stack wraps only when configured; Center has no breakpoint path                                    | No cross-member responsive integration matrix exists                                                                                   |
| FR8      | Source review against `architecture:container-padding`     | Stack and Center apply local padding without publishing container inset variables                                                                       | No browser assertion demonstrates the resulting non-bleed behavior for Divider/Table descendants                                       |

The tests are component-local and several assert only render success or class
change. They do not prove computed gap, alignment, logical-direction, or
cross-component parity. Those are named verification gaps, not implied coverage.

## Decision links

### DEC-1 — Composition primitives and structural regions have separate owners

**Decider:** `cixzhang`, `2026-08-30`

Stack, Grid, Center, and their modifiers form the layout-primitives family.
Section, Layout regions, and Toolbar have a separate structural-region owner.
This keeps membership predictive: primitive rules describe arbitrary-child
composition without forcing slot, surface, or toolbar semantics into the same
contract.

## Open questions

None. The adoption table names missing capabilities and verification; it does
not turn them into unresolved family policy.

## Content boundary

This record owns only shared composition vocabulary and behavior. It does not
repeat component prop tables, prescribe implementation mechanisms, define
structural regions, assign responsive breakpoints, or own theming anatomy and
targets.

---

### Families Layout Regions (`docs/families/layout-regions.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:layout-regions
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
owners: [cixzhang, imdreamrunner]
review_triggers: [public-api, behavior, layout, accessibility, theming]
verified_by:
  [
    packages/core/src/Section/Section.test.tsx,
    packages/core/src/Layout/Layout.test.tsx,
    packages/core/src/Layout/LayoutSlots.test.tsx,
    packages/core/src/Layout/__tests__/childrenAsContent.test.tsx,
    packages/core/src/Layout/__tests__/contentWidth.test.tsx,
    packages/core/src/Toolbar/Toolbar.test.tsx,
  ]
members:
  [
    component:Section,
    component:Layout,
    component:LayoutHeader,
    component:LayoutContent,
    component:LayoutFooter,
    component:LayoutPanel,
    component:Toolbar,
  ]
architecture:
  [architecture:container-padding, architecture:public-component-api]
contributing: []
deciding_specs: []
---

# Layout regions contract

## Intent

A page or bounded work area should have predictable structural regions before
its product content is added. Builders can use a generic Section, a five-slot
Layout primitive, or a contextual Toolbar without each surface inventing its
own inset, boundary, region direction, or content ownership. AppShell owns the
page shell that composes these lower-level regions with application navigation.

## Membership rule

A component belongs when its primary public purpose is defining a stable
structural region: a generic page/content region, a named position in Layout's
five-slot topology, or a contextual action region. Members may compose shared
layout primitives, but they own a stronger boundary than arbitrary-child
arrangement alone.

- **Members:** Section; Layout and its Header, Content, Footer, and Panel regions;
  Toolbar.
- **Collaborators:** `family:layout-primitives` supplies arbitrary-child
  arrangement utilities; `architecture:container-padding` supplies the broader
  inset and bleed system used by Section, Layout and its regions, Table, Toolbar,
  and Divider; useResizable and ResizeHandle may drive a panel's size; AppShell
  owns the page shell and composes Layout with application navigation; component
  theming owns visual targets.
- **Excluded:** Stack, Grid, Center, and their modifiers arrange arbitrary
  children without claiming a structural region. FormLayout owns field-specific
  arrangement and optionality. Card is a discrete-item surface. AppShell owns
  page-shell and navigation semantics rather than this reusable region grammar.
  Table owns structured data, and Divider owns separation; their participation
  in `architecture:container-padding` does not make either a layout-region
  member.

A component does not join because it happens to render a flex row, a padded
wrapper, or a header-like visual treatment.

## Shared owner

- Section owns the generic painted page/content region, including its variant,
  selected divider edges, component padding, and nested Section behavior.
- Layout owns its five-slot topology, slot presence, region position, shared
  outer/inner inset, height mode, content-width propagation, and default
  header/footer divider context. It does not own the page shell.
- LayoutHeader, LayoutContent, LayoutFooter, and LayoutPanel own their region
  element, optional landmark role/name, local padding, and region-specific size
  or scrolling behavior.
- Toolbar owns the contextual action region, its start/center/end lanes,
  toolbar semantics, keyboard movement, and size cascade. It delegates its
  painted surface, variant, and selected divider edges to Section.
- `architecture:container-padding` owns inherited inset and bleed mechanics.
- useResizable and ResizeHandle own resize state and interaction. LayoutPanel
  only accepts their current size as an alternative width owner.

## Canonical concepts

| Concept               | Values or states                                                    | Default semantics                                                           | Stability              |
| --------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------- |
| generic region        | Section                                                             | Related page/content area with an optional painted treatment                | current                |
| five-slot topology    | header, start, content, end, footer                                 | Layout places provided regions in logical reading direction                 | current                |
| contextual actions    | start, optional center, end                                         | Toolbar groups controls within a bounded content region                     | current                |
| outer and inner inset | Layout edge or adjacent-region edge                                 | A Layout region uses the inset appropriate to the edges it touches          | current                |
| boundary              | absent or divider                                                   | A member may draw only the edges its component contract exposes             | current                |
| region scroll         | clipped, scrollable, or page-owned where exposed                    | LayoutContent and LayoutPanel own their current overflow choice             | component-owned        |
| landmark              | no explicit role, or caller-supplied role and label                 | A region does not infer page-level landmark semantics from visual placement | current                |
| region size           | intrinsic, fixed, fill/auto, capped, or resize-driven where exposed | Each member owns its applicable size axis                                   | component-owned        |
| responsive adaptation | retained, omitted, or replaced by caller composition                | No shared automatic region swap exists                                      | caller/component-owned |

## Cross-component invariants

- **FR1 — A region owns structure, not product meaning.** A member establishes a
  spatial boundary and renders caller content without adopting the content's
  product semantics, state, or component contracts.
- **FR2 — Region direction is logical.** Start and end follow writing direction.
  Divider placement and panel adjacency use the same logical region position.
- **FR3 — Layout selects geometry from slot presence.** Header, Content, Footer,
  and Panel apply outer inset where they touch the Layout boundary and inner
  inset where they meet another region. LayoutContent keeps its root as the
  scroll, padding, and direct-child owner. Omitted slots do not leave an area
  wrapper. The inherited geometry republished to full-bleed descendants is
  subject to the current LayoutContent/LayoutPanel conformance gap in
  `architecture:container-padding`.
- **FR4 — Explicit region padding replaces the automatic applied inset.**
  Section publishes its component inset; Layout distributes outer and inner
  inset across its named slots; a region's explicit padding or zero-padding
  path selects its local styles. This does not claim that every automatic
  Layout edge currently republishes an exact matching descendant variable.
- **FR5 — Boundary ownership is caller-selected where composition permits two
  owners.** LayoutHeader and LayoutFooter each own their implied edge. Section
  and Toolbar expose selected edges. LayoutPanel may draw its content-facing
  edge, and ResizeHandle may also draw it; current code does not prevent both.
  A resizable panel composition must set `LayoutPanel hasDivider={false}` when
  the adjacent ResizeHandle owns the divider.
- **FR6 — Divider absence may change spacing.** Layout regions currently collapse
  the interior spacing associated with an absent header, footer, or panel
  divider; an explicit divider preserves the fenced boundary.
- **FR7 — Scrolling is explicit and local.** LayoutContent and LayoutPanel own
  their current `isScrollable` behavior. Section and Toolbar do not become
  scroll containers through family membership. Layout `height="fill"` contains
  region scrolling; `height="auto"` lets the containing page grow.
- **FR8 — Landmark semantics remain explicit.** Named visual placement does not
  automatically assign `banner`, `main`, `navigation`, `complementary`, or
  `contentinfo`. A caller supplies a supported role and label for the context.
- **FR9 — Responsive substitution is not automatic.** Section and Toolbar keep
  their current presentation. Layout renders the regions supplied by the
  caller. AppShell, product composition, or another component-specific owner
  decides when a region is omitted or replaced at a narrower width. AppShell
  keeps main content mounted (state preserved) when crossing the mobile
  breakpoint.
- **FR10 — Resize ownership remains delegated.** A LayoutPanel with `resizable`
  uses the hook-provided current size instead of its `width` prop. The region
  family does not redefine snapping, persistence, collapse, keyboard, or pointer
  behavior.
- **FR11 — Theming remains component-owned.** Structural membership does not
  create family targets. Current `.doc.mjs` metadata documents shipped targets
  and capabilities, runtime `themeProps()` emits them, and
  `architecture:component-theming-surface` owns the cross-component rules. A
  component spec may add optional anatomy-mapping metadata when one exists.
- **FR12 — Content width keeps the scrollbar at the open content edge.** When
  `contentWidth` is set without panels, LayoutContent spans the available middle
  area and aligns its direct children through context-aware inline insets. With
  exactly one panel, that panel stays aligned to the centered frame while
  LayoutContent extends through the opposite open side. The mirrored start/end
  cases use the same rule. With both panels, the complete middle composition
  stays constrained. Percentage and intrinsic width values, plus bare variables,
  keep the constrained composition because they cannot safely share one alignment
  basis; `calc(var(...))` is the explicit length-valued variable path.

## Allowed component variation

- **AV1 — Region strength.** Section is a generic region; Layout members are
  position-aware slots in the five-slot primitive; Toolbar is a semantic action
  bar.
- **AV2 — Surface treatment.** Section and Toolbar may use Section variants and
  selected edges. Layout regions use their existing divider and padding
  treatments. Family membership does not unify their visual API.
- **AV3 — Region content.** Header, footer, panel, content, and toolbar lanes may
  hold any content allowed by their component contracts.
- **AV4 — Size model.** Layout owns fill/auto container height and content-width
  propagation; individual regions own applicable height, width, padding, and
  scroll props; Section owns its box-size props.
- **AV5 — Interaction.** Toolbar owns roving focus and keyboard hints. Other
  members add no toolbar behavior. Resizable owns resize interaction.
- **AV6 — Composition mechanism.** Members may use Stack utilities, direct CSS
  layout, contexts, or component composition. The observable region contract is
  the owned surface.
- **AV7 — Responsive policy.** Callers may omit, replace, or externally adapt
  regions. The family sets no breakpoint.

## Representative matrix

| Member and state                                 | Shared invariant                                                | Deliberate variation                                                                                                              |
| ------------------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Section / default, transparent, or muted         | Owns one generic region and publishes its applied inset         | Variant and selected divider edges are Section-local                                                                              |
| Layout / content only                            | Slot presence selects Layout-edge inset                         | With `contentWidth`, LayoutContent spans the middle area, aligns children internally, and keeps its scrollbar at the Layout edge  |
| Layout / all regions                             | Start/end stay logical; named regions receive outer/inner inset | With exactly one panel, content extends to the opposite open edge; with both panels, the complete composition remains constrained |
| LayoutHeader or LayoutFooter / divider inherited | One boundary owner and explicit landmark semantics              | Parent `defaultHasDividers` supplies the default; local false may override it                                                     |
| LayoutContent / scrollable or page-owned         | Region owns current overflow choice                             | `isScrollable={false}` supports parent/page scrolling and sticky descendants                                                      |
| LayoutPanel / fixed or resize-driven             | Panel position selects edge treatment                           | useResizable may replace width ownership; ResizeHandle retains interaction ownership                                              |
| Toolbar / two or three lanes                     | Contextual action region delegates its surface to Section       | Center content switches internal arrangement; toolbar alone owns keyboard behavior                                                |

## Adoption and exceptions

| Component or concern | Adoption                                                          | Current gap or exception                                                                                                                                                                                                                                                                                                                                                    |
| -------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Section              | Generic region and container-padding publisher                    | Its nested escape is always inline but only first/last child on the block axis                                                                                                                                                                                                                                                                                              |
| Layout container     | Five-slot region owner with slot contexts                         | Content-only LayoutContent stays full width and aligns direct children through insets. With exactly one panel, the panel remains aligned while content extends to the opposite open edge. With both panels or intrinsic content widths, the middle composition stays constrained. Tests cover topology; browser evidence covers mirrored alignment and scrollbar placement. |
| LayoutContent        | Reads slot presence and applies outer/inner inset                 | Its no-start path writes the outer value to both inline geometry variables, while no-end changes padding without the matching variable update                                                                                                                                                                                                                               |
| LayoutPanel          | Applies position-aware padding and may accept resize-driven width | Automatic Layout-edge padding leaves baseline inner geometry variables; an adjacent `ResizeHandle hasDivider` can double the line unless the caller sets panel `hasDivider={false}`                                                                                                                                                                                         |
| LayoutHeader/Footer  | Region-specific inset, boundary, and landmarks                    | Cross-region computed-style parity is not covered by one browser matrix                                                                                                                                                                                                                                                                                                     |
| Toolbar              | Section-backed surface plus toolbar behavior                      | Inline inset and edge compensation follow the composed Section's current padding context                                                                                                                                                                                                                                                                                    |
| Responsive regions   | Caller/AppShell composition                                       | LayoutPanel has no current shared responsive visibility contract                                                                                                                                                                                                                                                                                                            |

These rows describe shipped behavior and verification gaps. They are not approved
exceptions to silently change in a documentation pull request.

## Change coupling

- Adding or changing a structural-region component checks membership and updates
  this contract only when the shared boundary changes.
- Changing Layout slot presence, area context, inset distribution, divider
  inheritance, height mode, or content-width propagation updates the
  corresponding region tests and browser evidence.
- Changing Section padding, nested escape, variant, or divider behavior updates
  its component contract and reviews `architecture:container-padding` when
  inherited geometry changes.
- Changing Toolbar lanes, semantics, keyboard behavior, or size cascade remains
  Toolbar work; changing its Section-backed surface or inset relationship also
  reviews this family and the container-padding architecture.
- Changing LayoutPanel resize integration reviews Resizable's component contract
  without copying resize mechanics into this family.
- Adding responsive hide/swap behavior requires a component or higher-level
  owner and compatibility evidence; family membership does not authorize a new
  breakpoint prop.

## Verification map

| Contract             | Verification                                                     | What the evidence proves                                                                                                                                                                                              | Missing evidence                                                                                                                        |
| -------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| FR1                  | Section/Layout/Toolbar render and slot tests                     | Caller content renders inside the current region structure; Toolbar exposes its current lanes                                                                                                                         | Tests do not independently prove that product semantics remain caller-owned                                                             |
| FR2                  | Logical-property source review plus Layout area-context tests    | Start/end slots are identified logically and source selects logical divider/padding properties                                                                                                                        | No real-browser LTR/RTL geometry matrix covers every region                                                                             |
| FR3                  | `Layout.test.tsx` and `childrenAsContent.test.tsx`               | Slot presence/context and content precedence are pinned; omitted slots do not render area providers                                                                                                                   | Tests do not assert computed outer/inner inset or exact descendant geometry; the published-variable mismatch is a named conformance gap |
| FR4                  | Section padding class-set tests and Layout region source         | Section's explicit edge overrides move matching variables; region source has automatic, explicit, and zero-padding branches                                                                                           | Layout tests mostly assert acceptance/rendering, not computed padding or automatic-variable parity                                      |
| FR5, FR6             | `Layout.test.tsx` and `LayoutSlots.test.tsx`                     | Header/footer divider defaults and explicit false overrides are reflected in `data-divider`; source contains collapse styles                                                                                          | No rendered test prevents a LayoutPanel and adjacent ResizeHandle from both drawing a divider, or proves computed collapse spacing      |
| FR7                  | LayoutContent/LayoutPanel source and local class/render tests    | The two regions expose their current `isScrollable` branches; Layout exposes fill/auto state                                                                                                                          | No browser test proves scroll ownership across every shell/region composition                                                           |
| FR8                  | `LayoutSlots.test.tsx` landmark assertions                       | Supplied roles and labels reach Header, Content, Footer, and Panel elements                                                                                                                                           | No family-wide accessibility-tree test covers repeated landmarks                                                                        |
| Layout content width | `contentWidth.test.tsx` and Storybook scrollbar-placement states | Header/footer retain aligned wrappers; no-panel LayoutContent owns a full-width scroll region; either single-panel state mirrors content through the opposite open edge; both panels keep the composition constrained | Real-browser evidence verifies computed content alignment and scrollbar placement across the four panel states                          |
| Toolbar variation    | `Toolbar.test.tsx`                                               | Lanes, role/name/orientation, Section variant delegation, and current keyboard behavior have focused assertions                                                                                                       | No computed inset/edge-compensation test covers non-default parent padding                                                              |
| FR10                 | LayoutPanel coverage in `LayoutSlots.test.tsx`                   | Hook-provided `_size` overrides the fixed width prop                                                                                                                                                                  | Resize interaction, persistence, snapping, and collapse are verified only by Resizable's own tests                                      |

The current tests prove selected structure, attributes, callbacks, and class/style
branches. They do not prove one computed visual alignment across every Section,
Layout, Toolbar, and ResizeHandle composition; the adoption table names those
limits explicitly.

## Decision links

### DEC-1 — Structural regions and composition primitives have separate owners

**Decider:** `cixzhang`, `2026-08-30`

Section, Layout and its named regions, and Toolbar form the layout-regions
family. Stack, Grid, Center, and their modifiers have a separate
layout-primitives owner. This keeps shared region rules focused on structural
boundaries without turning every flex/grid container into a page region.

### DEC-2 — AppShell owns the page shell

**Decider:** `cixzhang`, `2026-08-31`

AppShell owns the page shell and application-navigation composition. Layout is
the general five-slot primitive used to arrange `header`, `start`, `content`,
`end`, and `footer` regions within a page or bounded container. The broader
container-padding participants retain their own component ownership.

### DEC-3 — Content-only width alignment stays inside the content scrollport

**Decider:** `cixzhang`, `2026-09-09`

When `contentWidth` is set without panels, LayoutContent spans the available
middle area and aligns its direct children through context-aware inline insets.
With exactly one panel, that panel remains aligned to the centered frame while
LayoutContent extends through the opposite open side. Logical start and end
mirror. With both panels, or when the width is percentage-based, intrinsic, or
an unresolved bare variable and cannot safely share one CSS arithmetic basis, the
complete middle composition stays constrained. A guaranteed length-valued
variable uses `calc(var(...))` to opt into edge scrolling.

## Open questions

None. The adoption table contains checkable source and browser work, not
unresolved family policy.

## Content boundary

This record owns only shared structural-region behavior. It does not repeat
component prop tables, define arbitrary-child composition, own product or
navigation semantics, prescribe visual design, assign responsive breakpoints,
redefine resize interaction, or own component theming anatomy and targets.

---

### Families Navigation Destinations (`docs/families/navigation-destinations.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:navigation-destinations
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
owners: [cixzhang, imdreamrunner]
review_triggers: [behavior, accessibility, public-api]
verified_by:
  [
    packages/core/src/utils/safeUrl.test.ts,
    packages/core/src/Markdown/parser.test.ts,
    packages/core/src/Markdown/Markdown.test.tsx,
    packages/core/src/Markdown/Markdown.renderBoundary.test.tsx,
    packages/core/src/Link/useLinkComponent.test.tsx,
    packages/core/src/Link/__tests__/Link.navigation.a11y.chromium.spec.ts,
    packages/core/src/Citation/Citation.test.tsx,
    packages/core/src/TopNav/TopNavMenu.test.tsx,
    apps/docsite/src/__tests__/link-navigation.test.ts,
    packages/core/src/hooks/useClickableContainer.test.tsx,
    packages/core/src/ClickableCard/ClickableCard.test.tsx,
    packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
  ]
members:
  [
    component:Avatar,
    component:BreadcrumbItem,
    component:Button,
    component:Citation,
    component:ClickableCard,
    component:DropdownMenuItem,
    component:Item,
    component:Link,
    component:ListItem,
    component:Markdown,
    component:NavHeadingMenuItem,
    component:SideNavHeading,
    component:SideNavItem,
    component:Tab,
    component:Token,
    component:TopNavHeading,
    component:TopNavItem,
    component:TopNavMenu,
    component:TopNavMegaMenuItem,
    component:TopNavMegaMenuFeaturedCard,
    component:TreeListItem,
  ]
architecture: [architecture:public-component-api]
contributing: []
deciding_specs: [spec:AST-005/DEC-1, spec:AST-005/DEC-2]
---

# Navigation destinations contract



```json
{
  "scope": "global",
  "triggers": {
    "navigation": ["FR1", "FR3", "FR4", "FR5"]
  }
}
```

## Intent

People should receive the same safe navigation behavior from every Astryx
component that accepts or derives a destination. A component's visual role,
router integration, or enlarged click target must not determine whether a
blocked destination can execute.

## Membership rule

A component belongs when Astryx accepts or derives a destination that the
component can render, delegate, or activate as navigation. Membership follows
that observable responsibility rather than the component's visual category or
which shared hook it currently uses.

A component that only lays out caller-owned links does not join. A component that
creates a fixed same-document link from an Astryx-owned ID does not join unless
it also accepts caller-controlled destination text. Caller-owned JSX, plugin
renderers, and callbacks are outside the boundary after Astryx hands over
control.

- **Current members:** Avatar; BreadcrumbItem; Button link mode; Citation;
  ClickableCard; DropdownMenuItem link rows (and the data-mode item they render,
  in the pointer menu and the touch sheet); Item; Link; ListItem; Markdown
  links; NavHeadingMenuItem;
  SideNavHeading and SideNavItem; navigation-mode Tab; Token link mode;
  TopNavHeading, TopNavItem, TopNavMenu, TopNavMegaMenuItem, and
  TopNavMegaMenuFeaturedCard; and TreeListItem.
- **Collaborators:** `useLinkComponent`, `LinkProvider`,
  `useClickableContainer`, React DOM's native-anchor sanitizer, and Markdown's
  parser/render boundary.
- **Excluded:** Outline's Astryx-generated `#id` links; AppShell's fixed
  skip-to-content fragment; arbitrary links supplied as children; Markdown
  plugin output; image/media/resource URLs; and components that only compose a
  member without accepting or deriving its destination.

Membership is open-ended. A newly shipped Core component that meets the rule
must be added here even when it delegates to an existing member or shared hook.

## Shared owner

- `spec:AST-005` owns the normalized blocked-scheme rule for every navigation
  destination. This family owns its application across members.
- `useLinkComponent` owns destination handoff to native and custom link
  components, including the router-facing `href` and `to` seams. Accepted
  structured destinations retain object identity. Rejected destinations render
  inertly without invoking the custom component.
- `useClickableContainer` owns imperative navigation from enlarged surfaces,
  including same-tab, new-tab, modifier-click, middle-click, and delegated
  activation. Every activation preserves the same accept/block result.
- Markdown owns parsing untrusted source into a destination and preserving the
  same navigation decision through rendering. Its image/resource policy remains
  separate.
- Native anchors follow the full shared navigation rule. React DOM's own
  sanitization is not an exception to any blocked scheme.

## Canonical concepts

| Concept            | Values or states                                           | Default semantics                                                                        | Stability |
| ------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------- |
| destination source | caller prop, parsed content, Astryx-derived                | caller/parsed values require inspection; fixed Astryx fragments are safe by construction | current   |
| sink               | native anchor, custom router, imperative browser API       | every Astryx-owned sink enforces the same navigation decision                            | current   |
| decision           | accepted or blocked                                        | accepted destinations keep existing behavior; blocked destinations do not navigate       | current   |
| activation         | plain, new-tab, modified, middle-click, programmatic proxy | activation method does not change the decision                                           | current   |
| resource kind      | navigation or embedded/fetched resource                    | this family owns navigation only                                                         | current   |

## Cross-component invariants

- **FR1 — Every caller-controlled navigation destination is decided before its
  sink.** No member may render a native navigation destination or pass one to a
  custom router or imperative browser API before the shared rule runs.
- **FR2 — Alternate rendering keeps the rule.** Replacing a native anchor through
  `LinkProvider` or `as` does not bypass destination handling.
- **FR3 — Alternate activation keeps the rule.** `_blank`, Cmd/Ctrl-click,
  middle-click, same-tab assignment, and delegated surface clicks produce the
  same accept/block decision.
- **FR4 — Blocked schemes cannot execute.** After browser-compatible scheme
  normalization, `javascript:`, `vbscript:`, and `data:text/html` do not become
  navigation, including through native anchors.
- **FR5 — Accepted destinations retain browser behavior.** Relative paths,
  fragments, protocol-relative destinations, HTTP(S), mailto, tel, and safe custom
  schemes retain native and router navigation, targets, downloads, and browser
  affordances. Accepted structured router destinations retain their fields and
  object identity.
- **FR6 — Both custom-router props are sinks.** A supplied `href` and explicit
  `to` are checked independently, including the scheme-bearing fields of
  supported structured forms (`href`, `pathname`, and `protocol`) and the
  destination they form. If either supplied destination is rejected, the result
  is inert: the custom router is not invoked, even with `undefined`, and there is
  no fallback to the other destination. Accepted explicit `to` values keep their
  documented precedence when every supplied destination is accepted.
- **FR7 — Disabled and rejected are distinct.** Members keep their own disabled
  semantics. Rejecting a destination prevents navigation without inventing a
  disabled state, label, or visual treatment.
- **FR8 — Resource handling does not inherit navigation policy.** Images, media,
  CSS URLs, fetch targets, and downloaded content use their own sink-specific
  contracts. An accepted link's existing download behavior is preserved. A member
  that handles both navigation and resources applies this family only to its
  navigation path.

## Allowed component variation

- **AV1 — Rejected presentation.** Markdown may render rejected source as text;
  a custom-link member renders inert content without invoking its custom router;
  an enlarged surface may simply omit navigation. Each member preserves its own
  non-navigation structure and accessibility contract. No component bypass prop
  is provided; exceptional behavior belongs to caller-owned custom rendering
  outside this contract.
- **AV2 — Native versus router navigation.** Members may use React DOM anchors,
  provider-level router components, per-component `as` overrides, or imperative
  browser APIs when their public contract requires that mode.
- **AV3 — Destination vocabulary.** Public APIs may expose `href`, structured
  item data containing `href`, a Citation `url`, or parsed Markdown syntax. The
  shared behavior does not require renaming released props.
- **AV4 — Target and relationship.** Members retain their existing `target`,
  `rel`, referrer, download, and external-link presentation behavior after a
  destination is accepted.
- **AV5 — Embedded resources.** Markdown and Citation may separately own image
  or resource policy; those decisions do not alter this family's navigation
  matrix.

## Representative matrix

| Member and state                                    | Shared invariant                                          | Deliberate variation                                                       |
| --------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------- |
| `component:Link` / native anchor                    | all three blocked destination prefixes remain inert       | accepted destinations retain native anchor affordances                     |
| `component:Link` / custom provider or `as`          | either rejected `href`/`to` prevents router invocation    | router owns accepted client-side navigation and receives unchanged objects |
| `component:ClickableCard` / plain or modified click | every imperative or delegated exit uses the same decision | visible Card structure and nested-interactive handling remain local        |
| `component:Token` / link with remove action         | surface activation and hidden link agree                  | remove action remains an independent sibling control                       |
| `component:Markdown` / parsed link                  | blocked source does not become navigation                 | rejected source may render as text; resource policy remains separate       |
| navigation aggregate / member item                  | item destination uses the shared owner                    | tree, tab, breadcrumb, side-nav, top-nav, and menu semantics remain local  |
| `component:Citation` / linked source                | blocked URL does not execute                              | native-anchor path and citation presentation remain local                  |

## Adoption and exceptions

| Components or surface                             | Required adoption                                                                                                                   |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Markdown parsed and rendered links                | Parsing and rendering preserve the shared navigation decision; image/resource policy remains separate (AV5).                        |
| Native React anchors, including Citation          | The full blocked-scheme rule holds; React DOM's `javascript:` protection alone is not sufficient.                                   |
| `useLinkComponent` custom provider and `as` paths | Both supplied destinations and supported structured forms, including `protocol`, obey the rule; rejection never invokes the router. |
| `useClickableContainer` imperative paths          | Same-tab, new-tab, modifier, middle-click, and delegated activation agree; non-navigation consumer callbacks remain available.      |
| Components composing the two shared hooks         | Both delegated destinations and any separately rendered native anchor meet the full rule.                                           |

These are required outcomes, not a claim of completed verification. Adoption is
complete only after exact-head evidence covers every applicable row. Neither a
native-anchor React-only check, unchecked scheme-bearing fields, nor invoking a
router with an undefined rejected destination is an approved exception.

## Verification map

| Contract           | Verification                                                                         | Representative members and states                                                                                                                                       | Mutation or failure expectation                                                                                                                                               |
| ------------------ | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR4, FR5           | `safeUrl.test.ts`                                                                    | string and structured destinations; mixed case; controls; relative, fragment, protocol-relative, ordinary, safe custom schemes, and `data:image/*`; separate `protocol` | The shared rule accepts a blocked destination or rejects an ordinary one, including when a protocol is separate from its pathname.                                            |
| FR1, FR2, FR6      | `useLinkComponent.test.tsx` and `apps/docsite/src/__tests__/link-navigation.test.ts` | native Link, Button, navigation members; provider and `as`; safe/rejected `href` and `to` in either combination; structured destinations and object identity            | A rejected destination invokes the router (including with `undefined` or fallback), an accepted object loses identity, or a real router throws while rendering inert content. |
| FR1, FR3, FR4, FR5 | `useClickableContainer.test.tsx` and real Chromium navigation coverage               | ClickableCard and Token shapes; native Link; plain, `_blank`, Cmd/Ctrl-click, middle-click, delegated activation; accepted downloads                                    | A rejected destination remains activatable, a blocked native, imperative, or delegated navigation occurs, or an accepted destination loses an activation mode.                |
| FR4, FR5           | `parser.test.ts`, `Markdown.test.tsx`, and `Markdown.renderBoundary.test.tsx`        | parsed, reference, autolink, and transformed built-in links; mixed case; controls; ordinary schemes; `data:image/*` links versus images                                 | Markdown accepts a blocked scheme, rejects an ordinary navigation destination, applies resource policy to a link, or renders a rejected node as navigation.                   |
| FR1, IR3           | source/member audit required by `spec:AST-005`                                       | every caller-controlled Core navigation destination                                                                                                                     | A new sink or destination-bearing component ships outside this member snapshot                                                                                                |
| FR7, FR8           | member-focused behavior and resource suites                                          | disabled link modes; Markdown links versus images; Citation link versus image                                                                                           | Destination rejection changes disabled semantics or navigation policy silently becomes resource policy                                                                        |

## Decision links

- `spec:AST-005/DEC-1` — destination safety follows every navigation sink.
- `spec:AST-005/DEC-2` — embedded resources remain a separate policy.

## Open questions

None.

## Content boundary

This file owns navigation-destination membership, the cross-component
accept/block result, and adoption of the shared sinks. It does not duplicate
component prop tables, router implementation details, component-local disabled
or rendering behavior, embedded-resource policy, current audit results, or the
system-spec rationale.

---

### Families Overlay Dismissal (`docs/families/overlay-dismissal.md`)

---
schema_version: 1
template_version: 1
kind: family
id: family:overlay-dismissal
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
owners: [cixzhang, imdreamrunner]
review_triggers: [behavior, accessibility]
verified_by:
  [
    packages/core/src/Layer/useLayerDismissal.test.tsx,
    packages/core/src/Layer/layerDismissalInvariants.test.tsx,
    packages/core/src/Layer/layerDismissalFamilies.test.tsx,
    packages/core/src/Drawer/Drawer.test.tsx,
    packages/core/src/hooks/useFocusTrap.test.tsx,
    packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx,
    packages/core/src/ContextMenu/ContextMenu.test.tsx,
    packages/core/src/CommandPalette/CommandPalette.test.tsx,
  ]
members:
  [
    component:Dialog,
    component:AlertDialog,
    component:Popover,
    component:DropdownMenu,
    component:DropdownMenuSubMenu,
    component:MoreMenu,
    component:Tooltip,
    component:HoverCard,
    component:Lightbox,
    component:MobileNav,
    component:BottomSheet,
    component:BottomSheetSwitcher,
    component:CommandPalette,
    component:ContextMenu,
    component:PowerSearchEditPopover,
    component:Drawer,
    component:BreadcrumbItem,
    component:ChatComposerInput,
    component:ComplexSelector,
    component:DateInput,
    component:DateRangeInput,
    component:DateTimeInput,
    component:Selector,
    component:MultiSelector,
    component:PowerSearch,
    component:BaseTypeahead,
    component:Typeahead,
    component:Tokenizer,
    component:SideNavHeading,
    component:SideNavItem,
    component:TabMenu,
    component:TopNavHeading,
    component:TopNavMenu,
    component:TopNavMegaMenu,
    component:TourStep,
    component:ChatEmojiPicker,
    component:Table,
  ]
architecture: [architecture:public-component-api]
contributing: []
deciding_specs: []
---

# Overlay dismissal family contract



```json
{
  "scope": "global",
  "triggers": {
    "layering": ["FR1", "FR2", "FR3", "FR6"]
  }
}
```

## Intent

A person dismissing layered UI should affect only the topmost relevant surface.
A nested surface must not close its host on the same Escape press or platform
close request when logical depth or DOM containment establishes that ordering.

## Membership rule

A component belongs when it owns a layered surface for which Escape or a platform
close request is a valid dismissal command. Every member must participate in the
shared dismissal stack while that surface is present. Membership follows the
observable responsibility, not whether the current implementation has completed
adoption; a local Escape listener is a deviation to migrate, not an intentional
exclusion.

A component that creates its own popup with `usePopover`, `useLayer`, a focus
trap, or a native dialog is a member when it owns that popup's dismissal. A
component that merely composes another member as ancillary content inherits that
member's behavior without joining on that basis. Membership is open-ended:
every newly shipped surface that meets this rule must join the shared stack and
this record's current membership snapshot must be updated with it.

- **Current members:** Dialog, AlertDialog, Popover, DropdownMenu,
  DropdownMenuSubMenu, MoreMenu, Tooltip, HoverCard, Lightbox, MobileNav,
  BottomSheet, BottomSheetSwitcher, CommandPalette, ContextMenu,
  PowerSearchEditPopover, Drawer, and the component-owned popup surfaces
  listed below.
- **Component-owned input popups:** ChatComposerInput, ComplexSelector,
  DateInput, DateRangeInput, DateTimeInput, Selector, MultiSelector,
  PowerSearch, BaseTypeahead, Typeahead, and Tokenizer. TextInput, TextArea,
  NumberInput, TimeInput, and FileInput do not own a popup; rendering a Tooltip
  for supporting or disabled text does not make the input itself a member.
- **Other component-owned popups:** BreadcrumbItem, SideNavHeading,
  SideNavItem, TabMenu, TopNavHeading, TopNavMenu, TopNavMegaMenu, Table filtering,
  Lab TourStep, and Lab ChatEmojiPicker.
- **Collaborators:** `useLayerDismissal`, `layerStack`, `LayerDepthProvider`,
  and `useFocusTrap` own or adapt the shared protocol.
- **Nonparticipating layers:** A layer that has no dismissal behavior is not a
  member. A visual-only outline rendered through Layer is the canonical example:
  it neither handles Escape nor receives a platform close request.

## Shared owner

- `useLayerDismissal` registers an active surface and adapts a
  browser-initiated close request to the shared topmost check.
- `layerStack` owns presence filtering, ordering, and the single
  document-level Escape listener.
- `LayerDepthProvider` carries logical nesting through the React tree for members
  that provide it to their descendants. Members without that provider do not
  guarantee nested ordering after descendant content leaves DOM containment.
- `useFocusTrap` joins the same stack when it is active and receives an
  `onEscape` callback. A focus trap without `onEscape` is not a dismissible
  surface and does not register.

## Canonical concepts

| Concept         | Values or states           | Default semantics                                                                 | Stability |
| --------------- | -------------------------- | --------------------------------------------------------------------------------- | --------- |
| membership      | member or nonparticipant   | A surface with Escape/platform-close behavior is a member                         | current   |
| adoption        | shared, partial, or local  | Local handling is a deviation to migrate, not a membership exception              | current   |
| registration    | active or inactive         | Only an active, enabled surface participates                                      | current   |
| presence        | present or absent          | An absent registered surface is skipped                                           | current   |
| Escape behavior | `close` or `block`         | `close` invokes the member's dismissal callback; `block` consumes without closing | current   |
| logical depth   | non-negative nesting level | A provider marks descendant surfaces as deeper than its host                      | current   |
| close request   | Escape or platform request | The topmost registered present member decides the result                          | current   |

## Cross-component invariants

- **FR1 — Every dismissible layered surface participates.** A member must join
  the shared dismissal stack while present. A component-specific Escape
  listener or registry does not satisfy this invariant.
- **FR2 — One request affects one surface.** An unclaimed Escape press is routed
  to exactly one topmost registered present member. That member either invokes
  its dismissal callback or blocks the request; the request does not continue
  to a member behind it.
- **FR3 — Platform close requests use the same topmost rule.** A member receiving
  a browser or platform close request closes only when it is the topmost
  registered present member. A request aimed at a lower member is declined.
- **FR4 — Available nesting signals outrank host placement.** When a member
  provides `LayerDepthProvider`, React-tree depth orders its descendant layers
  ahead of the member even when both mount in the same commit. DOM containment
  may resolve equal-depth nesting; stable active-cycle registration order resolves
  unrelated surfaces. A member without the provider does not guarantee this
  ordering for descendant content that is no longer DOM-contained.
- **FR5 — Content may claim Escape first.** The shared listener runs in the
  bubble phase and stands down when content has already handled the event.
- **FR6 — Text composition is not layer dismissal.** Escape used to cancel an
  active IME composition is consumed without dismissing a member, and a
  platform close request is declined while composition is active.
- **FR7 — Registration does not define open-state ownership.** The stack invokes
  the selected member's dismissal callback. Whether that request immediately
  closes the surface is owned by the member's component contract and caller.

## Allowed component variation

- **AV1 — Focus.** Members own focus entry, containment, movement, and return.
- **AV2 — Modality.** Members may be modal or non-modal and own any inertness or
  backdrop behavior.
- **AV3 — Positioning and hosting.** Members may use native dialog placement,
  CSS anchor positioning, fixed coordinates, or a component-owned host.
- **AV4 — Outside dismissal.** Members own whether and how pointer, hover,
  focus-loss, backdrop, touch, or gesture interaction dismisses them.
- **AV5 — Open-state API.** Members own controlled and uncontrolled state,
  defaults, callback names, and imperative commands.
- **AV6 — Presentation.** Members own animation, visual treatment, and theming
  anatomy. The dismissal registry exposes no theme target or rendered wrapper.

## Representative matrix

| Member and state                              | Shared invariant                               | Deliberate variation                                                          |
| --------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
| Dialog inside Dialog                          | Inner dialog handles the first request         | Dialog supplies native modality and focus behavior                            |
| Popover inside Dialog                         | Popover handles the first Escape               | Popover supplies anchoring, portal placement, and focus policy                |
| Registered descendant inside CommandPalette   | Descendant handles the first Escape            | CommandPalette inherits Dialog's shared stack participation                   |
| Tooltip or HoverCard inside Dialog            | Present hover surface handles the first Escape | Hover families decide when they are present and how controlled state responds |
| Lightbox or MobileNav above a required Dialog | Top member handles the request                 | Required Dialog uses `block` only when it is topmost                          |
| Required Dialog alone                         | Request is consumed without closing            | Dialog purpose selects `block` behavior                                       |
| Self-owned input popup                        | Popup participates while present               | Input owns selection, focus, open state, positioning, and outside dismissal   |
| Two unrelated registered surfaces             | Later active-cycle registration is topmost     | Neither surface is treated as nested                                          |
| Platform close request sent to a lower Dialog | Lower Dialog stays open                        | The platform targets a host; the shared check decides whether it may close    |

## Adoption and exceptions

| Components or surface                                                                                                                                                                                                                                                                              | Adoption                                                      | Current deviation or limitation                                                                                                                                         |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dialog, AlertDialog, Popover, DropdownMenu root, ContextMenu, MoreMenu, Lightbox, MobileNav, Drawer, CommandPalette                                                                                                                                                                                | shared owner; CommandPalette composes Dialog                  | none                                                                                                                                                                    |
| Tooltip, HoverCard                                                                                                                                                                                                                                                                                 | shared owner with DOM presence reporting                      | Neither provides nesting depth to descendant layers                                                                                                                     |
| Focus traps with `onEscape`                                                                                                                                                                                                                                                                        | shared owner through `useFocusTrap`                           | They provide DOM containment, not descendant depth                                                                                                                      |
| BreadcrumbItem, ChatComposerInput, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, Selector, MultiSelector, PowerSearch, BaseTypeahead, Typeahead, Tokenizer, SideNavHeading, SideNavItem, TabMenu, TopNavHeading, TopNavMenu, TopNavMegaMenu, Table, Lab TourStep, Lab ChatEmojiPicker | shared owner through `usePopover` or a composed Popover owner | Table filtering owns controlled Popover state and discards its draft on close; TourStep routes Popover close to the Tour; ChatEmojiPicker owns controlled Popover state |
| BottomSheet, BottomSheetSwitcher                                                                                                                                                                                                                                                                   | shared owner                                                  | none                                                                                                                                                                    |
| DropdownMenuSubMenu, PowerSearchEditPopover                                                                                                                                                                                                                                                        | local only                                                    | Must migrate from component-specific listeners or registries to the shared owner                                                                                        |

The rows marked partial or local are current adoption gaps against FR1. They are
not approved exceptions. Migration must preserve each component's existing focus,
modality, positioning, outside-dismissal, and open-state contracts.

## Verification map

| Contract           | Verification                                                                                                    | Representative members and states                                                                                                                                                              | Mutation or failure expectation                                                                   |
| ------------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| FR1, FR2, FR4, FR5 | `useLayerDismissal.test.tsx`; `BottomSheetSwitcher.test.tsx`; `ContextMenu.test.tsx`; `CommandPalette.test.tsx` | synthetic provider depth, same-DOM containment, non-modal switcher nesting, ContextMenu nested layers, CommandPalette descendant nesting, unrelated, blocking, content-handled, present/absent | Removing depth, presence filtering, or event deferral sends Escape to the wrong surface           |
| FR2, FR3, FR4, FR6 | `layerDismissalInvariants.test.tsx`                                                                             | nested same-DOM Dialogs, Lightbox over Dialog, Dialog in BottomSheet, platform cancel, IME                                                                                                     | One request closes two surfaces, closes a host before its child, or dismisses during composition  |
| FR1, FR2, FR3, FR7 | `layerDismissalFamilies.test.tsx`                                                                               | Dialog, Lightbox, MobileNav, BottomSheet, Tooltip, HoverCard; controlled and blocking states                                                                                                   | A family bypasses the stack, a lower member handles a request, or controlled ownership is ignored |
| FR1                | `useFocusTrap.test.tsx`                                                                                         | active, nested, and deactivated traps with `onEscape`                                                                                                                                          | A dismissible trap stays off the shared stack or nested traps respond together                    |

## Decision links

### DEC-1 — Membership follows dismissal responsibility

**Decider:** `cixzhang`, `2026-08-30`

Every layered surface for which Escape or a platform close request is a valid
dismissal command belongs to this family and must participate in the shared
stack. Membership is open-ended: every newly shipped surface that meets this
rule must join the stack. Existing component-local listeners and registries are
adoption gaps, not intentional exclusions. Components that own their own input
popup are members; components that only compose another member as ancillary
content are not members on that basis. The members metadata and adoption table
are the audited current snapshot, not a closed set.

A layer with no dismissal behavior does not participate. A visual-only outline
rendered through Layer is the canonical nonparticipating example.

## Open questions

None. Focus, modality, positioning, outside dismissal, and open-state APIs are
explicitly outside this family contract. The mapped tests do not render a nested
layer through a real portal; a real portal fixture is required before this
contract can claim verified portal coverage.

## Content boundary

This file owns family membership, topmost registered Escape and platform-close
behavior, and the logical-depth and DOM-containment signals currently used to
order those requests. A member guarantees descendant depth only when it provides
`LayerDepthProvider`; this record does not claim verified nesting through a real
portal. It does not define focus entry or return, modality, positioning or
anchoring, portal placement, outside interaction, programmatic component
commands, open-state APIs, animation, or theming. Those remain component,
family, or architecture responsibilities.

---

### Specs AST 001 Spec (`docs/specs/AST-001/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-001
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: [architecture:public-component-api]
affects_families: [family:input-fields]
affects_contributing: []
affects_consumer_docs: [Selector, MultiSelector]
---

# Async option-source states

## Intent

People using Selector or MultiSelector should be able to use every option the
caller has provided, including while the field value is resolving or being
saved. When no option is available, the control should communicate whether the
source is still pending or has completed empty instead of presenting a silent,
actionable-looking selector.

## Non-goals

- Redefining input-family `isLoading`; it remains a value-resolution or saving
  state.
- Choosing the public API name or shape for initial option-source pending state.
- Defining failure, retry, pagination, caching, or stale-response ordering.
- Changing Typeahead's component-owned async search lifecycle.
- Implementing Selector or MultiSelector behavior in this specification pull
  request.

## Requirements

- **FR1 — Provided options determine availability.** The normalized options
  supplied to Selector or MultiSelector are the source of which options can be
  rendered and selected.
- **FR2 — Input loading does not suppress options.** Input `isLoading` MUST NOT
  hide, disable, or otherwise make a provided option unavailable. Search and
  keyboard navigation MUST operate on the same provided option set while the
  field value is busy.
- **FR3 — Zero provided options means no selectable option state.** When the
  normalized provided set contains no selectable option, the selector MUST NOT
  offer activation or navigation that implies a choice is available.
- **FR4 — Initial pending is explicit.** Initial option-source pending state MUST
  be supplied independently. It MUST NOT be inferred from zero options or input
  `isLoading`.
- **FR5 — Pending does not create false empty or read-only claims.** With zero
  provided options, explicit initial pending still has no selectable option, but
  it MUST be presented and announced as loading rather than as completed empty
  or read-only. With provided options, a pending source signal MUST NOT override
  FR1 or FR2.
- **FR6 — Completed empty is read-only and explicit.** With zero provided options
  and no explicit initial pending state, the selector is read-only with no
  selectable option. Its visible and programmatic state MUST communicate that
  completion without requiring a person to activate an unavailable popup.
- **FR7 — Loading, read-only, and empty stay distinguishable.** Visual output,
  accessibility semantics, focus behavior, and announcements MUST agree for:
  initial pending, completed empty, and provided options while input `isLoading`
  is true. A transition from initial pending to completed empty MUST announce the
  new outcome once without losing the control's accessible name or current
  value.

### Platform support

- Supported feature/engine floor: the same browser and assistive-technology
  support as Selector and MultiSelector.
- Unsupported behavior: input `isLoading` MUST NOT be used as a fallback
  option-source signal.
- Browser evidence: the loading, read-only, and completed-empty states require
  real-browser keyboard and accessibility-tree verification; DOM assertions alone
  are not sufficient. Screen-reader observation is recommended follow-up under
  `spec:AST-009`, not a merge or release requirement.

## Current-state impact

- Selector and MultiSelector already keep non-empty provided options rendered,
  keyboard-reachable, and selectable while input `isLoading` is true. That
  satisfies FR1 and FR2; implementation MUST preserve it.
- Input `isLoading` currently suppresses empty and no-results presentation and
  search result-count announcements. Source comments and consumer API text
  describe that suppression as meaning options have not arrived, although the
  flag owns value resolution or saving.
- Neither component exposes explicit initial option-source pending state. With
  zero options and no input loading, each still offers an actionable trigger that
  opens completed-empty content rather than a read-only no-choice state.
- `family:input-fields/FR5` and `family:input-fields/DEC-2` already record the
  approved meaning of input `isLoading`. This spec narrows the resulting
  zero-option, pending, and accessibility requirements for selectors.
- `architecture:public-component-api` and `spec:AST-002` govern the unresolved
  public API needed to represent explicit initial pending state.
- Implementation targets the pending API, zero-option read-only behavior, and
  accessible state transitions. It also removes incorrect option-nonarrival
  claims while preserving populated-option availability. The owning component
  contracts and consumer docs must then link the applicable AST-001 decisions;
  until that lands, `component:Selector` remains the source of truth for shipped
  Selector behavior.

## Verification

| Contract      | Verification                                                                            | Representative states                                                                                  | Mutation or failure expectation                                                                                                |
| ------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| FR1, FR2      | Selector and MultiSelector interaction tests                                            | populated options with input `isLoading`; searched and unsearched; pointer and keyboard                | A provided option disappears, becomes unreachable, or cannot be selected because the field value is busy                       |
| FR3, FR5, FR6 | State-transition and activation tests                                                   | zero options while initial pending; pending to completed empty; zero options without pending           | The control offers a nonexistent choice, claims completed empty while pending, or keeps presenting loading after completion    |
| FR7           | Real-browser keyboard and accessibility-tree tests; screen-reader follow-up recommended | initial pending, completed empty/read-only, populated while input `isLoading`, existing selected value | Visible and announced states disagree, focus is lost, name/value disappears, or a transition is silent or announced repeatedly |
| Public API    | Type-level and consumer-example tests after OQ1 is resolved                             | omitted/default state, explicit initial pending, dynamic completion                                    | Zero options or input `isLoading` silently substitutes for the explicit source state                                           |

## Decision log

### DEC-1 — Provided options stay available while the input value is loading

**Reference:** `spec:AST-001/DEC-1`
**Decider:** `cixzhang`, `2026-08-31`

Selector and MultiSelector derive option availability from the options the caller
provides. Input `isLoading` describes value resolution or saving and does not
suppress those options.

Rejected: treating input `isLoading` as evidence that independently supplied
options have not arrived, because it makes valid choices unavailable and combines
two unrelated states.

### DEC-2 — Zero options is a no-choice state, not evidence of pending work

**Reference:** `spec:AST-001/DEC-2`
**Decider:** `cixzhang`, `2026-08-31`

Zero provided options means there is no selectable option and, once the source is
not explicitly pending, the selector is read-only and communicates completed
empty. Initial pending is a separate explicit state: it has no selectable option
but communicates loading and MUST NOT be inferred from the empty array.

Rejected: using one zero-option observation to mean both “still loading” and
“completed with no choices,” because people and assistive technology could not
distinguish waiting from a finished read-only control.

## Open questions

- **OQ1 — What public API represents explicit initial option-source pending for
  Selector and MultiSelector?** (`human-api`) The API must distinguish pending
  from completed empty without changing input `isLoading` or overriding provided
  option availability.

---

### Specs AST 002 Spec (`docs/specs/AST-002/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-002
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-03
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: []
affects_families: []
affects_contributing: [contributing:api-conventions]
affects_consumer_docs: []
---

# Public API admission and operation shape



```json
{
  "scope": "global",
  "triggers": {
    "public-api": [
      "FR4",
      "FR15",
      "DEC-1",
      "DEC-2",
      "DEC-3",
      "DEC-6",
      "DEC-7",
      "DEC-8"
    ]
  }
}
```

## Intent

Keep Astryx public APIs intentional. A public API should represent a distinction
the caller owns, not expose a choice the component can make correctly by itself.
That need is only the first gate. The proposed API must also have clear meaning,
dependable behavior, and promises the component can keep.

Every public prop, hook, utility, and operation becomes a permanent concept that
consumers, documentation, tests, themes, migrations, and reviewers must
understand. The burden is to show why the caller must provide information, not
merely that an API can solve the immediate example.

## Non-goals

- Removing semantic state, controlled-value, accessibility, or composition APIs.
- Preventing utility components from exposing the fine-grained controls that are
  their purpose.
- Replacing existing styling escape hatches with component-specific layout props.
- Rejecting an API only because its implementation is difficult.

## Requirements

A public API proposal is admitted only when it passes both gates:

1. **Need:** the caller owns information the component cannot derive.
2. **Contract:** the API clearly describes a dependable result the component can
   enforce.

- **FR1 — A prop represents caller-owned intent.** A new public prop is justified
  only when two otherwise identical situations require different resolved design
  outcomes and the caller owns which outcome applies.
- **FR2 — Derivable differences stay internal.** Do not add a prop when the
  component can derive the correct outcome from its state, content, measured or
  CSS layout, parent context, platform capability, or another existing public
  concept.
- **FR3 — Implementation choices are not public concepts.** Internal thresholds,
  optical tuning, algorithm choices, and one-off fixes remain internal unless
  FR1 proves that callers need to distinguish semantically identical cases.
- **FR4 — Existing composition and styling seams come first.** A component does
  not add a specialized prop when an existing slot, child contract, theme target,
  `xstyle`, `className`, `style`, or owning layout component already expresses
  caller intent at the correct layer.
- **FR5 — The admission argument is reviewable.** A public-API proposal names the
  two otherwise identical cases, the different required outcomes, why the caller
  knows the difference, and why the component cannot derive it.
- **FR6 — High-level components have a higher threshold.** Opinionated
  compositions prefer internal resolution and composition over accumulating
  low-level tuning props.
- **FR7 — The public concept is understandable.** Its name and values describe
  caller intent without requiring consumers to know the rendering technique that
  implements it.
- **FR8 — The result is predictable.** Each value has a stable, observable effect
  that consumers can explain and tests can verify. A data attribute, theme hook,
  or implementation difference alone is not a public outcome.
- **FR9 — The API can keep its stated promise.** The API shape carries or owns
  the information needed to produce the claimed result. Documentation and review
  arguments do not credit an enum or styling switch with accessibility,
  association, or validation behavior it cannot perform. The contract depends
  only on props, owned children, owned context, and platform facts the component
  can verify.
- **FR10 — Every supported state is independently correct.** Accessibility,
  behavior, and required visual communication do not become valid only when an
  unverified external condition is assumed. Solve the base component problem
  before adding API to select between incomplete solutions.
- **FR11 — One semantic action has one canonical operation.** Within one
  component module, any public or package-internal operations that implement the
  same caller-owned action use one canonical operation name. The public contract
  may expose a narrower option set while the package-internal implementation
  accepts wider semantic options under that same operation. Add another operation
  only for genuinely distinct caller-owned intent with a different contract.
- **FR12 — Mechanical screening does not outrun contract coverage.** Every public
  API addition or semantic behavior change identifies its exact before → after,
  canonical owner, and applicable current record before acceptance. Component-local
  semantics belong in the component spec; family-, architecture-, or system-owned
  semantics update that owner instead. A draft record is valid review context but
  not policy and cannot clear the authority gate. Mechanical review also rejects
  derivable choices, overloaded inputs, hidden conditional precedence, and
  duplicate operations.
- **FR13 — Surviving changes make the semantic delta explicit.** Owner review
  receives a link to the canonical current owner, one sentence stating the semantic
  before → after, the applicable authority result from
  `architecture:knowledge-contracts`, and representative syntax only when public
  syntax changes.
- **FR14 — Contract restorations are preserves.** A fix is `preserves` only when it
  restores a current contract or standard and adds no public API or public behavior
  beyond that authority. It supplies regression evidence. Any additional public
  delta independently follows FR12 even when delivered in a bug-fix pull request.
- **FR15 — Invalid states are prevented where practical.** Public APIs prevent
  bad results when doing so does not make the API harder to understand. Make
  statically knowable invalid combinations unrepresentable where practical;
  otherwise validate or warn, or choose a safe documented fallback. Do not
  silently render a broken state or over-constrain legitimate composition.
- **FR16 — Public inputs keep one semantic responsibility.** Across its full
  value domain and every accepted input shape, each public input has one stable
  semantic responsibility, and its name and type disclose the caller-owned
  meaning. One semantic input may derive several visual details when they form one
  cohesive named outcome, such as a semantic status or variant owning both tone
  and a signifier. Reject an input when its value or shape changes which axis it
  controls, or when consumers need implementation knowledge to predict the
  controlled axes. Independently caller-owned axes use separate inputs with
  invalid and conflicting combinations prevented under FR15; system-owned
  coordination exposes the semantic concept and derives its details instead of
  naming the input after one mechanism. Parallel inputs do not create hidden
  conditional precedence: an override is valid only when its name, type, and
  behavior across every combination form an explicit coherent contract.
- **FR17 — Public module and utility function names disclose one atomic role.**
  Choose a verb from the function's primary caller-observable result and side
  effects, not from an internal implementation step. Distinguish construction,
  inspection, lookup, conversion, registration, and guaranteed state. `define*`
  constructs or normalizes and returns a durable typed value used by a supported
  consumer. It may reuse validation as a precondition, but a function that only
  inspects input or returns the same input unchanged is not `define*`. `validate*`
  and `check*` inspect input and return structured results; their contracts state
  which failures are returned and which conditions throw. If construction,
  validation, registration, or another capability is public, expose it as its own
  callable function rather than hiding two public roles behind one name. A released
  mismatch is deprecated and migrated under the compatibility contract, not
  silently renamed. Other verbs receive rules only when shipped repository
  evidence supports them. Component prop and event naming remains separate. CLI
  command verbs and their programmatic command twins belong to the
  [CLI surface architecture](../../architecture/cli-surface.md) and CLI
  conventions; this requirement does not define their command semantics.
- **FR18 — Public primitives support composition intentionally.** Astryx prefers
  public contracts for stable behavior that product builders must compose,
  coordinate, or build on across callsites. That preference does not promote raw
  implementation machinery. A proposed primitive API names semantic caller intent,
  defines accepted and rejected operations and outcomes, and receives an owning
  current contract before acceptance. Internal focus targets, gesture memory, DOM
  slots, timers, and equivalent mechanisms stay private unless independently
  admitted as durable caller-owned concepts.
- **FR19 — Bug fixes do not smuggle API design.** A bug fix that can restore current
  behavior without changing public API or behavior beyond current authority MUST do
  so. Any additional public delta follows `architecture:knowledge-contracts`
  independently. A separable contradictory or unsettled tagalong MUST be removed or
  split before review asks the repair to carry a new owner decision. Existing current
  authority may settle an intentional remaining delta; only a surviving, intentional
  gap requires owner judgment. Implementation convenience, removal of an internal
  wrapper, or exposure of existing internal fields is not evidence that an API should
  exist.
- **FR20 — Callsite impact and decision burden are explicit.** A public API change
  shows representative before and after callsites, including defaults and
  compatibility, and names every new choice the caller must understand or make. That
  burden is admitted only when FR1 proves the distinction is caller-owned, the
  component cannot derive it under FR2, and the choice has predictable meaning under
  FR7–FR10. Review-generated impact prose is context only; it cannot supply missing
  caller ownership or authorize the API.

### Platform support

- Supported feature/engine floor: the rule applies to every exported component,
  hook, and utility API on every supported renderer.
- Unsupported behavior: a browser or renderer implementation limitation is not
  itself evidence that callers should receive a permanent API.
- Browser evidence: layout-based derivability claims are verified in real
  supported browsers before concluding that public API is necessary.

## Current-state impact

- API review guidance requires both the caller-need argument and the dependable
  current-contract argument before new public API is accepted.
- Every public API addition and public behavior change identifies current committed
  authority, then follows the result and disposition owned by
  `architecture:knowledge-contracts`. This spec does not redefine those outcomes.
- Exact-head owner discussion or approval may supply the decision evidence, but it
  does not become reusable or acceptance-clearing authority until the canonical
  owning record commits that decision as `current`.
- Component specs own component-local semantic API contracts, including public
  hooks and utilities they explicitly co-own. Family, architecture, and system
  records own their respective shared semantics. The component's `.doc.mjs`
  remains the consumer syntax and reference authority.
- `module:Markdown/headingLinks` applies FR18 and DEC-8 to one admitted public
  module: its factory returns an opaque entry for the existing plugin list rather
  than adding a broad Markdown prop. The module record owns the API and behavior;
  this system record owns only that admission result.
- Component specs inherit current family contracts and record only local public
  concepts, additions, and explicit exceptions; they do not copy shared rules.
- Public module and utility function review compares the verb with the function's
  returned value, validation contract, side effects, and supported consumers.
  Component prop/event naming and CLI command semantics keep their separate owners.
  Existing released mismatches remain compatible while an explicit deprecation and
  migration is designed; this rule does not silently rename or change them.

### Current authority dependency

For every public API or public behavior delta, identify the canonical owner and
apply the routing defined by `architecture:knowledge-contracts`. This spec owns
API admission and semantic-shape requirements; the
[Release compatibility and versioning spec](../AST-017/spec.md) owns lifecycle,
classification, deprecation, correction, and release treatment. Neither record copies
review outcomes or pull-request disposition.

A draft record may provide context and route the unresolved question, but it
cannot clear current authority. Exact-head owner discussion supplies decision
evidence; the canonical record must commit an accepted decision as `current`
before implementation acceptance.

This authority dependency does not make public API undesirable. Once current
authority settles the semantic contract, ordinary correctness and evidence review
still determine whether the implementation satisfies it.

- Existing props and operations are not removed automatically. They are evaluated
  when touched, when adjacent API is proposed, or when they cause a concrete
  maintenance or consistency problem.
- The proposed PowerSearch editor-popover maximum width is the motivating case
  for the need gate: its width is an internal resolved design choice rather than
  a caller-owned distinction, so a new public tuning prop does not pass.
- A styling switch cannot be justified by accessibility, association, validation,
  or other behavior its mechanism cannot perform. Solve the component behavior
  first; consider API only when a clear, enforceable caller-owned distinction
  remains.
- [PR #5373](https://github.com/facebook/astryx/pull/5373) is evidence that
  parallel public and package-internal operations for one semantic action create
  avoidable surface area. Its particular operation names and options are not
  policy.
- `TableRowStatus.color` is the motivating overloaded-input counterexample. Its
  palette and raw values control tone only, while `success`, `warning`, and
  `error` also select a default themed icon; the optional `icon` then
  conditionally overrides that representation. This shape would be rejected
  under FR16 pending a canonical `component:Table` contract. AST-002 does not
  prescribe the replacement API.
- Implementing the mechanical API gate and its benchmark is follow-up work. This
  policy and template change does not add gate implementation code.

## Verification

| Contract   | Verification                                                                                           | Representative states                                                                              | Mutation or failure expectation                                                                                                                        |
| ---------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR1, FR5   | Blinded historical API review benchmark                                                                | recent utility, mid-range, and composition API additions                                           | Reviewer accepts a prop without identifying caller-owned semantic variation                                                                            |
| FR2, FR3   | Component tests and real-browser layout evidence                                                       | content, container, viewport, parent context, and platform variation                               | Public API exposes a value the component can derive reliably                                                                                           |
| FR4, FR6   | API surface review for utility, mid-range, and composition components                                  | existing slot, theme, style, layout, and behavior seams                                            | High-level component accumulates one-off tuning props instead of using its owning layer                                                                |
| FR7, FR8   | Consumer examples and behavior tests                                                                   | default, each public value, composed use, and unsupported use                                      | Meaning depends on implementation knowledge or tests assert only classes/data attributes                                                               |
| FR9, FR10  | Component ownership and accessibility review                                                           | owned content, external sibling content, missing or incorrect context                              | A state is correct only when the caller fulfills a promise the component cannot verify                                                                 |
| FR11       | Public and package-internal operation inventory; PR #5373                                              | one component module and one semantic action                                                       | One semantic action gains parallel operation names distinguished only by implementation needs                                                          |
| FR12, FR13 | Public-delta/canonical-owner check and exact authority routing                                         | missing delta/update; matching, contradictory, draft-only, or absent authority                     | Missing delta/owner update passes, contradiction is accepted, draft becomes policy, or automation decides semantics                                    |
| FR14       | Focused regression tests against the current contract or standard                                      | defect state, representative unchanged states, and any adjacent public delta                       | A restoration invents a new decision, lacks regression evidence, or hides an additional public change under a bug-fix label                            |
| FR15       | Type-level constraints plus runtime validation and behavior tests                                      | invalid values, unsupported combinations, and legitimate composition                               | The API silently renders a broken state or prevents a valid composition                                                                                |
| FR16       | Full value-domain, input-shape, and parallel-combination contract review                               | semantic variants, raw or palette values, explicit overrides, and defaults                         | One value or shape switches the controlled axis, or a parallel input silently changes precedence                                                       |
| FR17       | Public module/utility export inventory plus implementation, test, consumer, and release-history review | construction, inspection, lookup, conversion, registration, hooks, and released compatibility      | A verb hides the returned value or side effect, two public roles are fused, a non-hook utility uses `use*`, or a released mismatch is silently renamed |
| FR20       | Public callsite and caller-burden review                                                               | representative before/after callsites, defaults, compatibility, and every caller-owned choice      | A new caller decision is hidden, derivable, unpredictable, or justified only by review prose                                                           |
| FR18       | Multi-caller composition scenarios plus owning-contract review                                         | shared semantic operation, internal-only mechanism, and two representative consumers               | Product builders cannot compose stable behavior, or implementation fields are exposed without caller-owned semantics                                   |
| FR19       | Bug-fix before/after authority diff plus public-surface and behavior inventory                         | pure restoration, restoration plus separable novel API/visual change, and internal-wrapper removal | A bug-fix label bypasses authority, a removable tagalong forces a broader spec, or internal mechanics become public because exposure is convenient     |
| Burden     | Benchmark classification: allow, correct pause, false block, not applicable                            | recent accepted and rejected API changes                                                           | Clearly justified APIs are repeatedly paused or blocked without surfacing a real decision                                                              |

## Decision log

### DEC-1 — Public props require a non-derivable caller distinction

**Reference:** `spec:AST-002/DEC-1`
**Decider:** `cixzhang`, `2026-08-30`

A new public component prop is appropriate only when otherwise identical
situations need different resolved design decisions and the component cannot
derive the distinction from information available to it. Public props represent
caller-owned intent, not implementation knobs.

Rejected: adding props whenever a consumer asks for a different internal layout
value. That approach duplicates design decisions across callsites and expands the
permanent API without adding semantic information.

### DEC-2 — Caller need does not admit an unclear or unenforceable API

**Reference:** `spec:AST-002/DEC-2`
**Decider:** `cixzhang`, `2026-08-30`

A non-derivable caller distinction passes only the need gate. The proposed prop
must also name an understandable concept, produce a dependable observable result,
and use a mechanism capable of fulfilling the promise used to justify it.

Rejected: admitting a styling switch because review text gives it accessibility,
association, or validation meaning its mechanism cannot provide. Solve the base
component behavior first; consider public API only when a clear, enforceable
caller-owned distinction remains.

### DEC-3 — One semantic action uses one canonical operation name

**Reference:** `spec:AST-002/DEC-3`
**Decider:** `cixzhang`, `2026-08-31`

A component module uses one canonical operation name for one semantic action across
its public and package-internal forms. The public contract may expose a narrower
option set while the package-internal implementation accepts wider semantic
options under that same name. A second operation is admitted only for genuinely
distinct caller-owned intent and contract.

Rejected: creating a parallel operation because one internal call path needs
additional control. That choice exposes internal call-path differences and leaves
maintainers or consumers to distinguish two names for one action.

### DEC-4 — API review separates mechanical evidence from owner judgment

**Reference:** `spec:AST-002/DEC-4`
**Decider:** `cixzhang`, `2026-09-06`

Mechanical review inventories the exact public API and behavior delta, identifies
the canonical owner, and rejects objective API-shape violations such as derivable
choices, overloaded inputs, hidden conditional precedence, or duplicate operation
names for one semantic action. It does not decide unsettled semantics.

When current authority does not settle a surviving proposal, route it privately to
the human owner. The owner accepts, rejects, or refines the semantic contract; the
accepted decision becomes review authority only after it is committed in the
canonical record as `current`.

Rejected: asking owners to discover mechanical defects from implementation code,
treating generated evidence as a semantic decision, or publishing a contributor-
facing verdict before the owner settles an absent contract.

### DEC-5 — Public changes require committed current authority

**Reference:** `spec:AST-002/DEC-5`
**Decider:** `cixzhang`, `2026-09-06`

Every public API addition and public behavior change identifies applicable current
committed authority before implementation acceptance. Draft records provide
context and route questions, but they are not policy and cannot clear the gate.
Exact-head owner discussion or approval is decision evidence, not a substitute for
recording the accepted contract as current.

Missing authority creates the private unsettled path owned by
`architecture:knowledge-contracts`; it is not permission to accept the change and
not an automatic contributor-facing rejection. After the canonical decision is
current, review the implementation's exact head against it.

Rejected: the former staged exception that let one pull request proceed on owner
approval while leaving its reusable contract draft or missing. That path made the
same public behavior acceptable in one review and undiscoverable in the next.

### DEC-6 — Public inputs keep one semantic responsibility

**Reference:** `spec:AST-002/DEC-6`
**Decider:** `cixzhang`, `2026-09-01`

Every public input has one stable semantic responsibility across its full value
domain and every accepted input shape. Its name and type disclose the
caller-owned meaning. One semantic input may derive several visual details when
they form one cohesive named outcome, such as a status or variant owning tone and
a signifier. A property is rejected when its values or shapes change which axis
it controls, or when consumers need implementation knowledge to predict the
controlled axes.

When two axes are independently caller-owned, expose separate inputs and prevent
invalid or conflicting combinations under FR15. When the system owns their
coordination, expose the semantic concept and derive both details rather than
naming the input after one mechanism. Parallel inputs may override one another
only when their names, types, and behavior across every combination define an
explicit coherent contract and prevent invalid or conflicting states.

Rejected: `TableRowStatus.color`, whose palette and raw values mean tone only
while `success`, `warning`, and `error` mean tone plus a default themed icon, with
an optional `icon` that conditionally overrides representation. That shape would
not pass current API review pending a canonical `component:Table` contract. This
decision does not prescribe the replacement API.

### DEC-7 — Public module and utility verbs disclose atomic roles

**Reference:** `spec:AST-002/DEC-7`
**Decider:** `cixzhang`, `2026-09-03`

Choose a public module or utility function's verb from its primary
caller-observable result and side effects, not from an internal implementation
step. Distinguish construction, inspection, lookup, conversion, registration,
and guaranteed state. `define*` constructs or normalizes and returns a durable
typed value used by a supported consumer. Validation may be its precondition, but
inspection or an unchanged identity result alone is not definition. `validate*`
and `check*` inspect input and return structured results; their contracts state
which failures are returned and which conditions throw.

When construction, validation, registration, or another capability is public,
each has its own callable role. A released mismatch follows an explicit
deprecation and migration instead of a silent rename or behavior change. Other
verbs receive repository-wide rules only when shipped evidence supports them.
Component prop and event naming remains separate. CLI command verbs and their
programmatic command twins remain with the CLI surface owner.

Rejected: treating a function that only validates and returns its input as a
`define*` function, returning a bare predicate from a public `validate*` or
`check*` function, using `use*` for a non-hook utility, or hiding separately
public registration inside a constructor without a compatibility plan.

### DEC-8 — Public primitives are intentional semantic contracts

**Reference:** `spec:AST-002/DEC-8`
**Decider:** `cixzhang`, `2026-09-06`

Prefer a public primitive when product builders need to compose stable behavior
across callsites and the contract can name caller intent, operations, outcomes,
and failure behavior without exposing implementation machinery. Record that
contract as current before accepting the public surface.

A bug fix does not create an exception. When it restores current authority, keep
its API and behavior within that contract. Any additional public primitive or
observable behavior follows `architecture:knowledge-contracts`: first conform,
remove, or split a separable contradiction or unsettled tagalong; then reuse an
existing current decision when one settles an intentional surviving delta. Ask the
owner only for a surviving decision the team actually wants to pursue.

Rejected: promoting internal focus targets, gesture memory, DOM slots, timers, or
other existing fields merely because removing an internal wrapper or fixing one
path becomes easier. Existing implementation is evidence of mechanics, not intent
for permanent public API.

### DEC-9 — Caller decision burden must be visible and justified

**Reference:** `spec:AST-002/DEC-9`
**Decider:** `cixzhang`, `2026-09-12`

A public API proposal shows the callsite before and after the change and names each
new decision a caller must make. The decision is admitted only when it represents
caller-owned intent the component cannot derive and its outcome remains predictable
across defaults, combinations, and supported states.

Rejected: treating a shorter implementation, a persuasive user-impact summary, or
a completed review checklist as proof that callers should own another choice.

## Open questions

None.

---

### Specs AST 003 Spec (`docs/specs/AST-003/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-003
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture:
  [
    architecture:layer-runtime,
    architecture:interaction-modality,
    architecture:public-component-api,
  ]
affects_families: [family:overlay-dismissal]
affects_contributing: []
affects_consumer_docs: []
---

# Layer coordination and global hosting system spec



```json
{
  "scope": "global",
  "triggers": {
    "layering": ["FR10", "DEC-1", "DEC-2", "DEC-3", "DEC-5"]
  }
}
```

## Intent

Make every layered interaction close the intended owner or interaction branch,
without transient surfaces shielding it or one pointer gesture closing and then
reopening it. Selected app-global systems must remain visible and operable above
native modal dialogs through browser-valid native hosting.

The full anchored-layer contract must also state its platform dependency honestly:
native Popover API plus CSS Anchor Positioning provide full behavior; unsupported
browsers receive a usable reduced fallback, not equivalent behavior.

## Non-goals

- Making every layer dismissible through every channel.
- Replacing component-owned focus, modality, outside-dismiss policy, backdrop,
  swipe threshold, hover timing, open-state API, animation, or visual treatment.
- Giving every overlay a global or above-modal mode.
- Replacing native Popover and CSS Anchor Positioning with a legacy positioning
  and dismissal polyfill.
- Changing the public API merely to expose internal owner, branch, or gesture
  bookkeeping.

## Requirements

### Interaction ownership and association

- **FR1 — Coordination models interaction roles.** A registered surface is an
  active dismiss owner, a blocking owner, or a passive dependent. A visual-only
  surface does not register as an interaction owner.
- **FR2 — Active owners may dismiss.** An active owner owns task or interaction
  state and invokes its component-owned close behavior when the selected channel
  participates.
- **FR3 — Blocking owners consume without closing.** A blocking owner prevents a
  channel from reaching an owner behind it. Required Dialog is the representative
  Escape/platform case.
- **FR4 — Passive dependents never shield owners.** Tooltip is passive. It does
  not win or block dismissal and disappears as a consequence when its associated
  owner or trigger interaction closes.
- **FR5 — Associations are explicit.** Every active or blocking owner records an
  optional parent owner and an interaction-branch root. A passive dependent
  records its associated owner or trigger without creating an owner node or
  branch boundary.

### Channel-specific arbitration

- **FR6 — Escape and platform close select one eligible owner.** One-at-a-time
  channels select the highest associated active or blocking owner whose
  component policy participates. Active closes, blocking consumes, passive and
  visual-only surfaces are skipped.
- **FR7 — Outside interaction resolves branches.** Outside interaction is
  evaluated against owner surfaces, associated triggers or invokers, and branch
  relationships. It does not reuse visual or registration topmost order.
- **FR8 — Menu cascades close as one branch.** DropdownMenu and its open flyout
  submenus share one branch root. Outside that root closes the root and every
  open submenu.
- **FR9 — Nested child branches can close independently.** A distinct nested
  Popover creates a child branch. Interaction inside the parent branch but
  outside the child closes only the child; interaction outside both resolves
  both affected branches according to component policy.
- **FR10 — Component policy stays with the component.** Shared coordination
  determines the eligible owner or affected branch. The owning component or
  family determines whether that channel dismisses, blocks, or passes.

### Same-interaction protection

- **FR11 — Pointer dismissal claims the interaction before closing.** Every
  pointer-driven outside, light-dismiss, backdrop, touch, or swipe path that can
  collide with an associated trigger records a branch claim before visibility
  changes.
- **FR12 — Every reopen path honors the claim.** Associated triggers, native
  invokers, `show()`, and toggle paths refuse the same physical gesture. A later
  gesture may reopen.
- **FR13 — Browser event order is irrelevant.** The claim works whether native
  close is observed before or after click. Timestamp windows do not substitute
  for gesture identity.
- **FR14 — Menu hover suppresses exposure-induced hover.** When a closing panel
  exposes a stationary trigger, the resulting synthetic `mouseenter` does not
  reopen the branch. A genuine leave and later hover may open it again.

### Native hosting for selected global systems

- **FR15 — Global admission is explicit.** A system receives above-modal global
  hosting only through an owner-approved update to `architecture:layer-runtime`.
  Ordinary trigger- or task-associated overlays remain local.
- **FR16 — Toast is globally hosted.** ToastViewport remains visible and operable
  above the current native modal. Toast remains passive in layer dismissal:
  Escape/platform arbitration skips it, and auto-hide, explicit controls, or a
  direct toast swipe dismiss only the toast.
- **FR17 — CommandPalette is a global active owner in launcher mode.** Normal
  CommandPalette uses a later native modal host and forms an active interaction
  branch above the prior modal. `isInline` previews are not global or modal.
- **FR18 — Native modal ancestry supplies passive global hosting.** The app has a
  root global outlet and each active native modal supplies an outlet in its DOM.
  Passive global hosts render into the latest active modal outlet, falling back
  to the root outlet when no modal is active. An ordinary body portal or
  `z-index` is not sufficient.
- **FR19 — Global state survives rehosting.** Toast dispatch, visible entries,
  timers, focus handoff, and collision state live above the selected outlet so
  modal opening or closing does not reset them.
- **FR20 — Current nonqualifiers stay local.** Banner and FieldStatus remain
  in-flow; `useAnnounce` remains nonvisual; AlertDialog, imperative Dialog,
  Lightbox, and ordinary Popover-family surfaces remain task- or
  trigger-associated.

### Anchored-layer support requirements

- **FR21 — Native features define full anchored behavior.** Supported native
  Popover API plus CSS Anchor Positioning provide top-layer promotion, native
  light dismissal, auto-popover association, logical anchor geometry, and
  collision fallbacks.
- **FR22 — Unsupported browsers receive a usable reduced fallback.** Explicit
  controls still show and hide mounted content, callbacks and React state update,
  and fixed coordinates apply. The fallback is not required to reproduce native
  top-layer, outside-dismiss, close-request, auto-stack, invoker focus-order,
  anchor-geometry, or collision behavior.
- **FR23 — Public docs state the boundary.** `useLayer`, Popover, and relevant
  component docs distinguish full native behavior from reduced fallback behavior
  without calling the fallback equivalent.

### Implementation requirements

- **IR1 — Coordination exposes two operations.** Shared infrastructure provides
  an eligible-owner resolver for one-at-a-time channels and an associated-branch
  resolver for outside interaction. One generic `isTopmost` operation cannot
  implement both contracts.
- **IR2 — Branch identity is reusable.** Dropdown submenu context projects its
  existing root-close chain into shared branch identity. Independent nested
  Popovers create child branches rather than joining a menu cascade accidentally.
- **IR3 — Gesture claims extend the existing owner.** Branch claims build on
  `gestureCounter` rather than introducing per-component timestamp heuristics.
- **IR4 — Native global outlets are narrow infrastructure.** Global outlet
  selection does not become a public `global` prop on Layer or Popover.
- **IR5 — Runtime behavior changes stay atomic.** Eligibility, branch
  coordination, gesture claims, global hosting, and support documentation may
  ship as separate changes while preserving this one accepted contract.

### Platform support

- Supported feature/engine floor: the full contract applies where native Popover
  API and CSS Anchor Positioning are supported by Astryx's published browser
  matrix.
- Unsupported behavior: browsers below that feature floor receive FR22 only.
  Native modal Dialog behavior remains subject to the supported dialog floor.
- Browser evidence: native light dismissal, invoker behavior, top-layer order,
  focus/inertness, modal outlets, and anchor geometry require real Chromium and
  WebKit. Playwright WebKit is not evidence of Safari-specific behavior.

## Current-state impact

Current `main` does not satisfy this spec completely:

- the stack has `close` and `block` but no passive role; Tooltip currently
  consumes Escape ahead of associated owners;
- no shared owner/branch association graph or outside-branch resolver exists;
- DropdownMenuSubMenu has a local parent-close chain, but outside channels do not
  consume shared branch identity;
- native light dismiss has gesture memory, and menu hover has re-hover
  suppression, but local backdrop, touch, swipe, and manual outside paths do not
  share branch claims;
- body/root Toast hosts remain behind active native modal dialogs;
- Toast state lives inside ToastViewport rather than above a movable outlet;
- CommandPalette uses native Dialog but handles Escape locally; and
- reduced fallback behavior exists in code but is not fully described in public
  docs.

### Migration and adoption plan

1. **Owner and branch model.** Extend layer registration with active, blocking,
   and passive roles plus parent-owner and branch-root association. Add pure
   eligible-owner and outside-branch resolvers. Preserve the current
   Escape/platform contract for existing active/blocking members while migrating.
2. **Eligibility migration.** Make Tooltip passive and update Tooltip-over-owner
   tests. Keep HoverCard active because it can own interactive content. Migrate
   CommandPalette and the local Escape implementations tracked by
   `family:overlay-dismissal` onto eligible-owner delivery.
3. **Branch migration.** Project DropdownMenuContext's root/submenu chain into a
   shared branch. Add independent child branches for nested Popovers. Migrate
   native auto light dismiss, ContextMenu outside press, dialog backdrop, touch,
   and sheet swipe/scrim channels without changing their component policies.
4. **Gesture claims.** Generalize current Layer close memory into a branch claim
   recorded before pointer-driven closure. Require every associated reopen path
   to consult it. Preserve menu-hover's exposure suppression.
5. **Global native host.** Add root and modal outlets with activation order. Lift
   Toast state above the render outlet, render its manual-popover viewport inside
   the selected outlet, and preserve state through rehosting. Keep CommandPalette
   on native Dialog and verify it as a later active modal owner.
6. **Support projection.** Update public Layer/Popover/component docs with the
   full native contract and reduced fallback. Add the browser matrix required
   below.
7. **Architecture promotion.** As each requirement ships, update
   `architecture:layer-runtime` from observed gaps to shipped invariants. Move
   this spec to `shipped` only after every completion criterion is met.

No migration step adds a broad public API by default. A new public prop or hook
still follows `architecture:public-component-api` and `spec:AST-002`.
Interaction modality continues to follow `architecture:interaction-modality`.

## Verification

| Contract  | Verification                                               | Representative states                                                                           | Mutation or failure expectation                                                 |
| --------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| FR1–FR6   | Pure registry/resolver tests plus family interaction tests | active, blocking, passive Tooltip, visual-only, controlled owner                                | Passive surface wins, blocker leaks, or wrong owner closes                      |
| FR7–FR10  | Branch resolver tests and real-browser outside interaction | root menu with two submenu levels; parent + child Popover; associated trigger                   | Outside root leaves submenu open, or child-only outside closes parent           |
| FR11–FR14 | Gesture-counter and browser event-order tests              | native light dismiss before/after click; menu button; hover-open; touch; backdrop; swipe        | Same gesture reopens any associated trigger or exposed hover reopens panel      |
| FR15–FR20 | Chromium/WebKit native host tests                          | Toast at root; Toast through one/two modals; CommandPalette above modal; ordinary local Popover | Global surface is behind/inert, loses state, or ordinary overlay becomes global |
| FR21–FR23 | Supported/unsupported browser matrix and public-doc checks | anchor/fixed/custom; auto/manual; explicit controls; absent feature APIs                        | Docs promise equivalence or reduced fallback is unusable                        |
| IR1–IR3   | API/type tests and mutation tests for resolvers/claims     | one-at-a-time channel versus branch channel                                                     | One topmost query is reused for outside branches or timestamps replace identity |
| IR4, IR5  | Public-surface snapshot and atomic change review           | local Popover, global Toast, global CommandPalette                                              | Generic global prop appears or unrelated behaviors are coupled in one change    |

### Completion criteria

This spec is complete only when:

- Tooltip is passive in Escape/platform and outside-branch coordination;
- eligible-owner and associated-branch operations are shared and adopted by all
  members named by `family:overlay-dismissal` for their relevant channels;
- outside a dropdown/menu root closes its entire open submenu cascade, while
  child-branch outside behavior preserves its parent when appropriate;
- every pointer-driven branch dismissal uses the branch gesture claim and every
  associated reopen path honors it;
- Toast remains visible and operable above zero, one, and two active native
  modals without losing entries, timers, theme, or focus handoff;
- normal CommandPalette opens above an existing modal and participates as the
  selected active owner;
- ordinary overlays remain local and cannot opt into global hosting without an
  approved record change;
- public docs describe full native support and the reduced fallback accurately;
- required Chromium and WebKit browser matrices pass; and
- `architecture:layer-runtime` and `family:overlay-dismissal` reflect the shipped
  implementation without listing these items as gaps.

## Decision log

### DEC-1 — Dismissal follows eligible interaction ownership

**Reference:** `spec:AST-003/DEC-1`
**Decider:** `cixzhang`, `2026-08-30`

Escape and platform close select the topmost eligible active or blocking owner,
not the topmost visible or registered surface. Tooltip is passive and cannot
shield a Popover, menu, Dialog, or associated interactive element beneath it.

Rejected: letting every visible transient layer consume one Escape. That makes
supplementary information block the interaction the user is trying to dismiss.

### DEC-2 — Outside interaction resolves associated branches

**Reference:** `spec:AST-003/DEC-2`
**Decider:** `cixzhang`, `2026-08-30`

Outside interaction has different scope from Escape. Outside a dropdown or menu
cascade closes the whole associated root branch, including every open submenu.
Interaction outside only a distinct nested child branch closes that child while
preserving the parent when the event remains inside the parent branch.

Rejected: reusing one visually topmost gate for every channel. One-at-a-time and
branch-scoped interactions have different user intent.

### DEC-3 — Dismissing gestures are claimed across the branch

**Reference:** `spec:AST-003/DEC-3`
**Decider:** `cixzhang`, `2026-08-30`

A pointer gesture that dismisses a branch is claimed before close. It cannot
continue into an associated trigger and immediately reopen any surface in that
branch. Menu-hover also suppresses the synthetic hover created when a closing
panel exposes its trigger.

Rejected: timestamp-only debounce. Gesture identity must survive either browser
event order without delaying a deliberate next interaction.

### DEC-4 — Selected global systems use native above-modal hosting

**Reference:** `spec:AST-003/DEC-4`
**Decider:** `cixzhang`, `2026-08-30`

Toast and normal CommandPalette are the current selected global systems. They may
and must appear above active native modal dialogs through browser-valid native
hosting. This does not make ordinary overlays global.

Rejected: relying on a body portal or high `z-index`. Neither can cross a native
modal top-layer boundary.

### DEC-5 — Full anchored behavior depends on native platform features

**Reference:** `spec:AST-003/DEC-5`
**Decider:** `cixzhang`, `2026-08-30`

Native Popover API plus CSS Anchor Positioning define the full anchored-layer
contract. Unsupported browsers receive a documented usable reduced fallback,
not equivalent positioning, stacking, association, or dismissal behavior.

Rejected: describing the current `display` fallback as a polyfill. It does not
implement the missing browser systems.

## Open questions

None.

---

### Specs AST 004 Spec (`docs/specs/AST-004/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-004
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-30
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture:
  [
    architecture:component-theming-surface,
    architecture:icon-resolution-and-component-slots,
  ]
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Selector empty indicator-space collapse system spec

## Intent

Give unselected options the width currently consumed by an empty selection-mark
column. A person should not lose label space to an indicator that draws nothing.
The selected row may therefore shift or truncate earlier when its mark appears;
that difference is an accepted part of the selection state.

This spec records an approved future change. `component:Selector` remains the
source of truth for shipped behavior until the implementation and verification
below land and that current contract is updated in the same pull request.

## Non-goals

- Implementing the runtime change in this specification pull request.
- Adding a public prop to preserve or select indicator-column reservation.
- Removing `indicatorPosition` or changing its default logical edge.
- Hiding an unselected indicator that a theme intentionally renders, such as an
  unchecked radio replacement.
- Changing selection, keyboard, loading, adaptive presentation, Popover,
  BottomSheet, or listbox/dialog semantics.
- Establishing an input-family or selection-family layout rule.

## Requirements

- **FR1 — Empty indicator space collapses.** An unselected option whose resolved
  selection indicator draws no content MUST NOT reserve an empty indicator
  column.
- **FR2 — Rendered indicators keep their space.** A selected mark and any themed
  replacement that renders an unselected state MUST remain in row layout at the
  configured `indicatorPosition`.
- **FR3 — State-dependent width is intentional.** When only the selected row
  renders a mark, its label may start at a different inline position or have less
  available width than an unselected label. The implementation MUST preserve
  readable row content and existing overflow behavior rather than compensating
  with blank space.
- **FR4 — Logical placement still applies.** `indicatorPosition="start"` and
  `"end"` MUST place each rendered mark at that logical edge in both LTR and RTL.
- **IR1 — Derive layout from rendered content.** The component MUST determine
  whether space exists from the resolved indicator output; it MUST NOT add a new
  public reservation or alignment prop.
- **IR2 — Preserve theme replacement.** The change MUST continue resolving the
  indicator through the current theme-owned indicator path, including selected
  and unselected states.
- **IR3 — Preserve semantic ownership.** Collapsing visual space MUST NOT change
  option roles, `aria-selected`, listbox ownership, trigger relationships, or
  which element carries the stable `selector-check` theme target.

### Platform support

- Supported feature/engine floor: all browsers supported by Astryx Core.
- Unsupported behavior: none; the layout MUST use the existing cross-browser CSS
  and React support floor.
- Browser evidence: real Chromium verifies rendered spacing and truncation. RTL
  and compact-width cases are required; DOM structure or jsdom alone is not
  visual evidence.

## Current-state impact

Current `main` intentionally renders an indicator wrapper for every option and
applies a minimum inline width to that wrapper. The default unchecked check
indicator draws nothing, so unselected rows still reserve blank space. Existing
`Selector.test.tsx` coverage explicitly asserts two row children at both
indicator positions.

Implementation changes `component:Selector/FR3` and its mapped tests. It remains
bounded by the current systems Selector already uses:

- `architecture:component-theming-surface` continues to own stable theme targets;
- `architecture:icon-resolution-and-component-slots` continues to own resolved
  indicator replacement and stateful rendering;
- `architecture:interaction-modality` and `architecture:layer-runtime` remain
  unchanged; pointer Popover and touch modal-dialog behavior are not part of this
  layout change; and
- `architecture:public-component-api` continues to prohibit exposing a derivable
  layout difference as a new caller-controlled prop.

### Implementation requirements

1. Collapse the mark wrapper only when the resolved indicator renders no content.
2. Preserve layout space for the selected default check and for themed indicators
   that render an unselected state.
3. Replace the current reserved-column assertion with focused coverage for empty
   collapse and rendered replacement indicators at both logical positions.
4. Update `component:Selector` in the implementation pull request so its current
   requirements and verification map describe the shipped result.
5. Add stable real-browser visual regression coverage before this spec moves to
   `shipped`.

## Verification

| Contract      | Verification                                     | Representative states                                                                    | Mutation or failure expectation                                                                    |
| ------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| FR1, FR3      | Focused `Selector.test.tsx` layout assertions    | default check; selected and unselected rows; narrow long labels                          | An empty wrapper keeps width, or selected-state truncation is masked by restored blank spacing     |
| FR2, IR2      | Indicator replacement tests                      | default check and themed radio; checked and unchecked                                    | Unselected themed output disappears or loses its layout space                                      |
| FR4           | Logical-order tests and real-browser screenshots | `start`/`end`; LTR/RTL; selected/unselected                                              | A rendered mark appears on the physical rather than logical edge                                   |
| IR1, IR3      | Public-surface, role, and theme-target tests     | default and custom option rendering; search/non-search                                   | A new public prop appears, option/listbox semantics change, or the stable target moves             |
| Visual result | Stable visual regression in real Chromium        | pointer Popover and touch modal dialog; narrow/wide; start/end; default/themed indicator | Empty space remains, visible indicators overlap, or selected/unselected content becomes unreadable |

### Completion criteria

This spec moves from `accepted` to `shipped` only when:

- empty default-check wrappers consume no indicator-column width;
- selected marks and visible unselected theme replacements retain their required
  space at both logical positions in LTR and RTL;
- selected-row shift and narrower available width are covered as intentional
  behavior, including long-label truncation;
- focused unit and real-browser visual regressions cover pointer Popover and
  touch modal-dialog presentations without changing their semantics; and
- `component:Selector` is updated from the reserved-column baseline to the new
  shipped behavior in the same pull request.

## Decision log

### DEC-1 — Unselected rows collapse empty indicator space

**Reference:** `spec:AST-004/DEC-1`
**Decider:** `cixzhang`, `2026-08-30`

An unselected Selector option does not reserve blank space when its resolved
selection indicator draws nothing. This gives unselected labels more usable
width. A selected label may shift or truncate earlier when its indicator appears;
that state difference is intentional.

Rejected: reserving an empty column on every row solely to keep selected and
unselected labels aligned. That alignment spends content width on an absent
visual and makes the unselected state less useful.

## Open questions

None.

---

### Specs AST 005 Spec (`docs/specs/AST-005/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-005
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: [architecture:public-component-api]
affects_families: [family:navigation-destinations]
affects_contributing: []
affects_consumer_docs:
  [Link, Button, ClickableCard, Item, List, Token, Markdown]
---

# Safe navigation destinations



```json
{
  "scope": "global",
  "triggers": {
    "navigation": ["FR2", "FR3", "FR4", "FR6", "FR7", "FR8", "DEC-1", "DEC-2"]
  }
}
```

## Intent

A person activating an Astryx link should get the same destination safety no
matter which component drew it, whether navigation is native or imperative, and
whether a framework router replaced the native anchor. Component composition
must not create a path around the shared rule.

This spec accepts the shared navigation-destination contract.
`family:navigation-destinations` records adoption across native, custom-router,
imperative, and Markdown navigation. Acceptance of the contract does not by
itself establish implementation or exact-head verification.

## Non-goals

- Validating URL syntax, requiring absolute URLs, or restricting destinations to
  an allowlist of hosts.
- Defining content policy for image, media, downloaded files, CSS, fetch, or
  other embedded resource URLs. Those sinks have different capabilities and
  content-type risks; downloads from accepted link destinations stay supported.
- Sanitizing caller-owned JSX, custom Markdown plugin output, or arbitrary
  consumer callbacks after control has left Astryx.
- Replacing Content Security Policy, router authorization, server validation, or
  application-level access control.
- Adding a public prop that lets a component bypass or configure the rule.
- Changing link labels, target/rel behavior, disabled semantics, or ordinary URL
  resolution.

## Requirements

- **FR1 — One rule covers every Astryx-owned navigation exit.** Before Astryx
  renders, delegates, or imperatively activates a caller-controlled navigation
  destination, the destination MUST pass the contract in this spec. Moving from
  a native anchor to a router component or enlarged clickable surface MUST NOT
  weaken that contract.
- **FR2 — Inspection follows browser scheme normalization.** Scheme inspection
  MUST remove ASCII control characters `U+0000–U+001F` and `U+007F`, trim outer
  whitespace, and compare ASCII scheme text case-insensitively. This prevents a
  blocked scheme from being hidden by characters the browser ignores.
- **FR3 — Executable document schemes are blocked.** A normalized navigation
  destination beginning with `javascript:`, `vbscript:`, or `data:text/html`
  MUST NOT become executable navigation. The check applies before query, hash,
  target, modifier-key, or pointer-button differences can select another sink.
- **FR4 — Ordinary destinations keep working.** Relative paths, same-document
  fragments, protocol-relative URLs, and ordinary schemes such as `http:`,
  `https:`, `mailto:`, `tel:`, and safe custom schemes MUST retain their existing
  navigation behavior, including downloads from accepted destinations. This
  contract does not rewrite, resolve, or host-filter accepted destinations.
  Accepted structured router destinations MUST retain their object identity.
- **FR5 — Native, custom, and imperative paths are all covered.** The full rule
  MUST hold for React DOM anchors, a custom component supplied through
  `LinkProvider` or an `as` seam, and imperative exits including `window.open`
  and `window.location`. React DOM's own sanitizer is not a native-anchor
  exception. Middle-click and Cmd/Ctrl-click are not exceptions either.
- **FR6 — Rejection fails closed at the navigation boundary.** A blocked
  destination MUST render inertly and MUST NOT reach an imperative browser API.
  A custom router MUST NOT be invoked for a rejected destination, including with
  `undefined` substituted for the rejected value. The component MAY preserve
  non-navigation rendering and consumer callbacks when doing so cannot activate
  the rejected destination.
- **FR7 — Router destination props are independent sinks.** When a custom link
  component can receive both `href` and `to`, each supplied value MUST pass the
  rule independently. If either is rejected, the result MUST be inert: no custom
  router invocation and no fallback to the other destination, even if it is safe.
  When both are accepted, an explicit `to` retains its documented precedence.
  Supported structured destinations MUST obey the same rule, including every
  scheme-bearing `href`, `pathname`, or `protocol` field and the destination
  those fields form. A separate protocol or an unchecked field MUST NOT provide
  a path around the rule. Accepted fields and object identity remain unchanged.
- **FR8 — Parser and rendering checks agree for navigation.** Markdown parsing,
  Markdown link rendering, and shared Core navigation plumbing MUST reject the
  same blocked navigation schemes after the same normalization. Markdown MAY use
  a stricter policy for embedded resource URLs without narrowing this navigation
  contract.
- **IR1 — Shared exits use a shared owner.** Core components that delegate links
  or navigate imperatively MUST use one shared policy owner rather than maintain
  component-local blocked-scheme lists. Parser-local implementations MAY remain
  separate only while conformance tests pin the same navigation matrix.
- **IR2 — Every distinct sink has mutation-sensitive coverage.** Verification
  MUST fail when the guard is removed from native anchors, custom `href`, custom
  `to`, supported structured destinations (including `protocol`), new-tab,
  modified-click, middle-click, plain imperative navigation, or Markdown link
  parsing/rendering. Rejected-router coverage MUST fail if the custom component
  is invoked with `undefined` or another destination used as fallback.
- **IR3 — New navigation surfaces join the family.** A new component or hook that
  accepts a caller-controlled navigation destination MUST join
  `family:navigation-destinations` and reuse the shared owner before shipping.

### Platform support

- Supported feature/engine floor: every browser and framework-link integration
  supported by Astryx Core.
- Unsupported behavior: a custom component that ignores the filtered destination
  props or manufactures a destination from other caller data is outside the
  guarantee; Astryx MUST NOT claim that caller-owned behavior is sanitized.
- Browser evidence: real Chromium verifies that every blocked scheme remains
  inert on native and imperative paths and that ordinary relative, external,
  modified, middle-click, and download behavior remains available. Unit
  integration tests verify custom router handoff and object identity; real
  framework SSR verifies that rejection renders without invoking a router that
  requires a destination.

## Current-state impact

`family:navigation-destinations` is the complete shared owner for the behavior.
Its membership covers every current Core component that accepts or derives a
navigation destination, including aggregate component records whose public
members own the actual `href`.

Implementation and verification must cover:

1. the full blocked-scheme rule on native anchors, not just React DOM's own
   `javascript:` protection;
2. custom `href` and `to`, including supported structured destinations and their
   separate `protocol`, with inert rendering whenever either value is rejected;
3. every imperative and delegated activation while preserving accepted
   destinations and consumer callbacks;
4. conformant Markdown navigation with separate embedded-resource policy; and
5. exact-head evidence before recording completed family adoption.

No component-local public prop is added. Exceptional behavior belongs in
caller-owned custom rendering outside this safe-navigation contract, not in a
`LinkProvider` or `as` bypass. Consumer documentation should state the shared
outcome where a component documents an `href` or router seam; it should link to
the shared rule rather than reproduce a scheme list in every prop row.

## Verification

| Contract           | Verification                                                 | Representative states                                                                                                                                       | Mutation or failure expectation                                                                                                                         |
| ------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR2, FR3, FR4, FR8 | Shared predicate and Markdown parser/render suites           | mixed case; embedded controls; relative/hash; http(s); mailto/tel; safe custom schemes; downloads; blocked schemes                                          | Removing normalization or changing one blocked prefix makes the navigation matrices disagree                                                            |
| FR5, FR6, FR7      | `useLinkComponent` integration tests and framework SSR tests | native anchor; provider; `as`; safe/rejected `href` and `to` in either combination; structured `pathname`/`href`/`protocol`; accepted object identity       | A rejected destination invokes the router, including with `undefined` or a safe fallback; accepted destinations lose identity or precedence; SSR throws |
| FR1, FR3, FR5, FR6 | `useClickableContainer` integration tests                    | plain click; `_blank`; Cmd/Ctrl-click; middle-click; delegated interactive ref                                                                              | A blocked value reaches `window.open`/`window.location`, or an ordinary navigation path stops working                                                   |
| FR1, IR1, IR3      | Family membership and source-usage audit                     | every current `useLinkComponent`, `useClickableContainer`, Markdown, and direct native-anchor destination owner                                             | A new caller-controlled navigation sink ships outside the family or duplicates a local rule                                                             |
| Browser contract   | Real Chromium probe                                          | all blocked prefixes on native anchors; custom-router stand-in; imperative same-tab/new-tab; modified and middle click; accepted destinations and downloads | Script executes, blocked navigation occurs, a rejected destination gains link activation, or ordinary browser affordances regress                       |

### Completion criteria

This spec moves from `accepted` to `shipped` only when:

- every adoption gap in `family:navigation-destinations` is closed or documented
  as an explicit owner-approved exception;
- exact-head tests cover native anchors, structured router destinations, inert
  router rejection, and each distinct imperative and Markdown navigation sink;
- a source/member audit finds no caller-controlled Core navigation exit outside
  the family; and
- consumer docs describe the shared outcome without implying that embedded
  resources or caller-owned renderers receive the same policy.

## Decision log

### DEC-1 — Destination safety follows the navigation sink

**Reference:** `spec:AST-005/DEC-1`
**Decider:** `cixzhang`, `2026-08-31`

Every Astryx-owned path that can activate a caller-controlled navigation
destination follows one normalized blocked-scheme rule. The guarantee survives
component composition, custom framework routers, modified clicks, middle clicks,
and imperative navigation because those are alternate exits for the same user
intent.

Rejected: relying only on React DOM. React can protect an `href` it renders, but
it does not inspect props handed to a custom router and does not mediate
`window.open` or `window.location`.

### DEC-2 — Embedded resources remain a separate policy

**Reference:** `spec:AST-005/DEC-2`
**Decider:** `cixzhang`, `2026-08-31`

Navigation destinations and embedded resources are separate sink families. This
spec blocks executable document schemes for navigation without deciding which
image sources, downloaded content, or fetched resources are allowed. Downloads
from accepted navigation destinations keep working. Markdown may therefore
reject a broader resource set while matching the shared navigation matrix.

Rejected: one undifferentiated “safe URL” boolean for every URL-shaped value.
That hides material differences between navigating a document and embedding or
fetching a resource, and would accidentally broaden or narrow unrelated public
behavior.

## Open questions

None.

---

### Specs AST 006 Spec (`docs/specs/AST-006/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-006
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
phase: shipped
owners: [cixzhang, rubyycheung, imdreamrunner]
affects_architecture:
  [
    architecture:theme-authoring-contract,
    architecture:theme-tokens,
    architecture:theme-compilation,
  ]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme]
---

# Theme-local tokens system spec



```json
{
  "scope": "global",
  "triggers": {
    "tokens": ["DEC-1", "DEC-2", "DEC-3", "DEC-4", "DEC-5", "DEC-6"]
  }
}
```

## Intent

Let a maintained theme reuse one of its own semantic decisions across component
overrides without adding that decision to Astryx's portable token vocabulary.
Theme authors get one explicit, checked path for theme-family-local reuse;
component and application authors keep the same portable token contract they have
today.

The shipped API is one optional `DefineThemeInput.localTokens` field. Supplying it
explicitly enrolls a theme in the contract; omitting it preserves existing behavior. A
local token is declared with any valid CSS custom-property name and referenced by that
same exact name. Prefixes do not grant, reserve, or restrict ownership. Descendants
inherit enrollment and may replace exact names only through an enrolled exact
`extends` lineage. Mere emitted CSS creates no contract, but a shipped enrolled
definition recorded by its owning theme spec does: its exact name and semantic meaning
are public within that theme family, not portable across themes or intended for Core
component source.

This current record governs the cross-theme API and invariants independently of
whether any particular theme has completed its own adoption evidence. The original
local-token surface is shipped; the 2026-09-12 naming amendment is accepted and awaits
its implementation. Theme-local names, meanings, values, mappings, and rendered
evidence remain owned by each adopting theme's colocated record.

## Non-goals

- Changing the type, accepted inputs, runtime or build behavior, permissive
  spread behavior, names, precedence, inheritance, output, or resolution of the
  existing `DefineThemeInput.tokens` path.
- Adding a `defineLocalTokens` helper, a second authoring operation, a shorter
  role identifier, a reference alias, or a parallel local-token API.
- Adding theme-local names to `TokenName`, `tokenVar`, `tokenVars`,
  `resolveThemeToken`, generated portable token documentation, or Core component
  source.
- Creating an application-consumer token extension mechanism. A compiled custom
  property is an implementation output of its maintained theme family, not
  authorization for application code to depend on it.
- Defining any adopting theme's role meaning, value, component mappings, or
  evidence. Those belong to that theme's colocated record.

## Requirements

The requirements below are the current cross-theme contract.

- **FR1 — Local tokens are purely additive, optional, and explicitly enrolled.**
  `DefineThemeInput` MUST gain at most one new authoring surface: optional
  `localTokens`. Supplying `localTokens` explicitly enrolls that theme in the new
  contract. A descendant inherits enrollment only by extending an enrolled exact
  base. A theme that neither supplies the field nor extends an enrolled exact
  base remains unenrolled and MUST produce byte-equivalent `DefinedTheme` data,
  runtime behavior, static output, package output, and resolution behavior.
- **FR2 — Existing token behavior is frozen and local values reuse its value
  contract.** This contract MUST NOT change `tokens` typing, accepted inputs,
  runtime handling, static handling, permissive unknown-key or broad-spread
  behavior, names, precedence, inheritance, output, or resolution. Existing
  casts, spreads, external CSS variables, and legacy token references MUST NOT
  receive new warnings, failures, reinterpretation, or migration requirements.
  Inside the opt-in `localTokens` field, each value MUST accept the complete
  existing `TokenValue` contract: a CSS string or `[light, dark]` tuple. Runtime
  and static build MUST apply the same value normalization semantics existing
  `tokens` use, without changing that existing path or normalizing theme names.
- **FR3 — One exact name survives every stage.** A local token declaration MUST
  use its complete CSS custom-property name as the `localTokens` key. The same
  exact string MUST be used in `var(...)` references, `DefinedTheme` data,
  runtime CSS, static CSS, inherited replacement, and generated theme-specific
  types or metadata. No transform may create a second identifier.
- **FR4 — Any valid custom-property key is accepted.** Every newly declared
  `localTokens` key MUST be a valid CSS custom-property name and MUST be preserved
  byte-for-byte. No prefix is required, reserved, rewritten, or interpreted as owner
  or enrollment metadata. A theme name and a local-token key are independent; this
  contract defines no preferred replacement naming pattern.
- **FR5 — Local use stays inside the maintained theme family.** A local name MAY
  be referenced by the maintained enrolled theme that defines it and by
  descendants enrolled through its exact `extends` lineage. It MUST NOT become a
  portable cross-theme token or a dependency in Core component source. Mere CSS
  emission from an unenrolled legacy path creates no contract. Once an enrolled
  theme ships a local token documented in its colocated theme-level spec, the
  exact name and semantic meaning ARE a public compatibility contract of that
  theme family.
- **FR6 — Extension is exact-name, enrollment, and lineage-aware.** A descendant
  inherits enrollment and declarations only from an enrolled exact `extends` base. It
  MAY replace an inherited local token only by restating that inherited complete name.
  A newly declared name is owned by the declaring theme regardless of its spelling.
  Prefix, suffix, or value similarity MUST NOT create ownership or lineage, and a child
  MUST NOT claim another theme's declaration.
- **FR7 — Validation is atomic for enrolled themes only.** Runtime and static build
  MUST share one recursive validator. For a theme enrolled directly or through an
  enrolled exact base, it MUST validate each declaration's custom-property syntax,
  exact owner and lineage, collisions with portable `tokens`, and cycles among exact
  declared references across local-token values, component overrides, media surfaces,
  and adaptations before producing partial output. An adaptation `value.tokens` key
  that exactly matches an effective local declaration MUST fail so the write uses
  `value.localTokens` and retains owner, lineage, and cycle validation. A `var()`
  reference is a local edge only when it exactly matches an effective enrolled
  declaration; every other custom-property reference remains external. Prefixes have
  no validation meaning.
- **FR8 — Unenrolled legacy behavior is grandfathered unchanged.** When
  `localTokens` is absent and the exact base is not enrolled, runtime and static build
  MUST NOT add a new scan, rejection, warning, reservation, or interpretation for CSS
  custom-property references. Existing custom properties continue unchanged. A
  maintainer may leave that behavior unenrolled or explicitly migrate by supplying
  `localTokens`; no prefix changes either path.
- **FR9 — Source and built themes preserve one enrollment lineage.**
  Source-defined and built themes MUST retain equivalent exact local-token maps,
  enrollment state, and lineage metadata so descendants accept, inherit,
  replace, reject, and emit the same names without requiring a base stylesheet.
- **FR10 — Released names and meanings are compatibility obligations.** Once an
  enrolled local token ships, its exact name and documented semantic meaning are
  public contracts of the owning theme family. Renaming the local token or its
  enrolled theme, or changing that meaning, MUST use an explicit reviewed
  migration or alias that preserves the prior exact-name, meaning, and lineage
  contract for its compatibility window. Silent key replacement, semantic broadening,
  owner reassignment, and inferred descendant migration are prohibited.
- **FR11 — The colocated theme spec owns definitions and mappings.** Each
  theme-local token definition MUST live in the owning theme's colocated
  theme-level spec, including its exact name, semantic meaning, value, mappings,
  compatibility, and rendered evidence. A long generated name is acceptable when
  it accurately names the meaning shared by every context where it is applied.
  Adding a mapping is a theme-spec compatibility review: the context MUST
  genuinely match the documented meaning and MUST provide rendered evidence for
  its actual light/dark and relevant interaction states.

### Authoring shape

`defineTheme` remains the only authoring operation. Each `localTokens` key is the
complete CSS custom-property name used in `var(...)`, `DefinedTheme`, inheritance, and
emitted output. The key is preserved byte-for-byte and has no required prefix. Values
reuse the existing `TokenValue` contract and normalize exactly as existing `tokens`
values do. An adopting `theme:*` record owns each concrete role, value, and component
mapping.

### Platform support

- Supported feature/engine floor: the same CSS custom-property,
  `light-dark()`, theme scope, and browser support as existing web themes.
- Unsupported behavior: a platform compiler that cannot preserve a local role
  and its lineage MUST reject that enrolled input clearly rather than silently
  omit it or expose the CSS spelling as shared cross-platform API.
- Browser evidence: each adopting theme verifies its actual component mappings
  and color modes in real Chromium. Name, validation, inheritance, and
  runtime/static parity are structural and testable without visual evidence.

## Current-state impact

Current `main` keeps its closed portable core/domain token vocabulary and ships the
first-class `localTokens` field with the original prefix-based key validation. The
2026-09-12 amendment broadens only accepted local-token key spelling; implementation
remains pending. Unknown keys that reach permissive legacy paths can still serialize,
but only explicit `localTokens` enrollment creates ownership, reference closure, and
lineage validation.

- `architecture:theme-authoring-contract` owns the optional `localTokens`
  input, its exact-name map, explicit enrollment state, flattened inheritance,
  and lineage metadata;
- `architecture:theme-compilation` emits that exact map through the shared
  runtime/static compiler and applies one recursive validator only to themes
  enrolled directly or through an enrolled exact base; and
- `architecture:theme-tokens` continues to own the unchanged portable
  vocabulary and explicitly excludes theme-local names from its public helpers
  and documentation.

Those current architecture records project the approved behavior and cite the applicable
AST-006 decisions. Prefix-independent key acceptance remains a named conformance gap
until its implementation lands. A draft theme record may reference this current spec
while keeping its own value, mapping, and evidence decisions unresolved.

### Compatibility and adoption

- Adoption is per maintained theme family and optional. Existing themes are not
  migrated merely because the field exists.
- A theme that omits `localTokens` and does not extend an enrolled exact base remains
  byte-equivalent across `DefinedTheme`, runtime, static, and package outputs. Its CSS
  custom-property references receive no new scan, rejection, warning, reservation, or
  interpretation.
- Enrollment accepts any valid custom-property key. To migrate an existing custom
  property, the maintainer explicitly declares that exact key in `localTokens`;
  prefixes do not affect eligibility or ownership.
- Existing `tokens` and external-variable behavior remains byte-equivalent, including
  permissive broad spreads and runtime inputs.
- Local names and documented meanings become public theme-family compatibility
  obligations after release; they never become portable cross-theme or global
  Core token promises. Mere custom-property emission from an unenrolled legacy
  path does not create that contract.
- Each local definition and mapping lives in the owning package's colocated theme
  spec. Every new mapping reviews whether its context genuinely matches the
  documented meaning and supplies rendered evidence before adoption.
- Descendant compatibility follows explicit enrollment and exact lineage. A
  released enrolled theme or token rename, or semantic meaning change, requires
  an explicit migration or alias before descendants move.
- The infrastructure implementation carries its own Changeset. A later adopting
  theme output change carries a separate release note.

## Verification

| Contract      | Verification                                                                   | Representative states                                                                                                                            | Mutation or failure expectation                                                                                                                |
| ------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR2, FR8 | Legacy type, runtime, build, resolution, and byte-output fixtures              | omitted field; direct tokens; broad spread; cast; external custom property; portable reference                                                   | Adding a scan to the omitted-field path changes legacy behavior.                                                                               |
| FR2           | Local `TokenValue` parity fixtures                                             | CSS string; `[light, dark]` tuple; reference value; runtime/static normalization; existing-token control                                         | Local values reject an existing form, normalize differently across paths, or alter the existing `tokens` control.                              |
| FR3, FR4      | Exact-name authoring, type, and output fixtures                                | valid custom-property keys with different prefixes; invalid custom-property key; exact reference; runtime and static output                      | Any stage rewrites a key, or validity/ownership changes because of a prefix.                                                                   |
| FR5, FR6, FR9 | Source- and built-base enrollment/lineage fixtures                             | root opt-in; child inherits exact base; child exact replacement; child new role; unenrolled and unrelated names                                  | A child gains ownership from spelling rather than exact lineage, or source/built enrollment differs.                                           |
| FR7, FR8      | Shared recursive validator fixtures for runtime and static build               | exact declared reference; external reference; portable collision; adaptation `value.tokens` collision; lineage mismatch; cycle; unenrolled theme | Enrolled errors emit partial output, paths disagree, a local write bypasses `value.localTokens`, or a prefix changes reference classification. |
| FR10, FR11    | Theme-package compatibility fixtures, colocated theme spec, and release review | exact name; documented meaning; enrolled theme rename; local-token rename; meaning change; alias window                                          | A released name or meaning changes silently, a definition lacks an owner, or a descendant loses its inherited contract.                        |
| FR11          | Per-mapping theme-spec review plus focused rendered evidence                   | exact role meaning; mapped components/states; light/dark; relevant interaction states                                                            | A mapping uses the token outside its documented meaning or ships without contextual rendered evidence.                                         |

### Completion criteria

AST-006 remains `shipped`; the 2026-09-12 naming amendment reaches conformance when
its implementation satisfies these criteria alongside the already shipped contract:

- optional `DefineThemeInput.localTokens` is the only new local-token authoring
  surface and its presence is the root enrollment trigger;
- a theme with no `localTokens` and no enrolled exact base is proven byte-equivalent,
  with no new custom-property scan, rejection, or warning;
- local values accept both existing `TokenValue` forms—a CSS string and
  `[light, dark]` tuple—and runtime/static normalization matches the existing
  `tokens` value path without changing it;
- every valid CSS custom-property key is accepted and preserved byte-for-byte through
  declaration, `var(...)` use, `DefinedTheme`, source/built inheritance, and emitted
  output, with no prefix semantics;
- descendants inherit enrollment only from an enrolled exact base; spelling never
  creates ownership or lineage;
- custom-property syntax, token collision, exact owner and lineage, and cycle failures
  are atomic and equal across runtime and static paths for enrolled themes only;
- built themes retain enrollment and exact lineage metadata sufficient for descendants
  without a base stylesheet;
- every shipped local definition is owned by its colocated theme-level spec, and
  its exact name and semantic meaning are treated as public compatibility
  contracts within that theme family;
- every added mapping passes theme-spec compatibility review and rendered
  light/dark and relevant interaction-state evidence showing that its context
  genuinely matches the documented meaning;
- public portable token types, helpers, docs, and Core component source do not
  expose local names as cross-theme API; and
- each adopter's colocated `theme:*` record owns its names, meanings, values,
  mappings, migration, and rendered evidence.

## Decision log

The decisions below were approved by `cixzhang` as part of this current specification.

### Amendment — Make local-token names prefix-independent

**Decider:** `cixzhang`, `2026-09-12`

Any valid CSS custom-property key is accepted in `localTokens`. Prefixes have no effect
on validity, ownership, enrollment, or lineage. This supersedes the naming-pattern and
reserved-prefix clauses previously recorded in FR4, FR6–FR8, DEC-2, DEC-3, and DEC-4.
Exact key preservation, explicit enrollment, owner metadata, exact `extends` lineage,
inheritance, portable-token collision checks, and cycle checks remain in force. This
amendment defines no replacement naming pattern.

### DEC-1 — Add one optional local-token field without changing tokens

**Reference:** `spec:AST-006/DEC-1`
**Decider:** `cixzhang`, `2026-08-31`

Use a purely additive optional `DefineThemeInput.localTokens` field. Supplying it
explicitly enrolls a theme; an exact descendant may inherit that enrollment from an
enrolled base. Keep `defineTheme` as the only authoring operation and preserve every
existing `tokens` type, input, runtime, build, spread, precedence, inheritance, output,
and resolution behavior. A theme with no `localTokens` and no enrolled exact base
remains byte-equivalent regardless of custom-property spelling.

Rejected: a `defineLocalTokens` helper, parallel API, implicit enrollment from a key's
spelling, module augmentation of the portable token vocabulary, or stricter validation
on existing token paths.

### DEC-2 — Preserve one exact, prefix-independent name

**Reference:** `spec:AST-006/DEC-2`
**Decider:** `cixzhang`, `2026-08-31`; amended `2026-09-12`

A `localTokens` declaration accepts any valid CSS custom-property key. The declaration
key is the CSS variable, and references use that same byte-for-byte name. No prefix is
required, reserved, rewritten, or interpreted, and the theme name does not determine
the key. This decision defines no preferred replacement naming pattern.

Rejected: generated names, reference aliases, hidden normalization, and any naming rule
that changes validity or ownership based on a prefix.

### DEC-3 — Bind replacement to exact extension lineage

**Reference:** `spec:AST-006/DEC-3`
**Decider:** `cixzhang`, `2026-08-31`; amended `2026-09-12`

A descendant inherits enrollment only from an enrolled exact `extends` base and may
replace an inherited exact name only when that name arrived through that lineage. A
new declaration is owned by the declaring theme regardless of its spelling. Source and
built themes retain equivalent enrollment and lineage metadata. Released enrolled
theme or token renames require an explicit migration or alias.

Rejected: prefix-, suffix-, or value-based ownership or lineage inference, foreign
owner claims, and silent key rewriting during extension.

### DEC-4 — Validate the exact enrolled declaration graph

**Reference:** `spec:AST-006/DEC-4`
**Decider:** `cixzhang`, `2026-08-31`; amended `2026-09-12`

Runtime and static build share one exact validator. After direct or inherited
enrollment, it checks custom-property syntax, portable-token collisions, exact owner
and lineage metadata, and cycles among exact declared references. A reference is local
only when it exactly matches an effective enrolled declaration; every non-matching
custom-property reference remains external. An unenrolled theme receives no new scan,
rejection, or warning. Prefixes play no part in classification.

Rejected: prefix-based classification, a broad external-variable validator, or using
the new field to close legacy runtime permissiveness.

### DEC-5 — The owning theme spec defines a public theme-family contract

**Reference:** `spec:AST-006/DEC-5`
**Decider:** `cixzhang`, `2026-08-31`

Theme-local token definitions belong in the owning package's colocated
theme-level spec. Once shipped, the exact token name and semantic meaning are a
public compatibility contract within that maintained theme family, even though
the token is not portable across themes and is not intended for Core component
source. Mere output from an unenrolled legacy path is not enough; explicit
enrollment, shipment, and the theme spec establish the contract.

A long generated name is acceptable when it accurately describes every context
where the theme applies it. Adding any mapping is therefore a theme-spec
compatibility review: the context must genuinely mean the documented role and
must carry rendered evidence for its actual light/dark and relevant interaction
states.

Rejected: treating local names as either global portable tokens or disposable
implementation details, defining them outside the owning theme spec, and reusing
a name in contexts that only happen to share a value.

### DEC-6 — Reuse the complete existing `TokenValue` contract

**Reference:** `spec:AST-006/DEC-6`
**Decider:** `cixzhang`, `2026-08-31`

`localTokens` accepts the complete existing `TokenValue` contract: a CSS string
or `[light, dark]` tuple. Inside the new opt-in field, both runtime and static
build apply the same value normalization semantics existing `tokens` use. This
reuses one established light/dark authoring model without changing the existing
`tokens` path or introducing another value shape.

Rejected: limiting local values to CSS strings only or defining local-specific
mode syntax and normalization.

## Open questions

None at the cross-theme API level. Adopting theme specs may retain checkable
rendered-evidence gates; their ownership, exact names, and semantic meanings are
not re-opened by those gates.

---

### Specs AST 008 Spec (`docs/specs/AST-008/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-008
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-08
phase: accepted
owners: [cixzhang, rubyycheung, imdreamrunner]
affects_architecture:
  [architecture:theme-authoring-contract, architecture:theme-compilation]
affects_families: []
affects_contributing: []
affects_consumer_docs: [color, theme]
---

# Authoring-time tonal palette generation system spec

## Intent

Theme authors need a repeatable way to make complete tonal palette candidates.
Generation is authoring work: it produces colors for people to inspect, adjust,
and accept. The accepted result is saved as private, theme-owned palette data.
Ordinary theme builds do not rerun the generator.

The Palette Generator Lab compares candidate recipes, complete families, modes,
anchors, and existing themes, then lets an author copy candidate JSON. The
versioned OKLCH recipe is the visual basis for supported generation. A later
current authority—AST-018 only if amended and accepted—owns the shape and
lifecycle of accepted palette data and any first-class palette/theme integration.

The Astryx CLI theme-authoring workflow exposes the pure
`generateTonalPalette()` authoring API and the
`astryx theme palette generate` command. Both use one versioned engine. Neither
implicitly rewrites a theme.

New-authoring interfaces recommend the palette workflow and do not present the
legacy color-scale helper as though choosing one accent were an isolated edit or
an approved palette. The Playground omits its `Create from accent` affordance
while retaining explicit token editing.

`DefineThemeInput.color`, `ThemeAdaptationValue.color`, and
`expandColorScale()` are legacy authoring surfaces. They remain `stable` until a
replacement-first patch ships the complete modernization route described below;
that patch moves them to `deprecated`. Their existing runtime behavior remains
supported and byte-equivalent throughout the deprecation window. Deprecation is
static and source-level: declarations, authoring validation, and documentation
identify the replacement, with no compatibility flag, runtime branch,
rendered-app warning, or added executable production-bundle bytes. Static
TypeScript declaration metadata is not executable runtime behavior.

## Non-goals

- Choose the public API, accepted data shape, semantic-role reference model,
  compiler integration, or runtime projection for a future first-class palette.
  A later current authority—AST-018 only if amended and accepted—owns those choices.
- Remove `DefineThemeInput.color`, `ThemeAdaptationValue.color`, or
  `expandColorScale()` before their separately identified cleanup items are
  approved for a minor release.
- Add a runtime compatibility setting, runtime warning, executable production
  branch, or executable production-bundle bytes solely for deprecation.
- Require generated palettes or provenance for hand-authored or imported data.
- Treat a palette stop as design approval or an accessibility guarantee.

## Ownership

| Owner                           | Contract                                                                                                                         |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| AST-008                         | Candidate generation, the new-authoring recommendation, and the three legacy color-scale deprecation lifecycles.                 |
| Ruby                            | The visual recipe and recommendation, after the required comparison evidence.                                                    |
| Later current palette authority | Accepted palette shape, stable semantic-role references, validation, and any first-class authoring or compilation integration.   |
| `theme:<name>`                  | Accepted exact palette values, mappings and deviations, required states, compatibility, and rendered evidence.                   |
| Current theme architecture      | Productive `defineTheme` normalization and compilation, including byte-equivalent legacy behavior during the deprecation window. |
| CLI authoring surface           | The generation command, pure API, candidate serialization, receipts, safe writes, migration tooling, and authoring diagnostics.  |

## Authoring lifecycle

| Stage      | Required result                                                                                                                   |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Create     | Generate, hand-author, or import a complete candidate palette.                                                                    |
| Review     | Inspect complete families, modes, diagnostics, comparisons, and rendered contexts; adjust or reject freely.                       |
| Accept     | Save the final exact palette data under the accepted palette contract.                                                            |
| Map        | Record an inspectable, stable semantic-role mapping under the authority that owns accepted palette/theme integration.             |
| Verify     | Record contextual evidence for the exact palette, role mapping, and rendered values in the owning theme record.                   |
| Regenerate | Update mapped roles coherently from the accepted palette without an implicit generator run or a manual recopy of unrelated roles. |

## Requirements

- **FR1 — Generation is optional.** Generated, hand-authored, and imported
  palettes are equal after validation. Missing generation provenance means only
  “not generated by this recipe”; it MUST NOT make valid palette data invalid.
- **FR2 — Requests describe complete author intent.** A generation request MUST
  describe the complete family set; normalized seeds; versioned intensity and
  neutral profiles; exact, bounded, and flexible anchors; an explicit stop
  layout; light and dark strategy; target gamut and encoding; and every other
  output-affecting parameter. Astryx tooling SHOULD default family ramps to the
  21-stop `0, 5, …, 100` layout, while accepting any non-empty author-defined
  numeric stop list. Compact and specialized layouts are valid author choices,
  not exceptions. Every candidate exposes exact standalone black and white
  values for direct theme authoring. Stops 0 and 100 repeat those values in
  generated families. Authors MAY omit the repeated endpoints with a custom
  stop list without losing the standalone values. The family identifiers
  `black` and `white` are reserved for those standalone values.
- **FR2a — Custom stop syntax is exact.** Every stop MUST be a finite JSON number
  from 0 through 100 inclusive. Stops MUST be unique and strictly increasing.
  Integer and decimal stops are valid; equivalent numeric spellings such as `5`
  and `5.0` identify the same stop and therefore cannot coexist. Endpoints 0 and
  100 are optional. A one-stop palette is valid. Serialization uses the canonical
  JSON number spelling produced by the implementation and MUST NOT depend on
  locale.
- **FR2b — Stop numbers are stable coordinates.** For the same recipe, family,
  mode, seed, vibrancy, profile, and compatible anchors, a shared stop MUST
  produce the same exact color in compact, full, and custom layouts. Adding or
  removing other stops MUST NOT renumber or change it. Generated TypeScript MUST
  expose only the requested stop keys, so an omitted stop fails type checking.
- **FR3 — Mode intent is explicit.** Light-only, dark-only, independent light and
  dark inputs, shared candidates, and named dark transforms are distinct. A
  light ramp MUST NOT silently become a reviewed dark ramp, and labels MUST NOT
  be reversed for dark mode.
- **FR4 — Constraints and preferences stay distinct.** Gamut validity,
  monotonicity, exact anchors, and bounded-anchor tolerances are hard constraints.
  Intensity, optical balance, and flexible anchors are optimization preferences.
  A conflict MUST fail clearly rather than moving an exact anchor, weakening a
  tolerance, substituting a profile, or changing the stop layout. An anchor MUST
  NOT turn stop 0 or 100 into a chromatic value; a conflicting endpoint anchor
  fails with guidance to use an interior stop.
- **FR5 — Recipes are deterministic and versioned.** A recipe identity MUST pin
  color space, constants, profiles, schedules, anchors, gamut handling, precision,
  rounding, serialization, and tie-breaking. The same normalized request MUST
  produce byte-identical canonical candidate output across supported platforms,
  with no ambient time, locale, path, network, randomness, traversal, or
  unrecorded dependency input.
- **FR6 — Generated candidates carry reproducibility evidence.** The generator
  MUST emit the complete candidate plus a detached record naming the normalized
  request, recipe and implementation versions, output format versions,
  adjustments, deviations, and every canonical output digest. An output change
  MUST change a matching identity or digest.
- **FR7 — Generation stops at a candidate.** Output MUST identify itself as a
  candidate and MUST NOT adopt values, map roles, or claim visual or accessibility
  approval. Preview is read-only. An explicit output option MAY save the candidate
  as a separate TypeScript or JSON file, but MUST NOT overwrite an existing file
  without explicit author intent. Once adopted, the palette is ordinary
  author-owned data: it may be edited freely and does not remain under generator
  control. Edited accepted data MUST NOT claim exact regeneration, and ordinary
  theme builds MUST NOT rerun the generator.
- **FR8 — The selected recipe preserves complete-family evidence.** The first
  production recipe MUST be pinned by its normative algorithm and canonical
  fixtures rather than another implementation. Before release, its
  version-pinned evidence MUST cover complete family sets,
  rendered light/dark contexts, hue continuity, luminance and adjacent-stop
  distinction, gamut, family balance, CVD simulations, reproducibility, and
  performance. Fixtures cover blue-to-purple, yellow-to-brown, disproportionate
  family strength, and blue/purple, yellow/green, and red/orange distinction.
  Output remains a candidate until an author accepts it.
- **FR9 — Accepted mapping is explicit, inspectable, and coherent.** The current
  authority that admits accepted palette/theme integration MUST define stable
  semantic-role references or an equivalently deterministic authoring-time mapping
  contract. Once an edited or regenerated palette is explicitly accepted, every
  role mapped to it MUST update coherently without requiring authors to recopy
  unrelated values by hand. Tooling MAY suggest a mapping, but a separate explicit
  apply action MUST show the target and patch, preserve author edits, and permit
  adjustment or rejection. This requirement does not choose the final
  `DefineThemeInput`, compiler, or artifact shape.
- **FR10 — Regeneration never recolors themes implicitly.** Generating a new
  candidate MUST NOT replace an adopted palette or change saved theme values,
  normalized tokens, CSS, built runtime modules, or mounted behavior. A committed
  palette edit MAY intentionally change theme mappings that explicitly reference
  it; that source diff and its rendered impact MUST be reviewed together.
- **FR11 — Validation is separate from construction.** Palette inspection uses a
  `validate*` or `check*` role in theme-package tests, internal test utilities, or
  an explicitly accepted future CLI checker. Validation-only data and helpers
  MUST stay outside Core runtime theme input and output.
- **FR12 — Accessibility is contextual.** Tools MAY report measured properties
  and contrast for explicit pairs. They MUST NOT label an isolated palette,
  family, stop, or suggestion accessible. Theme evidence names the exact
  foreground, background, text size, component state, mode, and non-color cues.
- **FR13 — Authoring has no runtime cost.** Generator code, color-math
  dependencies, candidates, reproducibility data, and suggestion logic MUST NOT
  enter Core component runtime, theme mounting, default theme CSS or JavaScript,
  or a theme package's default runtime export.
- **FR14 — Supported generation surfaces stay narrow.**
  `generateTonalPalette(request)` is a pure authoring function that returns
  candidate data without reading or writing files.
  `astryx theme palette generate <config>` is its non-interactive file adapter.
  TypeScript is the primary committed output so exact family and stop keys remain
  checkable; JSON MAY be requested for interoperable tooling. These surfaces MUST
  NOT rewrite a theme or perform semantic mapping implicitly. A future explicit
  first-class palette input, stable mapping contract, or compiler integration
  requires separate current authority; this requirement neither approves nor
  forbids one.
- **FR15 — Visual review uses one standard artifact.** The CLI MAY explicitly
  write a self-contained HTML preview from the same candidate data. The preview
  MUST identify itself as `palette-preview-v1`, show every generated family,
  mode, stop, and hex value, and require no network resources. It MUST NOT make
  an accessibility claim. It follows the same overwrite protection as palette
  output and MUST NOT open a browser without a separate explicit action.
- **FR16 — Authoring guidance preserves intent.** Agent-facing documentation and
  evaluation fixtures MUST distinguish exact, bounded, and flexible anchors;
  preserve explicit decimal and custom layouts; omit an accent when none is
  requested; and ask whether an ambiguous accent is one theme value or a light
  and dark family rather than guessing.
- **FR17 — New authoring uses palettes; legacy expansion remains compatible.** A
  theme-authoring interface MUST NOT present one accent input as an isolated edit
  when it also replaces neutral, surface, text, border, or other derived values.
  The Playground MUST omit `Create from accent` and retain direct editing of
  explicit color tokens. The CLI theme docs and generated theme template MUST
  recommend the supported palette workflow for new authoring. In the
  replacement-first patch, explicit reference documentation MUST identify
  `DefineThemeInput.color`, `ThemeAdaptationValue.color`, and
  `expandColorScale()` as deprecated legacy authoring APIs while documenting their
  existing behavior accurately for unmigrated themes. `ColorScaleConfig` and
  `ColorScaleTokens` remain stable public types during this lifecycle; this record
  does not schedule their removal.
- **FR18 — Color-scale deprecation is static, replacement-first, and lossless.**
  Before the replacement-first patch, all three surfaces remain `stable` and no
  declaration or warning labels them deprecated. That patch MUST ship the working
  palette replacement, stable versioned instructions, the exact modernization
  command, maintained-source migration, declaration `@deprecated` metadata, and
  authoring/build diagnostics together. Every diagnostic MUST name the applicable
  `DEP-*` id, exact replacement, stable instructions, and migration command.
  Deprecation MUST NOT add a runtime compatibility flag, runtime branch,
  rendered-app warning, or executable production-bundle bytes. For unmigrated
  source, existing token value strings (including CSS references), normalization,
  adaptation layers, inheritance, generated CSS, builds, and mounted behavior
  remain byte-equivalent during the deprecation window. The previewable and
  idempotent migration follows `spec:AST-040`, preserves those observables as its
  baseline, and reports dynamic, inherited, adaptation-bearing, or otherwise
  uncertain cases without rewriting them unless it can prove the replacement
  preserves the same behavior. Migration never invokes the palette generator: it
  writes the exact current token value strings as imported, theme-owned palette
  data or literal token values and references them through `tokens`. Removal
  requires the separate cleanup ids and minor-release approval in the lifecycle
  table below.

## Deprecation lifecycle

The three legacy surfaces follow `spec:AST-017` FR28–FR31 and FR29a–FR29d.
They share one authoring mechanism but are distinct public contracts, so each is
described and removed separately. Their direct authority is `spec:AST-008/FR18`.

### `DefineThemeInput.color`

- **Package / surface:** `@astryxdesign/core` type field.
- **Deprecation / cleanup:** `DEP-AST-008-COLOR-INPUT` /
  `CLN-AST-008-COLOR-INPUT`.
- **Old contract:** a `color` seed derives broad theme token values during
  `defineTheme()` normalization and remains inheritable by descendant themes.
- **Replacement:** `astryx theme palette generate` produces reviewed,
  theme-owned TypeScript palette data; the AST-040 codemod may instead import the
  exact existing token value strings as equivalent palette data. The theme writes
  semantic roles through existing `tokens` entries that reference that committed
  data. A later current authority may replace this authoring projection with
  first-class integration.
- **Warning:** `@deprecated` plus exact-source theme build or validation diagnostics,
  each naming the deprecation id, stable versioned instructions, and
  `astryx upgrade --from <installed-version> --apply`.
- **Migration / proof:** the AST-040 codemod materializes byte-equal token value
  strings, including CSS references, and proves equivalent normalized tokens,
  generated CSS, adaptation layers, inheritance, and extension behavior. It
  reports and leaves unchanged dynamic or inheritable uses it cannot prove.
- **State / target:** `stable` until the replacement-first patch ships, then
  `deprecated`; cleanup is unscheduled and requires the exact cleanup id in a
  frozen minor plan.
- **Rollback:** before cleanup, revert the migration patch or keep the deprecated
  field; after cleanup, a compatible recovery patch restores the field and its
  byte-equivalent normalization.

### `ThemeAdaptationValue.color`

- **Package / surface:** `@astryxdesign/core` nested type field.
- **Deprecation / cleanup:** `DEP-AST-008-ADAPTATION-COLOR` /
  `CLN-AST-008-ADAPTATION-COLOR`.
- **Old contract:** an adaptation rule supplies a partial color-scale configuration
  completed from the effective root color axis.
- **Replacement:** adaptation rules write condition-specific semantic roles through
  existing `value.tokens` entries that reference the same committed theme-owned
  palette data.
- **Warning:** `@deprecated` plus exact-source theme validation diagnostics carrying
  the same instruction and migration fields.
- **Migration / proof:** the AST-040 codemod preserves each lowered adaptation token
  string and rule order. It reports and leaves unchanged partial, inherited, or
  dynamic rules unless it can prove equivalent completion and CSS.
- **State / target:** `stable` until the replacement-first patch ships, then
  `deprecated`; cleanup is unscheduled and independent of the root field cleanup.
- **Rollback:** before cleanup, retain or restore the deprecated field; after cleanup,
  a compatible recovery patch restores its completion and lowering behavior.

### `expandColorScale()`

- **Package / surface:** `@astryxdesign/core` function export.
- **Deprecation / cleanup:** `DEP-AST-008-EXPAND-COLOR-SCALE` /
  `CLN-AST-008-EXPAND-COLOR-SCALE`.
- **Old contract:** a public authoring helper expands `ColorScaleConfig` into broad
  token overrides, including live CSS references.
- **Replacement:** the AST-040 codemod inlines the exact returned token map as a
  committed TypeScript module whose strings are imported directly, or migrates a
  theme callsite to existing `tokens` / adaptation `value.tokens` references backed
  by that committed data.
- **Warning:** `@deprecated` plus exact-source authoring or build diagnostics carrying
  the same instruction and migration fields.
- **Migration / proof:** the AST-040 codemod materializes byte-equal returned token
  strings when the call is statically resolvable. Dynamic calls are reported and
  left unchanged. Old helper output and migrated theme output are compared for
  representative and downstream usage.
- **State / target:** `stable` until the replacement-first patch ships, then
  `deprecated`; cleanup is unscheduled and independent of both field cleanups.
- **Rollback:** before cleanup, retain or restore the deprecated export; after cleanup,
  a compatible recovery patch restores the export and its byte-equivalent output.

`ColorScaleConfig` and `ColorScaleTokens` remain stable public types and are not
assigned cleanup ids by this record.

The replacement-first patch ships the working palette path, stable versioned
instructions, AST-040 codemod, static declaration metadata, exact-source theme
build or validation diagnostics, maintained-source migration, and old/new
compatibility proof together. FR17's Playground and new-authoring guidance
requirements apply immediately and do not wait for the replacement-first patch.
The patch's maintained-source migration covers remaining uses of the three legacy
surfaces in examples, stories, tests, and repository-owned themes. `defineTheme`,
theme mounting, and rendered applications never emit deprecation warnings. Cleanup
approval for one id does not authorize removal of another.

## Implementation contract

1. `astryx-oklch-v1` is defined in one owned production engine that pins its
   constants, transformations, and exact regression vectors.
2. The pure authoring API and CLI adapter use one deterministic engine and agree
   on recipe semantics. The CLI follows `architecture:cli-surface` for its
   command, response, error, documentation, support, and write contract.
3. Generated TypeScript exports author-owned palette data without importing the
   generator. Generated JSON contains the same canonical palette values.
4. The optional HTML preview is generated by one versioned renderer rather than
   improvised by each agent or caller.
5. Every public function, command, package, schema, or artifact receives separate
   API and AST-017 compatibility acceptance before release.
6. Focused agent fixtures cover anchor language, optional accents, clarification,
   and custom stops. Expected decisions remain evaluation-only and MUST NOT be
   included in prompts sent to agents.

### `astryx-oklch-v1` normative recipe

The first production recipe is defined here and pinned by canonical fixtures:

- Normalize supported seed and anchor colors to lowercase six-digit sRGB hex.
- Convert sRGB through the standard D65 OKLab matrices, then express hue and
  chroma in OKLCH.
- Convert requested tone to lightness with the CIELAB L* transfer: values above
  8 use `((tone + 16) / 116)³`; lower values use `tone / 903.3`; the result is
  cube-rooted for OKLab lightness.
- Apply the pinned tone chroma envelope
  `0.18 + 0.82 × sqrt(sin(π × tone / 100))`.
- Apply hue-balance factors 0.94 for `[70,115)`, 0.78 for `[115,175)`, 0.82 for
  `[175,230)`, 0.90 for `[285,340)`, and 1 elsewhere. Orange hues `[40,70)`
  rotate toward red below tone 50 by at most 8 degrees so low orange stops stay
  distinct from the red family.
- Above tone 60, green hues `[115,175)` taper to 72% of their balanced chroma
  and teal hues `[175,200)` taper to 60% at tone 95 using smoothstep. This keeps
  their light stops optically balanced without muting cyan or changing the
  midtone anchors.
- Vibrancy 50 is neutral. Values 0–25 interpolate to multiplier 0.72; values
  25–50 interpolate from 0.72 to 1; values above 50 add 0.0096 per point.
- Cross-family coordination mixes source chroma 35% with reference chroma 0.18
  at 65%. Neutral profiles are `neutral-v1` (hue 0, chroma 0), `warm-v1`
  (hue 75, chroma 0.018), `cool-v1` (hue 250, chroma 0.018), and `custom`
  (the normalized neutral seed).
- Stop numbers retain the same literal tone meaning in both modes. Dark ramps
  multiply chroma by 0.85 without silently shifting the requested tone. Stop 0
  therefore resolves to exact black in either mode, and stop 100 resolves to
  exact white. Both endpoints are included by default and MAY be omitted by an
  explicit custom layout.
- Out-of-gamut OKLCH candidates preserve lightness and hue while chroma is found
  by 20 iterations of binary search over `[0, 0.4]`.
- Exact anchors replace their stop. Bounded anchors move toward the requested
  color only within their declared OKLab Euclidean `maxDeltaE`; flexible anchors
  blend 35% toward the requested color. Anchor corrections use smoothstep
  interpolation and taper to zero 25 tone units beyond the nearest anchor.
- Convert final colors to sRGB by rounding each clamped channel to the nearest
  8-bit integer. Emit lowercase `#rrggbb`. Canonical JSON uses two-space
  indentation, insertion-ordered families and stops, and one trailing newline.
  Candidates expose the requested stop layout as an ordered `stops` array;
  family ramp objects are lookup maps and do not define iteration order.
- Invalid requests fail before producing a candidate. Family-local anchor
  conflicts report the family and produce no adoptable output.

The following canonical candidate fixtures use SHA-256 over UTF-8 canonical JSON:

| Fixture                | Request summary                                                                                | Candidate bytes | SHA-256                                                            |
| ---------------------- | ---------------------------------------------------------------------------------------------- | --------------: | ------------------------------------------------------------------ |
| `default-three-family` | Neutral `#777777`, blue `#0074e2`, orange `#d57113`; both modes; vibrancy 50; 21 default stops |            3755 | `11c40191d508274d89d631bb4e1cb662f70ff0dce6c0dec101c317f9f69d3e25` |
| `exact-anchor`         | Blue `#0074e2`; light only; stops 20, 50, 80; exact stop-50 anchor `#1682d5`                   |             327 | `c42929be3c4b5cb857cada078f62bb5a2242c1a22cfa4ae7546a18592540f7f3` |
| `single-custom-stop`   | Red `#d62830`; dark only; stop 40                                                              |             258 | `88d3d69865c74c7fb14347b9967575285a7d44ab893087a08bc842bb66b29bbb` |
| `high-tone-balance`    | Green `#358a3a`, teal `#0c7365`, cyan `#0c6f82`; both modes; stops 60, 80, 95                  |             910 | `873821574fdbe3357304dbc06986bd2e5ec88af80d0f36305626fd826c3cf07b` |

Exact fixture equality is the release gate for recipe compatibility. Monotonicity,
adjacent-stop distance, hue drift, family distinction, gamut events, and CVD
simulations are recorded as design evidence for the complete candidate; they do
not become universal accessibility or contrast guarantees for isolated colors.

## Current-state impact

| State                       | Required result                                                                                                           |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Generation absent           | Existing themes, builds, runtime bytes, and supported package ranges stay unchanged.                                      |
| Candidate produced          | No source, theme, package, or runtime output changes.                                                                     |
| Palette accepted            | Exact values enter theme-owned palette data under `theme:<name>`; a later current authority may standardize the shape.    |
| Mapping accepted            | Under that authority, stable semantic-role references are inspectable, reviewable, and update coherently.                 |
| New candidate generated     | Adopted palettes and rendered output stay unchanged until an explicit review and save.                                    |
| Adopted palette edited      | Under that authority, explicitly accepting the edit updates mapped roles coherently; unrelated literal values stay fixed. |
| Playground color edit       | Explicit token editing remains; no accent edit silently expands into unrelated color tokens.                              |
| Legacy surface before patch | Stable, fully supported, and unlabeled; Playground and maintained new-authoring guidance follow FR17 immediately.         |
| Replacement-first patch     | Replacement, guidance, migration, static metadata, diagnostics, source migration, and compatibility proof ship together.  |
| Deprecated API used         | Normalization, token strings, inheritance, CSS, builds, and mounted behavior remain byte-equivalent.                      |
| Migration run               | Proven uses preserve exact observables; uncertain dynamic, inherited, or adaptation-bearing cases remain unchanged.       |

## Verification

Verification MUST cover cross-platform deterministic vectors; hard failures;
every candidate source and provenance state; complete-family regression/CVD
evidence; candidate-versus-accepted identity; inspectable stable role mappings;
runtime/default-bundle absence; real-Chromium evidence for accepted mappings;
Playground evidence that accent editing does not invoke legacy color-scale
expansion or mutate unrelated tokens; static `@deprecated` declaration metadata;
exact-source theme build or validation diagnostics; absence of deprecation-only
runtime branches, logs, executable bytes, or output changes; maintained-source
migration; and AST-040 preview, idempotence, and refusal fixtures. Migration proof
MUST compare token value strings including CSS references, normalized root and
adaptation layers, generated CSS, inheritance, and descendant extension behavior.

## Decision log

### DEC-5 and DEC-9 — Preserve complete-recipe evidence

**Decider:** `cixzhang`, `2026-09-01` through `2026-09-02`

FR8 preserves Ruby's complete-authoring requirements and the comparison evidence
behind the selected OKLCH direction. A color-space name or isolated ramps alone
do not define the production recipe.

### DEC-10 — Accepted palettes are committed snapshots

**Decider:** `cixzhang`, `2026-09-03`

Accepted palettes are committed snapshots. Theme mappings use literal CSS values
or explicit references to committed palette data; generation never runs implicitly.
A generated candidate changes no accepted source or rendered output until an author
explicitly accepts it.

Rejected: an implicit generator run during ordinary theme normalization or build,
because it would recolor a theme without an explicit accepted source change.

### DEC-11 — Generation has one authoring engine and two adapters

**Decider:** `cixzhang`, `2026-09-03`

The CLI theme-authoring workflow is the first supported consumer. The pure
`generateTonalPalette()` API enables programmatic authoring without filesystem
effects; `astryx theme palette generate` adds preview and explicit file output.
Both use the same `astryx-oklch-v1` engine. Neither enters Core or
`defineTheme()`.

### DEC-12 — The generator defaults without restricting palette authors

**Decider:** `rubyycheung`, `2026-09-03`

The first production generator defaults each family to the familiar 21-position
`0, 5, …, 100` layout and accepts any explicit non-empty numeric stop list.
Every candidate exposes exact black and white as standalone authoring values.
Stops 0 and 100 repeat them in each generated family so authors can retain a
complete coordinate range or remove the repeated endpoints explicitly without
losing access to either solid. Generated TypeScript names the values `black`
and `white`; theme-owned palette wrappers may expose them alongside families,
for example as `neutralPalettes.black` and `neutralPalettes.white`. Those names
are reserved from family identifiers. The generated candidate becomes
author-owned after review and acceptance. Generator defaults therefore do not
become palette-validity rules.

Generated palettes are reviewable starting points. Authors may adjust
family-level inputs and regenerate until the palette matches their intended
expression; only the explicitly approved output becomes the theme-owned palette.

Rejected: requiring every accepted palette to use the generator's 21-stop
default. Themes may need compact or specialized palettes for iconography,
illustration, visualization, or brand expression.

### DEC-13 — Intermediate stops are generated explicitly

**Deciders:** `cixzhang`, `rubyycheung`, `2026-09-03`

Compact and full presets share the same 0–100 tonal coordinate system, and
custom layouts remain valid. An author who needs an intermediate value such as
12.5 requests that stop during generation and reviews the emitted exact hex.
The committed TypeScript then exposes `family[12.5]` as an actual typed key.

Rejected: restricting palette authors to two stop counts, renumbering stops when
the layout changes, or adding a runtime `family.get(12.5)` that creates an
unreviewed color after authoring.

### DEC-14 — Playground color editing does not imply palette generation

**Decider:** `rubyycheung`, `2026-09-30`

The Playground keeps direct semantic-token editing and omits `Create from
accent`. A one-accent control is not palette authoring when it also replaces
neutral, surface, text, border, and related values without exposing a candidate
palette or a separate mapping decision. Maintained authoring surfaces recommend
the supported palette workflow instead.

Rejected: replacing the control with another generator while retaining automatic
token rewrites, because that still collapses generation, review, and mapping.

### DEC-15 — Legacy color-scale authoring follows static replacement-first deprecation

**Decider:** `cixzhang`, `2026-10-07`

`DefineThemeInput.color`, `ThemeAdaptationValue.color`, and
`expandColorScale()` remain stable until one patch ships the complete replacement,
versioned guidance, AST-040 modernization, static metadata, exact-source
diagnostics, maintained-source migration, and compatibility proof. They then
enter `deprecated` with byte-equivalent executable behavior and no runtime warning
or branch. Each public surface owns a separate deprecation and cleanup id.

Future first-class palette input, stable semantic-role mapping, and compiler
integration remain open to AST-018 if amended and accepted, or to a later current
authority. AST-008 requires explicit acceptance, coherent mapped updates, and no
implicit generator run without selecting that future API shape.

Rejected: a runtime compatibility flag or deprecation warning, or a permanent ban
on first-class palette/theme integration.

## Open questions

- **OQ1 — What is the first-class accepted palette/theme contract?** A later
  current authority—AST-018 only if amended and accepted—must settle the public
  input shape, stable semantic-role references, authoring and compilation
  behavior, validation, and artifact lifecycle. AST-008 requires explicit review,
  coherent mapped updates, and no implicit generator run without selecting among
  those API shapes.

---

### Specs AST 009 Spec (`docs/specs/AST-009/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-009
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-22
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: []
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Assistive-technology verification system spec



```json
{
  "scope": "global",
  "triggers": {
    "accessibility": [
      "FR1",
      "FR2",
      "FR3",
      "FR4",
      "FR5",
      "FR6",
      "FR7",
      "FR8",
      "FR9",
      "FR10",
      "FR11",
      "FR12",
      "FR13",
      "FR14",
      "FR15",
      "FR16",
      "FR17",
      "FR19"
    ]
  }
}
```

## Intent

People who depend on assistive technology should get the focus and announcement
outcome Astryx claims, not only plausible test-DOM markup. Astryx proves each
claim with the strongest evidence it can produce: unit, DOM, accessibility-tree,
axe, and real-browser checks. Real assistive-technology (AT) observation adds
proof of what is actually spoken or navigated. It is recommended follow-up when an
outcome depends on AT behavior.

No requirement in Astryx gates merge or release on evidence that cannot be
produced. Missing real-AT observation never blocks merge to `main` or a stable
release.

## Non-goals

This spec does not:

- require every accessibility change to run any assistive technology or every browser;
- gate merge or release on real-AT observation;
- treat real AT as a substitute for unit, DOM, accessibility-tree, axe, or browser tests that prove different claims more precisely;
- define consumer accessibility requirements or claim conformance with an entire accessibility standard from one tool or matrix;
- require a stable announcement transcript when an AT intentionally changes harmless wording but keeps the required information and order;
- implement release automation, contribution forms, labels, bots, fixtures, or test infrastructure; or
- copy this policy into contributor or release guidance before those separately owned records exist.

### Authority boundary

AST-009 supersedes the public [Accessibility Checklist](https://github.com/facebook/astryx/wiki/Accessibility-Checklist) only for real-AT follow-up classification, evidence boundaries, the merge and release evidence bar, and durable receipts. The checklist remains the consumer and contributor bar for component accessibility requirements.

AST-009 does not own focus-ring appearance, interaction-modality behavior, visual or contrast methodology, browser support, component theming, or overlay behavior. Those stay with [interaction modality](../../architecture/interaction-modality.md), [browser support](../AST-013/spec.md), [component theming](../../architecture/component-theming-surface.md), [theme tokens](../../architecture/theme-tokens.md), [layer runtime](../../architecture/layer-runtime.md), [overlay dismissal](../../families/overlay-dismissal.md), and the owning component records.

## Requirements

### When real-AT follow-up is recommended

| ID  | Rule                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1 | The recommendation follows the claimed user outcome. Real AT/browser follow-up is recommended only when the intended outcome depends on AT-specific behavior: how AT exposes or announces focus, AT-specific focus order, spoken/braille output, browse or virtual-cursor behavior, mode changes, or a known AT/browser divergence. A semantic code change or focus-only browser behavior is not the trigger. The recommendation never makes real-AT evidence a merge or release requirement (FR17).                                                                               |
| FR2 | Focus changes warrant follow-up only for AT-specific outcomes. Real-AT follow-up is recommended when the claim depends on how AT exposes, announces, or orders programmatic, initial, restored, composite, or after-dismissal focus. Focus destination, order, return, keyboard operation, focus-ring styling, a refactor preserving the same browser focus target, or a static `tabIndex` correction does not warrant it by itself. The owning component and [interaction-modality record](../../architecture/interaction-modality.md) define those browser and visual behaviors. |
| FR3 | Role, name, and description are proved at the accessibility tree. A computed role, accessible name, or description is proved by accessibility-tree evidence. Real-AT follow-up is additionally recommended when the change relies on that semantic being announced during entry or a state transition, changing interaction/browse mode, or resolving a known AT/browser divergence.                                                                                                                                                                                               |
| FR4 | Announcements are proved by their routing. A live region, status message, alert, validation announcement, or loading/completion transition is proved by DOM and accessibility-tree evidence of which element or channel carries it, its text, its urgency, and how many updates each occasion produces. Real-AT follow-up is recommended when success depends on what is spoken, its order, when it occurs, whether it repeats, or whether it is skipped.                                                                                                                          |
| FR5 | Virtual-cursor entry warrants follow-up. Real-AT follow-up is recommended when success depends on where screen-reader browse or virtual cursor enters, whether content is discoverable from that entry point, or whether the user can move between browse and interaction modes. Ordinary keyboard Tab order with no browse-mode claim does not warrant it.                                                                                                                                                                                                                        |
| FR6 | Known divergence widens follow-up. When a changed pattern has a recorded AT/browser divergence, follow-up covers every named side of it until an owner records that the divergence no longer applies. A passing result from one side does not stand in for another known to differ.                                                                                                                                                                                                                                                                                                |
| FR7 | Other changes use proportionate evidence. Static semantics, keyboard behavior, color/contrast, target size, source order, and similar accessibility changes use the least expensive evidence layer that directly proves the claim. They do not acquire a real-AT follow-up merely because they are accessibility work.                                                                                                                                                                                                                                                             |
| FR8 | AT-dependent claims are named. A change whose intended outcome depends on AT behavior says so in its record and names the lower-layer evidence that proves what can be proved. It needs no matrix, scenario, or release record to merge.                                                                                                                                                                                                                                                                                                                                           |

### What each evidence layer proves

This table bounds what each layer may claim. It does not replace evidence required by current component, family, browser-support, visual, theming, or audit records.

Each layer is bounded. Passing one layer MUST NOT be described as proving a claim assigned to a later layer.

| Evidence           | It proves                                                                                                                                                           | It does not prove                                                                                                                |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Unit               | State derivation, branch selection, callback effects, application-owned ordering, and deterministic regressions.                                                    | Browser focus navigation, accessibility API exposure, or AT output.                                                              |
| DOM                | Rendered elements, attributes, ownership markers, source order, and author-supplied ARIA relationships in the tested state.                                         | Computed accessible name/description, platform accessibility mapping, speech, browse mode, or real browser event behavior.       |
| Accessibility tree | The browser's computed role, name, description, state, relationships, and focused accessible node for the tested engine.                                            | What an AT announces, announcement timing/order, virtual-cursor placement, or behavior in another engine/platform stack.         |
| axe                | Rules the configured axe version can mechanically detect in the rendered states it was given.                                                                       | Absence of all accessibility defects, task usability, correct focus destination, complete announcements, or AT interoperability. |
| Real browser       | Actual focus destination/order/return, keyboard and pointer behavior, browser event timing, native semantics, and engine-specific state transitions.                | Speech/braille output, browse or virtual-cursor entry, or AT-specific divergence.                                                |
| Real AT/browser    | Observed speech/braille content and order, announcement timing/repetition, browse or virtual-cursor entry, mode transitions, and named AT/browser interoperability. | Untested pairings, future versions, or claims outside the recorded scenario.                                                     |

| ID   | Rule                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR9  | Evidence composes by claim. A change keeps its lower-layer evidence. Real-AT observation, when it runs, adds only the claims it can prove and MUST NOT replace deterministic regression coverage for behavior the lower layers own.                                                                                                                                                                                                                 |
| FR10 | The tested state is the claimed state. Evidence exercises the transition or state that carries the user outcome, such as opening the surface, moving focus, producing the status update, dismissing and restoring focus, or entering with the virtual cursor. A static baseline that merely contains the component is insufficient.                                                                                                                 |
| FR11 | Real means the shipping pairing. A real-AT check runs the named assistive technology against the named shipping browser on its supported operating system. A virtual machine, remote machine, or simulator is acceptable only when it runs that actual pairing and preserves the input/mode under test. A DOM emulator, accessibility snapshot, synthetic speech stub, or browser engine standing in for a branded browser is not real-AT evidence. |

### Representative matrix

When real-AT follow-up runs, its pairings are selected from the outcome and supported platform, not copied in full onto every change.

| Representative pairing       | Use when the outcome includes                                                                                         |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| NVDA + Chrome on Windows     | Browser-neutral desktop AT-specific focus exposure/order or announcement behavior on the Chromium/Windows stack.      |
| VoiceOver + Safari on macOS  | Browser-neutral desktop AT-specific focus exposure/order or announcement behavior on the Safari/macOS platform stack. |
| NVDA + Firefox on Windows    | Gecko-specific behavior or a named NVDA Chrome/Firefox divergence.                                                    |
| VoiceOver + Safari on iOS    | Touch-driven iOS behavior, mobile Safari, rotor/browse navigation, or iOS-specific divergence.                        |
| TalkBack + Chrome on Android | Touch-driven Android behavior, Android virtual navigation, or Android-specific divergence.                            |

| ID   | Rule                                                                                                                                                                                                                                          |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR12 | Desktop-neutral follow-up uses two independent stacks: NVDA + Chrome on Windows and VoiceOver + Safari on macOS. A platform-specific claim uses its matching pairing only unless FR14 expands it.                                             |
| FR13 | Mobile follow-up uses a matching mobile stack. A mobile or touch/virtual-cursor outcome uses at least one matching mobile pairing, and both iOS and Android only when the claim covers both and there is reason to expect different outcomes. |
| FR14 | Divergence expands rather than replaces. Every side named in a known divergence is included even when that adds a pairing beyond FR12 or FR13.                                                                                                |
| FR15 | Versions and modes are part of the result. A receipt names OS, AT, browser, input mode, and material settings or verbosity that affect the result. “Screen reader checked” or a browser name without its AT is not a matrix.                  |
| FR16 | Representative does not mean exhaustive. Passing the selected pairings supports only the recorded outcome and pairings. It does not claim every AT, browser, verbosity setting, locale, or input method behaves identically.                  |

### Merge, release, and follow-up

| ID   | Rule                                                                                                                                                                                                                                                                                                                                                        |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR17 | Merge and release need only producible evidence. A change merges to `main` and ships in a stable release once the unit, DOM, accessibility-tree, axe, and real-browser evidence its owning records require passes. Pending, missing, or not-yet-run real-AT observation MUST NOT block merge or stable release, and no record may make it a gate.           |
| FR18 | Follow-up is tracked, not held. A recommended real-AT follow-up MAY be tracked in a public issue naming the claim, scenario, and pairings. The issue holds no pull request or release.                                                                                                                                                                      |
| FR19 | Lower layers set the bar where real AT is unavailable. An announcement claim is met at merge by FR4 evidence. Focus-exposure, mode, and virtual-cursor claims are met by the accessibility-tree and real-browser evidence that observes the nearest producible state. That evidence MUST NOT be described as proving speech, timing, or cursor placement.   |
| FR20 | Failures remain findings. A failed real-AT observation is a defect: it records the user impact and links a fix, and is handled like any other defect. Editing an expected result to match an undesirable observation does not turn failure into pass.                                                                                                       |
| FR21 | Receipts remain valid until invalidated. A passing receipt stays usable while the tested semantic, focus, announcement, virtual-cursor, or divergence-sensitive path and relevant support claim remain unchanged. A material change to that path, a fix for the observed behavior, or a newly recorded divergence makes it stale. Unrelated commits do not. |
| FR22 | Stale receipts are labelled. When a later change makes a receipt stale, the tracking record says so and the follow-up returns to recommended. This is information, not a gate.                                                                                                                                                                              |

### Durable receipts and evidence storage

| ID   | Rule                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR27 | The textual receipt is durable and public. When a real-AT check runs, its canonical receipt lives in the merged pull request, a linked public tracking issue, or a repository-owned record. It MUST remain readable without access to expiring CI artifacts, private systems, or an unversioned local file.                                                                                                                                                                                                                                               |
| FR28 | Every receipt is reproducible. It records the source pull request and commit or tested build identifier; stable story, fixture, route, or exact reproduction steps; the claim under test; expected focus destination, semantics, announcement, timing, or virtual-cursor outcome; OS, AT, browser, and relevant settings/versions; input sequence and starting state; observed result, including announcement transcript or focus/cursor destination when applicable; `pass` or `fail`, tester, and date; and linked defect or follow-up when applicable. |
| FR29 | Text carries the decision-critical facts. Screenshots, recordings, speech logs, and CI artifacts may supplement the receipt. They MUST NOT be the only place that records the pairings, expected outcome, observation, or result. Evidence must contain no private user data or internal-only links.                                                                                                                                                                                                                                                      |
| FR30 | Reusable scenarios live with the code. When a scenario will be reused or represents a component contract, its stable story or fixture is checked into the repository. A one-off external test page is not the sole reproduction path.                                                                                                                                                                                                                                                                                                                     |
| FR31 | Guidance projects this spec. Future contributor accessibility guidance links to this spec for follow-up classification, evidence bounds, and receipt requirements. Future release guidance links to this spec for the merge and release evidence bar. Those guides may explain the current workflow but MUST NOT duplicate or redefine this policy.                                                                                                                                                                                                       |

### Platform support

- Supported feature/engine floor: [AST-013](../AST-013/spec.md) and the affected component or package own browser support. Follow-up pairings are selected within that support claim.
- Unsupported behavior: no passing claim is made for an untested pairing; a known supported-pairing divergence follows FR6 and FR14.
- Browser evidence: Playwright or another real-browser runner proves browser behavior and accessibility-tree exposure. It is real-AT evidence only when the actual named assistive technology is operating against the named browser.

## Current-state impact

- Earlier recorded stable-release holds for read-only Popover initial focus and
  CommandPalette empty-result announcements are recommended follow-ups. Their
  checked-in scenarios (`Read-only dialog focus`; `CommandPalette / Audit
Matrix`) and pairings stay valid for a real-AT run and hold no release.
- `spec:AST-086` status-announcement fixes merge on FR4 evidence (FR17, FR19).
- `spec:AST-001`, `spec:AST-020`, `spec:AST-021`, `spec:AST-056`, `spec:AST-086`,
  `component:FieldStatus`, `module:Table/useTableRowStatus`, and
  `module:Table/useTableColumnResize` describe real-AT observation as
  recommended follow-up, not a merge or release requirement.
- No contributing record owns assistive-technology verification. When one is
  authored, it links back to `spec:AST-009` rather than copying its policy.

## Verification

| Contract  | Verification                                                     | Representative states                                                                                                       | Mutation or failure expectation                                                                                            |
| --------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR8   | Classification review against the change's claimed user outcome. | static semantic fix; browser-only focus change; AT-announced autofocus; live update; virtual-cursor entry; known divergence | An AT-dependent claim ships without its lower-layer proof, or an unrelated accessibility fix is sent to real-AT follow-up. |
| FR9–FR11  | Evidence-map review plus focused tests/observations.             | unit state; DOM attributes; computed tree; axe state; browser focus; spoken/braille result                                  | A lower layer is credited with speech, timing, cursor, or interoperability it cannot observe.                              |
| FR12–FR16 | Pairings named in a real-AT receipt.                             | desktop-neutral; platform-specific; mobile; known divergent stacks                                                          | A generic “screen reader checked” result substitutes for the required independent or divergent pairing.                    |
| FR17–FR22 | Merge and release review.                                        | announcement fix with FR4 evidence; pending follow-up issue; failed real-AT observation; stale receipt                      | A pull request or release waits on real-AT evidence, or lower-layer proof is described as proving speech.                  |
| FR27–FR31 | Receipt completeness and link-durability review.                 | PR receipt; linked issue; reusable checked-in fixture; supplementary recording                                              | Critical evidence exists only in an expired/private artifact, cannot be reproduced, or guidance forks this policy.         |

### Completion criteria

A change meets this policy when:

- an AT-dependent claim is named under FR8;
- each claim has evidence from the producible layer that can observe it under FR4, FR9–FR11, and FR19;
- no merge or release waited on real-AT observation under FR17; and
- any real-AT receipt that exists is durable and reproducible under FR27–FR29.

## Decision log

### DEC-1 — Real-AT follow-up is outcome-triggered, not category-wide

**Reference:** `spec:AST-009/DEC-1` · **Decider:** `cixzhang`, `2026-08-31`
**Decision:** FR1–FR8 recommend real AT/browser follow-up for AT-specific focus exposure/order, announcement behavior, virtual-cursor entry, or known divergence. Focus destination, keyboard behavior, focus appearance, and other accessibility changes use the evidence and authority that directly own their claim.
**User-impact rationale:** AT-dependent outcomes get targeted attention without making unrelated changes run costly shallow checks. **Rejected:** running every AT on every accessibility change.

### DEC-2 — Evidence layers keep distinct proof boundaries

**Reference:** `spec:AST-009/DEC-2` · **Decider:** `cixzhang`, `2026-08-31`
**Decision:** Unit, DOM, accessibility-tree, axe, browser, and real-AT evidence each prove a different boundary. Changes compose needed layers instead of treating one tool as general accessibility proof.
**User-impact rationale:** People must get the heard or navigated result, not only plausible markup or a green automated check. **Rejected:** describing a green axe run, plausible ARIA, or an accessibility snapshot as proof of what a person hears or where their virtual cursor enters.

### DEC-3 — The real-AT matrix is representative and claim-specific

**Reference:** `spec:AST-009/DEC-3` · **Decider:** `cixzhang`, `2026-08-31`
**Decision:** Desktop-neutral follow-up uses NVDA + Chrome on Windows and VoiceOver + Safari on macOS. Mobile, platform-specific, and known-divergent claims select or expand that matrix according to the affected outcome. Passing results make no universal compatibility claim.
**User-impact rationale:** The pairings cover the claim without implying untested AT, browser, verbosity, locale, or input behavior. **Rejected:** one fixed exhaustive matrix for every change, or one convenient pairing standing in for a stack already known to behave differently.

### DEC-4 — Real-AT evidence never gates merge or release

**Reference:** `spec:AST-009/DEC-4` · **Decider:** `cixzhang`, `2026-10-22`
**Decision:** Merge and stable release require only evidence Astryx can produce. DOM, accessibility-tree, axe, and real-browser evidence set the bar; real-AT observation is recommended follow-up (FR17–FR19).
**User-impact rationale:** A requirement no contributor can meet stalls the fixes that remove known accessibility defects, which keeps those defects in front of users longer. **Rejected:** holding stable releases until named real-AT receipts pass.

### DEC-5 — Real-AT receipts stay durable at the change boundary

**Reference:** `spec:AST-009/DEC-5` · **Decider:** `cixzhang`, `2026-08-31`
**Decision:** When a real-AT check runs, the change or linked tracking record carries a reproducible textual receipt with build identity and result. Expiring artifacts may supplement it but cannot own its facts.
**User-impact rationale:** Future contributors and maintainers can audit what was observed and on which build. **Rejected:** private notes, local files, or transient CI artifacts as the only record.

## Open questions

None.

---

### Specs AST 010 Spec (`docs/specs/AST-010/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-010
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
phase: shipped
owners: [cixzhang]
affects_architecture: [architecture:public-component-api]
affects_families: [family:layout-regions]
affects_contributing: []
affects_consumer_docs: [Resizable]
---

# Structured percentage useResizable configuration system spec

## Intent

A person resizing a panel should get the same panel size that the handle presents
and assistive technology announces. Builders may configure an initial size or
bounds as percentages, optionally with one explicit pixel floor or ceiling through
`percent(value, {min: pixel(value)})` or
`percent(value, {max: pixel(value)})`. Every user-selected size
remains pixels so pointer, keyboard, persistence, and programmatic behavior stay
predictable.

This spec preserves the released meaning of `defaultSize: 'N%'` when no
`containerRef` is supplied: resolve once against `window.innerWidth` (or the
1200px server fallback), then continue as pixels. Supplying `containerRef`
changes only the basis used by percentage configuration: the container's
content-box active axis replaces the viewport. It does not create a persistent
relative sizing mode.

Numeric inputs are pixels. Resolved public outputs remain pixels. Shipped source,
tests, and consumer docs remain the evidence for current behavior until the
implementation and verification below land; the draft `component:Resizable`
record is updated alongside that implementation.

## Non-goals

- Implementing runtime behavior in this specification pull request.
- Preserving a percentage ratio after pointer, keyboard, or programmatic resize.
- Automatically distributing remaining space or requiring independently sized
  regions to total 100%.
- Supporting arbitrary CSS units, functions, expressions, or recursive composition,
  including `rem`, `vw`, `calc()`, CSS `min()`, and CSS `max()`.
- Adding percentage snap points or percentage collapse thresholds.
- Adding controlled size state; current controlled collapse behavior is
  unchanged.
- Widening `resize()` beyond its released numeric pixel input.
- Changing the persisted state shape or adding a relative-size field.
- Widening SideNav's simplified `defaultWidth`, `minWidth`, and `maxWidth`
  configuration. That integration requires a separate component decision because
  SideNav owns its root and handle.
- Changing LayoutPanel's ownership of structure, scrolling, inset, or divider
  presentation.

## Requirements

- **FR1 — Percentage configuration uses an explicit basis.** Without
  `containerRef`, a percentage leaf in `defaultSize` MUST resolve once against
  `window.innerWidth`, or 1200px when `window` is unavailable, preserving the
  released fallback. With `containerRef`, it MUST resolve once against that
  container's first positive measured content-box active axis. A structured
  percentage MUST then apply its single pixel floor or ceiling. After resolution,
  the selected size is pixels and MUST NOT scale when the viewport or container
  changes.
- **FR2 — Direction selects the container basis.** With `containerRef`,
  horizontal resizing MUST use the container's content-box inline size and
  vertical resizing MUST use its content-box block size. RTL may reverse pointer
  delta, but MUST NOT change the percentage basis. Without `containerRef`, the
  compatibility basis remains `window.innerWidth` regardless of direction.
- **FR3 — Interactions remain pixel-based.** Numeric defaults and every result of
  an atomic or structured percentage default resolve to pixels. Pointer, keyboard,
  snap, collapse, expand, persistence, callback, and programmatic resize paths MUST
  continue to read and write pixel selections exactly as the released API does. No
  interaction may create or preserve a percentage-selected mode.
- **FR4 — Mixed bounds remain valid.** Builders MAY combine pixel, atomic
  percentage, and structured percentage values across `defaultSize`, `minSize`,
  and `maxSize`. Numeric bounds remain pixels. Percentage values resolve against
  the same viewport or container basis as FR1; a structured value then applies its
  one pixel floor or ceiling. Percentage bounds MUST re-resolve whenever that basis
  changes. Re-resolved bounds clamp the existing pixel selection; they MUST NOT
  scale it proportionally. If the resolved minimum exceeds the resolved maximum,
  development MUST warn and the maximum MUST win, preserving the released
  `Math.min(max, Math.max(min, size))` ordering and guaranteeing finite,
  non-`NaN` geometry.
- **FR5 — Effective geometry stays synchronized.** The effective selected pixel
  size, LayoutPanel or caller-rendered size, persisted expanded value, and
  ResizeHandle's `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` MUST describe
  the same resolved geometry. CSS-only clamping that leaves hook state outside the
  painted bounds is prohibited. Existing controlled-collapse callbacks remain an
  explicit exception: they report a rejected collapse or expand intent without
  claiming that the controlled owner changed the rendered size.
- **FR6 — Basis changes update only percentage constraints.** Once an atomic or
  structured percentage default has initialized the selected pixel size, a viewport
  or container resize MUST NOT re-resolve that default. Percentage bounds MUST
  re-resolve, apply their optional pixel floor or ceiling, and clamp the selected
  pixel size. A basis-only layout change MUST NOT fire an interaction callback or
  otherwise claim that a person selected a new size.
- **FR7 — Pointer gestures preserve released behavior.** Pointer deltas continue
  from the selected pixel size. Percentage bounds MUST use one stable basis during
  a gesture; a basis change during the gesture takes effect after the gesture ends
  so the handle does not move away from the pointer.
- **FR8 — Keyboard, snap, and collapse semantics continue.** Arrow-key steps,
  Shift+Arrow steps, Home, End, collapse thresholds, and snap points remain
  pixel-based. Collapse retains the expanded pixel size, and expand restores that
  pixel size subject to the currently resolved bounds.
- **FR9 — Persistence remains pixel-only.** Existing positive plain numbers and
  `{size, isCollapsed}` entries MUST continue to restore as pixel choices. A
  legacy plain `0` MUST retain its current meaning: collapsed with no saved
  expanded size when collapse is enabled, otherwise no usable saved size. New
  percentage configuration MUST use the current persisted pixel shape; no
  percentage or relative-mode field is added.
- **FR10 — Callbacks report resolved pixels.** `onSizeChange` MUST continue to
  receive resolved pixel numbers for user interactions and released programmatic
  transitions. Initialization, hydration correction, viewport resize, container
  resize, and bounds-only re-clamping MUST NOT be reported as user interaction.
- **FR11 — Single and multi-region configurations share the contract.** A
  multi-region call MUST use one optional container and axis for all regions.
  Regions remain independently pixel-selected; this change MUST NOT add balancing,
  ratio preservation, or a 100% total invariant.
- **FR12 — Invalid configuration has safe deterministic fallbacks.** Accepted
  configuration is a non-negative finite number, an exact non-negative finite
  `Npx` string, an exact `N%` string from 0% through 100%, or a structured
  `percent()` value whose percentage is finite and within 0–100 and whose one
  `min` or `max` value is a finite non-negative `PixelWidth`. A structured value
  with neither bound, both bounds, another discriminator, or any invalid number is
  invalid. An invalid `defaultSize` MUST use 250px before normal bounds clamping;
  an invalid `minSize` MUST use 50px; and an invalid `maxSize` MUST use unbounded
  `Infinity`. Explicit `maxSize: Infinity` remains valid.
  Development MUST warn for every fallback; production MUST use the same fallback
  without warning. After initialization, an invalid raw value MUST NOT directly
  replace a persisted or otherwise legal selected pixel size; a fallback-normalized
  bound may affect that size only through normal clamping.

### Public API requirements

- **API1 — `containerRef` opts into a container basis, not a relative mode.**
  Single- and multi-region config MAY accept
  `containerRef?: React.RefObject<HTMLElement | null>`. When omitted, percentage
  configuration uses the released viewport basis. When supplied, every percentage
  default or bound uses that container's content-box active axis. The ref is
  caller-owned because only the caller can identify the intended layout container;
  the hook MUST NOT infer a panel or ResizeHandle ancestor.
- **API2 — Direction is explicit.** Single-region config MAY accept
  `direction?: 'horizontal' | 'vertical'`, defaulting to `horizontal`.
  Multi-region config's existing top-level `direction` supplies every region's
  axis. ResizeHandle direction and hook direction MUST agree; the implementation
  MUST make a mismatch detectable in development. Direction affects the
  container basis only when `containerRef` is present.
- **API3 — One explicit Resizable vocabulary covers pixels and bounded
  percentages.** Add the server-safe helper and types:

  ```ts
  type ResizablePercentSize = {
    type: 'percent';
    value: number;
  } & ({min: PixelWidth; max?: never} | {min?: never; max: PixelWidth});

  type ResizableSize =
    number | `${number}px` | `${number}%` | PixelWidth | ResizablePercentSize;

  function percent(
    value: number,
    options: {min: PixelWidth; max?: never} | {min?: never; max: PixelWidth},
  ): ResizablePercentSize;
  ```

  `minSize` and `maxSize` use `ResizableSize`. `defaultSize` keeps its released
  broad `SizeValue` and adds `PixelWidth | ResizablePercentSize`, preserving every
  existing `number | string` caller while runtime validation remains authoritative.
  Reuse Table's exact `pixel()` binding and `PixelWidth`; do not create a second
  fixed-size helper. The `percent()` options argument is REQUIRED and carries
  exactly one `min` or `max` whose value is a valid `PixelWidth`. An unbounded
  percentage keeps the existing canonical `N%` spelling; `percent(40)` is
  deliberately not a second spelling for `'40%'`. Numbers, exact `Npx`, and
  `pixel(value)` remain pixels. Exact `N%` and `percent()` percentages are finite
  and 0–100. Do not accept `proportional()` (a sibling weight, not a literal
  percentage), CSS functions, arithmetic, variables, other units, both `min` and
  `max`, or neither bound. Do not add parallel percentage props.

  Configuration mapping:

  - `333` or `'333px'` → unchanged; `pixel(333)` is the canonical structured
    static form for new compositions;
  - `'40%'` → unchanged canonical unbounded percentage;
  - `max(40%, 333px)` intent → `percent(40, {min: pixel(333)})`;
  - `min(10%, 400px)` intent → `percent(10, {max: pixel(400)})`.

- **API4 — Unified bounds are the only public spellings in 0.6.**
  `minSize` and `maxSize` are the complete bound vocabulary. The deprecated
  `minSizePx` and `maxSizePx` aliases were supported through 0.5 and are removed in
  0.6. Numeric `minSize` and `maxSize` retain identical pixel semantics, including
  explicit `maxSize: Infinity`. The 0.6 upgrade codemod rewrites static inline
  configurations; dynamically assembled configuration must rename the keys.

- **API5 — Resolved output and state stay pixels.** `ResizableRegion.size`,
  `onSizeChange`, internal `ResizableProps` geometry, and persisted state remain
  pixel numbers. Existing `width={region.size}`, arithmetic, persistence, and ARIA
  callsites MUST continue to type-check and behave as before. Controlled-collapse
  callbacks keep their released intent-reporting behavior: when the owner rejects
  a requested state change, the rendered size remains owner-controlled even
  though the callback reported the requested transition.
- **API6 — Programmatic resize remains pixels.** `resize(size: number)` retains
  its released signature and selects a clamped pixel size. Percentage strings are
  unsupported and MUST remain a type error; runtime-invalid, negative, or
  non-finite values warn in development and do not replace the last legal pixel
  selection.

### Invalid configuration behavior

| Input                                   | Accepted values                                                                                                 | Invalid fallback                | Development behavior              | Production behavior               |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------- | --------------------------------- |
| `defaultSize`                           | released `SizeValue` plus `PixelWidth` or `ResizablePercentSize`; structured default resolves once              | 250px, then normal bounds clamp | warn and use fallback             | use same fallback without warning |
| `minSize`                               | `ResizableSize`; full-match atomic strings; valid `pixel(value)` or `percent(value, {min XOR max: PixelWidth})` | 50px                            | warn and use fallback             | use same fallback without warning |
| `maxSize`                               | `ResizableSize`; full-match atomic strings; valid structured values; explicit `Infinity` remains unbounded      | unbounded `Infinity`            | warn and use fallback             | use same fallback without warning |
| resolved minimum above resolved maximum | both values are individually valid                                                                              | maximum wins                    | warn and use released clamp order | use same ordering without warning |

A fallback repairs configuration; it is not a new user selection. After
initialization, an invalid raw value never directly replaces persisted or otherwise
legal selected pixel state. Effective fallback bounds may affect that state only
through normal clamping.

### Platform support

- Supported feature/engine floor: every browser supported by Astryx Core with
  `ResizeObserver`; the shared observer remains the one runtime owner for a
  supplied container.
- Before a supplied container has a positive measurement, basis-dependent
  configuration MAY use the released deterministic 1200px basis as a temporary
  fallback. The first valid measurement resolves a basis-dependent default to its
  final initial pixel size and resolves percentage bounds without persisting or
  announcing the temporary fallback. A default-only observer MUST be removed after
  that initial choice; persisted pixel state MUST skip an otherwise unused default
  measurement.
- Browser evidence: real Chromium MUST verify the three canonical structured calls,
  wide and narrow initial containers, later basis changes, storage and ARIA, plus
  the unchanged viewport-compatibility and pixel paths. jsdom geometry stubs alone
  are not sufficient.
- SSR: without `containerRef`, atomic and structured percentage defaults preserve
  the released 1200px server basis and client initialization behavior. With
  `containerRef`, the server uses the same temporary 1200px basis until the first
  client measurement. Hydration MAY make one documented correction, but MUST NOT
  write the temporary fallback to storage or fire `onSizeChange` for that
  correction.

## Current-state impact

Current `main` ships the complete AST-010 model: `defaultSize`, `minSize`, and
`maxSize` accept pixel and percentage forms; `containerRef` supplies the optional
content-box basis; percentage defaults resolve once; and percentage bounds remain
live while selected state, paint, persistence, callbacks, and separator ARIA stay
in resolved pixels. The no-ref path retains the released `window.innerWidth` basis
and deterministic 1200px server fallback.

Before the unified bounds shipped, the pixel-only API could not express percentage
constraints. Applying CSS constraints separately could make paint disagree with
hook state and ResizeHandle ARIA. The unified bounds now own both forms. Astryx 0.6
removes the deprecated pixel-only key aliases while preserving numeric pixel
behavior under `minSize` and `maxSize`.

The shipped contract remains distributed across its current owners:

- `architecture:public-component-api` owns stable export and component contract
  shape;
- [`spec:AST-017` — Release Compatibility & Versioning system spec](../AST-017/spec.md)
  owns classification, deprecation/cleanup state, Changesets, and migration evidence;
  this spec keeps the exact Resizable mapping and its direct transition requirement;
- `family:layout-regions` delegates resize state and interaction to useResizable
  and ResizeHandle while LayoutPanel consumes resolved size;
- `spec:AST-002` governs the caller-owned container basis and predictable state;
- shipped Resizable source, tests, browser evidence, and consumer docs verify
  pointer, keyboard, collapse, snap, persistence, handle, SSR, and ARIA behavior;
  and
- `component:Resizable` records the component-specific implementation contract.

### Persistence representation

The persisted representation does not change:

```ts
interface PersistedResizableState {
  size: number | null;
  isCollapsed: boolean | null;
}
```

An atomic or structured percentage default becomes a pixel selection before it is
persisted. A persisted pixel selection wins over `defaultSize` on restore and is
clamped by the currently resolved bounds. A legacy plain `0` remains a collapse
marker with no expanded size, so expand falls back to the configured default; when
collapse is disabled it supplies no usable saved size. Pointer, keyboard, collapse,
expand, and numeric programmatic resize continue to persist pixels. Basis changes
never write a percentage descriptor or relative intent.

### Implementation requirements

1. Preserve the current no-ref resolver for every percentage default:
   `window.innerWidth` on the client and 1200px when `window` is unavailable.
2. Parse complete atomic strings and validate the complete `percent()` descriptor,
   including its XOR bound shape. Apply FR12's 250px/50px/`Infinity` fallbacks
   before clamping and warn only in development. Preserve explicit
   `maxSize: Infinity` as valid unbounded input.
3. Export `percent`, `ResizablePercentSize`, and `ResizableSize` from Resizable;
   expose `percent`, the same Table `pixel` binding, `PixelWidth`, and the
   Resizable types from the server-safe `Resizable/utils` subpath.
4. Expose only `minSize` and `maxSize` for bounds. Do not retain runtime parsing,
   conflict warnings, or source types for the removed pixel aliases.
5. When `containerRef` is supplied, measure its content box on the configured axis
   and observe it through Astryx's shared ResizeObserver.
6. Resolve a percentage default once into selected pixel state. Remove default-only
   observation once that selection is final. Re-resolve only percentage bounds when
   their viewport or container basis changes, apply their one pixel floor or
   ceiling, then clamp selected pixel state so paint, persistence, and ARIA stay
   synchronized.
7. Refresh a stale cached basis before a newly activated percentage bound can clamp,
   and remove default-only observation even when `resize()` selected pixels before
   the first positive measurement.
8. Preserve the released maximum-wins clamp order when a resolved minimum exceeds
   its maximum, warn in development, and never emit `NaN` or invalid geometry.
9. Keep pointer, keyboard, snap, collapse, expand, callback, persistence, and
   `resize(number)` behavior pixel-based. Do not add a relative mode or widen
   `resize()` to strings. Invalid later configuration must not replace persisted or
   otherwise legal selected state.
10. Preserve all current controlled-collapse and legacy persistence behavior not
    explicitly changed by this spec.
11. Keep SideNav's simplified resize configuration pixel-only until a separate
    integration spec or compatibility decision authorizes widening it.

## Verification

| Contract         | Verification                                                   | Representative states                                                                                                        | Mutation or failure expectation                                                                                                                           |
| ---------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, API1        | Focused hook tests plus Chromium geometry                      | atomic/structured default; no ref at 1200px; wide/narrow initial container; first positive measurement; later resize         | default uses the wrong basis, rescales after initialization, or supplied ref measures the wrong element                                                   |
| FR2, API2        | Axis tests and Chromium interaction                            | horizontal/vertical; LTR/RTL; reversed handle; no-ref vertical compatibility                                                 | vertical container sizing reads width, or compatibility stops using `innerWidth`                                                                          |
| FR3, FR6-FR8     | Interaction and basis-change tests                             | structured default before/after interaction; viewport/container resize; Arrow, Shift+Arrow, Home, End; collapse/expand       | selected state preserves a ratio, basis change rescales it, or existing pixel interactions change                                                         |
| FR4              | Structured floor/ceiling, mixed-bound, and invalid-order tests | canonical `percent(40, {min: pixel(333)})` / `percent(10, {max: pixel(400)})`; mixed units; min above max                    | descriptor bound is applied in the wrong direction, bounds use different bases, selection scales instead of clamps, or maximum does not win               |
| FR5              | Hook, LayoutPanel, persistence, and ResizeHandle assertions    | below/at/above max; basis shrink; controlled collapse intent                                                                 | effective paint, state, storage, and ARIA diverge, or controlled rejection changes owned state                                                            |
| FR9              | Persistence compatibility tests                                | positive number; legacy zero; legacy object; structured default; corrupt entry                                               | stored shape changes, a descriptor is persisted, an unused default measures a basis, or old state changes meaning                                         |
| FR10, API5       | Callback tests                                                 | initialization; hydration correction; basis-only re-clamp; pointer/keyboard/programmatic change                              | callback receives a descriptor or reports a non-interaction layout correction                                                                             |
| FR11             | Multi-region and shared-observer tests                         | two regions on one container; another hook observing the same node                                                           | regions use different bases, selected ratios are introduced, or one subscription removes another                                                          |
| FR12             | Production/development validation and state-preservation tests | malformed strings; missing/both descriptor bounds; invalid percent/pixels; invalid rerender; persisted selection; `Infinity` | fallback differs by build, warning occurs in production, invalid input replaces legal state, or any path produces `NaN`                                   |
| API3, API4, API6 | Type, export, runtime-validation, and codemod tests            | required helper options; XOR bound; server-safe subpath; removed alias type errors; `resize('50%')`                          | `percent(40)` or both bounds type-check, a removed alias still type-checks, server import gains a client directive, or `resize` accepts non-pixel input   |
| Platform/SSR     | Server render, hydration test, and Chromium evidence           | no ref; ref not measured; hidden/zero container; first positive measurement; resize before layout; later bound activation    | temporary basis persists, correction fires `onSizeChange`, default-only observation leaks, or a newly active bound clamps from a stale cached measurement |
| Compatibility    | Existing Resizable, LayoutPanel, SideNav, and template suites  | current pixel-only callsites; no-ref atomic percentage default; released broad `defaultSize` string                          | existing numeric/atomic percentage behavior, broad default typing, output, interaction, or persistence changes                                            |

### Completion criteria

The shipped contract remains satisfied while:

- no-ref atomic and structured percentage defaults preserve one-time
  `window.innerWidth` resolution and the 1200px SSR fallback without tracking
  viewport resize;
- supplied-container structured defaults resolve once against the first positive
  content-box active axis, remove default-only observation after that choice, and
  remain pixel selections through basis changes and every interaction path;
- atomic and structured percentage bounds re-resolve in both basis modes, apply
  their optional pixel floor or ceiling, and clamp existing pixel state without
  proportionally scaling it;
- `percent()` requires options and exactly one of `min` / `max`, carrying a valid
  `PixelWidth`, in both the type system and runtime validation; `percent(40)` is not
  an alias for `'40%'`;
- `Resizable/utils` remains server-safe, re-exports Table's exact `pixel` binding
  and `PixelWidth`, and exports `percent` plus the Resizable types; the root package
  resolves one `pixel` symbol and one `percent` symbol without collision;
- pointer, keyboard, snap, collapse, expand, persistence, callbacks, and
  `resize(number)` preserve released pixel semantics;
- `minSizePx` and `maxSizePx` no longer type-check or receive runtime handling,
  and the 0.6 upgrade codemod rewrites static inline configurations to `minSize`
  and `maxSize`;
- exact parsing accepts only non-negative finite numbers, complete `Npx` strings,
  and 0–100 `N%` strings; descriptor validation rejects a missing/both pixel bound
  or invalid numeric field. Invalid values use the documented 250px, 50px, or
  `Infinity` fallback without replacing persisted/legal selected state, while
  explicit `maxSize: Infinity` remains valid;
- inverted resolved bounds warn in development, deterministically choose the
  maximum, and never produce `NaN` or invalid geometry;
- mixed bounds keep effective state, paint, storage, and separator ARIA aligned,
  while controlled-collapse callbacks preserve released intent reporting;
- default-only subscriptions terminate even after a pre-measurement `resize()`,
  and newly activated percentage bounds measure the current basis before clamping;
- single and multi-region subscriptions coexist with every other observer of the
  same element;
- focused tests fail against the old implementation and pass against the new one;
- real Chromium proves the three canonical structured calls across wide/narrow
  bases, later resizes, storage, and ARIA, plus unchanged compatibility paths; and
- `component:Resizable` and consumer docs describe the shipped contract in the
  same pull request as the implementation.

## Decision log

### DEC-1 — Percentages configure pixel state; they do not create relative state

**Reference:** `spec:AST-010/DEC-1`
**Decider:** `cixzhang`, `2026-08-31`

Preserve the released no-ref percentage default: resolve once against
`window.innerWidth`, use 1200px when `window` is unavailable, and then behave as
pixels. An optional caller-owned `containerRef` changes the basis for percentage
defaults and bounds to the container's content-box active axis. It does not make a
selected size responsive.

All user interaction, programmatic `resize()`, collapse/expand, callbacks, and
persistence remain pixel-only. Percentage bounds may re-resolve with their basis
and clamp that pixel state. This keeps the API predictable and every represented
state valid under `spec:AST-002` while avoiding a second persistent sizing mode.

Rejected: preserving a percentage ratio after drag or widening `resize()` to
accept percentage strings. Both would change released pixel-based interaction and
add unit-aware state, persistence, callback, and restoration semantics that the
caller did not request.

### DEC-2 — Initial size and bounds share one configuration vocabulary

**Reference:** `spec:AST-010/DEC-2`
**Decider:** `cixzhang`, `2026-08-31`

Add `minSize` and `maxSize` with the same atomic values as `defaultSize` and the
structured `ResizablePercentSize` descriptor. Numbers and exact `Npx` remain
pixels; strings also accept finite 0–100 `N%`; Table's existing `pixel(value)` is
the canonical structured static value; `percent(value, {min: pixel(value)})` adds
one pixel floor and `percent(value, {max: pixel(value)})` adds one pixel ceiling.
Mixing units across the initial size and bounds is valid. This gives builders one
predictable vocabulary without parallel percentage props or duplicate helpers.

`minSizePx` and `maxSizePx` remained deprecated compatibility aliases through
0.5. Astryx 0.6 removes them after two stable releases; numeric `minSize` and
`maxSize` preserve their pixel meaning, and `astryx upgrade` rewrites static inline
configurations. Dynamically assembled configuration must rename the keys.

Invalid inputs use deterministic role-specific fallbacks in every build: 250px for
`defaultSize`, 50px for `minSize`, and unbounded `Infinity` for `maxSize`.
Development additionally warns. Explicit `maxSize: Infinity` remains valid. If a
resolved minimum exceeds its maximum, development warns and the maximum wins under
the released clamp order. These rules keep malformed input and inverted bounds
from producing `NaN` or replacing persisted/legal selected state.

### DEC-3 — Bounded percentages use one explicit structured helper

**Reference:** `spec:AST-010/DEC-3`
**Decider:** `cixzhang`, `2026-09-03`

Use a Table-style discriminated value created by `percent()`, not a JavaScript
parser for CSS functions. The descriptor carries a percentage and exactly one
`min` or `max` whose value is Table's existing `PixelWidth`. Its options argument
is required: the existing exact `N%` string remains the only spelling for an
unbounded percentage.

```ts
pixel(333);
percent(40, {min: pixel(333)});
percent(10, {max: pixel(400)});
```

Reuse Table's exact `pixel()` binding; do not create another fixed-value helper.
Do not reuse `proportional()`: it represents relative weight among sibling columns,
not a literal percentage of one measured basis. This fixed shape exposes every
supported combination to TypeScript, remains constant-time to validate and resolve,
and does not imply arbitrary CSS composition. Rejected: recursive CSS `min()` /
`max()` strings, optional helper options, descriptors with both bounds, and separate
percentage props.

### DEC-4 — Astryx 0.6 removes the deprecated pixel-bound aliases

**Reference:** `spec:AST-010/DEC-4`
**Decider:** `cixzhang`, `2026-09-07`

Remove `minSizePx` and `maxSizePx` in the 0.6 breaking window. Their replacements
have shipped across two stable releases, numeric `minSize` and `maxSize` preserve
the same pixel behavior, first-party callers migrate in the removal change, and
`astryx upgrade` rewrites inline configurations. Keeping both vocabularies would
retain type unions, runtime parsing, conflict warnings, tests, and documentation
without adding capability.

Rejected: retaining the aliases indefinitely. They were explicitly temporary and
the unified bounds cover every supported pixel case plus percentage constraints.

## Open questions

None. Changing the released 1200px server fallback or adding a persistent
relative-size mode requires a separate decision.

---

### Specs AST 011 Spec (`docs/specs/AST-011/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-011
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-08-31
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:public-component-api]
affects_families: [family:input-fields]
affects_contributing: []
affects_consumer_docs: [Selector, MultiSelector]
---

# Read-only selection inputs

## Intent

People should be able to review and submit a selected value that policy or
permissions prevent them from changing. Selector and MultiSelector should expose
that state as read-only rather than disabled, without offering a menu that cannot
change the value.

## Non-goals

- Adding read-only behavior to every input-family member in this change.
- Changing disabled, disabled-reason, loading, validation, or option-source state.
- Adding read-only explanation text or a tooltip API.
- Changing selected-value rendering, option rendering, or form serialization.

## Requirements

- **FR1 — Read-only preserves the value.** With `isReadOnly`, the current value
  MUST remain visible at full opacity, focusable in the ordinary tab order, and
  included in form submission. It MUST NOT invoke `onChange` or `changeAction`.
- **FR2 — Read-only exposes no selection surface.** Pointer, keyboard, typeahead,
  search, and `isDefaultOpen` MUST NOT open or render the Popover or BottomSheet
  selection surface while read-only.
- **FR3 — Editing affordances are absent.** Clear actions and the disclosure
  indicator MUST NOT render while read-only. Status and busy presentation remain
  independent because they describe the preserved value rather than an available
  edit.
- **FR4 — Accessibility matches behavior.** The focusable read-only value MUST
  retain `combobox` semantics, expose `aria-readonly="true"` and
  `aria-expanded="false"`, and preserve its accessible name and rendered value.
  It MUST NOT expose `aria-controls` or `aria-activedescendant` when no selection
  surface exists. The combobox role's implicit listbox popup describes the
  control's selection capability; `aria-readonly` communicates that the value
  cannot currently be changed. When a supported accessibility tree does not map
  that ARIA property for the trigger's host element, a localized accessible
  description MUST additionally announce the read-only state. Search-enabled and
  non-search selectors use the same read-only combobox semantics because neither
  exposes a search or selection surface in this state.
- **FR5 — Disabled takes precedence.** When `isDisabled` and `isReadOnly` are both
  true, disabled focus, interaction, appearance, accessibility, and form-submission
  behavior win.
- **IR1 — The public API follows the input convention.** Selector and
  MultiSelector MUST use the established optional `isReadOnly` boolean and expose
  the existing `readonly` theme state on their root targets.
- **IR2 — Read-only is not disabled paint.** Implementations MUST NOT reuse
  disabled opacity or disabled form behavior for read-only state. They MAY remove
  hover, pressed, and pointer affordances that would falsely imply editability.

### Platform support

- Supported feature/engine floor: every browser and assistive-technology
  combination supported by Astryx Core.
- WCAG baseline: [WCAG 2.2 SC 4.1.2](https://www.w3.org/TR/WCAG22/#name-role-value)
  requires the control's name, role, value, and state to be programmatically
  determinable. The retained `combobox` role, rendered value, and
  `aria-readonly="true"` state satisfy that contract without changing the
  control's programmatic identity.
- ARIA contract: [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/#aria-readonly)
  supports `aria-readonly` on `combobox` and defines read-only as a value the user
  can read but cannot set. A combobox has an implicit
  `aria-haspopup="listbox"`; that capability metadata remains valid even though
  read-only interaction never opens the surface.
- Browser evidence: DOM inspection MUST expose `aria-readonly="true"` and no
  nonexistent controlled surface. Chromium accessibility inspection MUST expose a
  focusable, collapsed combobox with the correct name and rendered value; its
  implicit popup remains `listbox`, and its accessible description MUST announce
  the localized read-only state because Chromium does not map `aria-readonly` on
  the button host. Pointer and keyboard probes MUST confirm that no selection
  surface opens and no value changes.

## Current-state impact

- TextInput, TextArea, NumberInput, CheckboxInput, and CheckboxList already
  establish `isReadOnly` as the input-family name for visible, focusable,
  non-editable values.
- Selector and MultiSelector currently expose only disabled state. Their triggers
  can open a selection surface, their clear actions can change the value, and
  their form carriers are removed only when disabled.
- The caller owns whether a selected value is policy- or permission-locked; the
  components cannot derive that distinction from the value, options, loading
  state, or layout. The prop therefore satisfies `spec:AST-002` public-prop
  admission.
- Implementation updates both component contracts, consumer docs, focused tests,
  and theme-state metadata in the same pull request.

## Verification

| Contract | Verification                                        | Representative states                                        | Mutation or failure expectation                                                                                               |
| -------- | --------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR5 | Focus, callback, and form-participation tests       | read-only; disabled; both; single and multiple values        | A read-only value changes, leaves the tab order, fails to submit, dims, or overrides disabled behavior                        |
| FR2, FR3 | Pointer/keyboard/typeahead and DOM affordance tests | popover, bottom sheet, adaptive, search, clear, default-open | A read-only trigger opens or renders a surface, clears a value, or displays a disclosure indicator                            |
| FR4      | DOM and real-browser accessibility inspection       | search and non-search triggers; standalone and InputGroup    | The value changes role, loses its name/value/read-only state, appears expanded, or points at a nonexistent controlled surface |
| IR1, IR2 | Theme-state and rendered-style tests                | input and ghost variants                                     | The root omits `data-readonly`, gains disabled opacity, or keeps interactive hover/pressed cues                               |

## Decision log

### DEC-1 — Selection inputs use the established read-only contract

**Reference:** `spec:AST-011/DEC-1`
**Decider:** `cixzhang`, `2026-08-31`

Selector and MultiSelector accept `isReadOnly` when the caller owns a value that
must remain visible and submitted but cannot be changed. Read-only controls stay
focusable, retain their combobox identity, expose their rendered content as the
value, and publish `aria-readonly="true"`, while selection and editing affordances
are absent.

The retained role follows WCAG 2.2 SC 4.1.2's stable programmatic name, role,
value, and state contract and WAI-ARIA 1.2's explicit support for
`aria-readonly` on `combobox`. [MUI Select](https://github.com/mui/material-ui/blob/bddf22f5c6d4c4383aae88dbd77b6c7a53c5fd17/packages/mui-material/src/Select/SelectInput.js#L841-L853)
and [React Aria ComboBox](https://github.com/adobe/react-spectrum/blob/7c6100839de0f53bfa3c4e2f99529bdaa96bf205/packages/react-aria/src/combobox/useComboBox.ts#L143-L151)
use the same shape: a read-only combobox remains identifiable as a combobox while
pointer and keyboard opening are blocked. The implicit listbox popup describes
the widget's selection capability; it does not promise that every state can open
it.

Rejected: mapping this state to `isDisabled`, because disabled values are dimmed
and excluded from form submission.

## Open questions

None.

---

### Specs AST 012 Spec (`docs/specs/AST-012/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-012
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-02
phase: accepted
owners: [cixzhang, rubyycheung, imdreamrunner]
affects_architecture:
  [
    architecture:component-theming-surface,
    architecture:public-component-api,
    architecture:theme-authoring-contract,
    architecture:theme-compilation,
    architecture:theme-application,
  ]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme, AppShell]
---

# Theme adaptations



```json
{
  "scope": "global",
  "triggers": {
    "theming": ["FR4", "FR5", "FR6", "FR7"]
  }
}
```

## Intent

Themes should express opt-in token and component adaptations for environmental
conditions without a second theme, handwritten media-query CSS, or React state.
The system is named `adaptations`, not `responsive`, because conditions may cover
width, pointer precision, contrast preference, motion preference, and future
reviewed environment axes.

```ts
defineTheme({
  adaptations: {
    widthBreakpoints: {
      sm: 640,
      md: 768,
      lg: 1024,
      xl: 1280,
      '2xl': 1536,
    },
    rules: [
      {
        when: {
          width: {from: 'lg', below: 'xl'},
          pointer: 'coarse',
          contrast: 'more',
        },
        value: {
          components: {/* overrides */},
        },
      },
    ],
  },
});
```

`widthBreakpoints` are named points where width tiers start. `width.from` includes
its point and `width.below` excludes it, so `{from: 'lg', below: 'xl'}` is the
exact `lg` band. Fields in `when` are ANDed.

A breakpoint map alone emits no CSS. Only rules with adaptation values do.

## Non-goals

- Adding adaptation rules to bundled themes or choosing product-specific values.
- Defining responsive policy for FormLayout, layout primitives, or product
  layouts.
- Accepting raw media-query strings, user-defined condition fields, or user-defined
  breakpoint names. New environment axes remain explicit API additions.
- Changing color-mode resolution or MediaTheme's surface detection and meaning.
- Making responsive components consume theme adaptation rules; components may
  resolve shared width points, but their behavior remains separately owned.
- Adapting non-CSS token reads used by canvas, data visualization, or other
  JavaScript consumers; those continue to resolve root theme values.

## Requirements

- **FR1 — Width breakpoints are fixed start points.** Every theme has the effective
  `widthBreakpoints` map `sm: 640`, `md: 768`, `lg: 1024`, `xl: 1280`,
  `2xl: 1536` CSS pixels unless it overrides values for those fixed names. Values
  MUST be finite, positive, and strictly increasing. Root values are the implicit
  base below `sm`; there is no configurable `xs: 0` point. Defining or overriding
  the map alone MUST NOT emit CSS.
- **FR2 — Conditions are named, closed, and composable.** A `when` object may
  contain `width`, `pointer`, `contrast`, and `motion`; fields are ANDed.
  `width` accepts `from` and `below` using the five fixed names and MUST contain
  one or both. `from` is inclusive, `below` is exclusive, either edge may be
  omitted but not both, and a bounded width MUST resolve to `from < below`.
  `pointer` accepts `coarse | fine`; `contrast` accepts
  `more | less | no-preference`; `motion` accepts `reduce | no-preference`.
  Future environment fields require explicit additions to this closed vocabulary.
- **FR3 — Rules are named objects.** `adaptations.rules` contains
  `{when, value}` objects, not positional tuples. Empty `when` is invalid. A value
  that must apply at every width after earlier rules is expressed by later rules
  covering `{width: {below: 'sm'}}` and `{width: {from: 'sm'}}`. Rules with no
  changed value may emit no CSS.
- **FR4 — Rules use the theme value contract.** `value` may contain `typography`,
  `color`, `radius`, `motion`, `tokens`, `components`, and `localTokens`
  overrides. `rules[].value.localTokens` is not an enrollment surface: only root
  `localTokens` or an enrolled exact base declares names. A rule may replace only
  an exact name already enrolled in the effective lineage and uses AST-006's
  `TokenValue` normalization and validation. Identity, inheritance, syntax, icons,
  indicators, media-surface fields, and adaptations remain root-owned. Within one
  rule, values use the root expansion, deep-merge, and kind precedence. A
  generative axis expands against the root axis metadata, and every produced leaf
  counts as a write by that rule except invariant type-scale component defaults
  at exact leaf paths already declared on the effective root `components`.
  Generated tokens and rule-authored component leaves remain writes.
  Missing required scale fields MUST be supplied
  rather than synthesized from approximate built-in defaults. Component writes in
  rules MUST use the same target, axis, value-domain, and extension validation as
  root `components`; adaptations define no separate component-value validation
  policy. A rule MAY style a built-in value or a value accepted by an
  authoritative open primitive domain even when the effective root has no style
  rule for that target or value. A value whose validity depends on theme
  enrollment MUST already be enrolled on the effective root `components` surface;
  a rule MUST NOT introduce it conditionally. An unresolved result from shared
  root validation carries into adaptation validation unchanged. It is neither a
  permanent adaptation exemption nor evidence that the axis is extensible. States
  are component-owned and never theme-generated.
- **FR5 — Media surfaces remain more specific.** Adaptation values resolve over the
  root theme. When an `onDark` or `onLight` override and an adaptation write the
  same resolved leaf, the media-surface value wins. Adaptations do not create a
  nested media-surface grammar.
- **FR6 — Rule order determines precedence.** Root values apply before adaptation
  rules, except media-surface overrides, which apply after them under FR5. Rules
  cascade in declaration order and the last matching write to each resolved token
  or component leaf wins. Condition shape creates no independent specificity
  score. Duplicate `when` objects are valid. Reordering overlapping rules is an
  intentional behavior change. A later scale-only rule preserves an earlier
  authored component leaf when its exact path exists at root; when absent at
  root, the generated component leaf remains a write and wins if both rules match.
- **FR7 — Theme extension preserves cascade order.** A child inherits the base
  width-breakpoint overrides and ordered rules. Breakpoints merge by name.
  Inherited rules keep their relative order and resolve against the child's
  effective map; child rules append after them. There is no removal operator: an
  empty child value is a no-op, and inherited behavior is neutralized only by a
  later child rule writing the affected leaf. A child's root value never overrides
  a matching inherited rule because root values apply before adaptation rules.
- **FR8 — AppShell shares width points.** `mobileNav.breakpoint` accepts the five
  width-breakpoint names plus `none`. For a selected name, mobile mode applies only
  while `width < effectiveWidthBreakpoints[name]`; equality belongs to the wider
  side. `none` never matches. AppShell reads the nearest Theme context; without
  provider context it follows the root-theme fallback in
  `architecture:theme-application`, and with no active theme it uses the FR1
  defaults. `defaultIsMobile` remains an SSR hint; the client query uses the
  effective map after hydration.
- **IR1 — Normalize and reject atomically.** One path validates width breakpoints,
  named condition fields, width ranges, theme extension, enrolled local-token
  names, and concrete expanded values for both `defineTheme` and
  `astryx theme build` before either produces partial output. `defineTheme` retains
  normalized generative-axis metadata on every effective `DefinedTheme`; static
  build preserves the same metadata under IR4.
- **IR2 — Adaptation values are CSS-first.** Once the theme stylesheet is applied,
  adaptation values resolve from CSS alone without a JavaScript rerender, resize
  listener, or hydration correction. Runtime Theme injection and static build use
  the same normalized rules, scope, and cascade. Root values emit before rules;
  rule blocks preserve declaration order; media-surface overrides emit after rules
  where both apply. Output MUST NOT merge, reorder, or drop rule blocks when doing
  so changes the cascade, including duplicate `when` objects and later rules that
  restore root values. Every component leaf lowers to the same declaration target
  as its root equivalent, including derived private-variable expansion. `useTheme`
  token reads and server-safe token helpers remain root-value reads.
- **IR3 — Tooling sees rule-only values.** Validation, component diagnostics,
  private-variable checks, font notices, and `light-dark()` color-scheme detection
  MUST inspect values declared only in rules. Component writes in rules MUST pass
  through the same shared validation path as root component writes. Adaptation adds
  only the no-conditional-enrollment check: a value accepted solely through theme
  enrollment MUST already exist on the effective root component surface. Built-in
  finite values and values accepted by authoritative open primitive domains do not
  require a matching root style declaration. Rule validation MUST inherit future
  improvements to root validation automatically; tooling MUST NOT maintain an
  adaptation-specific allowlist, unresolved-domain exception, or domain-inference
  path.
- **IR4 — Built themes preserve extension semantics.** A built theme's JavaScript
  module retains the effective width-breakpoint map, normalized generative-axis
  metadata, enrolled local-token lineage, and ordered normalized rules required
  for source-equivalent `extends`; it does not embed emitted CSS text. A child
  inherits and resolves adaptations from a built base exactly as from a source
  base.

### Platform support

- Adaptations use viewport-width, primary-pointer, contrast-preference, and
  reduced-motion queries within Astryx's browser support floor; there is no
  JavaScript styling fallback.
- Real Chromium MUST verify conditions, nesting, AppShell agreement, and first
  paint. Real Safari MUST verify exclusive width edges and primary-pointer
  classification; Playwright WebKit alone is not Safari evidence.

## Current-state impact

Current `main` has no theme adaptation API. AppShell exports only
`sm | md | lg | none`, hardcodes the first three points, and treats equality as
mobile. This proposal adds `xl` and `2xl`, resolves all names through the active
theme, and makes the upper edge exclusive. The AppShell union expansion is
additive; the equality change is a breaking behavior change and requires the
corresponding release note when implemented.

This change affects `architecture:component-theming-surface`,
`architecture:public-component-api`, `architecture:theme-authoring-contract`,
`architecture:theme-compilation`, and `architecture:theme-application`. Their
current contracts remain authoritative until this draft is accepted and
implemented.

#5543's public tier API is superseded by DEC-1 and DEC-2 and MUST NOT land as-is.
Its value resolver, explicit-value preservation, CSS generation/build parity, and
extension metadata remain useful implementation evidence.

## Verification

| Contract | Verification                                                  | Representative states                                                                                                                                                                                                                               | Failure signal                                                                                                                                                                                                                 |
| -------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR1–FR3  | Width-breakpoint and condition matrix plus browser boundaries | defaults/overrides; from/below/range; pointer/contrast/motion; multi-field AND; empty/no rules                                                                                                                                                      | A point acts as an upper bound, intervals gap/overlap, fields OR together, or an unused map emits CSS.                                                                                                                         |
| FR4–FR7  | Shared resolver, surface, cascade-order, and extension tests  | enrolled/unenrolled local tokens; incomplete scales; finite built-ins; open primitive values absent from root; root-enrolled and rule-only custom values; unresolved shared results; later generated vs earlier explicit leaves; surface collisions | A rule enrolls a local or component value, a valid open value requires a dummy root rule, adaptation validation diverges from root validation, an unresolved result becomes a permanent exemption, or inherited order is lost. |
| FR8      | Theme/AppShell integration                                    | all names/`none`; SSR hint; no/nearest/root/nested theme; exact boundaries                                                                                                                                                                          | AppShell uses the wrong map, equality, or scope.                                                                                                                                                                               |
| IR1–IR3  | Runtime/build and CLI positive/negative fixtures              | invalid map/range/field; source axis metadata; duplicate `when`; root-restoring rules; finite/open/unresolved component domains; declaration targets; root-enrolled and rule-only custom values; non-CSS token reads                                | Invalid input writes output, root and adaptation component validation disagree, an independently valid value is forced into root, an adaptation conditionally enrolls a custom value, or tooling misses a rule-only value.     |
| IR4      | Generated-module import and size tests                        | no rules/rules; source/built parent and child; generative/local-token metadata                                                                                                                                                                      | Breakpoints, rules, order, or metadata disappear; extension diverges; CSS text enters the module; or resolved layers duplicate.                                                                                                |

## Decision log

The decisions below were approved by `cixzhang` as part of this current accepted
specification.

### DEC-1 — Use adaptations with width-specific breakpoints

**Reference:** `spec:AST-012/DEC-1`
**Decider:** `cixzhang`, `2026-09-02`

Use `adaptations`, not `responsive` or `responsiveness`, because the system covers
multiple environmental axes. Width points live under `widthBreakpoints`, not a
generic `breakpoints` field, leaving room for a future independent
`heightBreakpoints` namespace. Astryx owns `sm`, `md`, `lg`, `xl`, and `2xl`
with defaults 640, 768, 1024, 1280, and 1536. Width points mark tier starts and
emit no CSS without rules.

Rejected: responsive-only naming, generic breakpoint storage, custom width names,
and upper-bound named tiers.

### DEC-2 — Use named rules with nested width conditions

**Reference:** `spec:AST-012/DEC-2`
**Decider:** `cixzhang`, `2026-09-02`

Rules use `{when, value}` objects. `width: {from, below}` expresses inclusive
lower and exclusive upper edges. Environmental fields in `when` are peers and are
ANDed.

Rejected: positional tuples, flat `minWidth` / `maxWidth`, nested literal
`@media` keys, and arbitrary raw queries.

### DEC-3 — Use declared rule order as precedence

**Reference:** `spec:AST-012/DEC-3`
**Decider:** `cixzhang`, `2026-09-02`

Rules follow cascade order. Root values apply first, inherited rules keep their
order, and child rules append. In any matching environment, the last rule to write
a resolved leaf wins. Duplicate conditions are valid, and reordering overlapping
rules is intentionally behavioral.

Rejected: inferred precedence from condition shape or conflict rejection for
incomparable conditions.

### DEC-4 — Preserve adaptations through built extension

**Reference:** `spec:AST-012/DEC-4`
**Decider:** `cixzhang`, `2026-09-02`

Built and source themes preserve equivalent width-breakpoint and normalized-rule
data for `extends`, while emitted CSS remains a separate artifact.

Rejected: compiling away authoring intent from built bases and silently narrowing
the existing theme-extension contract.

### DEC-5 — Reuse root component validation; reserve root for enrollment

**Reference:** `spec:AST-012/DEC-5`
**Decider:** `cixzhang`, `2026-09-08`

Component writes in adaptation rules use the same target, axis, value-domain, and
extension validation as root `components`. A built-in finite value or a value
accepted by an authoritative open primitive domain may appear only in a rule; it
does not require a matching root style declaration. The effective-root requirement
applies only when a value's validity comes from theme enrollment. Unresolved shared
results carry into adaptations unchanged, and future improvements to root
validation tighten adaptations automatically.

Rejected: adaptation-specific value resolvers, allowlists, unresolved-domain
exceptions, and dummy root rules for independently valid values.

## Open questions

None.

---

### Specs AST 013 Spec (`docs/specs/AST-013/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-013
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-01
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture:
  [architecture:layer-runtime, architecture:public-component-api]
affects_families: [family:overlay-dismissal]
affects_contributing: [contributing:api-conventions]
affects_consumer_docs:
  [browser-support, useLayer, Popover, Toast, Dialog, useEntryAnimation]
---

# Browser and platform support system spec



```json
{
  "scope": "global",
  "triggers": {
    "platform": [
      "FR1",
      "FR3",
      "FR5",
      "FR6",
      "FR7",
      "FR8",
      "FR9",
      "FR10",
      "FR12",
      "FR13"
    ]
  }
}
```

## Intent

Give stable Astryx web components one owner for browser support, native-feature
adoption, and reduced fallback. Component specs inherit this shared baseline and
record only explicit exceptions.

Full and fallback paths must satisfy the component's current accessibility
contract and applicable [WCAG 2.2 Level AA](https://www.w3.org/TR/WCAG22/)
requirements. Assistive-technology evidence remains separately owned.

## Non-goals

- Requiring identical implementation or pixels when user outcomes are equivalent.
- Polyfilling every missing browser feature.
- Replacing the layer-specific contract in [AST-003](../AST-003/spec.md) or the
  public API admission rules in [AST-002](../AST-002/spec.md).
- Implementing generated rows, consumer-doc updates, CI, or release tooling in
  this specification pull request.

## Requirements

- **FR1 — One rolling compatibility model.** Current Web Platform Baseline is the
  full-support tier. Baseline minus two years is the reduced-fallback tier. Astryx
  makes no support promise below that tier.
- **FR2 — Compatibility rows are generated.** Browser/version rows are generated
  from the approved Baseline data and authoritative browser release data; they are
  never maintained as handwritten version claims. The generated rows live in the
  public [Browser Support guide](../../../packages/cli/assets/docs/browser-support.doc.mjs)
  and record their source and refresh date. TypeScript targets, build targets, and
  feature tables are not support evidence.
- **FR3 — Two explicit browser support rows are always included.** Independently
  of the generated Baseline coverage, Astryx explicitly supports:
  - the latest stable Chrome release on desktop; and
  - Safari on the latest stable iOS release.

  “Latest” means the current stable release identified and refreshed from
  authoritative browser release data, not a handwritten version. Other browsers
  may remain represented by the generated Baseline compatibility rows; this
  decision does not name desktop Safari or Firefox as additional explicit support
  rows.

- **FR4 — Compatibility, explicit support, and test coverage stay distinct.** The
  Browser Support guide labels generated Baseline compatibility, named explicit
  support, and current test coverage separately. A browser's inclusion in
  generated compatibility or explicit support does not imply an automated lane
  or per-pull-request proof in that browser.
- **FR5 — Supported browsers receive full required behavior.** Every browser in
  the full-support tier and each named explicit support row receives the
  component's current behavior, accessibility, state, renderer, and interaction
  contracts. A missing native feature does not silently narrow those requirements.
- **FR6 — Native features are capability-detected.** Components detect the API or
  CSS capability they use rather than inferring it from a browser name. A narrow
  engine workaround is allowed only for a reproduced defect with a regression
  fixture and a review or removal condition.
- **FR7 — Reduced fallback preserves the task.** In the Baseline-minus-two tier, a
  component either provides equivalent behavior or documents a reduced fallback.
  The fallback may omit only named enhancements and must preserve the primary
  task, applicable accessibility requirements, stable state and callbacks,
  cleanup, and repeated use.
- **FR8 — Reduced fallback must not be broken.** It must not throw, render invalid
  or `NaN` geometry, hide essential content, make controls unreachable, orphan or
  trap focus, block the surrounding page, corrupt controlled state, remain stuck
  pending, or flash a false open, closed, loading, positioned, or styled state.
- **FR9 — SSR and hydration are consistent.** A Core component is SSR-capable
  unless its component spec records an FR12 exception. A client directive does
  not exempt a component, because frameworks render client components on the
  server. SSR-capable components do not read browser capability, geometry, input
  modality, paint timing, or user-agent state during server render or during the
  client render that hydrates server markup. The hydrating render produces the
  same markup as the server. Post-hydration enhancement begins from a usable
  state and introduces no mismatch, broken intermediate state, or visible false
  state.
- **FR10 — Browser-owned behavior needs real-browser evidence.** Source, unit, and
  jsdom tests may prove guards and deterministic state. Changes to layout, paint,
  focus, native Popover or Dialog behavior, top-layer order, browser event timing,
  or hydration presentation require real-browser evidence. When a change makes a
  claim about a named browser, fixes a defect reproduced only there, or changes
  browser compatibility, that evidence must come from the actual browsers covered
  by the claim. Chromium does not substitute for an iOS Safari claim, and
  Playwright WebKit does not prove Safari behavior.

  Explicit support is a maintenance promise, not a per-change proof obligation.
  Routine changes use evidence appropriate to their claims and do not need to
  prove every supported browser before landing. A defect reproduced in an
  explicitly supported browser remains Astryx's responsibility to investigate
  and fix; missing automation does not narrow support or justify dismissal.

- **FR11 — Raising the floor is compatibility work.** Removing a supported browser,
  moving it from full behavior to reduced fallback, or removing a documented
  fallback requires owner approval, the classification and lifecycle required by
  [`spec:AST-017`](../AST-017/spec.md), user-impact and migration notes, updated
  generated rows, actual-browser evidence for the browsers affected by the
  compatibility claim, and evidence that every remaining full-support and named
  explicit support row still receives full required behavior.
- **FR12 — Component specs inherit the baseline.** Component specs link
  `spec:AST-013` and record only stricter requirements or explicit exceptions. An
  exception names the affected capability, observable reduction, preserved
  FR7–FR9 obligations, evidence, owner, and review condition.
- **FR13 — Server-rendered elements skip their mount animation.** An element present in
  server markup hydrates in its settled state and does not play the animation it
  plays when it mounts (its entry animation). That mount animation plays only for
  an element the client mounts after hydration, or in a client-only root after
  the first paint. Later open, show, and state transitions of a hydrated element
  are outside this requirement, and reduced-motion behavior is unchanged.

### Platform support

- Full support: current Web Platform Baseline, plus the named explicit support
  rows in FR3.
- Reduced fallback: Baseline minus two years, satisfying FR7–FR9.
- Unsupported: below Baseline minus two years; Astryx makes no support promise.
- Browser evidence: browser-owned behavior requires real-browser evidence. The
  actual named browser is required for claims specific to it, not as a blanket
  gate on every change.

## Current-state impact

The public guide currently has handwritten Baseline tiers and version rows. The
required three-way labeling of generated compatibility, explicit support, and
current test coverage remains implementation work. Current automated browser
coverage primarily exercises Chromium; that coverage neither defines nor narrows
support. Routine changes may land with evidence appropriate to their claims.
Defects reproduced in an explicitly supported browser remain actionable whether
or not an automated lane exists.

`useEntryAnimation`, and FieldStatus through it, decides whether to animate from a
first-paint flag captured on its first render. When markup hydrates after the
page paints, the hydrating render produces entry-animation classes the server did
not send, and the hook's consumer documentation says it does not support server
rendering.

Other known FR9 and FR13 gaps: a Dialog rendered open in server markup plays its
entry animation on page load; CodeBlock's collapse chevron plays its reveal
animation on page load; and CodeBlock chooses between token-span and Highlight
API markup from browser capability and user-agent checks during render, so the
hydrating render can differ from the server's markup.

## Verification

| Contract  | Required evidence                                                                                                                                                                                                                                            | Failure signal                                                                                                                                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR5   | Generated-row check plus guide/package/support/test reconciliation                                                                                                                                                                                           | A handwritten/build version becomes a support claim, explicit support rows disappear, or supported behavior is reduced                                         |
| FR6–FR8   | Feature-present/absent tests and active fallback fixtures                                                                                                                                                                                                    | Browser sniffing selects ordinary behavior, or fallback is unusable, inaccessible, unstable, or visibly broken                                                 |
| FR9, FR13 | Server render then hydrate: no hydration warning or recoverable error, hydrated attributes equal the server markup, a client mount after hydration still plays its mount animation, plus a real-browser server render, hydration, and post-hydration fixture | Markup diverges, browser state is read on the server or while hydrating, a server-rendered element plays its mount animation, or a false/broken state flashes  |
| FR10      | Real-browser evidence for browser-owned behavior; the actual named browser for specific claims, browser-only defects, and compatibility changes                                                                                                              | Browser-owned behavior is credited to jsdom, another engine stands in for a named-browser claim, or a supported-browser defect is dismissed for lack of a lane |
| FR11–FR12 | Release review and knowledge-record checks                                                                                                                                                                                                                   | A floor/fallback disappears without compatibility treatment, or a component silently overrides the baseline                                                    |

## Decision log

### DEC-1 — Rolling Baseline tiers plus two official browser rows

**Reference:** `spec:AST-013/DEC-1`
**Decider:** `cixzhang`, `2026-09-01`

Astryx uses current Web Platform Baseline for full support, Baseline minus two
years for reduced fallback, and no promise below that tier. Latest stable Chrome
on desktop and Safari on latest stable iOS are always included as official support
and test rows independently of generated Baseline coverage. Generated compatibility
rows and the named official test matrix remain distinct public facts.

### DEC-2 — Explicit support does not require proof on every change

**Reference:** `spec:AST-013/DEC-2`
**Decider:** `cixzhang`, `2026-09-02`

DEC-1's two named browser rows are explicit support rows, not a required automated
or per-change test matrix. Astryx investigates and fixes defects reproduced in
those browsers, but routine changes do not need to prove every supported browser
before landing. Actual-browser evidence is required when the change makes a
browser-specific claim, fixes a defect reproduced only there, or changes
compatibility. Missing automation neither narrows support nor justifies dismissing
a reproduced defect.

### DEC-3 — Core hydrates to the server markup; server-rendered elements skip their mount animation

**Reference:** `spec:AST-013/DEC-3`
**Decider:** `cixzhang`, `2026-10-15`

Core components render the same markup on the server and in the hydrating client
render, including client components, and a server-rendered element stays settled
rather than playing its mount animation. Rejected: consumer-doc "does not support
SSR" exceptions, because FR12 records exceptions only in component specs.
Rejected: a "has painted" snapshot that turns true after hydration, because it
animates elements present on page load.

## Open questions

None.

---

### Specs AST 017 Spec (`docs/specs/AST-017/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-017
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-07
phase: accepted
owners: [cixzhang, josephfarina]
affects_architecture:
  [
    architecture:public-component-api,
    architecture:cli-surface,
    architecture:theme-tokens,
  ]
affects_families: []
affects_contributing:
  [
    contributing:release-process,
    contributing:templates,
    contributing:cli-conventions,
  ]
affects_consumer_docs: [release-process, templates]
---

# Release Compatibility & Versioning system spec



```json
{
  "scope": "global",
  "triggers": {
    "compatibility": [
      "DEC-1",
      "DEC-5",
      "DEC-6",
      "DEC-7",
      "DEC-8",
      "DEC-9",
      "DEC-10",
      "DEC-11",
      "DEC-12",
      "DEC-13",
      "DEC-14",
      "DEC-17",
      "DEC-18",
      "DEC-19",
      "DEC-20",
      "DEC-21",
      "FR3",
      "FR4",
      "FR5",
      "FR12",
      "FR14",
      "FR15",
      "FR16",
      "FR17",
      "FR18",
      "FR19",
      "FR20",
      "FR21",
      "FR22",
      "FR23",
      "FR24",
      "FR25",
      "FR26",
      "FR27",
      "FR28",
      "FR29",
      "FR29a",
      "FR29b",
      "FR29c",
      "FR29d",
      "FR30",
      "FR31",
      "FR32",
      "FR33",
      "FR34",
      "FR35",
      "FR36",
      "FR37",
      "FR38",
      "FR39",
      "FR40",
      "FR41",
      "FR42",
      "FR43",
      "FR44",
      "FR45",
      "FR46",
      "FR47",
      "FR48",
      "FR49",
      "FR50",
      "FR51",
      "FR52",
      "FR53",
      "FR54",
      "DEC-15",
      "DEC-16",
      "DEC-22"
    ]
  }
}
```

## Intent

Give contributors, reviewers, and release owners one durable contract for evolving
published Astryx surfaces. Classify every change from the installed consumer it
affects, isolate experimentation from stable behavior, stage deprecations and
behavior corrections explicitly, and make a planned minor traceable to one locked
final-patch baseline.

A valid consumer of the latest stable release is the compatibility test. The label is
not a risk or severity score: a small rename can be incompatible, while a large
rewrite can remain compatible when released usage is preserved. A real bug can also
be incompatible when consumers validly rely on its released behavior; that case is a
`[breaking]` change that names the authority the released behavior violated and lands
in a declared minor (FR31–FR34).

Components take their compatibility rules from this spec. Every other published
surface, such as the CLI, takes its compatibility contract from its own current record
(FR54). Every surface goes through the one release process this spec defines.

For CLI controls, keep every supported behavior discoverable and put each control on
the narrowest surface that owns it. Global controls require evidence that their value
and meaning are global. Cross-cutting guarantees must be complete, observable, and
independent for each invocation.

Admission is part of that contract. Main declares the next planned version, and a
release branch releases exactly that version. Incompatible work ships only in a
release whose declared version is a minor. A release does not change shape because
one removal merged ahead of the decision to allow it.

## Non-goals

- Freezing implementation details or every byte of generated example code.
- Defining component or template naming conventions.
- Treating private packages, canaries, unreleased work, or explicitly marked
  experimental surfaces as stable public API.
- Replacing the public Release Process, Changesets, migration tooling, or direct
  component and feature authorities. This spec owns the lifecycle and evidence those
  mechanisms implement; it does not duplicate their operational or product rules.

## Requirements

- **FR1 — Breaking changes require caller adaptation.** A change is breaking only
  when a valid consumer scenario supported by the latest stable release of a
  published package must change source or configuration to keep its code,
  interaction, data, semantic output, or supported workflow working, or loses a
  stable public surface. The review names that scenario and the released surface
  it uses. For a surface other than a component, FR54 decides which scenarios are
  valid and supported.
- **FR2 — Classify against the latest stable release.** A surface added and then
  renamed, removed, or reworked before any stable release has no installed
  consumer and is not breaking. Private or ignored packages are outside the
  published compatibility promise. Promotion from a private package into a
  published package is additive from the published side.
- **FR3 — Released contracts include function and semantics, not exact appearance.**
  Compatibility covers public exports and import paths; requiredness, accepted values,
  defaults, return types, callbacks, and error behavior; documented interaction and
  accessibility behavior; stable CLI command names, options, exit behavior, and
  machine-readable schemas; and other explicitly documented public extension points.
  Internal implementation, undocumented markup, source layout, and exact appearance are
  not stable merely because a consumer can observe them. A change only to existing
  content's appearance, spacing, typography, layout styling, or visual default is
  nonbreaking when public API, accepted inputs, interaction, accessibility semantics,
  data, and machine-readable output keep working. It uses `[fix]` when it restores
  current visual authority and `[feat]` when it intentionally changes visual direction.
  Its Changeset names the old and new appearance and how to keep the old look when a
  supported escape exists. A default that changes a functional or semantic contract is
  judged under FR1 instead. Its Changeset names the old and new default and the prop or
  option that restores the old default, which is the migration. A change to documented
  interaction or accessibility behavior made for accessibility conformance is
  classified under FR53.
- **FR4 — Preserved old usage is nonbreaking.** A rename or replacement is
  nonbreaking when the old released call, import, option, identifier, or input
  continues to work with equivalent meaning through a compatibility alias or
  adapter. A deprecation may warn and point to the replacement, but removing that
  compatibility path is the breaking event.
- **FR5 — Additive and victim-free contract restorations are nonbreaking.** New
  optional capabilities, new components, new commands, broader accepted inputs, and
  new templates are nonbreaking when old usage is unchanged. A fix that restores an
  already-documented contract is nonbreaking only when no valid consumer of the
  released faulty behavior must change. When a supported consumer relies on that
  behavior and the correction breaks the consumer, FR32–FR35 classify it as an
  incompatible fix even though the implementation violated authority. A proposal
  that intentionally replaces the documented contract is not a fix, except an
  accessibility behavior fix under FR53.
- **FR6 — Risk does not override compatibility.** Low adoption, low likelihood,
  engineering approval, or confidence that affected callers are uncommon does not
  make an incompatible released contract nonbreaking. Conversely, implementation
  size, visual scope, or review difficulty does not make a compatible change
  breaking.
- **FR7 — Changeset tags and lifecycle metadata are orthogonal.** Existing primary
  tags remain the authoring vocabulary: corrections use `[fix]`, compatible
  capabilities and replacement-first staging use `[feat]` or the established narrower
  tag, approved removals use `[breaking]`, and confined experiments use
  `[experimental]`. The tag and the Changeset body are the whole declaration: a
  victim-free correction is `[fix]` with a patch bump; a replacement-first
  deprecation is `[feat]` that names the replacement and the migration and stays
  patch-compatible while old usage works; a removal or a behavior-correcting
  incompatibility is `[breaking]` and states the old valid usage, the replacement or
  corrected behavior, and the migration (FR8). Lifecycle ids (`DEP-*`, `IFIX-*`,
  `CLN-*`) and classification metadata are optional descriptive cross-references,
  never a requirement to merge. While Astryx packages remain on `0.x`, the
  incompatible tier is minor; compatible work is patch. A Changeset's bump states the
  tier its change requires; it never sets the release version, which is the version
  main declares under FR46.
  Documentation-only, test-only, and private-package-only changes need no Changeset.
  Translation-catalog updates and newly added languages need no Changeset, nor
  English-catalog description or formatting edits that preserve every message key
  and default message. This exemption never covers removal of a released locale
  import path, which follows FR3–FR4 and FR31.
  Existing Changesets keep their current tag and validated bump semantics and MUST NOT
  be mass-migrated. `pnpm changeset:new` remains the authoring path, and
  `pnpm check:changesets` validates tag, bump, contributors, and the declared tier;
  the migration text is reviewed under FR8. Classify each published package update in
  multi-package work. A fixed-group version-only co-bump needs no Changeset entry by
  itself, but a generated dependency or peer-range edit is part of that package's
  update and must be classified and named. When primary tags differ, use separate
  Changesets so each package retains its own author-facing category.
- **FR8 — Migration evidence matches the affected surface.** Every planned removal
  and incompatible fix names the old valid usage, the replacement or corrected
  behavior, and how consumers migrate. Supply an `astryx upgrade` codemod when
  consumer source, configuration, metadata, or project files can be rewritten
  mechanically. When no rewrite is possible, preserve an explicit compatibility
  adapter or provide concrete replacement instructions and record why a codemod would
  be vacuous. Codemods follow `spec:AST-040` and include positive, negative, and
  second-run idempotence fixtures plus downstream proof.
- **FR9 — Template slugs are mutable catalog data.** A template slug identifies an
  entry in the current template catalog; it is data about that template, not a
  contractual CLI API. Renaming a slug is nonbreaking even when
  `astryx template <old-slug>` no longer resolves. The Changeset and release note
  describe the catalog rename so builders can find the new value, but the rename
  does not use the `[breaking]` category or require an alias or codemod.
- **FR10 — Template content and metadata are expected to evolve.** Adding a
  template, rebuilding its starter source, or changing its slug, human-facing
  name, description, category, keywords, or example data is nonbreaking while the
  CLI's stable command and machine-readable schema contracts remain compatible.
  Generated starter code is not promised byte-for-byte stability across releases,
  and existing projects are not rewritten when a template improves.
- **FR11 — The CLI contract surrounds the catalog.** The `template` command name,
  supported options, exit behavior, and machine-readable response schema are
  contractual surfaces under FR3. Individual catalog values returned through that
  schema, including template slugs, names, descriptions, categories, keywords, and
  starter content, are not independently stable API identifiers.
- **FR12 — Supported inter-package ranges are contracts.** For an update to a
  published package, its latest stable release's documented installation scenarios
  and declared dependency or peer ranges define the supported companion package
  versions. The update is breaking for that package when upgrading it alone makes
  any such combination stop working unchanged, including by narrowing or raising
  the range. A coordinated upgrade that works does not change that classification.
  A dependency bump is not breaking when every previously supported combination
  keeps working. Keep every previously supported version in range and provide an
  adapter when needed; otherwise classify the narrower or higher-floor range as
  breaking, describe the range change in its Changeset release note, and meet FR8's
  migration obligation.
- **FR13 — Stable machine schemas project every field.** A stable CLI response
  contract includes fields inside discriminated `data` entries as well as the
  outer envelope. Every field is present in the canonical response type, contract
  tests, text projection where the command provides one, and complete
  consumer-facing schema documentation. Adding an optional field is nonbreaking
  when old consumers continue unchanged, but it remains a public schema update and
  does not bypass current-authority review or documentation.
- **FR14 — No supported CLI behavior is hidden.** Any Astryx CLI behavior
  available in a supported flow—including behavior selected automatically and
  behavior selected by an Astryx-owned control—is a public surface and MUST be
  documented and discoverable. Every caller-settable control that changes command
  selection, work, output, side effects, or exit behavior is part of that surface,
  whether spelled as a command, option, positional argument, package field, or
  configuration key. The CLI MUST NOT define or read an Astryx-owned environment
  variable for any purpose. A variable is Astryx-owned when Astryx defines its
  semantics, regardless of its name or prefix; standard platform variables remain
  outside this prohibition unless Astryx assigns them an Astryx-specific meaning.
  Automation MUST use the documented programmatic API.
  Public controls MUST define their accepted values, default, precedence and
  interactions, error behavior, and a representative invocation. Commands and
  options MUST appear in generated help and the machine-readable manifest.
  Automatic behavior and other controls MUST either appear there or be linked from
  those discovery surfaces to consumer documentation. A
  normal invocation mode MUST have a documented command, option, or configuration
  surface. Private test hooks and maintainer rollout gates MAY affect tests,
  diagnostics, or staged availability without becoming public controls, but they MUST
  NOT use environment variables, require action from supported consumers, or serve as
  the sole interface to a supported mode. The user-facing behavior they gate remains
  subject to this requirement once supported.
- **FR15 — Global controls require CLI-wide value and semantics.** An option is
  global when the root parser accepts it across commands or the manifest lists it in
  `globalOptions`. A new behavior-changing option MAY be global only when it
  represents one coherent CLI-wide invariant, solves a demonstrated
  supported-consumer need that a narrower surface cannot solve, and has defined,
  meaningful, non-no-op behavior for every executable command and subcommand. The proposal MUST enumerate those surfaces
  from generated help and the machine-readable manifest and state the control's
  behavior, output or error effect, and verification for each one. A command on
  which the control has no meaningful effect is evidence that the proposed scope is too broad. A behavior that applies to only part of the
  CLI MUST be placed on the nearest command, command group, or programmatic API that
  owns it. The same name MUST keep the same meaning throughout that scope, and names
  MUST describe the exact controlled capability rather than make broad claims such as
  `safe`, `secure`, `trusted`, or `isolated`. A safety or trust control MUST also
  define its trust boundary and fail closed before untrusted input can execute or
  cause a side effect.
- **FR16 — Cross-cutting guarantees define and close their boundary.** A claim that
  prevents a class of execution, data access, or side effect MUST define the trusted
  inputs, denied actions, and the points where protection begins and ends. It MUST
  cover every current entry point and fail closed for an unknown or newly added
  extension point.
- **FR17 — Suppressed work is observable.** When a safety or reduced-capability
  control intentionally omits a normally available source, capability, or unit of
  work, every successful machine-readable result MUST identify the active mode and
  what category was omitted. Human-readable output MUST project the same fact. A
  partial or empty result MUST NOT look like an ordinary complete success. If the
  operation cannot produce a useful conforming result, it MUST return a stable
  structured error and, for the CLI, a nonzero exit code before any write or external
  side effect.
- **FR18 — Programmatic controls are explicit and invocation-scoped.** A
  programmatic control MUST be an explicit API input and MUST NOT depend on ambient
  mutable process state. Concurrent calls MUST be able to select different values
  independently. When the CLI and programmatic API expose the same control, they MUST
  share accepted values, defaults, precedence, observable behavior, and error
  semantics.
- **FR19 — Configuration is admitted only on evidence.** A new configuration key,
  or a new accepted value that changes CLI behavior, MUST be justified by a
  reproducible supported-consumer case in which automatic detection and
  established project conventions produce the wrong result. The proposal MUST
  record that case and the convention it tried first. When an established
  convention or automatic detection can express the need, the CLI MUST use it
  instead of adding configuration. Configuration MUST NOT duplicate a value the
  CLI can derive.
- **FR20 — Integration contributions compose predictably and visibly.** A
  configuration setting MAY accept contributions from integrations only when its
  entry in the public configuration contract (the exported `AstryxConfig` type
  and the configuration schema reference consumers read through the CLI) states:
  that integrations may contribute; the exact form of a contribution; how the app
  value and all contributions combine, and in what order; the one documented
  control through which an app refuses inherited contributions; and what happens
  when a contribution fails. The setting owns that rule; an integration cannot
  change it. An integration declares each contribution statically in its
  integration module, either as a field of the exported `AstryxIntegration`
  manifest type or as a documented named export, and the integration authoring
  reference documents that form. Importing an integration module MUST NOT
  contribute anything as a side effect. The CLI MUST let a caller inspect the
  effective value of each such setting and the source of each part (the app or a
  named integration) through a documented command or programmatic API. Unless a
  setting's contract states and justifies otherwise: app and integration
  contributions combine rather than replace one another; the app's own value
  applies first, then integrations in their resolved order; a failing
  contribution is skipped without removing the others or changing the command's
  result; the app refuses all inherited contributions through one documented
  control; and loading the project more than once in one invocation applies each
  contribution once. No integration contribution can remove or weaken a value
  supplied by the app or by another integration. When a setting protects the
  project, a failed contribution MUST fail closed instead of being skipped.
- **FR21 — Experimental APIs are explicit and opt-in before publication.** A
  public surface inside a stable published package is outside the stable
  compatibility promise only when its first stable release exposes it through a
  canonical experimental boundary and marks it in both declarations and consumer
  documentation. On an existing stable component, experimental props and callbacks
  live inside one optional `experimental` prop object; omitting that object preserves
  the component's stable defaults and behavior. Experimental hooks, functions, and
  types use the owning component's `/experimental` import subpath and are not
  re-exported from the package root or the component's ordinary subpath. Declarations
  carry `@experimental`; authored docs carry machine-readable
  `stability: 'experimental'` metadata and state that the surface may change or be
  removed in a patch release. Prose-only warnings and reviewer knowledge do not
  establish this boundary.
- **FR22 — Experimental changes are patch-level and stay confined.** Adding,
  changing, renaming, or removing an explicitly experimental surface uses an
  `[experimental]` Changeset and a patch bump. An incompatibility confined to that
  boundary is `[experimental]`, not `[breaking]`; the Changeset authoring and
  validation process MUST derive and enforce a patch bump for the category even when
  the experimental surface changes incompatibly. Its release note names the affected
  surface and replacement when one exists. A compatibility alias is optional; a
  codemod is supplied when a mechanical migration would materially reduce caller
  work. This exception never covers a change to stable defaults, stable runtime or
  accessibility behavior, ordinary import paths, non-experimental props, or stable
  CLI commands and machine schemas; those changes follow FR1–FR8. A released stable
  surface cannot be retroactively demoted to experimental.
- **FR23 — Promotion creates a stable contract deliberately.** Promotion is an
  owner-approved change that removes the experimental marker, exposes the settled
  prop, callback, hook, function, or type on its normal stable surface, and uses a
  `[feat]` Changeset. From that release forward, FR1–FR8 protect the promoted surface.
  Moving a component API out of the `experimental` prop object or moving an export
  out of a component's `/experimental` subpath names the stable replacement and
  supplies a codemod when the rewrite is mechanical. Keeping a deprecated
  experimental alias for a transition is encouraged but is not itself part of the
  new stable promise.
- **FR24 — Experimental status is verified per public surface.** Release review
  compares the previous published surface with current declarations and authored
  docs, verifies that their experimental markers agree, and rejects an
  `[experimental]` Changeset when any incompatible delta reaches a stable surface.
  Integration-theme contribution capability remains the one grandfathered surface;
  no other released surface may be enrolled retroactively. Its contribution
  metadata, discovery, and authoring contract may evolve in patch releases until an
  explicit promotion removes the marker. This prohibition does not turn an
  unpublished field or behavior into stable API merely because an adjacent feature
  was released. Integration-template replacement-specific fields and behavior—its
  `replaces` declaration, replacement selection, optional list projection, and
  expanded conflict fields—remain pre-publication surfaces under FR2 until their first
  stable release; that classification is not experimental enrollment. Previously
  released same-id conflict warning behavior remains stable. The surrounding stable
  CLI command names, options, exit behavior, and machine-readable envelope remain
  protected under FR3 and FR13.
- **FR25 — Stable Core and every published stable package default to compatibility.**
  Outside a declared minor, changes to behavior, types, exports, CSS or tokens,
  documented markup or selectors, accessibility semantics, persistence, CLI,
  configuration, schemas, build tooling, contractual documentation operations, and
  package files MUST be additive, implementation-equivalent, a nonbreaking default
  change under FR3, a victim-free contract restoration under FR5, or an accessibility
  behavior fix under FR53; a breaking change lands only in a declared minor (FR31). Documentation catalog names and routes
  follow
  FR45. An internal label, low adoption, or lack of a source-level API change does
  not weaken this rule.
- **FR26 — Public lifecycle state is explicit.** Each public surface is
  `experimental`, `stable`, `deprecated`, or `removed`; private and unreleased surfaces
  remain outside the published lifecycle. Stable cannot become experimental. Deprecated
  behavior remains supported until a `[breaking]` removal merges for a declared minor
  (FR31).
- **FR27 — Experimental isolation covers every surface kind.** Whole experimental
  components remain in the private canary-only Lab package governed by the public
  Component Lifecycle. FR21 continues to govern stable-component props and exports.
  Experimental CLI controls use a dedicated experimental namespace; configuration
  uses an experimental object; CSS and tokens use dedicated experimental exports and
  names; persistence uses a feature- and schema-versioned experimental namespace;
  build tooling uses experimental exports/configuration; and docs use marked
  experimental routes. An omitted opt-in MUST leave stable defaults, output, routes,
  data, and behavior unchanged. Promotion requires owner approval and a `[feat]`
  Changeset; withdrawal uses `[experimental]` and MUST prove no stable spillover.
- **FR28 — A planned minor needs no prior deprecation; a patch does.** A change
  landing in a declared minor needs no deprecation path: its `[breaking]` Changeset
  describing the change, with the replacement and migration where they help the
  reader, is enough. A deprecation path is required only when the next planned release
  is a patch, because a compatible release can only prepare a break: the patch ships
  the working replacement while old usage remains equivalent, with an exact migration
  command or codemod when the rewrite is mechanical or ordered, testable instructions
  when it is not, and the removal lands in a later minor. A public record MAY describe
  a deprecation with a stable `DEP-*` id and its replacement, migration, and state; the
  record describes and never gates.
- **FR29 — Deprecation adds no production-runtime behavior.** Marking a surface
  deprecated MUST NOT add a production runtime branch, log, byte, network action,
  changed render, or changed output. Compatibility code needed to keep an already
  released old contract working is separate from warning delivery and MUST preserve
  that contract. A development-only runtime diagnostic is permitted only when runtime
  information is necessary to explain an ambiguous coexistence state, such as both an
  old and replacement prop being supplied; it explains the direct owner's precedence
  rule and is not the primary deprecation channel.
- **FR29a — Warnings live at the earliest accurate authoring surface.** TypeScript
  declarations use static `@deprecated` metadata. Build-interpreted source and
  configuration warn in the authoring, validation, or build reader that can name the
  exact source use. CLI commands and options expose the replacement
  in their machine result and emit at most one human-readable stderr warning per
  invocation. CSS and tokens use static diagnostics. Rendered applications never emit
  a warning merely because a deprecated surface is used. Warnings MUST NOT change a
  successful exit status, canonical output, machine-readable stdout, generated output,
  or rendered behavior.
- **FR29b — Guidance teaches one modern path.** Every warning names the exact
  replacement, stable versioned instructions, and exact migration command when one
  exists. Maintained API docs, generated docs, templates, examples, stories,
  starter output, and agent-facing guidance teach only the replacement. Explicit
  reference remains available for the deprecated surface, visibly labeled with the
  same replacement and modernization route. A warning MUST NOT ship before those
  instructions and that route exist.
- **FR29c — Modernization is safe and verifiable.** A mechanical migration is
  previewable, idempotent, evidence-scoped, and complete under `spec:AST-040`; it
  preserves the old observable result as its baseline and reports every uncertain or
  blocked case. Non-mechanical migration gives short, ordered, testable instructions
  rather than saying only to update manually. The direct owner names the applicable
  source, type, build, runtime, visual, accessibility, and CLI proof. Maintained Astryx
  packages and authoring surfaces migrate in the deprecation patch.
- **FR29d — Each public surface projects the lifecycle without inventing another one.**
  Whole components remain exported, renderable, accessible, themeable, and explicitly
  discoverable, but leave recommendations and copyable examples; their exports and
  docs carry the replacement and modernization route, with no render-time deprecation
  warning. Deprecated props and values keep old-only behavior, declare deterministic
  old-plus-new precedence, and statically name the replacement; codemods rewrite proven
  static uses and report spreads or dynamic expressions they cannot prove. Theme API
  fields, helpers, tokens, targets, and source keep identical normalization and rendered
  output; declarations use `@deprecated`, and theme build or validation reports exact
  source uses without `defineTheme`, theme mounting, or applications warning at
  runtime. Deprecated CLI commands and options remain accepted with their released
  exit status, canonical stdout, JSON envelope, and side effects; help and explicit
  lookup label them, human mode warns once on stderr, machine mode carries the
  deprecation metadata, and generated examples use the replacement. When old and new
  CLI controls coexist, their direct command contract defines precedence or rejects
  the ambiguous combination identically in text and JSON modes.
- **FR30 — Deprecation minima come only from direct authority.** This global spec
  imposes no elapsed-time or release-count minimum. The structural minimum is that the
  final patch preserves old and new behavior together. A direct current spec MAY
  require a duration or number of stable releases for its own surface and names it
  there. Release cadence MUST NOT be
  interpreted as a deprecation clock or eligibility gate.
- **FR31 — No breaking change ships without a planned minor; the gate is the declared
  version.** When `main` declares a minor (`x.y.0`), a `[breaking]` Changeset is
  admitted and the change merges on ordinary review and CI. When `main` declares a
  patch (`x.y.z`, `z > 0`), any `[breaking]` Changeset fails pull-request CI; what may
  merge is the deprecation under FR28. `pnpm check:changesets` enforces this on every
  pull request from the fixed group's declared version. Each incompatible delta maps to
  exactly one `[breaking]` Changeset, which is the one place a reader finds the
  description; maintained sources teach the replacement once the removal lands.
  Lifecycle records and the release manifest describe removals; neither gates a merge.
- **FR32 — A correction with a released victim is a breaking change.** When
  pre-existing current authority proves released behavior wrong, unsafe, or
  nonconforming AND a valid latest-stable consumer relies on that behavior such that
  the correction breaks code, interaction, persisted data, output, or a supported
  workflow, the correction is `[breaking]` and follows FR31 for the declared tier,
  stating the corrected contract and the migration. A visible correction with no valid
  released victim remains `[fix]` under FR5. An accessibility behavior fix is not a
  breaking correction: FR53 classifies it. For a surface other than a component, the
  surface's compatibility contract decides whether a caller is a released victim
  (FR54).
- **FR33 — A correction's authority and victim are named.** The `[breaking]`
  Changeset or its pull request names the pre-existing authority the released behavior
  violated, a latest-stable reproducer, and the migration. Without pre-existing
  authority, the proposal is a new contract decision for the direct owner, not a fix.
  For an accessibility behavior fix, the authority is the conformance target FR53
  names, and the owner records any behavior decision the fix needs in the component's
  record.
- **FR34 — A correction follows the same tier rule.** In a declared minor a breaking
  correction merges on its `[breaking]` Changeset. When `main` declares a patch, the
  patch ships the corrected opt-in or adapter beside the old behavior (FR28) and the
  later minor removes the old behavior. A breaking correction cannot enter a release
  as an incidental compatible fix.
- **FR35 — Emergency correction is narrow and never relabeled as a patch.** Only
  urgent security, privacy, severe accessibility, or comparable critical ongoing
  harm MAY bypass replacement-first coexistence or ordinary scheduling. Waiting must
  materially extend the harm; no additive mitigation, containment, adapter, or safe
  rollout may be available; the contract owner and relevant incident owner must
  approve a dedicated record; public-safe incident evidence and restricted
  attestation must justify urgency; exact-main quality and migration gates still run;
  and the release contains no unrelated feature, cleanup, or incompatible fix.
  Migration ships with the release; an incomplete emergency codemod records an owner,
  bounded follow-up, fixtures, and delivery target. Rollback never restores known
  harmful behavior: use containment or safe-forward correction.
- **FR36 — Minor frequency is planning guidance, not eligibility.** Plan minors
  generally no more often than once every fourteen calendar days. The plan records
  the interval from the prior minor for owner review. CI reports the interval but
  MUST NOT derive deprecation eligibility, a final-patch-to-minor wait, or an
  exception requirement from it.
- **FR37 — Every minor has an owner-named target and a manifest generated at the
  cut.** The owner names the target version on `main` (FR46). The minor's breaking set
  is the set of `[breaking]` Changesets merged for it. At the cut the release generates
  its manifest from the consumed Changesets as authored (FR41) — the breaking ones are
  the minor's migration inventory, the rest its compatible fixes — and freezes it with
  the cut for release notes and audit. The manifest describes; it never gates a merge. The release fails closed on any incompatible delta between the final patch and
  the minor that no merged `[breaking]` Changeset describes (FR39, FR43). Operational
  runbooks may add execution detail but cannot authorize or widen the breaking set.
- **FR38 — The final patch is the paired minor's locked baseline.** Publish the final
  patch with deprecated behavior still working, record its
  exact source and package/docsite/visual/contract receipts, then lock that baseline.
  The paired minor MAY follow sequentially and immediately when ready and explicitly
  authorized; no wait exists unless a direct authority requires one.
- **FR39 — Every final-patch-to-minor delta has exactly one class.** The diff MUST
  partition into: (a) removals or corrections each described by one merged
  `[breaking]` Changeset; (b) a closed
  list of version, changelog, lockfile-integrity, tag, provenance, digest,
  build-timestamp, and source-map metadata; or (c) independently patch-compatible
  fixes under FR40. Strict equivalence is the target. Unrelated features, additive
  product capabilities, incompatible fixes outside approved corrective cleanup,
  refactors with public deltas, and unclassified changes are prohibited.
- **FR40 — Compatible fixes may be disclosed separately in the minor.** Each has its
  own `[fix]` Changeset, current authority or standard, latest-stable regression test,
  representative unchanged paths, and downstream proof, and is listed apart from the
  breaking set in the release description. If the fix lands before final-patch lock,
  include it in both
  releases. If it lands after publication or lock, it MAY enter the minor as a
  disclosed compatible delta; do not recut the final patch solely for bookkeeping.
  CI MUST independently prove patch eligibility. Release notes separate cleanup from
  compatible fixes.
- **FR41 — Release artifacts use closed schemas and invent no fields.** The cut-time
  manifest (FR37) freezes each consumed Changeset exactly as authored — path, content
  digest, packages, category, headline, contributors, and body — and derives breaking
  versus compatible from the category; editorial text is generated from the body, never
  from parallel fields a human must backfill. It also carries the release-metadata
  deltas and the baseline receipts. The equivalence receipt carries final and candidate
  digests plus `unclassifiedDeltas`. Unknown fields and one delta mapped more or less
  than once fail.
- **FR42 — Contributors declare compatibility intent once.** The Changeset is the
  declaration: its tag and body state whether the change is compatible, a deprecation
  with its replacement and migration, or a `[breaking]` change with its migration.
  No parallel classification vocabulary, id, or plan field is required. Existing
  Changesets need no backfill. Contributors state intent; maintainers resolve missing
  authority. External contributors are not required to invent policy.
- **FR43 — CI validates semantics, not labels.** Pull-request CI compares latest
  stable to head to establish released victims and base to head to attribute deltas.
  Release CI compares final patch to paired minor and assigns every delta exactly once
  to cleanup, release metadata, or compatible fix. It checks exports/types, runtime,
  CSS/tokens, DOM/accessibility/visual/RTL, CLI/help/routes/schema, persistence, docs,
  package files, supported package combinations, old-client/new-metadata behavior,
  fixed-group membership, required evidence, and tag/classification/bump coupling.
  Plain failures name the victim, missing evidence, and exact repair path.
- **FR44 — Exact-main release gates and rollback preserve one identity.** The final
  patch and paired minor each use one immutable source identity for build, package
  artifacts, gate receipts, tag, publish, and release record. Missing authority,
  manifest coverage, required migration, exact-main evidence, or any unclassified
  delta aborts the cut. Partial publishing resumes the same bytes. A bad release rolls
  forward with a compatible patch that restores aliases or the prior safe
  compatibility mode; critical-harm recovery follows FR35 and never restores the
  harmful behavior.
- **FR45 — Documentation catalog display names and routes are mutable data.** A
  display name or docs-tree route identifies an entry in the current documentation
  catalog; it is not a contractual CLI operation or machine-readable schema. Renaming
  or moving an entry is nonbreaking even when its former value stops resolving, and
  requires no compatibility alias or codemod. Release metadata identifies the
  replacement value. The `docs` command name, options, exit behavior, and response
  schemas remain contractual under FR3 and FR13. Template identifiers, slugs, display
  names, and names are outside this requirement and remain governed separately by FR9,
  FR11, and applicable registry identity contracts.
- **FR46 — Main declares the next planned version.** The fixed package group's
  version in main's `package.json` files is the owner-selected next release plan. A
  release owner may raise or lower it before a cut; after a cut, changing it affects
  only main's future plan because the release branch remains bound to its cut
  declaration. Pending Changesets do not derive or promote the version. Pull-request
  lint does not compare main's declaration with the newest stable tag or an active
  cut. Canary publication owns that ordering boundary instead: the fixed group MUST
  declare one plain version; a declaration strictly above the newest stable tag
  publishes `<declared version>-canary.<commit>`, equality publishes nothing, and a
  lower or split declaration fails rather than publishing. Changeset admission still
  refuses `[breaking]` work while main declares a patch and admits it while main
  declares the minor successor.
- **FR47 — A release branch releases the declared version.** A release branch's
  version is the fixed-group version declared at its cut commit. Admission, the
  release plan, Changeset consumption, changelog generation, and stable
  publication run only on a release branch. Consuming Changesets writes changelog
  entries under the declared version and never moves it: a `[breaking]` Changeset
  on a branch that declares `0.7.0` releases `0.7.0`, and a patch Changeset on a
  branch that declares `0.6.8` releases `0.6.8`. Pending Changesets MUST NOT
  choose the version: an entry waiting to be released is the work admission
  governs, never evidence for the version that carries it.
- **FR48 — Admission checks the declared tier.** The declared version is either
  the patch successor or the minor successor of the latest stable release, and
  that successor is the release tier. In a patch release, a Changeset carrying the
  `[breaking]` category is inadmissible and MUST be rejected before the release
  consumes it. The admissible routes are FR28's replacement-first deprecation,
  which keeps old usage equivalent and lets the removal ship in a minor release,
  or an owner bump of main to the minor successor before the cut. This gate
  decides patch-versus-minor admission and nothing else; the lifecycle conditions
  of FR28, FR31, FR32, and FR37, and every review and release requirement outside
  this record, continue to apply on their own terms and are neither restated nor
  enforced here.
- **FR49 — The declared version fails closed.** A release is refused when the
  fixed group does not declare one identical `MAJOR.MINOR.PATCH` version; when the
  release version differs from the version declared at the cut commit; when no
  stable release exists to compare against; and when the declared version is
  neither the patch nor the minor successor of the latest stable release,
  including a version that is already released. The latest stable release is the
  newest `vX.Y.Z` tag. A prerelease or canary identifier is publication metadata
  and is never a declared version or a base.
- **FR50 — The published release branch squashes back into current main.** After
  branch-owned tag and stable publication are verified, one merge-back change applies a
  squash projection of the exact published release branch onto then-current main. It
  carries the branch's exact generated changelogs, release notes, promoted codemods,
  and frozen Changeset deletions; it does not regenerate or copy those outputs
  independently. Branch-only active lifecycle marker and plan files do not land on
  main. Between cut and merge-back, main's declaration is whatever main currently
  holds; the release branch cannot change it. The three-way merge MUST preserve that
  declaration, every post-cut main commit, every post-cut Changeset byte, and every
  unrelated newer main field; a conflict holds the merge for explicit resolution. A
  trusted required check binds the merge-back to the immutable tag, release branch head,
  and plan digest, proves the release-owned bytes and frozen deletions equal the branch,
  proves post-cut preservation, and refuses every unrelated mutation. FR50 never chooses
  or lowers the next plan. Merge-back preserves main's current declaration. When it
  equals or falls below the released version, FR46's canary publication check emits no
  prerelease at equality and fails below it.
- **FR51 — The next plan is prepared at the cut and remains separate from merge-back.**
  When a release cut succeeds, release tooling prepares one pull request from current
  main that changes every fixed-group package version, every generated exact internal
  pin, the lockfile, and version-stamped compatibility asset to the successor plan. The
  default is the cut version's patch successor. The minor successor is used only when
  Cindy explicitly names that minor as the next plan; pending `[breaking]` Changesets
  are not that decision. The plan MUST NOT merge before an immutable tag or stable
  package publication makes the cut version non-reusable. After that point it may land
  through normal gates before or after FR50; merge-back preserves an already-landed
  newer plan. A terminal cut with neither an immutable tag nor stable package
  publication closes its prepared plan unmerged. If main already carries a newer owner plan, the prepared change closes or
  refreshes instead of duplicating or lowering it. Other main work need not wait, and
  FR46 remains the independent canary-publication safety boundary. The plan remains
  malleable under FR46 until the next cut.
- **FR52 — Repository evidence follows codemods through promotion.** A codemod staged
  for an unreleased change may move from `transforms/next/` into the immutable
  `transforms/v<declared-version>/` folder during release generation. A checked-in test,
  validator, documentation generator, or other repository evidence that reads that
  transform's source MUST identify the transform independently of its lifecycle folder
  and resolve its staged or promoted location as appropriate. It MUST fail when the
  transform is missing, when multiple candidates make the identity ambiguous, or when
  the promoted source no longer exposes the same transform identity and declared entry
  set. It MUST NOT keep a duplicate transform solely to preserve the staged path.
  Promotion remains one generated release output under FR47 and FR50; this requirement
  changes no consumer migration contract.
- **FR53 — Accessibility behavior fixes are not breaking.** A change whose purpose is
  to make a released component conform to its accessibility target — a WCAG 2.2 Level
  A or AA success criterion, the required parts of an APG pattern the component adopts
  under `spec:AST-020/FR15`, or a current accessibility requirement in its record — is
  `[fix]` and patch-eligible, even when it replaces documented interaction or
  accessibility behavior: roles, accessible names, states and properties, focus
  movement, and keyboard behavior. No deprecation precedes it and no coexistence path
  is required. An accessibility behavior fix requires no app-layer code change:
  existing public props, types, exports, tokens and documented usage keep working
  unchanged. It may add optional public surface (for example a new optional prop),
  which is not breaking and is admitted under the existing rules for public additions;
  it never removes, renames, or narrows existing surface. It ships as a patch `[fix]`,
  with a release-note line when documented behavior changes; its Changeset names the
  old and new behavior and the accessibility target it serves. A change that would
  require apps to edit code must be redesigned so the component absorbs it, or it is
  classified under FR1.
- **FR54 — A surface other than a component defines its own compatibility contract.**
  Each published surface other than a component takes its compatibility contract from
  its own current record. For the CLI, that record is `architecture:cli-surface`. The
  contract states which consumer scenarios are valid and supported, what counts as a
  released victim, and which corrections to the surface are `[fix]` and which are
  `[breaking]`. The surface record's owners decide that contract through the record's
  normal approval, and this record does not restate it. Where the surface record
  states a rule for a case, that rule decides the classification of that case. Where
  it is silent, FR1–FR6 and this record's rules for that surface apply. A surface rule
  turns on the change and its contract, never on risk or adoption (FR6), and covers
  only released, published surfaces (FR2). The release process in this record applies
  to the result unchanged: Changeset categories and bump tiers (FR7, FR22), migration
  evidence (FR8), experimental isolation (FR21–FR27), the patch, minor, and emergency
  paths (FR28, FR31, FR34, FR35, FR48), deprecation and coexistence (FR29–FR30), and
  the release gates (FR37–FR44). A surface record defines only whether a change is
  breaking; it never changes how a change ships. A change any surface record classifies
  as `[breaking]` lands only in a declared minor (FR31) or through the emergency path
  (FR35), never in a patch release, and no surface record can make it patch-eligible by
  redefining a release rule. Components keep the rules in this record, including FR3 and
  FR53.

### Platform support

- Supported feature/engine floor: every published Astryx package and stable CLI
  release.
- Unsupported behavior: private packages, unreleased branch state, canary-only
  surfaces, and surfaces that meet FR21's explicit experimental contract carry no
  stable compatibility promise.
- Browser evidence: not applicable to the classification itself; a browser-owned
  compatibility claim still follows the governing component or platform spec.

## Authority relationships

This spec owns compatibility classification for components, and the release process
every surface's classification goes through: lifecycle state, release-pair delta
classes, and the evidence that admits them. A surface other than a component defines
its own compatibility contract in its current record (FR54). It delegates narrower
questions rather than restating them:

- [`spec:AST-002` — Public API admission and operation shape](../AST-002/spec.md)
  owns whether a public API concept should exist and its semantic shape.
- [`architecture:public-component-api` — Public component API](../../architecture/public-component-api.md)
  owns the stable component contract; this spec owns how it evolves.
- [`architecture:cli-surface` — CLI surface architecture](../../architecture/cli-surface.md)
  and [`spec:AST-042` — CLI command admission and programmatic parity system spec](../AST-042-cli-command-admission/spec.md)
  own CLI structure, admission, and parity. `architecture:cli-surface` also owns the
  CLI's compatibility contract under FR54; this spec owns the release process that
  applies it.
- [`architecture:theme-tokens` — Theme token architecture](../../architecture/theme-tokens.md)
  owns token semantics; this spec owns compatibility and experimental isolation.
- [`spec:AST-013` — Browser and platform support system spec](../AST-013/spec.md)
  owns support floors and fallbacks; this spec owns release treatment of a support
  reduction.
- [`spec:AST-035` — Integration template replacement system spec](../AST-035/spec.md)
  owns template-replacement semantics. This spec classifies each field and behavior
  against the latest stable release: unpublished replacement-specific surfaces follow
  FR2, while previously released same-id conflict warning behavior remains stable.
- [`spec:AST-040` — Evidence-based consumer file modification system spec](../AST-040-consumer-file-modification/spec.md)
  owns what a codemod may edit and the evidence it requires; this spec owns when a
  migration must supply one.
- [`spec:AST-010` — Structured percentage useResizable configuration system spec](../AST-010/spec.md)
  and [`spec:AST-043` — Date and time input presentation system spec](../AST-043-input-presentation/spec.md)
  own their concrete mappings and any surface-specific deprecation minimums.
- [`spec:AST-030` — Positive CI surface routing system spec](../AST-030/spec.md)
  owns pull-request lane routing; this spec owns the compatibility checks each
  affected surface must receive and the exact-main release gate.
- [`spec:AST-039` — Integration contribution descriptor system spec](../AST-039/spec.md)
  is a related draft, not authority. If promoted, it links its compatibility readers
  and removals to this lifecycle.
- The public [Component Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle)
  owns whole-component Lab-to-Core readiness. Lab remains the private canary-only
  incubation boundary; this spec owns the compatibility effect of promotion.
- The public [API Conventions](https://github.com/facebook/astryx/wiki/API-Conventions),
  [Distribution](https://github.com/facebook/astryx/wiki/Distribution), and
  [Release Process](https://github.com/facebook/astryx/wiki/Release-Process) pages
  are operational projections. When they discuss compatibility lifecycle, they MUST
  link here and cannot create competing rules.
- [`.changeset/config.json`](../../../.changeset/config.json),
  [`scripts/check-changesets.mjs`](../../../scripts/check-changesets.mjs), and
  [`.github/workflows/ci.yml`](../../../.github/workflows/ci.yml) implement fixed
  grouping, declaration validation, and checks. Their current behavior is not policy
  when it differs from this spec. The `fixed` group in that config is why one
  incompatible Changeset puts every published package in a minor release.
  `check-changesets.mjs` validates Changeset format on every pull request, and the
  release-branch tooling under [`scripts/release/`](../../../scripts/release/)
  implements FR47–FR51 admission, versioning, merge-back, and next-plan validation;
  none of these files may narrow or widen the rule it implements.

The direct current owner decides the intended product contract. For a surface other
than a component, its record also decides what counts as breaking (FR54). This spec
decides the release process every change goes through. The public Release Process
decides how an eligible release is operated. A direct spec cannot waive compatibility silently;
an incompatible change to its surface lands in a declared minor under FR31.

## Current-state impact

Public lifecycle classification remains in force with these operational effects:

- stable changes default to compatible patch treatment;
- experimental containment extends to every public surface kind;
- a breaking change ships only in a declared minor, described by its `[breaking]`
  Changeset; when `main` declares a patch, a deprecation ships first — warnings stay
  at authoring surfaces, production runtime stays unchanged, and an executable
  modernization route precedes warning delivery;
- planned minors use a locked final-patch baseline, and the cut generates the
  release description from the consumed Changesets;
- minor frequency remains planning guidance and creates no deprecation or pair wait;
- contributor declarations are the Changeset tag and body, with no parallel
  lifecycle metadata and no migration of existing Changesets;
- documentation catalog display names and routes remain mutable data while the stable
  `docs` command and machine-readable schemas stay contractual;
- exact-main release comparison rejects unclassified deltas; and
- a surface other than a component is classified by its own record's compatibility
  contract, and the release process here applies to the result.

Main declares the next planned version, and Changeset admission uses that declaration
to refuse `[breaking]` work on a patch plan. Pull-request lint does not compare the
declaration with stable or an active cut. The canary publisher compares at publication
time: strictly above stable publishes, equality skips, and lower or split declarations
fail closed. A release branch releases the cut's declaration unchanged and admits
incompatible work only when it is a minor; the Changeset cannot choose that version
itself. Published branch bytes squash back into current main without regeneration,
post-cut consumption, or plan changes. Release tooling prepares the separate successor
plan at cut time—patch by default, minor only when Cindy names it—and holds it until the
cut version becomes non-reusable. It may then land before or after merge-back without
holding unrelated main work; a terminal cut with neither an immutable tag nor stable
package publication closes it unmerged.

Codemod promotion relocates staged transform source into the immutable release folder.
Repository evidence that reads a transform follows that lifecycle location and treats a
missing or ambiguous identity as a failure rather than preserving a duplicate source.

These requirements alter no published package by themselves and need no Changeset.
Authoring helpers, validators, metadata projections, compatibility snapshots, release
workflows, and contributor guidance implement the contract. Until each projection
lands, maintainers apply its requirement in review and release approval.

## Verification

| Contract  | Verification                                                                                                                                                                                                                                                                                                                                                    | Representative states                                                                                                                                                                                                   | Mutation or failure expectation                                                                                                                                                                                                                                                                            |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR3   | PR compatibility statement plus latest stable package inspection                                                                                                                                                                                                                                                                                                | released export, narrowed type, changed interaction default; unreleased and private surface; visual-only spacing, typography, layout styling, or default appearance                                                     | A change is labeled from diff size or possibility alone, a functional contract loss is hidden as visual-only, or a pixel-only change is tagged `[breaking]` even though callers need no source or configuration change                                                                                     |
| FR4–FR6   | Old-usage type/runtime/CLI regression test                                                                                                                                                                                                                                                                                                                      | alias retained, deprecation warning, broad rewrite, low-adoption caller                                                                                                                                                 | Contractual old usage fails despite a nonbreaking label, or risk is substituted for compatibility                                                                                                                                                                                                          |
| FR7–FR8   | `pnpm check:changesets` (tag, bump, contributors, declared tier) plus FR8 migration review of `[breaking]` text                                                                                                                                                                                                                                                 | existing `[fix]`, `[feat]`, `[breaking]`, and `[experimental]` tags paired with compatible, deprecation, planned-removal, or incompatible-fix metadata                                                                  | A new lifecycle replaces an established tag, tag/metadata/bump diverge, an incompatible correction uses bare `[fix]`, or migration is unusable                                                                                                                                                             |
| FR9–FR13  | CLI contract tests, response-schema/type snapshots, text projections, generated consumer docs, and template catalog/output tests                                                                                                                                                                                                                                | slug rename, metadata edit, source rebuild, optional field addition, command or schema change                                                                                                                           | Catalog data is frozen as API, a command/schema incompatibility is mislabeled as catalog-only, or a response field lacks a complete projection                                                                                                                                                             |
| FR12      | Minimum and representative supported-version tests plus manifest and release-note review                                                                                                                                                                                                                                                                        | retained range, narrowed range, adapter, coordinated upgrade                                                                                                                                                            | An in-range combination breaks under a nonbreaking label, or release coordination hides the affected package or migration                                                                                                                                                                                  |
| FR14      | Help/manifest snapshots, public API and consumer docs, and focused contract tests                                                                                                                                                                                                                                                                               | command, option, API/config, private rollout/test hook                                                                                                                                                                  | Supported behavior is hidden, an environment variable changes behavior, or automation lacks a documented API                                                                                                                                                                                               |
| FR15      | Full manifest-derived command matrix, supported-consumer evidence, and scope-specific contract tests                                                                                                                                                                                                                                                            | global invariant, scoped command group, single command, programmatic API                                                                                                                                                | A global control is a no-op for any command, has different meanings, or replaces a narrower owning surface                                                                                                                                                                                                 |
| FR16–FR18 | Boundary inventory, hostile side-effect probes, response snapshots, and concurrent API tests                                                                                                                                                                                                                                                                    | known and new extension, partial result, text/JSON/API parity, independent concurrent calls                                                                                                                             | A route bypasses the guarantee, omitted work looks complete, or one invocation changes another                                                                                                                                                                                                             |
| FR19–FR20 | Proposal evidence with a regression fixture for the detection failure, plus composition and provenance tests                                                                                                                                                                                                                                                    | convention covers the case, detection fails, app plus two integrations, refusal, failing contribution, repeated load                                                                                                    | A key ships without a reproduced detection failure, a contribution displaces the app or applies twice, a part of the effective value has no inspectable source, or a failure silently weakens protection                                                                                                   |
| FR21–FR24 | Published-surface comparison, declaration/doc metadata checks, import-boundary checks, and Changeset classification review                                                                                                                                                                                                                                      | experimental prop object, experimental subpath, patch evolution, promotion, unpublished adjacent field, stable warning behavior, integration theme                                                                      | A prose-only marker excludes a stable API, adjacency falsely publishes a new field, an experimental export leaks through a stable path, a patch changes stable behavior, or a promoted API remains unprotected                                                                                             |
| FR25–FR27 | Stable-surface inventory and experimental-boundary matrix                                                                                                                                                                                                                                                                                                       | package/API, CSS/token, CLI/config, persistence, build tooling, docs, whole Lab component                                                                                                                               | An unmarked experiment changes stable defaults or a stable surface bypasses compatibility review                                                                                                                                                                                                           |
| FR28–FR31 | Declared-tier check: on a minor, the `[breaking]` Changeset describes the change (old usage, replacement, migration); on a patch, a breaking Changeset is refused and the deprecation ships instead; declaration/doc/build/CLI warning projections; old/new fixtures; dry-run/idempotence/migration tests; maintained-source and latest-stable upgrade evidence | whole component, prop/value, theme field/helper/token/target/source, CLI command, CLI option; old-only, new-only, both, static/dynamic/uncertain migration, text/JSON, development/production                           | A warning has no usable modernization route, production runtime changes for deprecation, maintained guidance teaches the old surface, a migration guesses, old behavior degrades early, cadence invents a wait, or one cleanup id covers multiple incompatible deltas                                      |
| FR32–FR35 | Latest-stable victim fixture, pre-existing authority named in the Changeset or pull request, owner review, declared-tier check, and emergency attestation                                                                                                                                                                                                       | victim-free `[fix]`, `[fix]` plus incompatible-fix metadata, coexistence, cleanup transition, critical-harm emergency                                                                                                   | A released victim uses bare `[fix]`, a new Changeset tag replaces metadata, a correction record disagrees with its public-surface state, or urgency bypasses migration                                                                                                                                     |
| FR36–FR40 | Owner-declared target, locked final-patch receipt, three-way delta classification against the merged `[breaking]` Changesets, and separated release notes                                                                                                                                                                                                       | ordinary cadence, immediate pair, cleanup, release metadata, pre/post-lock compatible fix                                                                                                                               | Cadence becomes eligibility, the final patch is recut for bookkeeping, a feature or incompatible fix enters the incidental-fix lane, or release notes merge cleanup with fixes                                                                                                                             |
| FR41–FR44 | Schema validation, PR declaration, semantic stable/base/head comparisons, exact-main gate, and immutable publish/rollback receipt                                                                                                                                                                                                                               | duplicate ids, missing evidence, route/schema removal, old-client metadata, partial publish, safe rollback                                                                                                              | A label passes without semantics, a delta maps zero or multiple times, fixed-group membership drifts, or a release rebuilds under one identity                                                                                                                                                             |
| FR45      | Docs route inventory, repository-reference checks, and Changeset review                                                                                                                                                                                                                                                                                         | catalog entry rename, former-route miss, stable command and JSON schema                                                                                                                                                 | Catalog routing is frozen as API, or a rename silently changes the contractual command or response schema                                                                                                                                                                                                  |
| FR46–FR47 | Declared-tier Changeset admission, canary publication ordering, and release-versioning fixtures                                                                                                                                                                                                                                                                 | owner increase/decrease, above/equal/below stable, split group, `[breaking]` on patch and minor plans                                                                                                                   | Pending Changesets derive the plan, `[breaking]` passes on a patch or fails on a minor, equality publishes a canary, or a lower/split declaration publishes instead of failing                                                                                                                             |
| FR48      | Category and classification admission under each declared tier                                                                                                                                                                                                                                                                                                  | patch release with `[breaking]`, patch release with a deprecation, minor release with `[breaking]`                                                                                                                      | Incompatible work passes in a patch release, or a deprecation is refused in one                                                                                                                                                                                                                            |
| FR49      | Malformed and inconsistent declaration fixtures                                                                                                                                                                                                                                                                                                                 | disagreeing group versions, canary identifier, release version unlike the cut manifest, already-released version, skipped version, no stable tag                                                                        | An untrustworthy or mismatched declaration is released                                                                                                                                                                                                                                                     |
| FR50      | Three-way squash merge-back fixtures bound to the immutable tag, branch head, and plan digest                                                                                                                                                                                                                                                                   | exact branch outputs, frozen deletion, branch-only marker exclusion, post-cut Changeset/manifest edit, newer main declaration, merge conflict, regenerated output                                                       | Merge-back differs from release bytes, lands lifecycle files, consumes post-cut work, overwrites a newer main declaration or unrelated field, or chooses a version                                                                                                                                         |
| FR51      | Cut-time successor-plan preparation plus manifest/pin/lockfile/compatibility-asset and lifecycle fixtures                                                                                                                                                                                                                                                       | default patch successor, explicitly named minor, abort before any tag or publication, immutable tag before merge-back, pre-advanced main plan, unrelated main work during equality                                      | The plan merges before the cut version is non-reusable, survives a terminal cut with neither an immutable tag nor stable publication, skips the default patch, derives a minor from pending Changesets, splits pins/assets, or the prepared plan or merge-back lowers or duplicates an existing newer plan |
| FR52      | Promotion fixtures plus every checked-in source consumer run before and after codemod promotion                                                                                                                                                                                                                                                                 | staged transform, promoted transform, missing source, ambiguous candidates, changed entry identity                                                                                                                      | Evidence hardcodes only `next/`, keeps a duplicate source, silently chooses among candidates, or passes when the promoted transform no longer exposes the same identity and entries                                                                                                                        |
| FR53      | Changeset classification review against the governing component record's accessibility evidence                                                                                                                                                                                                                                                                 | a fix that replaces documented keyboard or focus behavior to meet an adopted APG pattern's required parts; a fix that changes a role, state, or accessible name to meet WCAG 2.2 A/AA; a fix that adds an optional prop | An accessibility fix is held for a minor, given a coexistence path, or classified `[breaking]`; or a change that needs an app-layer code edit, or that removes, renames, or narrows public surface, ships as an accessibility `[fix]`                                                                      |
| FR54      | Changeset classification review against the surface's current record, then the same tier admission every Changeset receives                                                                                                                                                                                                                                     | a CLI correction the CLI record classifies as `[fix]`; a CLI change the CLI record leaves undecided; a CLI change the CLI record classifies as `[breaking]` on a patch plan; a component change                         | A non-component change is classified by a rule this record restates instead of its surface record, a surface rule turns on risk or adoption, a surface-classified `[breaking]` change ships in a patch or a surface rule makes it patch-eligible, or a component change is classified by a surface record  |

## Decision log

### DEC-1 — Breaking means a released consumer must change

**Reference:** `spec:AST-017/DEC-1`
**Decider:** `cixzhang`, `2026-09-02`

Classify compatibility from the latest stable published contract and name the
consumer scenario that stops working. This makes labels predictable, keeps
unreleased and private experimentation free to change, and prevents low-risk but
incompatible edits from hiding in patch releases.

Rejected: classifying by implementation size, perceived risk, reviewer confidence,
or any observable difference. Those tests either miss real contract breakage or
freeze implementation details and data values that were never promised.

### DEC-2 — Template slugs and source are mutable catalog data

**Reference:** `spec:AST-017/DEC-2`
**Decider:** `cixzhang`, `2026-09-02`

Treat a template slug as data describing the current catalog entry, alongside its
name, description, category, keywords, and starter content. Those values may evolve
in patch releases, including a slug rename that makes the previous value stop
resolving. The release note should make the new value discoverable, but the change
is not `[breaking]` and needs no compatibility alias or codemod.

Keep the surrounding CLI operation contractual: incompatible changes to the
`template` command, its options, exit behavior, or machine-readable schema still
follow the normal breaking-change rule. This lets the template library improve
without confusing current catalog contents with the interface that serves them.

Rejected: treating a slug as stable API merely because it is interpolated into an
exact CLI invocation. That would freeze catalog data and discourage clearer naming
without protecting a contract the template system intends to make.

### DEC-3 — Package updates keep their own range promises

**Reference:** `spec:AST-017/DEC-3`
**Decider:** `cixzhang`, `2026-09-03`

Treat the latest stable package's declared dependency or peer range and documented
installation scenario as its compatibility promise. Consumers may upgrade that
package with any companion version the range still allows; a coordinated release
cannot require an undeclared lockstep upgrade. Classify the package update that
creates the mismatch. Supporting package updates keep their own classifications.
A coordinated breaking range change remains allowed when its Changeset release note
describes the new range and its FR8 migration tells consumers how to move.

Rejected: marking every dependency bump breaking, which would freeze compatible
maintenance, or calling an update nonbreaking merely because a coordinated upgrade
works, which would break supported independent consumers.

### DEC-4 — Stable CLI response fields receive complete projections

**Reference:** `spec:AST-017/DEC-4`
**Decider:** `cixzhang`, `2026-09-06`

Treat every field in a stable machine-readable CLI response—including nested
entry fields—as public schema. Keep its canonical type, contract tests, applicable
text output, and complete consumer documentation aligned in the same change.

An optional field addition may remain nonbreaking when old consumers continue
unchanged. That compatibility result does not make the field private or waive
current-authority and documentation requirements.

Rejected: documenting only the outer envelope, relying on implementation typedefs
as consumer documentation, or treating a response-entry field as mutable catalog
data merely because the value it carries may evolve.

### DEC-5 — Supported CLI behavior is never hidden

**Reference:** `spec:AST-017/DEC-5`
**Decider:** `josephfarina`, `2026-09-23`

Any Astryx CLI behavior available in a supported flow is public and must be
documented and discoverable, including behavior selected automatically. A control
that can change supported CLI behavior is public and must be documented and
discoverable. Its status follows the behavior it changes, not whether it is spelled
as a command, option, package field, or configuration key.

Use an option for invocation-scoped behavior, placed on the nearest command or
command group that owns it; a global option must meet FR15's CLI-wide evidence bar.
Use a documented configuration surface for persistent project behavior. Do not
introduce Astryx-owned environment variables, including aliases for another surface.
Automation uses the documented programmatic API. Private tests use injected test
seams instead of environment variables. A maintainer rollout gate may control staged
availability, but it is not a public user interface, must not use an environment
variable, and cannot be the only way to reach a supported mode. The user-facing
behavior it gates becomes subject to this requirement once supported.

Rejected: documented or undocumented environment-variable controls, naming a hidden
rollout gate as the user interface, or describing a behavior only in prose while
omitting it—or a direct link to its documentation—from generated help and the
machine-readable manifest.

### DEC-6 — Global CLI controls prove global value

**Reference:** `spec:AST-017/DEC-6`
**Decider:** `josephfarina`, `2026-09-23`

Treat a behavior-changing global control as a last resort, not as the default home for
invocation-scoped behavior. Because it changes every command, it requires current
system-spec authority before implementation. Its proposal must show one stable
invariant with meaningful behavior for every command, a supported-consumer need that
narrower command or API scope cannot satisfy, and a manifest-derived command matrix that verifies the claim. A
no-op or different meaning on any command means the control is scoped too broadly.

Put narrower behavior on the nearest command, command group, or programmatic API that
owns it. Name the control for the exact capability it changes rather than using broad
quality claims such as `safe`, `secure`, `trusted`, or `isolated`. A safety or trust
control also defines its trust boundary and fails closed before untrusted input can
execute or cause a side effect.

Rejected: making a control global because several implementations share a switch,
because root parsing is convenient, or because unrelated commands can silently ignore
it. Those approaches enlarge the public surface without establishing coherent value.

### DEC-7 — Cross-cutting controls are complete, observable, and per invocation

**Reference:** `spec:AST-017/DEC-7`
**Decider:** `josephfarina`, `2026-09-23`

Define a cross-cutting guarantee by its complete boundary, not by the current list of
implementation sites. Unknown and future extension points fail closed.

Make work omitted by a safety or reduced-capability mode part of the result contract
so a partial result cannot be mistaken for a complete one. When no useful result
remains, return a stable error before writes or external effects. Pass programmatic controls as explicit inputs, keep
them independent across concurrent calls, and preserve semantics across equivalent CLI
and API surfaces.

Rejected: best-effort interception of known loaders, silent fallback to partial or
empty results, process-global mutable switches, and separate CLI and API meanings. Each can
make the advertised guarantee false while the happy-path tests remain green.

### DEC-8 — Configuration is a last resort, and contributions compose

**Reference:** `spec:AST-017/DEC-8`
**Decider:** `josephfarina`, `2026-09-23`

Every configuration key is permanent public surface under FR3 and FR14, and it
asks every consumer to make a decision. A key often hides a gap in detection, so
detection and established conventions come first, and a new key needs a
reproduced case where they fail.

Integration contributions let one package set shared behavior once, as the
`debug` setting does for organization-wide debug logs, while the app keeps the
final say. The setting defines how contributions combine; an integration only
declares its contribution, in the documented form. Contributions combine with the
app value instead of replacing it, they are isolated from one another, each part
of the effective value can be traced to its source, and a protective setting
fails closed.

Rejected: adding a key because it is easy to add, a key that duplicates a
convention or a derivable value, an integration silently replacing an app value,
an integration defining its own merge rule, contributions made by import side
effects, and one broken integration disabling a setting for every app.

### DEC-9 — Existing stable components incubate APIs behind one explicit boundary

**Reference:** `spec:AST-017/DEC-9`
**Decider:** `cixzhang`, `2026-09-28`

Keep Lab as the canary-only home for an entire experimental component. When an
existing stable component needs to test a new prop or callback, put it inside the
component's optional `experimental` prop object so every callsite opts in visibly
and the ordinary prop namespace stays stable. Put experimental hooks, functions,
and types on the owning component's `/experimental` subpath. Mark the same surface
in declarations and authored docs, and keep stable defaults unchanged when the
experimental boundary is unused.

Allow those marked surfaces to evolve through `[experimental]` patch releases.
Promotion is a separate `[feat]` decision that moves the settled API onto its stable
surface; after that release the ordinary compatibility rules apply. Never use an
experimental marker to demote an already stable contract. Integration-theme
contributions remain the single explicitly recorded grandfathered surface because
they shipped before this declaration mechanism while still being introduced for early
iteration.

That retroactive-marker prohibition does not make unpublished additions stable by
association. Integration-template replacement-specific declarations, selection,
optional projection, and expanded conflict fields remain pre-publication under FR2;
their classification is not an experimental enrollment. The already released same-id
conflict warning behavior remains stable.

Rejected: moving a stable component into Lab to test one new API, scattering
`unstableFoo` names through its ordinary prop namespace, relying on prose warnings
that tooling cannot verify, treating every recently added API as implicitly
experimental, and retroactively marking another released stable surface unstable.

### DEC-10 — Replacement-first records own deprecation and cleanup

**Reference:** `spec:AST-017/DEC-10`
**Decider:** `cixzhang`, `2026-09-29`

Keep old and replacement behavior together in the final patch when the next planned
release is a patch; direct authorities may impose a longer transition for their own
surfaces, and release cadence never invents one. DEC-18 supersedes the record-and-
manifest gate this decision once carried.

Rejected: prose-only deprecation, silent removal, a global wait inferred from cadence,
and one cleanup id covering unrelated deltas.

### DEC-11 — Behavior-correcting incompatibility is not an ordinary fix

**Reference:** `spec:AST-017/DEC-11`
**Decider:** `cixzhang`, `2026-09-29`

A correction with a valid released victim is not an ordinary fix: pre-existing
authority proves the expected behavior, latest-stable evidence proves the victim, and
the change ships as `[breaking]` in a declared minor under FR31–FR34. A victim-free
restoration is `[fix]` with patch treatment. DEC-18 supersedes the `incompatible-fix`
classification, `IFIX-*` record and `CLN-*` item this decision once required.

Rejected: treating every visible correction as breaking, and hiding a released victim
behind a bare `[fix]`.

### DEC-12 — The paired minor admits only three delta classes

**Reference:** `spec:AST-017/DEC-12`
**Decider:** `cixzhang`, `2026-09-29`

Lock the final-patch baseline and classify every paired-minor delta as approved
cleanup, closed release metadata, or an independently patch-compatible fix. A fix
that lands after baseline lock may be disclosed in the minor without recutting the
patch solely for bookkeeping. Keep cleanup and compatible fixes separate in both the
manifest and release notes.

Rejected: unrelated features, unclassified deltas, an incompatible fix in the
compatible-fix lane, and strict byte identity that forces a needless replacement
final patch for an independently valid patch fix.

### DEC-13 — Cadence guides planning; readiness and authority gate the cut

**Reference:** `spec:AST-017/DEC-13`
**Decider:** `cixzhang`, `2026-09-29`

Plan minor releases generally no more often than once every fourteen calendar days,
but do not turn that interval into deprecation eligibility or a required wait between
the final patch and paired minor. A ready, authorized pair may publish sequentially.

Rejected: an inferred elapsed-time gate, a special exception merely to publish a
ready pair, and using cadence to waive replacement, migration, cleanup, or exact-main
evidence.

### DEC-14 — Documentation catalog display names and routes are mutable data

**Reference:** `spec:AST-017/DEC-14`
**Decider:** `josephfarina`, `2026-09-29`

Treat a documentation catalog display name or tree route as data identifying a current
entry. It may change in a patch release even when its former value stops resolving.
Release metadata identifies the replacement; no compatibility alias or codemod is
required.

Keep the surrounding operation contractual: incompatible changes to the `docs`
command, its options, exit behavior, or machine-readable response schemas still
follow FR1–FR8 and FR13. Template identifiers, slugs, display names, and names remain
outside this documentation rule and follow FR9, FR11, and applicable registry identity
contracts.

Rejected: treating every route-shaped positional value as stable CLI API. That would
freeze documentation organization rather than protect the command and schema
contracts readers and automation rely on.

### DEC-15 — Main declares the plan; publication enforces ordering

**Reference:** `spec:AST-017/DEC-15`
**Decider:** `cixzhang`, `2026-10-09`; revised `2026-10-12`

Main's `package.json` carries the owner-selected next planned version. Release tooling
runs on a release branch and takes the cut declaration unchanged; admission, versioning,
changelogs, and stable publication happen there. Later main planning cannot change that
branch release. After publication, a three-way squash projection returns the exact
release-owned branch bytes to current main while preserving post-cut work and main's
declaration. A separate owner change declares the patch successor by default, or the
minor successor only when Cindy names it.

Required pull-request lint does not compare main's declaration with stable or an active
cut, because prerelease ordering belongs to canary publication rather than ordinary PR
admission. The canary publisher is the smaller authority boundary: it publishes only
when one plain fixed-group declaration is strictly above the newest stable tag, skips
equality, and fails below-stable or split declarations. Changeset admission independently
refuses `[breaking]` work on a patch plan and admits it on a declared minor.

Rejected: deriving the version from pending Changesets, because the entry being judged
would be its own authorization; publishing a canary at or below stable; regenerating
release outputs during merge-back; and folding the next owner plan into shipped-byte
reconciliation.

Rejected: a dated schedule file beside `package.json`, because two declarations of the
next version can disagree.

Rejected: resetting a newer main declaration to the released version, because release-
branch authority owns shipped outputs, not post-cut planning on main.

### DEC-16 — Deprecation acts at authoring surfaces, not production runtime

**Reference:** `spec:AST-017/DEC-16`
**Decider:** `cixzhang`, `2026-10-07`

A deprecation reaches a builder where the obsolete choice is authored: declarations,
generated reference, theme validation or build, CLI help and invocation, and supported
migration tooling. The working replacement and executable modernization route exist
before warnings begin. Maintained guidance teaches the replacement, while explicit
lookup keeps the deprecated surface and its exact route discoverable.

Production runtime behavior and bytes remain unchanged merely because a surface is
deprecated. The old contract continues working until its separately approved cleanup;
a runtime-only coexistence conflict may receive a development diagnostic explaining
the direct owner's precedence rule. Cleanup follows only after the migration can
preserve or explicitly account for every observable result it owns.

Rejected: a runtime compatibility flag or branch used only to acknowledge deprecation,
a warning without an available modernization path, copyable guidance that keeps
teaching the old surface, and a migration that guesses at uncertain consumer intent.

### DEC-17 — Visual-only changes are compatible even when pixels change

**Reference:** `spec:AST-017/DEC-17`
**Decider:** `cixzhang`, `2026-10-12`

Compatibility asks whether a valid latest-stable caller must edit source or
configuration to keep a supported functional contract working. Removing or renaming a
public surface, narrowing an accepted type, or changing interaction, accessibility
semantics, data, machine-readable output, or another behavior contract can therefore be
breaking. A functional default change's Changeset names the old and new default and the
prop or option that restores the old default; that restoring input is its migration.
Changing only the appearance, spacing, typography, layout styling, or visual default of
existing content is not: it ships as `[feat]` or `[fix]` under FR3 even when an untouched
caller sees different pixels. Its release note names the old and new
appearance and how to keep the old look when a supported escape exists; that escape is
guidance, not a required compatibility migration.

Rejected: treating any pixel difference as a released victim; and calling an
interaction, semantic, data, or machine-output change visual-only because its UI also
looks different.

### DEC-18 — Two rules and one gate

**Reference:** `spec:AST-017/DEC-18`
**Decider:** `cixzhang`, `2026-10-11`

Anything going into a planned minor needs no deprecation first; its `[breaking]`
Changeset describing the change is enough. The two rules that govern compatibility
are: no breaking change ships without a planned minor, and when the next planned
release is a patch a deprecation path is required — deprecate in the patch, remove in
a later minor. The one mechanical gate is the pull-request check that fails a
`[breaking]` Changeset while `main` declares a patch version and admits it while
`main` declares a minor. Everything else — lifecycle records, cleanup ids, the release
manifest, transition rows, migration-evidence cells — is description: the manifest is
generated at the cut from the consumed Changesets exactly as authored and frozen with
the cut as the minor's migration inventory, and the cut fails closed on any
incompatible final-patch-to-minor delta that no `[breaking]` Changeset describes.

Rejected: a lifecycle record and frozen manifest that every removal had to appear in
before merging, which duplicated the Changeset and made bookkeeping the blocker; a
per-record transition requirement and waiver, which the patch rule already expresses;
and dropping the manifest, which would lose the minor's migration inventory.

### DEC-19 — Promotion moves codemod source without invalidating its evidence

**Reference:** `spec:AST-017/DEC-19`
**Decider:** `cixzhang`, 2026-10-14

A staged codemod and its promoted release copy are one transform at two lifecycle
locations. Checked-in evidence identifies the transform and resolves whichever location
the repository state owns; promotion cannot turn a valid source-level guard red merely
by moving the source, and it cannot make that guard silently stop running. Missing,
ambiguous, or identity-changing promotion fails.

Rejected: retaining a duplicate under `next/` after promotion; hardcoding only the
staged path or one release folder; and copying transform declarations into a test.

### DEC-20 — Accessibility behavior fixes are not breaking

**Reference:** `spec:AST-017/DEC-20`
**Decider:** `cixzhang`, `2026-10-14`

Every user of a component that misses its accessibility target is harmed until the
fix ships, and the people harmed most are the ones least able to work around it.
Holding such a fix for a planned minor, or shipping it behind an opt-in beside the
inaccessible behavior, keeps that harm in place to protect callers who depended on
behavior that excluded someone. So a change made for accessibility conformance is a
`[fix]` in the patch line even when it replaces documented roles, names, states, focus
or keyboard behavior. The boundary is the public surface: the fix needs no app-layer code change, may add optional surface, and never removes, renames, or narrows existing surface; a change that would make apps edit code is redesigned so the component absorbs it, or classified as any other change.

Rejected: classifying accessibility corrections as breaking whenever a released
caller observes the old behavior, which delays every conformance fix to a minor.

### DEC-21 — The cut prepares but does not authorize the successor plan

**Reference:** `spec:AST-017/DEC-21`
**Decider:** `cixzhang`, 2026-10-14

The cut knows the default successor and every file that must move together, so it
prepares the separate plan change immediately. That change cannot merge until an
immutable tag or stable package publication makes the cut version non-reusable. It may
then land before or after merge-back because FR50 preserves a newer main declaration.
A terminal cut with neither an immutable tag nor stable package publication closes the
prepared change unmerged.

Rejected: merging the successor before the cut version becomes non-reusable, which
strands a terminal cut with neither a tag nor a publication on a skipped plan; and waiting until merge-back to
prepare information already fixed at the cut.

### DEC-22 — Each surface defines what breaks; this record owns the release process

**Reference:** `spec:AST-017/DEC-22`
**Decider:** `cixzhang`, 2026-10-15

The owners of a surface know which of its behaviors callers may rely on and which of
its corrections leave a real victim. A surface other than a component therefore
defines its compatibility contract in its own current record, decided by that record's
owners: the CLI's lives in `architecture:cli-surface`. This record applies the result
through one release process, so every surface uses the same categories, tiers, paths,
gates, and coexistence rules. Components keep their rules here, because the component
contract and its accessibility fixes (DEC-20) are defined across all components in
this record. Where a surface record is silent, this record's rules for that surface
still apply, so no surface is left without a classification.

Rejected: keeping every surface's breaking rules in this record, which makes this
record's owners decide contracts for surfaces they do not own.

## Open questions

None.

---

### Specs AST 018 Spec (`docs/specs/AST-018/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-018
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: proposed
owners: [cixzhang, rubyycheung, imdreamrunner]
affects_architecture: [architecture:theme-authoring-contract]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme, color]
---

# Approved palette authoring contract

## Intent

Give maintained themes a consistent, reviewable color reference without making
that reference part of the runtime theme contract.

A theme may keep an approved palette in a theme-owned source file. Authors and
tools can use it to select and audit explicit theme colors. `defineTheme()` still
receives the explicit values that control rendered output; it does not receive,
inherit, validate, publish, or resolve palette data.

Semantic tokens remain the primary interface for components and applications.
The palette is a starting point for color decisions and contrast testing, not a
claim that every component context already passes accessibility.

“Approved” means that the palette has been intentionally reviewed by its owning
theme. Structural validation does not grant design approval or certify contrast.

## Terminology

- A theme may own one **palette map**.
- The map contains named **families**, such as `neutral`, `red`, or `blue`.
- A family contains at least one mode-specific **ramp**: light, dark, or both.
- A ramp contains one or more author-defined numeric **stops**. Stop labels are
  finite JSON numbers in the inclusive range `0` through `100`; integer and
  decimal labels are valid, and equivalent spellings such as `5` and `5.0`
  identify the same stop. Different palettes may use different stop layouts to suit UI, iconography,
  illustration, data-visualization, or brand work.
- An **explicit theme color** is either a literal CSS color or an intentional
  reference to one exact value in the theme's committed palette file. In both
  cases, `defineTheme()` receives the resolved CSS value rather than palette
  metadata.
- An **alignment check** compares an intentionally palette-backed theme color to
  the approved palette stop from which it was selected.

## Authoring flow

```mermaid
flowchart TD
  Generator["Optional palette generator<br/>(separate follow-up)"]
  Manual["Hand-authored or imported palette"]
  Choice{{"Choose one starting path"}}
  Candidate["Candidate palette"]
  Review["Human review and approval"]
  Palette["Theme-owned palette file<br/>for example neutralPalettes.ts"]

  Generator --> Choice
  Manual --> Choice
  Choice --> Candidate
  Candidate --> Review
  Review --> Palette

  Palette --> Validate["Palette checker<br/>checks structure only"]
  Validate --> Validation["Structured errors and warnings"]
  Validation --> PaletteCI["Palette validation test"]

  Palette -. "Optional authoring help:<br/>suggest candidate colors" .-> Suggestions["Mapping suggestions"]
  Roles["Semantic roles and existing values"] --> Suggestions
  Suggestions --> ColorReview["Author reviews intent,<br/>appearance, and contrast"]
  ColorReview --> Hex["Approved CSS color<br/>literal or committed palette reference"]

  Hex --> Define["defineTheme({tokens, components})"]
  Define --> Runtime["Runtime theme and CSS"]

  Palette --> Alignment["Alignment test for mappings<br/>that intentionally use the palette"]
  Hex --> Alignment
  Alignment --> ThemeCI["Theme mapping test"]
```

The palette file remains available for later authoring and audits. It does not
disappear after suggestions are generated. Mapping suggestions are optional and
must never rewrite a theme automatically. The author reviews each selected color
and either stores its CSS value directly or references that exact value from the
committed palette file. Changing a referenced value is therefore a deliberate,
reviewable theme change.

## Non-goals

- Adding palettes to `DefineThemeInput` or `DefinedTheme`.
- Changing color expansion, inheritance, runtime CSS, or static theme builds.
- Requiring every theme to maintain a palette.
- Requiring every theme color to match a palette stop.
- Treating a stop as a semantic role or accessibility guarantee.
- Publishing palette files as runtime package artifacts in this change.
- Generating palettes or mapping suggestions in this change.
- Choosing the algorithm or production API for a future palette generator.

## Requirements

- **FR1 — Palette adoption is optional.** A maintained theme MAY keep a
  theme-owned palette file. A theme without one keeps its existing authoring,
  runtime, build, inheritance, and package behavior. Adopting a palette MUST NOT
  make it a required field of `defineTheme()`.
- **FR2 — The palette is authoring-only data.** Palette data MUST remain outside
  `DefineThemeInput`, `DefinedTheme`, runtime providers, generated CSS, and the
  generic `astryx theme build` output. It MAY be colocated with its theme or
  imported from another authoring source. This contract does not require one
  repository layout, but each maintained palette MUST have a clear owner. A
  theme-owned palette MUST remain private to that theme unless a separate public
  API explicitly accepts compatibility obligations for its family and stop keys.
- **FR3 — Declared ramps use an explicit, author-defined shape.** A palette map
  MUST contain at least one named family. Each family MUST declare a non-empty
  light ramp, a non-empty dark ramp, or both. Missing modes MUST NOT be inferred.
  A theme such as Gothic MAY declare only dark ramps, while a theme such as Stone
  MAY maintain only one family. A shared ramp MUST be assigned to both modes
  explicitly. Each ramp defines its own strictly increasing stop labels and
  opaque six-digit hex values. Labels MUST be finite JSON numbers from `0`
  through `100`, unique after numeric normalization, and strictly increasing.
  Endpoints `0` and `100` are optional, and a one-stop ramp is valid. This
  contract MUST NOT require 11, 21, or any other fixed stop count. A generator
  MAY recommend a default layout without making that layout a validity rule.
  Optional hue, chroma, purpose, and description fields are metadata only.
- **FR4 — Validation is explicit and non-mutating.** A palette checker MUST
  inspect declared data without defining, normalizing, generating, or returning
  a replacement palette. It MUST report all structural problems with stable
  paths and messages. Structural errors include an empty palette, an empty ramp,
  duplicate or invalid stop labels (including non-finite values, values outside
  `0..100`, non-unique numeric values such as `5` and `5.0`, or labels that are
  not strictly increasing), malformed colors, and invalid mode shapes.
  A checker MUST validate the stop layout the author declared rather than require
  a universal stop inventory. It MUST NOT run from `defineTheme()`, a runtime
  provider, or the generic theme build. A public checker or CLI requires its own
  accepted API contract.
- **FR5 — Theme choices remain explicit.** Authors and tools MAY use a palette to
  suggest or choose a color. A theme mapping MAY store the accepted CSS color
  literally or intentionally reference that exact value from its committed
  palette file. A reference MUST resolve to a supported CSS value before runtime
  output and MUST NOT make `defineTheme()` aware of palette families or stops.
  Renaming or removing a referenced stop MUST fail type checking, validation, or
  the theme build. Changing a referenced value is an intentional theme change
  and MUST be reviewed with its rendered impact.
- **FR6 — Validation and alignment are separate checks.** An adopting theme MUST
  test that its palette has no structural errors. It SHOULD separately test the
  theme mappings intentionally selected from palette stops. The alignment test
  MUST NOT imply that every direct theme color is a violation. Alpha overlays,
  brand colors, composited colors, and contrast-specific adjustments MAY remain
  explicit intentional deviations.
- **FR7 — Accessibility remains contextual.** A structurally valid palette or an
  aligned mapping does not prove that a foreground/background pair, interaction
  state, or component passes WCAG. The owning theme MUST test contrast in the
  actual component contexts it intends to support.
- **FR8 — Generation is a separate capability.** A future generator or CLI MAY
  create candidate palette files or mapping suggestions. That work MUST have its
  own accepted contract and review. Generated output remains a candidate until a
  theme author reviews and adopts it; merely adding a palette MUST NOT trigger
  automatic nearest-color replacement.

## Handling existing and intentional differences

This contract applies only when a theme chooses to adopt the palette shape. It
does not retroactively reject unrelated themes or their existing color systems.

The validator distinguishes the declared contract from theme usage:

- Invalid palette containers, malformed colors, duplicate or invalid stop labels,
  empty ramps, or a family with neither mode are structural errors.
- Light-only, dark-only, dual-mode, single-family, and multi-family palettes are
  supported shapes rather than exceptions.
- Colors outside the palette are not validator errors because the validator does
  not inspect theme tokens or components.
- When a palette includes a neutral family, authors SHOULD use it for near-black,
  near-white, and other achromatic UI roles. Exact black and white remain
  ordinary explicit theme values rather than required family endpoints. This is
  authoring guidance rather than a structural validation rule.
- An intentional mapping difference is handled in the owning theme's alignment
  test: update the theme, retain and document the explicit deviation, or revert
  the palette change.

Future repository tooling may add warnings after maintained palettes are audited
for real patterns. It must not convert an intentional existing pattern into a
hard failure without first adding that pattern to the contract or documenting a
narrow exception.

## Current-state impact

Current `main` stores explicit theme colors and has no palette field in the
normalized theme contract. This proposal preserves that behavior.

Neutral is the intended first adopter in a separate follow-up. Its palette can
live in a private theme-owned TypeScript file, while `neutralTheme.ts` explicitly
references reviewed values that resolve to CSS colors. That adoption requires its own
visual review, tests, theme record, and release treatment rather than receiving
implicit approval from this contract.

The existing sandbox generator remains useful design evidence. A production
generator is intentionally deferred to a separate proposal informed by the
sandbox and the discussion in PR #5800.

## Verification

This specification-only change runs the repository knowledge checks and changes
no package, runtime, build, theme, or public API behavior. Follow-up
implementations must prove that palette authoring remains outside the runtime
theme contract and test only the palette shapes they explicitly support.

## Decision log

### DEC-1 — Palettes guide consistency without enforcing it

**Reference:** `spec:AST-018/DEC-1`

**Decider:** `rubyycheung`, `2026-09-02`

Themes may use a palette to establish consistent color expression, but adoption
and usage remain optional. Semantic tokens are preferred, while intentional
direct colors remain valid.

Rejected: requiring every theme or every color to use the palette. That would
turn authoring guidance into runtime policy and misrepresent legitimate
overlays, brand colors, and contrast-specific values.

### DEC-2 — Palette data stays outside the runtime theme contract

**Reference:** `spec:AST-018/DEC-2`

**Decider:** `rubyycheung`, `2026-09-03`

Palette files are theme-owned authoring references. `defineTheme()` does not
accept, validate, retain, inherit, or publish them. This follows
`architecture:theme-authoring-contract`: validation-only data does not enter the
normalized runtime theme.

Rejected: a `palettes` field on `DefineThemeInput` or `DefinedTheme`. It adds
runtime compatibility cost without constructing a runtime value.

### DEC-3 — Validation reports; it does not define or prescribe a stop count

**Reference:** `spec:AST-018/DEC-3`

**Decider:** `rubyycheung`, `2026-09-03`

Palette validation reports structural errors without returning the unchanged
palette as though it constructed a new value. It validates the author's declared
stop layout rather than prescribing 11 or 21 stops. Theme-package tests or an
accepted future CLI decide how validation results affect CI.

Rejected: a Core `defineTonalPalettes()` or `validateTonalPalettes()` export.
Under `spec:AST-002/FR17` and DEC-7, a `define*` helper must construct or
normalize a durable supported value, while validation-only authoring data does
not belong in the Core runtime contract.

### DEC-4 — Theme mappings explicitly select reviewed CSS values

**Reference:** `spec:AST-018/DEC-4`

**Decider:** `rubyycheung`, `2026-09-03`

Palette-based mappings are authoring decisions. Once approved, the theme may
store the selected CSS value literally or intentionally reference that exact
value from its private committed palette file. The latter makes the palette the
single source of truth: changing it is a visible theme-affecting source change,
and missing references fail instead of falling back. Neither form adds palette
semantics to `defineTheme()` or runtime output.

Rejected: rerunning generation during ordinary theme builds and automatic
nearest-color replacement. Either could change rendered colors without a
reviewed edit to the committed palette or theme source.

### DEC-5 — Neutral's remap is a contrast-testing baseline

**Reference:** `spec:AST-018/DEC-5`

**Decider:** `rubyycheung`, `2026-09-02`

Neutral's approved palette and explicit remap provide an anchor for future color
decisions and component contrast tests. Approval does not claim that every
existing component, mode, or interaction state already passes accessibility.

Rejected: making complete component-level conformance a prerequisite for the
palette. The palette is the baseline used to find and fix those contextual
issues.

### DEC-6 — Supported modes are explicit

**Reference:** `spec:AST-018/DEC-6`

**Decider:** `rubyycheung`, `2026-09-03`

A family declares light, dark, or both ramps. Missing modes are not inferred.
This supports dark-only themes such as Gothic and single-family themes such as
Stone without pretending they have a different mode or inventory.

### DEC-7 — Palette generation is follow-up work

**Reference:** `spec:AST-018/DEC-7`

**Decider:** `rubyycheung`, `2026-09-03`

This proposal validates adopted palette files. A production generator and any
CLI commands for creation or mapping suggestions require a separate proposal.
The existing sandbox prototype and PR #5800 are inputs to that future design,
not runtime dependencies or accepted APIs in this contract.

### DEC-8 — Stop layouts belong to palette authors

**Reference:** `spec:AST-018/DEC-8`

**Decider:** `rubyycheung`, `2026-09-03`

The contract accepts any non-empty author-defined stop layout whose finite
numeric labels are unique, strictly increasing, and within `0..100`. A future
Astryx generator may default to 21 stops for finer authoring choices, but an
11-stop, one-stop, or specialized palette for icons, illustrations,
visualization, or brand work is equally valid. Tools with narrower input
requirements must state and validate those requirements locally rather than
redefining palette validity.

Rejected: requiring every palette to contain both endpoints or a fixed increment
such as five. That would turn one generator default into an unnecessary
restriction on unrelated authoring needs.

## Open questions

None.

---

### Specs AST 020 Spec (`docs/specs/AST-020/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-020
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-03
phase: accepted
owners: [cixzhang]
affects_architecture: []
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Accessibility spec-test authoring system spec



```json
{
  "scope": "global",
  "triggers": {
    "accessibility": ["FR1", "FR2", "FR6", "FR15"]
  }
}
```

## Intent

People using Astryx components should get the keyboard, focus, meaning, and state
promised by [WCAG 2.2](https://www.w3.org/TR/WCAG22/) and the
[WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) widget pattern
Astryx adopts. Reusable accessibility spec tests turn those promises into
reviewable contracts instead of requiring each component author to reinterpret
the standards.

A spec test states one user-observable requirement, cites its source, names the
evidence layer that can prove it, and runs unchanged against every conforming
binding. The test suite complements axe, browser checks, and assistive-technology
review. It does not claim that one automated pass proves complete accessibility.

## Non-goals

- Implementing the spec-test package or CI jobs in this specification pull request.
- Defining how existing component tests migrate; that is owned by
  [AST-021](../AST-021/spec.md).
- Publishing the first implementation as a supported package.
- Reimplementing axe rules or replacing browser, visual, manual, or real-AT
  verification.
- Treating WAI-ARIA Authoring Practices as a substitute for WCAG conformance.
- Using the WCAG 3.0 working draft as a pass/fail baseline before it is ready.
- Encoding page-owned requirements in an isolated component contract when the
  component cannot satisfy them.

## Requirements

### Normative basis and scope

- **FR1 — WCAG 2.2 A and AA are the conformance baseline.** Every WCAG
  conformance expectation MUST cite an applicable WCAG 2.2 Level A or AA success
  criterion. A pattern expectation MAY instead cite an exact APG requirement when
  APG supplies the interaction detail. In that case it MUST identify the WCAG 2.2
  user outcome it supports without inventing a success-criterion mapping. WCAG 3.0
  principles MAY inform plain-language intent, user needs, and future research, but
  a WCAG 3.0 draft requirement MUST NOT create or clear a pass/fail check without a
  later current Astryx record.
- **FR2 — APG defines an adopted widget pattern, not universal conformance.** When
  Astryx adopts a WAI-ARIA APG pattern, the pattern contract MUST cite the exact APG
  roles, states, properties, or keyboard interaction it adopts and the WCAG 2.2
  outcome those mechanics support. A component MAY use another standards-valid
  model only when its current component, family, or system contract records that
  choice; the spec test MUST follow the owning contract rather than forcing an APG
  example mechanically.
- **FR3 — Contracts have two layers.** Every pattern review MUST consider both:
  broad component-owned requirements that apply across patterns, and
  pattern-specific role, state, relationship, keyboard, and focus requirements.
  A component part may bind to more than one pattern. A whole component MUST NOT be
  forced into one pattern when its parts have distinct roles.
- **FR15 — Adoption follows the role a component already plays.** Where a
  component part already plays an APG pattern's role, Astryx adopts that pattern's
  required parts for it: the role, the accessible name, the state and relationship
  properties, and activation by Tab plus Enter or Space. The pattern's optional
  keyboard extras — arrow-key movement between parts, Home and End — are adopted
  only where the component already acts as one composite control; a set of
  independently focusable controls does not gain them. The owning component record
  names the parts it adopts, any required part it does not adopt with the reason,
  and any extra it adopts beyond this rule as a named exception.

### Expectation contract

- **FR4 — Every expectation is traceable.** An expectation MUST have a stable id,
  a short user outcome, an exact normative source (WCAG 2.2 success criterion, APG
  requirement, current Astryx contract, or an applicable combination),
  applicability conditions, an evidence layer, and an enforcement class. The test
  name and failure output MUST expose the expectation id and source reference so a
  failure can be understood without opening the runner implementation.
- **FR5 — Applicability is explicit.** For every checklist dimension, an author
  MUST either encode an applicable expectation or record why the dimension is not
  owned by this pattern, binding, or evidence layer. Silence is not an exemption.
  An exemption MUST name the actual owner or verification method; it MUST NOT be a
  generic “not applicable” escape hatch.
- **FR6 — Evidence layers keep distinct proof boundaries.** Expectations are
  classified as unit, DOM, accessibility-tree, axe, real-browser, visual, lint,
  manual, or real-AT checks. A lower layer MUST NOT claim a result only a higher
  layer observes. Announcement wording or timing, virtual-cursor entry, and known
  AT/browser divergence follow [AST-009](../AST-009/spec.md); an accessibility-tree
  snapshot does not prove spoken output.
- **FR7 — Contracts assert outcomes, not Astryx internals.** A reusable expectation
  MUST query and act through public semantics and observable behavior. It MUST NOT
  depend on Astryx class names, private DOM wrappers, implementation callbacks, or
  source layout unless an owning current record makes that structure contractual.
  Component-specific API effects remain in component tests.
- **FR8 — Interactive expectations prove the complete transition.** Keyboard,
  pointer, state, and focus expectations MUST exercise the relevant starting state,
  action, resulting state, and reverse or dismissal path when the pattern supports
  one. Merely finding a role or proving one direction of a toggle is insufficient.
- **FR9 — Enforcement is separate from severity and tool coverage.** A directly
  applicable WCAG 2.2 Level A or AA expectation is `required`. An APG or
  Astryx-specific expectation is `required` only when a current Astryx record adopts
  that outcome. `Advisory` expectations report best practice, an unsettled choice,
  or an outcome not yet adopted as an Astryx contract. `Advisory` expectations
  report only; `required` expectations gate new or changed behavior at a layer that
  can objectively verify them. A coverage score, tool capability, or ease of
  automation MUST NOT promote or demote either class.

### Completeness and review

- **FR10 — Every pattern uses one completeness review.** The review starts with
  the source map below, then adds applicable WCAG 2.2 component outcomes such as
  focus not obscured, status messages, target size, contrast, forced colors, and
  the pattern's APG interactions. Page-owned rows are considered only for fixtures
  or surfaces that own a page. Every row follows FR5 and FR6 rather than being
  forced into the reusable runtime contract.

| WCAG 2.2 source                     | Outcome to consider                                                                        | Usual ownership                       |
| ----------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
| 1.1.1 Non-text Content              | Informative non-text content has an equivalent alternative; decorative content is ignored. | Component or content                  |
| 1.3.1 Info and Relationships        | Visual structure and relationships are programmatically available.                         | Component or composition              |
| 1.3.2 Meaningful Sequence           | Programmatic reading sequence preserves meaning.                                           | Component or composition              |
| 1.4.1 Use of Color                  | Color is not the only way to convey information or state.                                  | Component, theme, and caller content  |
| 1.4.3 Contrast (Minimum)            | Text and images of text meet the required contrast.                                        | Component and theme                   |
| 1.4.11 Non-text Contrast            | Controls, states, and meaningful graphics meet non-text contrast.                          | Component and theme                   |
| 2.1.1 Keyboard                      | Every component-owned function is operable by keyboard.                                    | Component                             |
| 2.1.2 No Keyboard Trap              | Keyboard focus can leave any component unless the user has a documented exit.              | Component or composition              |
| 2.4.2 Page Titled                   | A page has a descriptive title.                                                            | Page only                             |
| 2.4.3 Focus Order                   | Sequential focus preserves meaning and operation.                                          | Component or composition              |
| 2.4.4 Link Purpose (In Context)     | A link's purpose is determinable.                                                          | Component and caller content          |
| 2.4.6 Headings and Labels           | Headings and labels describe their topic or purpose.                                       | Component and caller content          |
| 2.4.7 Focus Visible                 | Keyboard focus has a visible indicator.                                                    | Component and theme                   |
| 2.4.11 Focus Not Obscured (Minimum) | Focused content is not entirely hidden by author-created content.                          | Component or composition              |
| 2.5.8 Target Size (Minimum)         | Pointer targets meet the minimum size or an allowed exception.                             | Component or composition              |
| 3.1.1 Language of Page              | The page language is programmatically available.                                           | Page only                             |
| 3.2.4 Consistent Identification     | Repeated functions are identified consistently.                                            | System, component, and caller content |
| 3.3.2 Labels or Instructions        | Inputs have persistent labels or needed instructions.                                      | Component and caller content          |
| 4.1.2 Name, Role, Value             | Name, role, state, value, and changes are programmatically available.                      | Component                             |
| 4.1.3 Status Messages               | Status changes are exposed without moving focus.                                           | Component or composition              |

- **FR11 — The contract itself has positive and negative proof.** Each expectation
  MUST pass against a minimally conforming fixture or binding and fail when its
  required outcome is deliberately removed. Pattern-level tests MUST distinguish a
  real violation from an unsupported harness operation or fixture error.
- **FR12 — Completion requires an independent accessibility review.** A pattern is
  complete only when every checklist dimension is encoded or explicitly assigned
  elsewhere, all expectations are traceable, representative bindings pass or expose
  exact known gaps, mutation evidence proves required expectations can fail, and an
  accessibility subject-matter reviewer finds no unaccounted contract-encodable
  requirement.
- **FR13 — The pull request describes the spec test in readable form.** A new or
  materially changed pattern pull request MUST state who is affected, the adopted
  pattern and scope, the expectation table with source and evidence layer, explicit
  exemptions, representative bindings and states, known component gaps, and the
  mutations or failures that prove the contract. The description is the readable
  specification of the test; the executable contract remains the checked source of
  truth.
- **FR14 — Stable ids survive refactors.** Renaming files, helpers, or harnesses MUST
  preserve expectation ids. Splitting, combining, narrowing, or changing the user
  outcome creates an explicit contract change and migration for every binding and
  known-gap record that refers to the affected ids.

### Platform support

- Supported feature/engine floor: the affected component's current browser-support
  contract under [AST-013](../AST-013/spec.md).
- Unsupported behavior: a harness that cannot observe an assigned evidence layer
  reports that limitation; it does not pass, skip silently, or substitute a lower
  layer.
- Browser evidence: browser-class expectations run in a real shipping engine. A
  DOM emulator cannot prove focus navigation, native top-layer behavior, computed
  accessibility-tree exposure, contrast, target geometry, or forced-color paint.

## Current-state impact

[Issue #4112](https://github.com/facebook/astryx/issues/4112) established the
reusable pattern-contract direction. The closed
[Switch prototype](https://github.com/facebook/astryx/pull/4113) demonstrated one
contract running through jsdom and Chromium bindings, source-linked expectation ids,
and exact known failures. It also showed why axe and pattern contracts are
complementary: axe finds broad markup violations, while the contract verifies the
state and interaction that make a switch behave as a switch.

The prototype is evidence, not current policy. Its package name, scoring model,
priority names, and harness shape remain implementation choices unless this or
another current record adopts them. This spec keeps the proven contract boundary
while correcting two risks before scale-out: a numeric score must not stand in for
conformance, and each expectation must name the evidence layer that can actually
observe its outcome.

The completeness review incorporates recurrent component-owned failures such as
text alternatives, semantic relationships and order, focus order and visibility,
descriptive labels and links, consistent identification, persistent instructions,
and name/role/value/state. It updates that review to WCAG 2.2 and keeps page-owned,
browser-owned, and real-AT-owned checks outside a component runtime contract when
that is the honest boundary.

This accepted spec creates no tests, package, CLI documentation, component behavior,
or CI gate. Those changes follow in implementation work governed by this record.

## Verification

| Contract  | Verification                                                                  | Representative states                                                                                     | Mutation or failure expectation                                                                                                                      |
| --------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR3   | Source and ownership review against WCAG 2.2, APG, and current Astryx records | component-owned A/AA criterion; page-owned criterion; one component with multiple parts                   | A draft WCAG 3.0 statement gates CI, an APG example is treated as universal WCAG, or one component is forced into one pattern                        |
| FR4–FR9   | Contract schema and runner self-tests                                         | required/advisory; unit/DOM/tree/axe/browser/manual/AT; toggle round trip; component-specific callback    | A check lacks a source or layer, passes from an unsupported harness, depends on private markup, or one-way behavior hides a reverse-path failure     |
| FR10–FR12 | Completed checklist, positive/negative fixture suite, and independent review  | simple control; composite widget; overlay; page-owned criterion                                           | A dimension disappears silently, a mutation still passes, or review finds an unrecorded contract-encodable requirement                               |
| FR15      | Adoption review against the component's current role claims                   | a disclosure set adopting required parts only; a composite control adopting arrow keys; a named exception | A required part is skipped without the owning record's reason, or arrow keys are added to independently focusable controls without a named exception |
| FR13–FR14 | Pull-request template check plus id migration tests                           | new pattern; narrowed expectation; file-only refactor                                                     | Reviewers cannot understand the test from the PR, or an id change orphans bindings and gaps                                                          |
| Platform  | Real-browser and AST-009 evidence-boundary checks                             | focus navigation; accessibility tree; forced colors; announcement timing                                  | jsdom or a tree snapshot is credited with pixels, browser behavior, or spoken output it cannot observe                                               |

### Completion criteria

This spec moves from `accepted` to `shipped` only when:

- the reusable contract schema enforces FR4–FR6 and FR9;
- the authoring guide and pull-request template implement FR10–FR13;
- contract self-tests include conforming and deliberately violating fixtures under
  FR11;
- at least one pattern runs against representative jsdom and browser bindings
  without relying on Astryx-private structure;
- unsupported harness capabilities fail honestly rather than passing or skipping;
- no aggregate coverage score is presented as proof of conformance; and
- the shipped guide links AST-009 for real-AT claims and AST-021 for migration.

## Decision log

### DEC-1 — Use WCAG 2.2 for conformance and WCAG 3.0 only for forward-looking intent

**Reference:** `spec:AST-020/DEC-1`
**Decider:** `cixzhang`, `2026-09-03`

WCAG 2.2 Level A and AA provide the current testable baseline. WCAG 3.0 may help
frame user needs, assistive-technology experience, and future evaluation, but its
working draft does not decide current pass/fail results.

Rejected: waiting for WCAG 3.0 or treating a draft outcome as a current conformance
requirement.

### DEC-2 — Author one reusable outcome contract per adopted pattern

**Reference:** `spec:AST-020/DEC-2`
**Decider:** `cixzhang`, `2026-09-03`

Separate the standards-derived pattern contract from component bindings. This lets
multiple components share one interpretation while allowing a component's parts to
bind to different patterns and keeping component-specific API behavior local.

Rejected: copying APG assertions into every component test or forcing all
accessibility checks into one global scanner.

### DEC-3 — Make the pull request a readable specification of the executable test

**Reference:** `spec:AST-020/DEC-3`
**Decider:** `cixzhang`, `2026-09-03`

A reviewer must be able to judge the user outcome, standards basis, evidence layer,
exemptions, bindings, and mutation proof without reverse-engineering runner code.
The checked contract remains executable truth; the pull request makes that truth
reviewable by accessibility specialists and maintainers.

Rejected: code-only review or a generic accessibility checklist with no exact
mapping to executable expectations.

### DEC-4 — Adopt what the component already is

**Reference:** `spec:AST-020/DEC-4`
**Decider:** `cixzhang`, `2026-10-13`

A pattern's required parts belong to any component part that already plays the
pattern's role, because a user who meets that role expects its name, state, and
activation. Its optional keyboard extras belong only to one composite control,
because arrow keys and Home/End move within one widget; independent controls keep
the page Tab order. A component that already ships an extra records it as a named
exception rather than losing it.

Rejected: adopting every part of a pattern wherever its role appears (arrow keys on
independent controls); adopting nothing until a component matches an APG example in
full.

## Open questions

None.

---

### Specs AST 021 Spec (`docs/specs/AST-021/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-021
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-08
phase: accepted
owners: [cixzhang]
affects_architecture: []
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Component accessibility-test migration system spec

## Intent

People using existing Astryx components should gain the same standards-traceable
protection as new components without waiting for a repository-wide rewrite. Astryx
migrates accessibility assertions progressively into reusable spec-test contracts,
locks in behavior that already passes, and exposes each existing failure as owned
work instead of hiding it behind missing or skipped tests.

Migration separates shared pattern behavior from component-specific behavior. A
component binding says which pattern and states it implements. The reusable contract
owns the shared WCAG 2.2/APG outcome; the component suite keeps API, composition, and
implementation-specific regression coverage.

## Non-goals

- Defining how accessibility spec tests are authored; that is owned by
  [AST-020](../AST-020/spec.md).
- Fixing every accessibility defect in the same pull request that reveals it.
- Deleting component-specific tests merely because they mention ARIA or keyboard
  input.
- Treating a known failure, skipped expectation, or high coverage percentage as a
  passing accessibility result.
- Blocking all existing components on every historical gap before migration can
  begin.
- Migrating page-level, visual, or real-AT requirements into a component runtime
  contract that cannot prove them.

## Requirements

### Migration inventory and unit of work

- **FR1 — Migration depends on the authoring contract.** This spec MUST NOT become
  `current` before AST-020 is `current`. Every migrated pattern and binding MUST
  satisfy AST-020's traceability, applicability, evidence-layer, review, and
  readable-pull-request requirements.
- **FR2 — Inventory precedes deletion.** Before changing tests for a component, the
  migration MUST record each interactive part, its adopted pattern or patterns,
  representative states, existing shared-pattern assertions, component-specific
  assertions, assigned evidence layers, and known failures. A component with
  multiple interactive parts MUST map each part separately.
- **FR3 — Migration pull requests stay bounded.** A pull request SHOULD add or
  materially revise one reusable pattern contract and its first representative
  bindings, or migrate one bounded set of bindings to an already-current contract.
  It MUST NOT combine unrelated patterns, broad component remediation, or visual
  redesign merely to raise coverage.
- **FR4 — Representative states are explicit.** Each binding MUST cover every
  component state that can change the adopted pattern outcome. Depending on the
  component, that includes default and changed value, controlled and uncontrolled,
  enabled and disabled, focusable-disabled, required or invalid, open and closed,
  orientation, direction, loading, error, and relevant composed parts. The inventory
  records why omitted states cannot change the shared outcome.

### Moving and retaining coverage

- **FR5 — Move only equivalent assertions.** An existing test moves into the shared
  contract only when its user outcome, applicability, and evidence layer are the
  same for every binding. Tests for callback payloads, public props, form submission,
  component-owned composition, styling, or a deliberate component exception remain
  local even when they also inspect ARIA or keyboard events.
- **FR6 — Duplicate ownership ends in the migration pull request.** Once a contract
  and binding prove an existing shared outcome at the same or stronger layer, the
  exact local assertion MUST be removed or rewritten to cover only the remaining
  component-specific contract. Temporary duplication requires a named removal
  blocker; “extra coverage” is not a permanent reason to keep two owners.
- **FR7 — Existing behavior is preserved unless a separate change authorizes it.**
  A migration pull request records observed failures but does not silently change
  component API, interaction, focus, semantics, visuals, or compatibility. A small
  prerequisite fix may land first or in a separately reviewable commit and pull
  request under its owning current records.

### Known failures and regression gates

- **FR8 — Every known failure is exact and actionable.** A checked-in
  known-failure record MUST name the expectation id, component binding and state,
  observed user impact, standards reference, evidence layer, exact failure, and
  reason the migration does not fix it. Wildcards, whole-pattern suppression, and
  unowned text reasons are prohibited. Operational tracking MUST keep one durable,
  owned gap record for that exact expectation/binding/state tuple. That gap record
  MUST state whether current authority or an objective standard settles the required
  outcome, or whether API, behavior, ownership, compatibility, or design direction
  remains a human decision. Recording a gap does not approve remediation. Unsettled
  direction stops implementation and routes one exact owner question. A private
  tracker identifier or URL MUST NOT be copied into public source, commits, or
  pull-request text; the operational registry maintains that external mapping.
- **FR9 — A known failure changes only its exact gate result.** A binding with a
  known failure still runs the expectation and reports `known-failure`, never
  `pass`. Only that exact expected result stops gating the first migration. A
  different error, another state, a new expectation, or a wider failure MUST fail.
  When the expectation starts passing, CI MUST report an unexpected pass and
  require removal of the stale record rather than continuing to count it as debt.
- **FR10 — Migration locks the baseline without calling debt conformance.** Required
  expectations that pass gate immediately for that binding. Recorded historical
  failures remain visibly failing debt but do not prevent the first migration.
  New components and newly supported states MUST NOT add a required known failure.
  Adding or widening a known failure after the baseline requires an explicit owner
  decision and a durable owned gap record; it is not a routine test update.
- **FR11 — Reports show facts, not one quality score.** Coverage output MUST report
  patterns, bindings, applicable expectations, passes, known failures, unrun layers,
  and exemptions separately. It MUST NOT collapse those states into one percentage
  or grade presented as component accessibility or conformance. A pattern with one
  required failure is not “mostly conformant.”

### Repository integration and review

- **FR12 — Current component records point to the new owner.** When a migrated
  component has a current component or module record, its `verified_by` metadata and
  verification map MUST name the binding and keep separate evidence for outcomes the
  shared contract does not own. Migration does not create a component record solely
  to satisfy this backlink.
- **FR13 — New work uses an existing contract first.** Once a pattern contract is
  current, a new component or newly added component part that adopts that pattern
  MUST bind to it from its first implementation. Authors MUST NOT create a parallel
  ad hoc copy of the shared assertions. If the contract is incomplete, extend and
  review the contract before relying on local tests.
- **FR14 — The migration pull request is a readable ledger.** Its description MUST
  name the affected users and pattern, component parts and states bound, assertions
  moved, local tests retained and why, exact known failures and their authority
  classification, evidence layers run, and mutation or before/after evidence. The
  description MUST distinguish test migration from component remediation and MUST
  NOT expose a private operational tracker.
- **FR15 — Progress is ordered by user leverage.** The first binding remains the
  Switch reference proven by the prototype. Later work prioritizes widely reused
  interactive patterns, components with known keyboard/focus/semantic failures, and
  shared primitives whose correction protects multiple components. Repository order
  or ease of producing a high count MUST NOT override user impact.

### Platform support

- Supported feature/engine floor: each binding follows its component's current
  support contract and AST-020's assigned evidence layer.
- Unsupported behavior: an unrun browser, visual, manual, or real-AT layer remains
  explicitly unrun; jsdom success does not clear it.
- Browser evidence: component changes that can affect browser-owned pattern behavior
  run the relevant real-browser binding. Real-AT outcomes follow
  [AST-009](../AST-009/spec.md) as recommended follow-up with durable receipts.

## Current-state impact

Current component suites mix shared accessibility outcomes with public API,
implementation, visual, and regression tests. For example, Switch currently tests
role, accessible name and description, checked and disabled states, activation,
form participation, loading and error semantics, disabled-reason composition,
forced-color CSS, callback payloads, and layout details in one file. The shared
Switch pattern can own only the standards-derived role, name, state, focusability,
and activation outcomes. Form data, callback payloads, tooltip composition, and
component styling remain Switch-owned.

The first implementation proved that an existing component can bind to one reusable
contract in jsdom and Chromium. Its expected-failure mechanism is retained only with
FR8–FR10's exactness: a known failure is visible debt, never a pass or a broad skip.

Operational gap tracking is separate from the public contract source. The public
known-failure record remains self-contained and reviewable, while the project's
configured gap registry maps the same expectation, binding, and state to its owner,
triage, and later remediation. Migration work MUST NOT create a public issue solely
to carry known-failure debt, and private tracker references MUST NOT enter this
public repository.

### Progressive rollout

1. **Reference pattern.** Rebuild the Switch prototype under current AST-020 types,
   self-tests, evidence boundaries, and reporting. Bind the current Switch states
   without changing behavior.
2. **Simple controls.** Migrate other single-control patterns where role, name,
   state, disabled behavior, and keyboard activation can be isolated cleanly.
3. **Composite widgets.** Migrate selection, navigation, grid, tree, and menu parts
   with orientation, direction, roving focus, typeahead, and open/closed states.
4. **Overlays and async outcomes.** Add browser-owned focus, top-layer, inert, and
   dismissal checks, while routing announcement and virtual-cursor claims through
   AST-009.

The tracker may reorder components within these phases by user impact. A later phase
MUST NOT weaken the evidence boundaries or known-failure rules to increase throughput.

This accepted spec changes no component test, behavior, package, public API, or CI
gate. Those changes follow in implementation work governed by this record.

## Verification

| Contract  | Verification                                                               | Representative states                                                                         | Mutation or failure expectation                                                                                                             |
| --------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR4   | Migration inventory schema and review                                      | one control; component with trigger and popup; controlled/disabled/error/RTL states           | Migration starts without an applicable current contract, treats a whole component as one part, or omits a state that changes semantics      |
| FR5–FR7   | Before/after assertion ownership review and unchanged component suites     | shared role/state check; callback payload; form data; style rule; discovered component defect | Shared assertion remains duplicated, component-specific proof is deleted, or migration silently changes behavior                            |
| FR8–FR10  | Runner tests for exact known failures, new failures, and unexpected passes | one historical gap; wrong error; second state; fixed expectation                              | A wildcard masks regression, a stale gap remains, or new work adds debt without decision                                                    |
| FR11      | Coverage-report snapshot and assertions                                    | pass; known failure; exemption; unrun browser layer                                           | One percentage hides a required failure or reports an unrun layer as pass                                                                   |
| FR12–FR14 | Knowledge validation, component verification map, and PR-template check    | current component record; component with no record; first new adopter                         | Backlink points to deleted local tests, parallel ad hoc assertions appear, or the PR hides migration/remediation scope                      |
| FR15      | Durable gap-registry review                                                | high-use control; known keyboard defect; easy low-impact component                            | Work is ordered only to maximize migrated counts, duplicate gap records are created, or unresolved direction is treated as an automatic fix |
| Platform  | Existing unit/axe/browser checks plus AST-009 classification               | jsdom behavior; real focus; tree exposure; spoken announcement                                | A lower layer clears browser pixels or real-AT output it cannot observe                                                                     |

### Completion criteria

A pattern migration is complete only when:

- its contract is current under AST-020 and passes its positive and negative
  self-tests;
- every affected component part and relevant state has a binding or an explicit
  ownership reason for exclusion;
- equivalent shared assertions have one owner and component-specific tests remain;
- every historical failure has the exact public-safe known-failure record and the
  separate durable owned gap record required by FR8;
- required passing expectations gate regressions and unexpected passes remove stale
  debt;
- reporting separates passes, known failures, exemptions, and unrun layers;
- current component or module records point to the new binding when those records
  exist; and
- the pull request presents the readable migration ledger required by FR14.

The repository migration is complete only when every shipped interactive component
part is mapped to a current pattern contract or explicitly records why no reusable
pattern applies, and no duplicate ad hoc test remains for a shared expectation.

## Decision log

### DEC-1 — Migrate progressively with exact known failures

**Reference:** `spec:AST-021/DEC-1`
**Decider:** `cixzhang`, `2026-09-03`

Apply contracts one pattern and bounded binding set at a time. Preserve historical
failures as exact, runnable known-failure debt while immediately gating outcomes that
already pass. This adds protection now without pretending existing defects are
conformant or waiting for a repository-wide remediation.

Rejected: all-or-nothing migration, silent skips, and enabling one broad report-only
suite that cannot stop regressions.

### DEC-2 — Keep one owner for shared tests and preserve historical failures exactly

**Reference:** `spec:AST-021/DEC-2`
**Decider:** `cixzhang`, `2026-09-03`

Keep test migration separate from component remediation. Move only reusable
standards-derived outcomes into pattern contracts; retain component-specific API,
composition, form, styling, and callback tests with the component. Existing defects
stay as exact runnable `known-failure` results tied to one expectation, binding, and
state, with a separate durable owned gap record. They never count as passing, cannot
mask a different or wider failure, and become unexpected passes when fixed so stale
debt is removed.

Report passes, known failures, exemptions, and unrun layers as separate facts rather
than averaging them into one accessibility score.

Rejected: changing component behavior inside migration work, deleting
component-owned tests, broad skips, and a percentage that can make a required
failure look conformant.

### DEC-3 — Gap tracking does not turn every mismatch into an approved fix

**Reference:** `spec:AST-021/DEC-3`
**Decider:** `cixzhang`, `2026-09-08`

Keep the checked-in known-failure record public-safe and self-contained. Track its
operational ownership separately in the project's durable gap registry. The gap
records the current authority result: an objective requirement may settle the
outcome, while a new API, behavior, ownership, compatibility, or design choice
remains a human decision. Recording the mismatch does not authorize remediation.

Rejected: creating public issues only to carry test debt, exposing private tracker
identifiers in the public repository, and treating every accessibility finding as an
obvious bug fix.

## Open questions

None.

---

### Specs AST 022 Spec (`docs/specs/AST-022/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-022
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: proposed
owners: [rubyycheung]
affects_architecture:
  [
    architecture:cli-surface,
    architecture:theme-authoring-contract,
    architecture:theme-compilation,
  ]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme]
---

# Theme ownership, inheritance, and materialization system spec

## Intent

Theme authors need to know whether a theme follows another theme or merely uses
it as a starting point. `defineTheme({extends})` intentionally keeps a source
dependency on a resolved base theme. That is useful when an author wants fixes,
new component coverage, and other updates from the base, but it can also change
the child theme when the dependency is upgraded.

Astryx should therefore support two explicit workflows:

- **Follow:** retain `extends` and continue adopting changes from the base theme.
- **Own:** resolve the current authoring inputs into reviewable local source that
  no longer depends on the selected base theme or on mutable generation formulas.

Materialization is the shared technical operation behind taking a fully owned
snapshot.
It resolves inherited and generated theme-authoring values, previews the exact
change, and writes an equivalent local theme only through a separate explicit write
invocation, not a prompt or read from stdin.

## Non-goals

- Change the current meaning or precedence of `defineTheme({extends})`.
- Remove `extends`, typography, motion, radius, or color configuration in this
  specification pull request.
- Define tonal palette generation, accepted palette validation, semantic color
  recommendations, or contextual contrast analysis.
- Define how authors create new typography, spacing, motion, or radius values
  beyond Astryx's built-in scales. Theme-local scale extension is owned by the
  separate AST-023 proposal.
- Promise that a snapshot freezes component implementations across Astryx package
  upgrades. It freezes theme-authoring inputs, not the library code that consumes
  them.
- Serialize arbitrary executable React nodes or functions as if they were plain
  data.
- Update, overwrite, or detach a theme without an author-visible preview and an
  explicit write decision.

## Terms

- **Following theme:** source with an `extends` relationship. Re-resolving it may
  adopt changes from the selected base theme.
- **Fully owned snapshot:** local theme source containing every resolved value
  exposed by the public theme-authoring contract. Reproducing it does not require
  the selected base theme or mutable scale-generation behavior.
- **Materialize:** resolve inherited and generated authoring inputs into explicit,
  reviewable local source.
- **Detach:** materialize a following theme and remove its `extends` relationship.
- **Freeze:** materialize selected generated scales while retaining any intended
  inheritance relationship.
- **Lineage-bound local token:** a theme-local name whose declaration and use are
  valid only through the enrolled exact `extends` lineage that owns it.
- **Unresolved executable value:** an inherited icon, indicator, syntax definition,
  or other value that tooling cannot safely express as generated source.

## Ownership

| Owner                      | Contract                                                                                       |
| -------------------------- | ---------------------------------------------------------------------------------------------- |
| AST-022                    | Follow-versus-own choice, materialization behavior, preview, writes, and equivalence evidence. |
| Theme author               | The selected lifecycle, committed output, retained dependencies, and accepted visual changes.  |
| `defineTheme` architecture | Current normalization, precedence, inheritance, and supported authoring surfaces.              |
| CLI authoring surface      | Discovery, preview, source generation, receipts, safe writes, and actionable failures.         |
| Palette specifications     | Palette generation, accepted palette ownership, and exact palette-reference behavior.          |

## Choosing a lifecycle

The choice controls who is responsible for future theme changes. Neither option is
universally better, so authoring tools must explain both before writing.

| Choice                       | Benefits                                                                                                                                             | Tradeoffs                                                                                                                                      | Best fit                                                                                                     |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Follow a base with `extends` | Smaller source; receives reviewed fixes, new theme coverage, and coordinated updates from the base; convenient for related variants.                 | Upgrading the base may change the child theme; reviewers must consider inherited changes; reproducing the source requires the base dependency. | A variant that intentionally stays aligned with another theme, especially when both are maintained together. |
| Take a fully owned snapshot  | Every public theme value is explicit and locally reviewable; base and Core default changes do not alter saved values; the author can diverge freely. | Larger source; the author maintains copied values; later fixes and new coverage from the former base are not adopted automatically.            | A product or application theme that prioritizes control and visual stability.                                |
| Detach an existing theme     | Preserves the theme's current resolved values, name, and imports while ending a previously chosen base relationship.                                 | Produces a larger change to review; future base improvements require an explicit comparison and adoption.                                      | A following theme whose author later decides to own its current appearance.                                  |
| Freeze selected generators   | Makes chosen generated values explicit without requiring a complete detach.                                                                          | Retained base and generator dependencies can still change other values; the result is partial materialization, not full ownership.             | A theme that wants control over one scale while intentionally continuing to follow elsewhere.                |

The CLI MUST describe the selected result in these terms. It MUST NOT use
“self-contained,” “detached,” or “fully owned” for output that still depends on an
unreported base or mutable generator.

## Requirements

### Explicit lifecycle choice

- **FR1 — Following and owning are different promises.** Tooling that starts a new
  theme from an existing theme MUST present following the base and taking a fully
  owned starting point as explicit choices. It MUST explain that following receives
  later base changes while ownership accepts local maintenance, and MUST require an
  explicit option selecting one before writing. When the option is absent, the CLI
  MUST return the available choices without writing or reading stdin. Tooling MAY
  recommend one choice from the author's stated goal, but MUST NOT silently select
  it or describe `extends` as a fully independent copy.
- **FR2 — Following preserves current `extends` semantics.** A following theme
  retains its base dependency and may adopt the base's resolved token, component,
  media-surface, icon, indicator, syntax, and local-token changes after an
  intentional dependency update. The resulting `DefinedTheme` remains flattened
  for consumption, but flattening at runtime or build time MUST NOT be presented as
  source independence.
- **FR3 — An owned start writes local source.** Choosing a base only as a starting
  point MUST create a fully owned snapshot. Subsequent changes to the selected base
  or Core theme defaults MUST NOT alter its saved theme values unless the author
  explicitly runs a migration or comparison workflow.

### Materialization boundary

- **FR4 — Materialization covers configurable theme surfaces.** The operation MUST
  account for inherited values and for values generated by current typography,
  motion, radius, and color configuration. It MUST preserve explicit-token and
  explicit-component precedence. It MUST also inventory local tokens, `onDark`,
  `onLight`, syntax, icons, and indicators rather than silently dropping them.
- **FR4a — Detach cannot copy lineage-bound local tokens unchanged.** Before
  removing `extends`, tooling MUST inspect inherited local-token declarations,
  owner metadata, and every reachable reference. An inherited name is valid only
  through its exact enrolled base lineage; copying that name into the detached
  theme would make it a foreign declaration and fail the current validator.
  Detach MUST therefore stop before writing and require an explicit local-token
  migration that is reviewed under the owning theme specifications. That migration
  MUST map affected names into the detached theme's namespace, rewrite every
  reachable declaration and reference, and preserve any compatibility alias
  required by the released token contract. If the author does not choose such a
  migration, tooling MUST retain `extends`, report the lineage dependency, and MUST
  NOT describe the result as detached or fully owned.
- **FR5 — Materialization produces authoring source, not only compiled output.** A
  CSS or JavaScript build artifact alone is insufficient because it does not give
  the author an editable theme definition. Output MUST be suitable for committing,
  reviewing, and rebuilding with the supported theme toolchain.
- **FR5a — Full ownership includes all resolved public theme values.** A fully owned
  snapshot MUST write every value exposed by the public theme-authoring contract,
  including values currently equal to Core defaults. Output that records only
  differences remains dependent on future Core defaults and MUST NOT be labeled
  fully owned.
- **FR5b — Authoring projection is separate from runtime representation.**
  Materialization MAY produce a larger, fully explicit source projection so an
  author can read, edit, review, and rebuild every resolved value. That projection
  MUST NOT by itself force the shipped theme to carry the same expanded data.
- **FR5c — Preview shows the size tradeoff.** Before writing, the preview MUST show
  the source projection and the equivalent compiled/runtime output, including the
  resulting CSS, JavaScript, and package-size impact compared with the current
  theme. Inspecting or editing a resolved view MUST NOT be described as free of
  runtime cost when the selected ownership choice would increase it.
- **FR5d — Receipts and authoring metadata stay out of runtime artifacts.** Receipts,
  provenance, comparison evidence, and authoring-only metadata MUST remain in
  sidecar or development output and MUST NOT be bundled into runtime CSS,
  JavaScript, or package exports. Equivalent runtime output MUST retain the
  existing compact representation or use an equivalent deduplicated form. Detach
  MAY accept a real size increase only as an explicit, previewed ownership tradeoff.
- **FR6 — Theme ownership has an honest boundary.** Materialization freezes the
  theme values expressible through the current public authoring contract. It MUST
  state the Astryx version used and MUST NOT claim to freeze internal component
  markup, default styles outside the theme contract, browser rendering, or future
  package behavior.
- **FR7 — Theme-owned palette references remain owned.** A reference to an exact
  value in the same theme's committed palette file remains a reference by default;
  it does not need to be replaced with a copied hex value. When a palette belongs
  to another package, tooling MUST require an explicit option to retain that visible
  dependency or copy the current value locally.

### Executable values and failure behavior

- **FR8 — Executable values require source-preserving handling.** Generated source
  MAY preserve an icon, indicator, syntax definition, or other executable value
  through an explicit import only when the author intentionally retains and reports
  that dependency. A fully owned snapshot MUST replace a value inherited from the
  selected base with equivalent owned local source or an author-supplied local
  reference that does not import the former base. When tooling cannot safely
  construct or validate that source, the operation MUST report the exact field and
  stop before writing. If the author instead retains the base import, the preview
  and receipt MUST report it and the result MUST NOT be labeled fully owned or
  source-independent. Tooling MUST NOT stringify, omit, or replace executable
  values with placeholders.
- **FR9 — Writes are atomic and opt-in.** Preview is the default. Writing requires
  an explicit author action, uses a temporary destination, validates the complete
  result, and replaces target files only after success. Existing modified output
  MUST NOT be overwritten without a separate explicit overwrite option. Detach
  updates the selected existing theme by adding its resolved values and removing
  `extends`; it does not create a second theme or require callers to change imports.
- **FR10 — Partial materialization is explicit.** An author MAY choose to freeze only
  selected generated scales while continuing to follow a base. The preview and
  receipt MUST identify every retained dependency. A partial operation MUST NOT be
  labeled detached or owned.

### Equivalence and review

- **FR11 — Before and after resolve equivalently.** A successful full
  materialization MUST compare the original and proposed themes under the same
  Astryx version. Tokens, component overrides, media-surface overrides, local-token
  declarations, local-token owner and lineage metadata, and registry selections
  MUST match. A difference blocks the write unless the author explicitly chooses a
  separate migration that records the intended change. For a local-token migration,
  equivalence evidence MUST additionally show the old-to-new name mapping, every
  rewritten reference, equivalent resolved behavior after applying that mapping,
  and any required compatibility alias. Compiled differences MUST be limited to the
  recorded renames and aliases and included in the preview and size comparison; an
  unaccounted foreign or undeclared name blocks the write.
- **FR12 — The preview explains consequences.** Before writing, tooling MUST show
  the removed base or generator dependencies, files to be created or changed,
  unresolved executable values, retained dependencies, and an exact semantic
  summary. Visual comparison MAY supplement this evidence but MUST NOT replace the
  structural comparison.
- **FR13 — Every write has a receipt.** The receipt MUST include the tool and
  Astryx versions, source theme identity, original base identity and version when
  applicable, selected operation and surfaces, input and output digests, retained
  dependencies, generated file paths, and verification result. It MUST NOT be
  required at runtime.
- **FR14 — Repeated output is stable.** With the same source, versions, options,
  and environment-independent inputs, materialization MUST produce byte-identical
  generated files and receipts except for an explicitly excluded invocation time.
- **FR14a — Future comparison is read-only.** A fully owned snapshot MAY later compare
  itself with a newer release of its former base using receipt metadata. The
  comparison MUST be explicitly requested, MUST show the proposed structural and
  visual differences, and MUST NOT update the owned source or recreate inheritance
  automatically.

### Compatibility and migration

- **FR15 — Existing convenience APIs remain stable until migration exists.** The
  implementation behind an existing `defineTheme` typography, motion, radius, or
  color configuration MUST NOT silently adopt incompatible generation behavior.
  Versioning, deprecation, or replacement requires a supported comparison and
  materialization path first. `defineTheme({color})` MUST NOT be marked deprecated
  until the supported palette generator, explicit mapping workflow, visual
  comparison, migration command, documentation, and compatibility fixtures are
  available. Removal requires a later breaking release.
- **FR16 — Legacy color migration is explicit.** Migrating
  `defineTheme({color})` to an author-owned tonal palette MUST use the current
  supported palette generator, produce a reviewable palette and token mapping, and
  present the resulting visual and structural delta. It MUST NOT swap the legacy
  HCT implementation for a new recipe underneath unchanged source.
- **FR17 — Lifecycle commands use plain language.** The CLI MAY expose distinct
  `detach` and `freeze` commands or another reviewed vocabulary. Help and output
  MUST explain the outcome in terms of following updates versus owning a snapshot.
  Commands MUST remain non-interactive: choices and write approval use explicit
  options or separate preview and write invocations, never prompts or stdin. The
  internal term `materialize` alone is insufficient user guidance.
- **FR18 — No current palette work is blocked.** AST-022 governs later lifecycle
  tooling. It MUST NOT delay accepted-palette, palette-generation, validation, or
  theme-adoption work that preserves the current `defineTheme` boundary.

## Proposed author flow

### Start a theme

```text
Choose Neutral as a base
            |
            +-- Follow Neutral
            |      Keep extends; receive reviewed Neutral changes on upgrade
            |
            +-- Use as a starting point
                   Generate owned local source; no Neutral theme dependency
```

### Change an existing theme

```text
Inspect dependencies
        |
        +-- Detach from a base
        |      Inspect inherited local-token lineage
        |      Migrate lineage-bound names or stop
        |      Resolve inheritance and remove extends
        |
        +-- Freeze generated values
               Replace selected scale config with explicit local values

Preview -> verify equivalence -> explicit write invocation -> atomic write -> receipt
```

The exact command names remain an implementation decision. A possible split is
`astryx theme detach` for inheritance and `astryx theme freeze` for generated
scales, both backed by the same materialization engine.

## Current-state impact

Today, `defineTheme({extends})` correctly flattens a base into the resolved
`DefinedTheme`, which lets runtime and static builds consume one self-contained
result. Source authors nevertheless retain a dependency on the imported base.
Updating that package and rebuilding may therefore change inherited theme values.

Typography, motion, radius, and legacy color helpers also generate values during
normalization. Their concise intent is useful and remains supported, but changing a
formula under unchanged source can change output. This spec introduces no such
change. It defines the future escape hatch that lets an author keep the concise
configuration or freeze its current result deliberately.

The CLI has theme templates and build output, but it does not currently provide a
general operation that removes `extends`, expands every supported generated scale,
preserves source-level registry references, and verifies equivalent authoring
source. That implementation follows this proposed contract. Creating new values
beyond built-in scales is related authoring work but is specified independently by
AST-023.

## Verification

| Contract  | Verification                                                                                                                   | Representative failure                                                                                                                                   |
| --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR3   | CLI option and source-output fixtures for follow and owned-start choices                                                       | A missing choice prompts or writes, a copied theme still imports its selected base, or a following theme is described as independent.                    |
| FR4–FR7   | Full-surface fixtures covering generators, inheritance, local tokens, media surfaces, and palette references                   | A supported surface disappears, precedence changes, or a theme-owned palette reference is needlessly severed.                                            |
| FR4a      | Neutral-derived detach fixtures with inherited declarations and references, with and without an explicit local-token migration | `extends` is removed while Neutral-owned names remain foreign, or the operation claims ownership while retaining an unreported lineage dependency.       |
| FR5b–FR5d | Source/runtime projection fixtures, compiled-size comparisons, and artifact-boundary checks                                    | Reviewing a resolved theme inflates runtime output, or receipts and authoring metadata ship to consumers.                                                |
| FR8–FR10  | Executable-registry ownership and base-upgrade fixtures, overwrite, partial-freeze, and injected-failure tests                 | An owned icon changes after the former base upgrades, an import dependency is hidden, a target is partly overwritten, or retained inheritance is hidden. |
| FR11–FR14 | Normalized-theme equivalence, deterministic snapshots, receipt schema, and preview tests                                       | Before and after differ silently, output changes across identical runs, or a receipt omits a dependency.                                                 |
| FR15–FR18 | Compatibility fixtures and legacy-color migration comparison                                                                   | An unchanged convenience config renders differently, migration hides a color delta, or lifecycle work blocks palette foundations.                        |

### Completion criteria

This spec moves from `proposed` to `shipped` only when:

- theme creation offers an explicit follow-versus-owned-start choice;
- the supported detach/freeze surface is documented with non-destructive defaults;
- full and partial materialization account for every current `DefineThemeInput`
  surface;
- detach fixtures prove that lineage-bound local tokens either receive an explicit,
  validated migration or block removal of `extends` before any write;
- authoring projections are separate from compact runtime artifacts, and previews
  show the source and compiled/package-size consequences;
- unrepresentable executable values fail before writes with actionable guidance;
- fully owned executable-registry fixtures remain unchanged when the former base is
  upgraded, while intentionally retained imports are reported as dependencies and
  prevent a fully owned claim;
- before/after structural equivalence is tested under one pinned Astryx version;
- generated source and receipts are deterministic;
- legacy color migration uses the supported palette generator without silently
  changing existing `defineTheme({color})` behavior; and
- consumer guidance clearly distinguishes theme-source ownership from freezing the
  Astryx component library itself.

## Open questions

- **Should the CLI expose `detach`, `freeze`, or both?** Detach removes a base-theme
  relationship; freeze converts selected generated scales into explicit values.
  They use the same materialization engine but communicate different outcomes. Is
  one guided command clearer, or do separate verbs make the consequences safer?
- **How should large component maps be represented?** One generated theme file is
  easy to import but difficult to review. Splitting tokens, component overrides,
  media surfaces, and registries into deterministic modules is clearer but creates
  more files. Which source shape best balances reviewability and faithful rebuilds?
- **What counts as equivalent?** A palette reference and its current hex value may
  render identically today while having different future behavior. The same is true
  of a `calc()` expression and its current computed result. Should equivalence
  require identical authoring intent, identical normalized theme data, identical
  compiled output, or a documented combination?

## Decision log

### DEC-1 — Let authors explicitly choose whether to follow or own

**Reference:** `spec:AST-022/DEC-1`
**Decider:** `rubyycheung`, `2026-09-04`

The palette remains owned by its theme regardless of the theme's inheritance
choice. A theme author may reasonably prefer independent source or continuing base
updates, so tooling explains both consequences and requires a selection. It may
recommend a choice based on the stated goal, but does not choose silently.

### DEC-2 — Use one materialization engine for inheritance and generated scales

**Reference:** `spec:AST-022/DEC-2`
**Decider:** pending

Base-theme detachment and scale freezing both convert resolved authoring behavior
into explicit source. One engine provides consistent preview, equivalence, write,
and receipt guarantees even if the CLI uses different user-facing verbs.

### DEC-3 — Preserve existing generator behavior until migration is reviewable

**Reference:** `spec:AST-022/DEC-3`
**Decider:** `rubyycheung`, `2026-09-04`

Replacing a formula behind unchanged theme source is a visual compatibility break.
Existing helpers remain stable until authors can compare, materialize, and accept a
replacement explicitly.

### DEC-4 — Own inherited executable values or report retained dependencies

**Reference:** `spec:AST-022/DEC-4`
**Decider:** `rubyycheung`, `2026-09-04`

Stable icon, indicator, and syntax imports remain explicit only for dependencies the
author intentionally retains. A fully owned snapshot cannot import an executable
value from its former base because a later base upgrade could change that value.
Tooling instead writes equivalent owned local source or requires the author to
supply a local reference on a later invocation. Retaining the base import is a
reported dependency and prevents the result from being described as fully owned.

### DEC-5 — Preserve locally owned palette references

**Reference:** `spec:AST-022/DEC-5`
**Decider:** `rubyycheung`, `2026-09-04`

A reference to the same theme's committed palette maintains one owned source of
truth and remains a reference by default. External palette dependencies require an
explicit keep-or-copy choice.

### DEC-6 — Compare future bases only on request

**Reference:** `spec:AST-022/DEC-6`
**Decider:** `rubyycheung`, `2026-09-04`

Receipts retain the former base identity and version so an author may request a
future comparison. Comparison does not update the theme or recreate inheritance.

### DEC-7 — Detach updates the existing theme

**Reference:** `spec:AST-022/DEC-7`
**Decider:** `rubyycheung`, `2026-09-04`

Detach exists only as an exit from a previously chosen `extends` relationship. It
adds the resolved values to the selected theme and removes `extends`, preserving the
theme name and caller imports. Version control and the required preview provide the
before/after comparison; the operation does not create a second theme.

### DEC-8 — Full ownership includes every public theme value

**Reference:** `spec:AST-022/DEC-8`
**Decider:** `rubyycheung`, `2026-09-04`

A differences-only file still inherits unspecified Core defaults and therefore is
not fully owned. A fully owned snapshot includes every resolved value available
through the public theme-authoring contract, even when that output is larger.

### DEC-9 — Detach requires an explicit migration for inherited local tokens

**Reference:** `spec:AST-022/DEC-9`
**Decider:** `rubyycheung`, `2026-09-05`

Materialization cannot make a lineage-bound local token portable by copying its
name. Removing the exact base lineage would make that declaration foreign under the
current validator. Detach therefore stops for a reviewed local-token migration; if
the author retains the lineage instead, the result continues to follow that base
and is not fully owned.

---

### Specs AST 024 Spec (`docs/specs/AST-024/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-024
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-04
phase: accepted
owners: [rubyycheung]
affects_architecture: [architecture:theme-authoring-contract]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme, typography]
---

# Theme-extensible Heading visual roles system spec

## Intent

Product themes sometimes need named heading treatments beyond Astryx's three
built-in display roles. A theme may need a hero treatment, a compact section
heading, or another locally named visual role while still rendering the correct
`h1`–`h6` element. Authors may also need to change the weight of one Heading use
without creating a new visual role.

Astryx already supports theme-defined `Text` types and generates their TypeScript
augmentation during `astryx theme build`. `Heading` does not provide the same
extension point: its `type` is a closed union and it has no `weight` prop. This spec
makes Heading visual roles theme-extensible, keeps document semantics independent
from appearance, and gives generated theme CSS and generated types one dependable
contract.

## Non-goals

- Add product-specific heading names to the options Astryx provides in every
  theme.
- Infer an `h1`–`h6` level from a visual type name or font size.
- Allow visual styling to repair an incorrect document outline.
- Add arbitrary Heading sizes without a theme declaration or local source.
- Replace `Text`, typography tokens, or the existing type-scale generator.
- Define how additional typography values are calculated; theme-local scale
  extension authoring remains a separate proposal.
- Expand the font-weight choices available in every theme in this change.

## Terms

- **Semantic level:** the `Heading.level` value that selects `h1`–`h6` and the
  element's place in the document outline.
- **Visual type:** a named treatment that controls size, line height, font family,
  weight, or other approved visual properties without changing semantic level.
- **Built-in type:** `display-1`, `display-2`, or `display-3`.
- **Custom type:** a theme-owned visual type declared through a Heading component
  override and exposed to TypeScript through an augmentable map.
- **Weight override:** an explicit `Heading.weight` value for one rendered heading;
  it wins over the visual type or semantic-level default without changing either.

## Current limitation

`HeadingType` is currently the closed literal union
`'display-1' | 'display-2' | 'display-3'`. TypeScript module augmentation cannot
widen a type alias. The theme builder therefore skips a theme rule such as
`heading['type:hero']` when generating `.variants.d.ts`; emitting a new unrelated
interface would not change `HeadingProps.type`.

The browser can match the generated `data-type="hero"` selector, but application
source cannot pass `type="hero"` without a type error. If the error is bypassed,
Heading also has no defined baseline StyleX entry for that name. The current system
therefore permits CSS that its public component API cannot use safely.

## Requirements

### Extensible visual types

- **FR1 — Heading exposes a real augmentation point.** The public Heading subpath
  MUST export an interface whose keys form the custom portion of `HeadingType`.
  Built-in types remain available without augmentation. A generated declaration
  MUST widen the same interface consumed by `HeadingProps.type`; a declaration
  against a barrel-only or unused interface is invalid.
- **FR2 — Theme build enrolls declared custom types.** When a theme contains a
  custom `heading['type:<name>']` override, `astryx theme build` MUST emit the
  corresponding type augmentation and reference it from the generated theme
  declaration. If the augmentation cannot be generated or loaded, the build MUST
  fail with actionable guidance rather than emit CSS that typed source cannot use.
- **FR3 — Custom types are theme-owned but TypeScript availability is
  program-wide.** A custom name becomes type-safe after a consumer imports the
  owning theme's generated declarations. TypeScript module augmentation applies to
  the whole compilation, so it cannot prove that a matching Theme is active at one
  call site. Documentation MUST state that distinction. A custom name MUST NOT
  enlarge Heading's built-in type set for applications that do not import it.
- **FR4 — Runtime styling is deterministic.** A valid custom type MUST reflect its
  name through the same theming selector path as a built-in type. Before theme CSS
  applies, it MUST retain the selected semantic level's safe baseline rather than
  becoming unstyled or inheriting an unrelated ambient text size. If application
  source uses an imported custom name under a theme that does not define it, the
  safe baseline applies. Development tooling SHOULD warn when it can determine the
  active theme and mismatch without adding production runtime cost.
- **FR5 — Partial custom types are valid.** A custom Heading type MUST contain at
  least one meaningful visual declaration. Font size, line height, font family,
  weight, letter spacing, color, and other supported properties MAY be changed
  independently; omitted properties inherit from the selected semantic level's
  baseline. An empty or wholly invalid custom type fails before output.
- **FR6 — Existing types do not change.** Themes that declare no custom Heading
  type MUST retain their current types, runtime output, and generated artifacts.
  Existing `display-1` through `display-3` behavior remains compatible.

### Per-use weight

- **FR7 — Heading accepts an optional weight override.** `Heading` MUST accept the
  same named `TextWeight` values as `Text`: `normal`, `medium`, `semibold`, and
  `bold`. When omitted, the current level- or type-derived default remains.
- **FR8 — Explicit weight has clear precedence.** A supplied `weight` prop MUST win
  over the default weight of either a built-in or custom visual type. It MUST NOT
  change `level`, `accessibilityLevel`, or the selected HTML element. Heading MUST
  reflect the selected weight through its theming target, and the emitted cascade
  MUST guarantee this order regardless of authored rule order: explicit weight,
  then visual-type default, then semantic-level default.
- **FR9 — Theme values remain authoritative.** Named weight props resolve through
  the existing font-weight tokens, so a theme may change what `bold` or `semibold`
  means without changing component source. Raw numeric or arbitrary weight strings
  are not added to the component prop in the first version.

### Semantics and author guidance

- **FR10 — Level and type remain independent.** Every custom-type example MUST
  include an explicit semantic `level`. Tooling and documentation MUST NOT infer a
  level from names such as `hero`, `title`, or `display`.
- **FR11 — Visual reuse does not justify skipped levels.** Guidance MUST tell
  authors to choose heading levels from the document outline, then choose a visual
  type. It MUST NOT recommend changing `level` merely to obtain a different size.
- **FR12 — Non-heading text keeps using Text.** If content is not a section or page
  heading, authoring guidance MUST use `Text` even when the same visual treatment is
  desired. A shared visual name MAY be declared separately for both components, but
  does not make non-heading content semantic.

### Verification and generated artifacts

- **FR13 — Source, runtime, and build agree.** A custom Heading type MUST pass
  TypeScript, render the requested `h1`–`h6`, expose its `data-type`, receive the
  generated declarations, and compute the expected CSS in both runtime-injected
  and statically built theme modes.
- **FR14 — Missing declarations fail visibly.** A consumer that uses a custom type
  without importing the owning theme declarations MUST receive a TypeScript error.
  Documentation MUST show the required theme artifact import and explain that
  importing it cannot prove which Theme is active at runtime.
- **FR15 — Accessibility behavior is unchanged.** Adding a visual type or weight
  MUST NOT alter accessible name, heading role, semantic level, truncation behavior,
  or `aria-level` handling. Existing Heading accessibility tests remain applicable.

## Accepted API

```ts
// Excerpt from @astryxdesign/core/Heading
export interface HeadingTypeMap {
  'display-1': true;
  'display-2': true;
  'display-3': true;
}

export type HeadingType = keyof HeadingTypeMap;

export interface HeadingProps {
  level: 1 | 2 | 3 | 4 | 5 | 6;
  type?: HeadingType;
  weight?: TextWeight;
}
```

A theme may then declare and build a local role:

```ts
defineTheme({
  name: 'product',
  localTokens: {
    '--astryx-theme-product-text-hero-size': '4.5rem',
    '--astryx-theme-product-text-hero-leading': '1.05',
  },
  components: {
    heading: {
      'type:hero': {
        fontSize: 'var(--astryx-theme-product-text-hero-size)',
        lineHeight: 'var(--astryx-theme-product-text-hero-leading)',
      },
    },
  },
});
```

After importing the built theme artifacts, application source can use the visual
role without changing its semantic level:

```tsx
<Heading level={1} type="hero">
  Product overview
</Heading>

<Heading level={2} type="hero" weight="bold">
  Featured report
</Heading>
```

## Current-state impact

The accepted spec changes no runtime or public API by itself. Its implementation
will replace Heading's closed type alias with a public map-backed union, teach the
theme builder to augment that map, give custom types a semantic-level baseline,
and add the existing named weight vocabulary to `HeadingProps`.

Themes and applications that use only the three built-in display types require no
migration. A theme that already emits `heading['type:<custom>']` CSS currently has
an unusable typed selector; after implementation and rebuild, its generated
declarations will make that existing local name available to consumers that import
the theme artifact. The augmentation is available throughout that TypeScript
program, while the custom styling remains owned by the active theme. No custom name
becomes an option that every Astryx application must support.

## Verification

| Contract  | Verification                                                                 | Representative failure                                                                                         |
| --------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| FR1–FR3   | Public-subpath augmentation and generated-declaration type tests             | The CSS builds but `type="hero"` still fails, or docs claim TypeScript knows which Theme is active.            |
| FR4–FR6   | Heading unit tests and before/after CSS snapshots                            | A custom type loses its level baseline, an intentional partial role fails, or a built-in theme changes output. |
| FR7–FR9   | Weight target and cascade tests for level, built-in type, and custom type    | `weight="bold"` loses to a type default, depends on authored rule order, or bypasses theme weight tokens.      |
| FR10–FR12 | Docs and authoring fixtures                                                  | Tooling infers `h1` from `hero`, skips a level for size, or uses Heading for non-heading copy.                 |
| FR13–FR15 | Consumer typecheck, runtime/static build parity, DOM and accessibility tests | The built artifact is not loaded, the selector differs by build path, or visual props alter heading semantics. |

### Completion criteria

This spec moves from `accepted` to `shipped` only when:

- Heading exposes and consumes one public augmentable type map;
- theme build emits and loads custom Heading type declarations;
- a custom type has a safe semantic-level baseline and at least one valid visual
  declaration;
- Heading accepts the existing named weight values with documented precedence;
- explicit weight wins through the runtime and built theme cascade, independent of
  authored rule order;
- runtime and static theme builds produce equivalent custom-type styling;
- built-in Heading behavior and themes without custom types remain unchanged; and
- examples and tests keep semantic level independent from visual treatment.

## Open questions

None.

## Decision log

### DEC-1 — Make Heading type extensible through a public map

**Reference:** `spec:AST-024/DEC-1`
**Decider:** `rubyycheung`, `2026-09-04`

Use the same proven map-based module-augmentation model as other extensible
component props. A closed type alias cannot be widened, and generating an unrelated
interface would falsely suggest type safety without changing `HeadingProps`.

### DEC-2 — Keep semantic level separate from visual type

**Reference:** `spec:AST-024/DEC-2`
**Decider:** `rubyycheung`, `2026-09-04`

Custom names control appearance only. Authors continue to choose `level` from the
document structure. This permits the same visual role at different valid levels
without coupling typography to accessibility semantics.

### DEC-3 — Add weight parity with Text

**Reference:** `spec:AST-024/DEC-3`
**Decider:** `rubyycheung`, `2026-09-04`

Add the existing named `TextWeight` vocabulary to Heading and make an explicit prop
override the type or level default. Do not add a second weight vocabulary or raw
numeric values in the first change.

### DEC-4 — Enroll visual roles independently by component

**Reference:** `spec:AST-024/DEC-4`
**Decider:** `rubyycheung`, `2026-09-04`

A visual-role name does not automatically become available to both `Text` and
`Heading`. Authors enroll it on each component whose semantics they intend to use.
The underlying theme-local typography values may be shared, and authoring tooling
may offer to create both mappings together, but it must show and confirm each use.

This prevents a Text treatment from silently becoming a heading treatment while
still avoiding duplicate value definitions.

### DEC-5 — Keep component weight choices consistent across themes

**Reference:** `spec:AST-024/DEC-5`
**Decider:** `rubyycheung`, `2026-09-04`

`Heading.weight` and `Text.weight` continue to use the same named choices in every
theme. The active theme decides whether a name such as `bold` resolves to 700, 750,
or another supported value. A custom visual type may also declare an exact weight
in theme source.

Rejected: accepting arbitrary numeric values directly at each component call site,
which would couple application source to one font and bypass theme ownership.

### DEC-6 — State the limit of theme-generated TypeScript clearly

**Reference:** `spec:AST-024/DEC-6`
**Decider:** `rubyycheung`, `2026-09-04`

Importing a theme's generated declaration makes its custom Heading names available
throughout that TypeScript program. It does not prove that the matching Theme is
active around every use. Runtime styling remains theme-scoped, mismatches retain the
semantic-level baseline, and development tooling warns when it can identify one
reliably.

Rejected: promising provider-aware static typing that TypeScript module augmentation
cannot enforce.

### DEC-7 — Permit partial visual roles

**Reference:** `spec:AST-024/DEC-7`
**Decider:** `rubyycheung`, `2026-09-04`

A custom Heading type may intentionally change only one supported visual property
and inherit the rest from its semantic level. It must contain at least one valid
declaration, but it does not have to restate font size and line height.

Rejected: requiring every custom type to be a complete typography replacement,
which would make small theme variations unnecessarily repetitive.

---

### Specs AST 025 Spec (`docs/specs/AST-025/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-025
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-11
phase: accepted
owners: [cixzhang]
affects_architecture:
  [architecture:container-padding, architecture:public-component-api]
affects_families: [family:layout-regions, family:layout-primitives]
affects_contributing: []
affects_consumer_docs:
  [Layout, Stack, Table, Popover, DropdownMenu, Carousel, CodeBlock]
---

# Scrollable container behavior system spec



```json
{
  "scope": "global",
  "triggers": {
    "scrolling": ["FR1", "FR12", "FR13", "FR14", "FR15", "FR16", "FR18"]
  }
}
```

## Intent

People should be able to reach and scroll overflowing content, continue scrolling
the containing page when a nested area has nothing to scroll, and understand which
content remains pinned while they move. Builders should not have to coordinate
accessibility, sticky positioning, overscroll containment, scrollbar presentation,
and browser-specific overflow behavior independently.

This spec defines one axis-aware scroll-container capability. Shared utility hooks
form the canonical reusable behavior surface so components that already own suitable
structure can participate directly. `ScrollableArea` composes those hooks into a
convenience component and reference implementation; it is not the exclusive path to
the behavior.

## Non-goals

- Selecting the final exported hook names, prop names, or TypeScript spelling in
  this draft; the hooks-first ownership and `ScrollableArea` reference composition
  are settled by DEC-1.
- Replacing browser scrolling with JavaScript-driven movement.
- Requiring custom scrollbar DOM or styling every platform scrollbar identically.
- Making arbitrary overflow values or independent `overflow-x` / `overflow-y`
  combinations part of the public design-system vocabulary.
- Allowing content to paint visibly through the non-scrolling axis of the same
  viewport; CSS does not provide that combination reliably.
- Defining Sticky's complete public API, stacking model, or visual treatment.
- Automatically migrating every current `overflow: auto` callsite.
- Changing Layout's five-slot topology or AppShell's ownership of the page shell.

## Terms

- **Viewport:** the element whose scroll offsets move content.
- **Content box:** a real layout box inside the viewport whose geometry can be
  observed without asking arbitrary descendants to participate.
- **Requested axis:** the logical axis or axes on which the owner intends to allow
  scrolling: `inline`, `block`, or `both`.
- **Effective scroll axis:** a requested axis whose resolved computed overflow is
  user-scrollable and whose content extent exceeds its viewport extent by more
  than the shared tolerance.
- **Effective scroll owner:** the nearest ancestor that has an effective scroll
  axis matching the behavior being resolved.
- **Scrollbar presenter:** native CSS or optional custom UI that represents the
  viewport's existing scroll state without creating another viewport.

## Requirements

- **FR1 — Scroll intent uses logical axes.** The system MUST represent scroll intent
  as `inline`, `block`, or `both`. Writing direction and writing mode determine the
  physical axis. Left, right, top, and bottom MUST NOT define the shared contract.
- **FR2 — Scroll ownership is explicit and local.** A participating container MUST
  identify one viewport and its content box. Descendants MUST receive the same
  per-axis owner state whether the capability is delivered through a standalone
  component or integrated into an existing component-owned element.
- **FR3 — Effective scrolling requires scroll-capable style and measurable
  overflow.** Resolve the requested logical axis through the viewport's computed
  writing mode before reading physical geometry. In horizontal writing modes,
  inline compares `scrollWidth` with `clientWidth` and block compares
  `scrollHeight` with `clientHeight`; vertical and sideways writing modes reverse
  those physical measurements. An axis is effective only when its resolved
  computed overflow is `auto`, `scroll`, or the legacy `overlay` value and its
  content extent exceeds its viewport extent by more than the shared tolerance.
  The initial shared tolerance is 1 CSS pixel. An `overflow: auto` declaration or
  excess geometry alone MUST NOT make the container an effective owner.
- **FR4 — Effective state is axis-specific.** A container may be effective on one,
  both, or neither requested axes. Focusability, sticky ownership, overscroll
  containment, scrollbar presentation, and edge state MUST consume the applicable
  axis rather than one undifferentiated `hasOverflow` boolean.
- **FR5 — Live measurement observes viewport and content geometry.** The system
  MUST observe both the viewport box and a real content layout box. A viewport-only
  `ResizeObserver` is insufficient because content may grow while the viewport's
  own box remains unchanged. `display: contents` is not a valid content observation
  target because it has no dependable box geometry.
- **FR6 — Existing structure may supply the content box.** A component that already
  owns a suitable content root MAY register it directly. A reusable behavior MUST
  NOT silently insert a wrapper around arbitrary children. When an implementation
  creates a content wrapper, it MUST document and verify that wrapper's formatting,
  sizing, flex/grid participation, percentage sizing, and intrinsic-size effects.
- **FR7 — Invalidation covers more than element resize.** The system MUST measure
  after initial connection, viewport or content-box resize, requested-axis or
  relevant style changes, and transition from unmeasurable/hidden to rendered.
  Integrations whose overflow can change without either observed box resizing MUST
  provide an explicit invalidation path or an equivalent bounded signal. Repeated
  invalidations in one frame MUST coalesce into one measurement.
- **FR8 — Unmeasurable is not fitting.** A disconnected, `display: none`, or
  zero-geometry viewport MUST NOT be treated as evidence that content fits. The
  implementation MAY keep the last valid state or expose an internal unknown state,
  but MUST remeasure when the viewport becomes measurable.
- **FR9 — State updates are stable.** The shared observer MUST publish a new state
  only when effective axes or requested edge information changes. Measurement MUST
  converge without an overflow/scrollbar feedback loop, including on platforms
  whose native scrollbars consume layout space.
- **FR10 — Sticky resolves against effective ownership by axis.** A block-start or
  block-end Sticky participant MUST use the nearest effective block owner. An
  inline-start or inline-end participant MUST use the nearest effective inline
  owner. A non-overflowing candidate and a candidate effective only on the other
  axis MUST be skipped. Arbitrary Sticky placement in flowing content MUST remain
  possible; fixed header/footer slots are not a general substitute.
- **FR11 — Native CSS sticky is conditional implementation detail.** Native
  `position: sticky` MAY be used when the browser's nearest CSS scroll container
  is also the system's effective owner on the requested axis. The implementation
  MUST NOT rely on native ancestor selection where CSS overflow-axis coupling makes
  those owners disagree. This spec does not choose the fallback positioning
  mechanism.
- **FR12 — Keyboard access follows effective scrolling.** Effective scroll content
  MUST remain keyboard reachable as required by WCAG 2.1.1. A delivery surface that
  assigns keyboard scrolling to the viewport MUST add it to the tab order only
  while at least one applicable axis is effective, and MUST provide an appropriate
  role and accessible name. An integration MAY instead rely on an existing
  focusable descendant when that path gives keyboard users access to all overflowed
  content. In shared `contentOrViewport` mode the named viewport MUST retain
  `tabIndex=0` while effectively scrollable. On forward keyboard Tab entry the
  hook MUST inspect the first sequential descendant once and MAY delegate only
  to a native link or button that preserves native scroll keys. It MUST NOT skip
  an excluded first target to reach a later eligible one. Composite widgets,
  nested scroll owners, editable surfaces, and navigation-key-owning roles MUST
  retain the viewport stop. Reverse traversal from the delegated first child
  MUST skip the viewport without a loop. Pointer and programmatic focus MUST NOT
  delegate. Eligibility MUST be recalculated at each keyboard entry without
  persistent subtree or ancestor observation for keyboard eligibility; existing
  geometry observation remains governed by FR5–FR9. Both paths MUST retain clear
  accessible naming and native Arrow/Page access to the full scroll range.
- **FR13 — Losing overflow does not move focus.** If a focused viewport stops
  overflowing, the system MAY remove it from future sequential navigation but MUST
  NOT move or blur current focus solely because geometry changed.
- **FR14 — Scroll chaining is the default.** Nested wheel, touch, and keyboard
  scrolling MUST continue to an ancestor when the current area has no movement in
  the requested direction. A containment policy MAY stop chaining, but its
  `overscroll-behavior` effect MUST be active only on axes that currently overflow.
  This avoids dead scroll zones in engines that honor containment on non-overflowing
  `overflow: auto` elements.
- **FR15 — Native scrollbars are the default presentation.** The default presenter
  MUST preserve native scrolling and use standards-based CSS such as
  `scrollbar-width`, `scrollbar-color`, and `scrollbar-gutter` where supported.
  Unsupported declarations MUST fall back to the platform scrollbar without
  changing scroll ownership or access.
- **FR16 — Custom scrollbars are optional presentation.** A custom track or thumb
  MAY represent an existing viewport, but MUST NOT create a second scroll container,
  replace native scroll mechanics, or maintain an independent source of truth.
  It MUST use the same effective-axis, extent, offset, writing-direction, and
  forced-colors state as the viewport. Hiding the native scrollbar without an
  equivalent visible and operable affordance is not a general default.
- **FR17 — Axis combinations are closed.** The system MUST NOT expose arbitrary
  pairs of `visible`, `clip`, `hidden`, `auto`, and `scroll`. CSS computes
  `visible` to `auto` and `clip` to `hidden` when paired with a non-visible value on
  the other axis, so those apparent combinations do not preserve independent-axis
  semantics. Content that must escape the non-scrolling axis requires separate
  structure or a Layer.
- **FR18 — Nested ownership is deterministic.** The nearest effective owner on the
  applicable axis wins. Orthogonal nested areas MAY coexist. Same-axis nesting MUST
  preserve chaining by default and MUST be deliberate in the owning component's
  contract rather than emerging from incidental overflow styles.
- **FR19 — Table may integrate rather than wrap.** Table MAY adopt the capability
  on its existing scroll wrapper so column sizing, keyboard access, scroll shadows,
  sticky columns, and sticky headers share one viewport. Table MUST NOT require a
  separate public ScrollableArea component merely to participate. An arbitrary
  external wrapper cannot satisfy Table's internal ownership contract unless Table
  explicitly accepts and coordinates that owner.
- **FR20 — The reference component integrates container geometry.** ScrollableArea
  MUST publish the actual logical padding of its content box, using zero when no
  padding is requested. Its viewport MAY consume inherited padding only through an
  explicit full-bleed option; scrollability alone MUST NOT escape a parent container.
- **FR21 — Fitting scroll intent does not imply Sticky containment.** A participating
  viewport whose requested axes have no excess geometry MUST use `clip` on both
  physical axes. This prevents pre-measure paint overflow without creating a CSS
  scroll container, so native Sticky descendants can resolve to an outer effective
  owner. The viewport MUST switch to its requested `auto`/`hidden` pair when content
  exceeds the viewport, before the axis becomes an effective owner. A public
  `stickyContainment="always"` option MAY preserve that boundary while fitting, but
  containment MUST be explicit rather than an incidental result of scroll intent.

### Delivery requirements

- **IR1 — Shared hooks are the behavioral core.** Shared utility hooks MUST own
  effective-axis measurement, observation, invalidation, and owner publication.
  `ScrollableArea` and component-integrated forms MUST consume that core rather
  than creating parallel detectors.
- **IR2 — Behavior does not require `ScrollableArea`.** Components that already
  own a viewport and content root MUST be able to adopt the hooks without adding
  the convenience component or changing their semantic outer element.
- **IR3 — `ScrollableArea` is the reference composition.** The component owns and
  documents its viewport/content structure, applies the shared accessibility,
  chaining, sticky-owner, and scrollbar-presentation rules, and demonstrates how
  another structure-owning component integrates the hooks. It MUST NOT contain
  behavior unavailable to direct hook adopters.
- **IR4 — Structure and prop composition remain explicit.** A hook MUST accept
  caller-owned viewport/content props and refs through prop getters or an equivalent
  composition contract. It MUST compose refs, event handlers, class/style inputs,
  and ARIA without making spread order decide whether caller or behavior is lost.
  Component-owned accessibility and non-cancellable behavior win documented
  conflicts; cancellable handlers use the shared event contract. The hook does not
  pretend structure is unnecessary or silently wrap arbitrary children.
- **IR5 — Public API follows intent.** Future hook and component names expose
  semantic scrolling, chaining, accessibility, or presentation intent. Raw CSS
  values, observer controls, tolerance knobs, and browser workarounds remain
  internal unless a separate admission decision proves caller ownership.

### Candidate shared hook boundary

The exact names and TypeScript spelling remain API-review work. The candidate
boundary is:

```ts
type ScrollAxis = 'inline' | 'block' | 'both';

type KeyboardAccess =
  | {owner: 'content'}
  | {owner: 'viewport'; label: string; role?: 'group' | 'region'}
  | {
      owner: 'contentOrViewport';
      label: string;
      role?: 'group' | 'region';
    };

interface ScrollAxisState {
  isScrollable: boolean;
  atStart: boolean;
  atEnd: boolean;
}

interface UseScrollableAreaOptions {
  axis: ScrollAxis;
  keyboardAccess: KeyboardAccess;
  overscroll?: 'allow' | 'contain';
  stickyContainment?: 'whenScrollable' | 'always';
}

type ElementProps<E extends HTMLElement> = BaseProps<E> &
  React.RefAttributes<E>;

interface UseScrollableAreaResult {
  getViewportProps<E extends HTMLElement>(
    props: ElementProps<E>,
  ): ElementProps<E>;
  getContentProps<E extends HTMLElement>(
    props: ElementProps<E>,
  ): ElementProps<E>;
  state: {
    inline: ScrollAxisState;
    block: ScrollAxisState;
  };
}
```

Each prop getter receives the adopter's already-resolved element props and public
ref, composes them with the hook's behavior through the shared prop/ref utilities,
and returns one safe spread object. `getViewportProps` consumes caller `xstyle` and
adds behavior-owned fitting clip, axis-specific active overflow, explicit Sticky
containment, keyboard, ARIA, event, data-state, overscroll, and registration behavior
without choosing viewport sizing or scrollbar presentation. `getContentProps`
composes content observation with an existing content ref and props. Neither a loose
prop bag nor a separate callback ref makes spread order part of the contract.

Native scrollbar width, color, and gutter remain CSS on the viewport rather than
hook options. Optional custom presenters and scroll shadows consume the same
registered metrics through a narrower subscription so ordinary owners do not
rerender for every scroll offset. Sticky uses a private nearest-owner consumer over
the registered DOM ancestry; adopters do not render a Provider.

An explicit invalidation method remains internal unless real adopters demonstrate an
overflow change that neither observed box nor a bounded platform signal can detect.

### Behavioral matrix

| Requested axes | Inline effective | Block effective | Effective owner state             | Sticky ownership                                              |
| -------------- | ---------------- | --------------- | --------------------------------- | ------------------------------------------------------------- |
| `inline`       | no               | n/a             | none                              | no inline owner; block Sticky skips the candidate             |
| `inline`       | yes              | n/a             | inline                            | inline Sticky resolves here; block Sticky skips it            |
| `block`        | n/a              | no              | none                              | no block owner; inline Sticky skips the candidate             |
| `block`        | n/a              | yes             | block                             | block Sticky resolves here; inline Sticky skips it            |
| `both`         | yes/no           | yes/no          | each effective axis independently | each Sticky edge resolves only on its matching effective axis |

The browser may still classify one viewport as a CSS scroll container on both
physical axes because of overflow computed-value coupling. That platform fact does
not change the system owner state in this table.

### Scrollbar presentation matrix

| Presentation   | Scroll mechanics                   | Rendering rule                                                                     | Required fallback                                                                    |
| -------------- | ---------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Native/default | Browser-owned on the viewport      | Platform scrollbar; standards-based width, color, and gutter styling may refine it | Ignore unsupported styling and retain native behavior                                |
| Native/hidden  | Browser-owned on the viewport      | Allowed only when another clear affordance and keyboard path expose the overflow   | Restore native presentation in forced colors or when the replacement is unavailable  |
| Custom         | Browser-owned on the same viewport | Track/thumb mirror effective axes and live offsets                                 | Native scrolling, touch, wheel, keyboard, and programmatic APIs remain authoritative |

### Platform support

- The baseline implementation MUST work in every browser supported by Astryx Core
  using `ResizeObserver` and DOM scroll geometry. CSS scroll-state queries are a
  future optimization, not the sole implementation, because
  `scroll-state(scrollable: ...)` remains unavailable in Firefox and Safari at the
  time of this proposal.
- `scrollbar-width`, `scrollbar-color`, and `scrollbar-gutter` are within Astryx's
  browser compatibility window and form the supported native presentation surface.
  Forced-colors and platform scrollbar preferences take precedence over decorative
  matching; unsupported or ignored values still degrade to the platform scrollbar
  without changing ownership or access.
- Browser evidence MUST cover Chromium, Firefox, and real Safari. It MUST include
  classic and overlay scrollbars where available, LTR and RTL, zoom, writing-mode
  changes, hidden-to-visible transitions, async content, nested orthogonal and
  same-axis areas, and effective-state changes while focused.
- Chromium evidence MUST cover two regression-sensitive interactions:
  containment on a non-overflowing candidate after Chrome 144's
  [overscroll behavior change](https://chromestatus.com/feature/5129635997941760),
  and hit testing around transformed/perspective ancestors plus clipped rounded
  descendants represented by [Chromium issue 456164629](https://issues.chromium.org/issues/456164629).

## Current-state impact

- `family:layout-regions` currently defines scrolling as explicit and local:
  LayoutContent and LayoutPanel own `isScrollable`; Layout `height="fill"`
  contains region scrolling, while `height="auto"` lets the page grow. The new
  capability preserves those owners but distinguishes requested scrolling from
  effective per-axis scrolling.
- LayoutContent and LayoutPanel currently use the same root element as landmark,
  themed surface, padded content box, and eager `overflow: auto` viewport. They do
  not expose an inner content root. Direct hook adoption therefore requires an
  explicit Layout decision about an internal observed box and its padding/full-
  bleed semantics; the generic hook must not insert that wrapper silently.
- `family:layout-primitives` currently lets Stack and StackItem expose component-
  specific `overflow: auto`; Grid and Center do not inherit that behavior. This
  proposal does not automatically add scrolling to every layout primitive.
- Popover already makes internal scrolling conditional on measured overflow and
  coalesces open-state observation. DropdownMenu has a separate block-axis helper.
  Carousel has a separate inline-axis helper. These are evidence for the shared
  need, not three acceptable long-term definitions.
- Table, CodeBlock, Carousel, and some examples make scroll wrappers keyboard
  focusable unconditionally. Adoption must review whether each wrapper is always
  expected to overflow or should follow effective state.
- Outline already identifies the nearest effective block scroll ancestor by
  combining computed overflow with live geometry, but it performs a point-in-time
  lookup rather than publishing a reusable live owner.

## Prior-art comparison

| System                            | What it gets right                                                                                            | Boundary for this proposal                                                                                                               |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Native CSS overflow               | Browser owns scrolling, input, momentum, and native scrollbar accessibility                                   | Axis computed values are coupled; native Sticky chooses a CSS scroll ancestor even when that conflicts with effective per-axis ownership |
| axe `scrollable-region-focusable` | Requires both measurable overflow and a scroll-capable computed overflow value before testing keyboard access | Snapshot audit, not a live observer or owner registry                                                                                    |
| Radix ScrollArea                  | Owns viewport and real content boxes, observes both, and derives overflow separately per axis                 | Primarily a custom-scrollbar component; does not define shared Sticky, overscroll, or conditional focus policy                           |
| Zag Scroll Area                   | Owns viewport/content structure, tracks each axis, offsets, edges, and resize-driven thumb state              | State-machine component machinery is broader than the minimum reusable Astryx capability                                                 |
| Astryx current helpers            | Popover, menu, Carousel, Table sticky columns, and Outline each prove parts of the model                      | Duplicated axis/tolerance/invalidation behavior and no shared effective-owner context                                                    |

Representative public evidence:

- [axe effective-scroll detection](https://github.com/dequelabs/axe-core/blob/4d306cbb7c456849c6f964444a6a7174d2be502a/lib/core/utils/get-scroll.js)
- [Radix viewport/content structure](https://github.com/radix-ui/primitives/blob/f7ecd5ab16f5e1e820eb5786a1419a98a2d594ae/packages/react/scroll-area/src/scroll-area.tsx#L149-L311)
- [Radix per-axis overflow observation](https://github.com/radix-ui/primitives/blob/f7ecd5ab16f5e1e820eb5786a1419a98a2d594ae/packages/react/scroll-area/src/scroll-area.tsx#L481-L512)
- [Zag viewport/content resize tracking](https://github.com/chakra-ui/zag/blob/a40f3f34a2d8a7899885d20fda112e26a91ae50c/packages/machines/scroll-area/src/scroll-area.machine.ts#L460-L482)
- [CSS overflow computed-value coupling](https://drafts.csswg.org/css-overflow-3/#overflow-properties)
- [CSS scroll-state queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Conditional_rules/Container_scroll-state_queries)
- [Native scrollbar width](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width), [color](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color), and [gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter)

## Verification

| Contract         | Verification                                               | Representative states                                                                                                                                                                                        | Mutation or failure expectation                                                                                                                      |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR4          | Unit geometry and computed-style tests plus browser matrix | inline, block, both; fitting, exact fit, >1px overflow; scroll-capable and non-scrollable computed values; horizontal, vertical, and sideways writing modes; LTR and RTL                                     | A declaration or excess geometry alone becomes an owner, an effective axis is missed, or physical direction leaks into the contract                  |
| FR5–FR9          | Observer lifecycle and integration tests                   | viewport resize, content resize, async resource, hidden/show, reconnect, classic scrollbar                                                                                                                   | State remains stale, reports fitting while unmeasurable, loops, or publishes redundant updates                                                       |
| FR10, FR11, FR18 | Real-browser nested Sticky matrix                          | arbitrary flow position; each logical edge; orthogonal and same-axis owners; inactive inner candidate                                                                                                        | Sticky binds to a non-effective or cross-axis owner, cannot appear mid-flow, or stacks against the wrong boundary                                    |
| FR12, FR13       | Keyboard and accessibility-tree tests                      | fit/overflow, named viewport, native link/button delegation, excluded roles, nested owners, forward/reverse entry, pointer/programmatic focus, dynamic eligibility, full Arrow/Page range in Chromium/WebKit | Content is unreachable, a fitting area adds an unexplained stop, an existing path gains a duplicate stop, naming is absent, or focus moves on change |
| FR14             | Wheel/touch/keyboard chaining browser tests                | fitting child, active child, each edge, contain and chain                                                                                                                                                    | A fitting child creates a dead wheel zone or explicit containment leaks to an inactive axis                                                          |
| FR15, FR16       | Native/custom presentation and forced-colors tests         | overlay/classic, thin/default, stable gutter, hidden/replacement, custom thumb                                                                                                                               | Presentation creates a second owner, masks state, loses platform fallback, or changes scrolling mechanics                                            |
| FR17             | Type/API tests and browser fixtures                        | requested inline, block, both; attempted visible opposite axis                                                                                                                                               | Public API admits a CSS combination the platform computes to different semantics                                                                     |
| FR19             | Table browser integration                                  | inline-only, bounded both-axis, sticky columns, sticky headers, intersection cell                                                                                                                            | Table plugins observe different owners or an external wrapper silently breaks sticky/accessibility behavior                                          |
| FR20             | ScrollableArea component and container-padding tests       | zero/default padding, uniform/edge overrides, nested bleed consumer, contained and full-bleed viewport                                                                                                       | Content publishes stale inset, a nested bleed child compensates incorrectly, or scrolling escapes its parent without explicit intent                 |
| FR21             | Real-browser fitting/overflowing Sticky transition         | fitting default, fitting `always`, content growth, content shrink, outer block Sticky owner                                                                                                                  | A fitting default viewport silently captures Sticky, explicit containment is lost, or overflow growth fails to activate scrolling                    |

## Decision log

### DEC-1 — Shared hooks own behavior; ScrollableArea is the reference composition

**Reference:** `spec:AST-025/DEC-1`
**Decider:** `cixzhang`, `2026-09-11`

Shared utility hooks are the canonical owner for effective-axis measurement,
observation, invalidation, and scroll-owner publication. Components with existing
viewport/content structure adopt those hooks directly. `ScrollableArea` composes
the same hooks into a convenience component and demonstrates the complete default
composition; no behavior is available only through that component.

This keeps scroll behavior reusable without forcing structure-owning components such
as Layout regions or Table through a behavior-named wrapper. It also gives builders a
complete default when they do want the system to own the required structure.

Rejected: making `ScrollableArea` the only entry point, because that would require
extra wrappers and turn a behavior component into the owner of component-specific
structure.

### DEC-2 — ScrollableArea is an explicit container-padding participant

**Reference:** `spec:AST-025/DEC-2`
**Decider:** `cixzhang`, `2026-09-11`

The reference component publishes its content box's logical padding, zero by
default, so nested bleed consumers receive the geometry actually applied.
Viewport bleed remains explicit rather than a side effect of becoming scrollable.
This aligns ScrollableArea with the shared container system without making the
behavior hook own padding or layout.

Rejected: automatic parent-padding escape, because adding scrolling must not
silently widen a container's visual boundary.

### DEC-3 — Fitting scroll intent does not silently contain Sticky

**Reference:** `spec:AST-025/DEC-3`
**Decider:** `cixzhang`, `2026-09-12`

The shared hook applies `clip` on both physical axes until requested content
exceeds its geometry. This avoids pre-measure paint overflow without creating a CSS
scroll container, so a fitting area does not silently intercept native Sticky from
an outer owner. Geometry-only overflow state activates the writing-mode-resolved
`auto`/`hidden` pair before effective ownership is measured.

`stickyContainment="always"` is the explicit opt-in for consumers that deliberately
want the fitting viewport to remain a Sticky boundary. Its camelCase enum follows
the public API conventions.

Rejected: retaining `auto`/`hidden` for every requested viewport, because scroll
intent alone would change Sticky behavior even when no scrolling can occur.
Rejected: no overflow declaration while fitting, because content could flash before
the first geometry measurement.

### DEC-4 — Shared automatic keyboard ownership delegates at entry

**Reference:** `spec:AST-025/DEC-4`
**Decider:** `cixzhang`, `2026-09-13`

`keyboardAccess.owner="contentOrViewport"` owns the automatic keyboard path in
one shared hook. Components do not maintain parallel focusability detectors.
The initial continuous-eligibility approach is replaced by focus-time delegation:
the overflowing viewport remains a named stop, and forward Tab entry inspects the
first sequential descendant. The conservative delegation set is native links and
buttons outside composite widgets, editable surfaces, and nested scroll owners.
Positive-tabindex ordering and unproven interactive roles retain the viewport.

Native reverse traversal skips the viewport when returning from the delegated
first child. Pointer/programmatic focus stays on the viewport. Content changes
never move existing focus; eligibility is checked again on the next keyboard
entry. Native scrolling remains responsible for Arrow/Page keys and chaining.

Rejected: continuous keyboard-eligibility scans after subtree and ancestor
mutations, because shared infrastructure should not pay that cost while idle.
Geometry observation remains separate. Also rejected: forwarding past an excluded
first descendant, because that changes the consumer's sequential focus order.

## Open questions

- **OQ1 — How is the Layout `isScrollable` migration observed and sequenced?**
  (`human-api`) LayoutContent and LayoutPanel will migrate their existing
  `isScrollable` path to the shared managed behavior. Because `isScrollable`
  defaults to true, implementation MUST land as a separate change above the shared
  hooks and `ScrollableArea`. Prototype and compare one-element hybrid observation
  (viewport plus direct-child resize, subtree mutation, resource/font, transition,
  and explicit invalidation signals) against an internal observed content box.
  Either path requires compatibility evidence for async and CSS-only geometry
  changes, direct-child selectors, flex/grid children, percentage sizing, full
  bleed, container padding, and refs before it replaces current behavior.

---

### Specs AST 026 Spec (`docs/specs/AST-026/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-026
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: implementing
owners: [cixzhang, josephfarina]
affects_architecture: [architecture:public-component-api]
affects_families: []
affects_contributing: [contributing:templates]
affects_consumer_docs: [shadcn-compatibility, templates, components]
---

# shadcn Registry compatibility system spec

## Intent

Let builders install Astryx components, component showcases, example blocks, and
page templates through the standard shadcn Registry protocol without making
Astryx a shadcn-based library or copying Astryx component implementations into
consumer repositories.

The compatibility layer meets builders inside an existing shadcn workflow. The
Astryx CLI remains the primary, richer interface for discovery, composition,
integrations, themes, validation, and upgrades.

This record governs the approved compatibility foundation. The implementation
may merge behind the canary docsite while copied-composition upgrades remain the
launch gate for the production endpoint and announcement.

## Non-goals

- Replacing the Astryx CLI with the shadcn CLI.
- Defining a second, Astryx-only registry format.
- Copying Core or Lab component implementation source during a normal install.
- Treating generated examples, blocks, or pages as byte-stable public API.
- Publishing an unsupported service-level agreement or making copied
  compositions package-controlled implementation source.
- Changing the explicit `astryx swizzle` escape hatch for deep customization.

## Requirements

- **FR1 — Standard protocol.** Every registry item MUST validate against the
  standard shadcn Registry schema and use a standard registry item type. A
  standard shadcn client MUST NOT need Astryx-specific code to read or install
  the item.
- **FR2 — Preserve the package boundary.** A component install MUST add the
  published Astryx package dependency and create only consumer-facing import or
  re-export source. It MUST NOT copy the component implementation, private
  helpers, or uncompiled StyleX source.
- **FR3 — Complete requested catalog.** The experiment MUST cover every public
  component or hook, every component showcase and example block, every other
  block, and every ready page template available from the same catalog the CLI
  and docsite use.
- **FR4 — Editable compositions.** Showcase, example, block, and page items MAY
  copy application-level composition source. That source MUST import Astryx
  through published package paths, declare all non-React package dependencies,
  and contain no relative import that escapes the copied item.
- **FR5 — Deterministic, stable identity.** Item names, organized URL paths,
  target paths, dependency lists, and JSON output MUST be deterministic and
  collision-free. Identity MUST derive from stable doc/catalog fields, not
  display labels or source filenames. Display-name changes MUST NOT change
  install URLs. Published renames MUST preserve prior routes as aliases.
- **FR6 — One catalog, two clients.** The shadcn client consumes standard fields.
  The Astryx CLI MAY read optional `astryx` metadata from the raw JSON for
  integration, provenance, and upgrade guidance. Standard clients may discard
  that metadata without changing the install.
- **FR7 — Discoverable compatibility.** The public docsite MUST describe the
  compatibility boundary and show a secondary copyable install command at the
  end of each applicable component, example, block, and page surface. The
  normal Astryx documentation remains the human browse experience; raw
  `/shadcn/` paths remain machine endpoints.
- **FR8 — Staged launch.** Preview builds MUST serve the full registry from their
  own `/shadcn` origin with `@canary` package dependencies. Production MUST use
  exact released package versions and remain disabled until copied-composition
  upgrades are ready.
- **FR9 — Upgrade-safe copied compositions.** Every copied showcase, example,
  block, and page MUST install an adjacent machine-readable receipt containing
  the stable item route, the copied target, the exact installed bytes, and their
  hash. `astryx upgrade` MUST only apply a canonical item that matches the
  installed Astryx release, auto-update an unchanged file, three-way merge
  edits, leave the original untouched when edits conflict, and never recreate a
  deleted or moved file.
- **FR10 — Required full-catalog CI.** Required pull-request CI MUST generate
  the complete preview catalog, reject production output before launch, install
  every canonical item through the pinned shadcn client, verify every route and
  exact written file, and compile every installed source against the current
  Astryx package exports.
- **IR1 — Generated from current sources.** Registry output MUST come from the
  existing docsite and CLI catalogs, never a parallel handwritten item list.
- **IR2 — Build-time static output.** The docsite build MUST generate static JSON
  under one registry root. Generated JSON MUST NOT be committed when a clean
  build can reproduce it.
- **IR3 — Failure is loud.** Missing source, duplicate names, invalid schemas,
  unresolved package versions, escaping relative imports, and stale generated
  output MUST fail generation or verification.
- **IR4 — End-to-end evidence.** Required verification MUST install every
  canonical component, hook, showcase, example, block, and page into a clean
  shadcn-style app through the pinned stock client, verify exact written bytes
  and declared dependencies, and compile every written source file. Alias
  routes MUST resolve to the same canonical item bytes.
- **IR5 — Stable route contract.** Generated item names and canonical paths MUST
  match the reviewed route lock. Display-name edits MUST NOT change them. An
  intentional rename MUST retain old paths through `registry.aliases` unless a
  separately reviewed breaking change approves removal.

### Platform support

- Supported feature/engine floor: the current Astryx Node floor and the pinned
  shadcn version used by the experiment.
- Unsupported behavior: copying Astryx implementation source without an
  explicit `astryx swizzle` action; installing hidden, incomplete, or
  non-resolvable catalog entries.
- Browser evidence: a representative installed page MUST render in real Chrome
  in both light and dark modes before publication is proposed.

## Current-state impact

The CLI already ships components, hundreds of examples and showcases, and page
templates. The docsite generator already discovers components, individual
showcases and examples, blocks, pages, package versions, and source. The
compatibility layer adds a serializer over that existing catalog rather than a
second discovery system.

The current catalog generates 970 items. Its 700 copied compositions each carry
an adjacent receipt whose base matches the exact bytes stock shadcn writes.
Required CI installs all 970 entries through shadcn 4.19.0 into one clean
consumer, verifies all 1,670 written source and receipt files, and compiles all
970 source entry points. Fourteen compositions that author local StyleX are
precompiled to compiler-free JSX during generation; all other composition
source stays typed TSX. Component implementation copying failed because private
imports and uncompiled StyleX crossed the package boundary; FR2 avoids that
path by installing the package and creating a public re-export only.

## Verification

| Contract  | Verification                                                        | Representative states                           | Mutation or failure expectation                            |
| --------- | ------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- |
| FR1, FR5  | shadcn schema validation and deterministic snapshot                 | component, showcase, example, block, page       | Unknown type, duplicate name, or unstable output fails     |
| FR2       | clean consumer install plus source inspection                       | Core component, hook, non-Core package          | Implementation source or private import fails              |
| FR3, IR1  | reconcile registry counts and names with generated docsite catalogs | visible and hidden entries, grouped families    | Missing or extra catalog entry fails                       |
| FR4, IR3  | dependency extraction and relative-import audit                     | heroicons, recharts, StyleX import, page source | Escaping import or undeclared package fails                |
| FR6       | raw JSON and shadcn parse tests                                     | optional `astryx` metadata present              | shadcn install changes or Astryx metadata becomes required |
| FR7       | docsite tests and copy-button interaction                           | component, block, page, compatibility guide     | Command is absent, stale, or misstates copy behavior       |
| FR8       | canary preview plus production-target assertion                     | preview and released package dependencies       | Production enables before the upgrade gate passes          |
| FR9       | receipt mutation tests plus stock ShadCN install                    | pristine, edited, conflicting, missing, aliased | User source is overwritten or an old route stops resolving |
| FR10, IR4 | required full-catalog clean-consumer install and build              | every canonical item, route, dependency, target | Any item fails to install, match its receipt, or compile   |

## Decision log

### DEC-1 — Use shadcn as a compatibility protocol, not an Astryx foundation

**Reference:** `spec:AST-026/DEC-1`
**Decider:** `josephfarina`, `2026-09-02`

Generate standard registry JSON so an existing shadcn user can install Astryx
without changing tools. Keep the Astryx CLI as the primary product and the
source of richer knowledge and maintenance behavior.

Rejected: a custom Astryx registry format. It gives third-party clients no
interoperability and requires Astryx adoption before Astryx can be discovered.

### DEC-2 — Copy composition, not component implementation

**Reference:** `spec:AST-026/DEC-2`
**Decider:** `josephfarina`, `2026-09-02`

A normal install keeps `@astryxdesign/core` or the owning Astryx package as a
real dependency. Component items create a public re-export; showcases, blocks,
and pages copy editable composition code that imports the package.

Rejected: copying Core implementation source. It breaks package-controlled
upgrades and crosses private-import and uncompiled-StyleX boundaries.

### DEC-3 — Stage the launch behind the canary docsite

**Reference:** `spec:AST-026/DEC-3`
**Decider:** `josephfarina`, `2026-09-09`

Merge the compatibility foundation after end-to-end verification, but keep the
production registry and broad announcement disabled until copied-composition
upgrades are ready.

### DEC-4 — Derive stable IDs from docs and organize URLs by item kind

**Reference:** `spec:AST-026/DEC-4`
**Decider:** `josephfarina`, `2026-09-03`

Treat registry names and paths as public API before publication. Derive them
from stable component/hook names, block `name` plus `exampleFor`, and the
existing template slug. Keep `displayName` editorial. Use these path families:
`components`, `hooks`, `showcases/<component>`, `examples/<component>`,
`blocks`, and `templates`. A block without `exampleFor` is standalone under
`blocks`; a block with component ownership is an example or showcase.

Allow a doc to override its leaf with `registry.slug` and retain old relative
paths with `registry.aliases`. Check all names and paths against a reviewed lock
so a rename cannot silently break existing install commands.

Every item is also served at `<name>.json` in the registry root with its
canonical bytes. Standard clients build that address from the index name, in
search results and in `@namespace/<name>` installs. Each alias path also keeps
the name it stood for, so a renamed item still installs by its old name.

### DEC-5 — Use `/shadcn`, exact releases, and canary previews

**Reference:** `spec:AST-026/DEC-5`
**Decider:** `josephfarina`, `2026-09-09`

Use `https://astryx.atmeta.com/shadcn` as the canonical production root and
reserve `/r` for a future Astryx-native protocol. Production items pin exact
released Astryx package versions; preview items use the matching preview origin
and `@canary` dependencies.

### DEC-6 — Install adjacent receipts and reconcile from the canonical registry

**Reference:** `spec:AST-026/DEC-6`
**Decider:** `josephfarina`, `2026-09-10`

Each copied composition carries a unique JSON receipt beside its source. The
receipt stores the installed base bytes and hash; the stable item route resolves
the release's compiled source. `astryx upgrade --registry` refuses a registry
item that does not match the installed Astryx release, then updates pristine
files or performs a real three-way merge without treating editable application
code as package-owned implementation. Conflicts are written to a separate
artifact and never replace the user's file. Deleted or moved files stay deleted
or moved.

Rejected: hash-only receipts. They cannot reconstruct the merge base after a
user edits the file. Also rejected: rebuilding the latest composition from raw
CLI template assets at upgrade time. StyleX-precompiled registry items require
the registry's compiled bytes.

### DEC-7 — Gate every registry item through one clean consumer

**Reference:** `spec:AST-026/DEC-7`
**Decider:** `josephfarina`, `2026-09-10`

Required pull-request CI proves the release-channel selector removes compatibility
output for production, then passes every canonical preview item to the pinned
shadcn client in one clean consumer. The consumer replaces Astryx package pins
with the current local package builds so a new component can prove its exports
before that version exists on npm; all third-party dependencies retain the
versions declared by the generated catalog. CI compares every written file with
its registry bytes and compiles every source while resolving Astryx package
exports.

Rejected: one client process per item. It repeats dependency installation
hundreds of times without adding protocol coverage. Also rejected: validating
only representative items. A source or target defect can be unique to any one
of the generated compositions.

## Open questions

- **OQ1 — Catalog visibility.** (`human-design`) Should hidden or not-ready
  catalog entries stay addressable by URL, or be omitted entirely?

---

### Specs AST 027 Spec (`docs/specs/AST-027/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-027
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-04
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: [architecture:layer-runtime]
affects_families:
  [family:input-fields, family:layout-regions, family:overlay-dismissal]
affects_contributing: []
affects_consumer_docs: []
---

# Local stacking and browser top-layer routing system spec



```json
{
  "scope": "global",
  "triggers": {
    "layering": ["DEC-1", "DEC-2", "DEC-3", "DEC-4", "FR11"]
  }
}
```

## Intent

An Astryx component should be able to order its own painted parts without
changing how unrelated components, page content, or application chrome stack.
Menus, dialogs, and other floating interaction surfaces should escape ordinary
document stacking through Astryx's Layer system and the browser top layer, not
through larger `z-index` values.

This spec separates two mechanisms:

- **local stacking** orders parts inside one component-owned surface; and
- **top-layer routing** hosts floating interaction surfaces through
  [`architecture:layer-runtime`](../../architecture/layer-runtime.md).

A local number never becomes a claim on the whole page.

## Non-goals

- Define shadows, borders, or perceived elevation. Those remain visual-design
  concerns.
- Create a global numeric z-index scale or semantic page-wide z-index tokens.
- Change which interaction dismisses a menu, dialog, popover, or other overlay.
- Require `isolation: isolate` on components that have no local paint-order
  relationship.
- Treat a React portal as equivalent to browser top-layer promotion.

## Terms

- **Local layer:** a positioned or stacking-context descendant whose paint order
  is meaningful only inside one component-owned surface.
- **Isolation owner:** the component-owned element that establishes
  `isolation: isolate` for one local stacking relationship.
- **Surface owner:** the element that paints or directly owns the component
  surface whose parts are being ordered, including its background, border,
  clipping, or elevation boundary.
- **Floating interaction surface:** a menu, dialog, popover, tooltip, hover card,
  sheet, command surface, or similar UI that must escape ordinary content or
  clipping to perform its interaction role.
- **Browser top layer:** the platform stacking plane entered through native
  mechanisms such as the Popover API or modal `<dialog>`.

## Requirements

### Local stacking

- **FR1 — z-index is local only.** A `z-index` value in Astryx MUST order parts
  within one component-owned stacking relationship. It MUST NOT establish an
  ordering contract against unrelated components, application content,
  AppShell chrome, or another page region.
- **FR2 — The surface owns the isolation boundary.** Every local stacking
  relationship MUST have an `isolation: isolate` owner on the component surface
  that contains the relationship. The isolation boundary MUST live on the
  surface owner rather than an arbitrary page wrapper or distant ancestor.
- **FR3 — The isolation owner does not escalate itself.** The outer isolation
  owner MUST remain at its normal parent paint level. Local `z-index` values
  belong to descendants inside that owner. If a nested surface must be ordered
  inside a larger component, that larger component supplies another local
  isolation owner.
- **FR4 — Local values have no system-wide rank.** Values such as `-1`, `0`, `1`,
  `2`, or a dynamic local depth MAY express a relationship inside an isolation
  owner. The same value MAY mean something different in another component.
  Astryx MUST NOT document or depend on page-wide bands such as “inputs at 1,”
  “sticky content at 10,” or “overlays at 1000.”
- **FR5 — Fix the leaking owner, not the unrelated neighbor.** When a component
  part paints above unrelated UI, the fix MUST contain that part at its
  component-owned surface. Raising the unrelated header, shell, or neighbor to a
  larger value is not a conforming fix.
- **FR6 — Every local state stays contained.** Hover, focus, validation, drag,
  sticky, selected, resized, animated, and negative-layer states MUST remain
  inside the same isolation boundary. A state change MUST NOT promote the
  component surface into a page-level stacking competitor.

### Floating interaction surfaces

- **FR7 — Floating interactions use the Layer system.** Menus, dialogs,
  popovers, tooltips, hover cards, sheets, command surfaces, and equivalent
  floating interactions MUST use the hosting and promotion mechanisms owned by
  [`architecture:layer-runtime`](../../architecture/layer-runtime.md). A local
  component MUST NOT recreate that system with positioned DOM and page-level
  `z-index`.
- **FR8 — Browser promotion owns cross-surface order.** A floating interaction
  that must escape ordinary content or clipping MUST enter the browser top layer
  through the applicable Layer/native host. A body portal, a fixed position, or
  a larger number is not a substitute.
- **FR9 — Numeric values inside a top-layer host remain local.** A promoted
  surface MAY use locally isolated z-index values to order its own backdrop,
  panel, arrow, controls, or animation plates. Those values MUST NOT be treated
  as the reason the surface appears above page content or another native host.
- **FR10 — Existing Layer support boundaries remain authoritative.** Native
  Popover and modal-dialog behavior, corrective portals, anchored positioning,
  reduced browser fallback, and global-host admission continue to follow
  `architecture:layer-runtime` and
  [AST-003](../AST-003/spec.md). This spec does not authorize a component to
  bypass those contracts when native support is unavailable.
- **FR11 — In-flow UI remains in flow.** A label, status message, banner, sticky
  page region, focus ring, or other in-flow presentation does not become a
  floating interaction merely because it needs local paint ordering. It follows
  FR1–FR6 unless its owning contract explicitly assigns it to Layer.

### Implementation requirements

- **IR1 — Every shipped z-index is classified.** Before this spec ships,
  every production `z-index` in published package source and CLI-emitted template
  source MUST be classified as a contained local relationship, a local
  relationship inside a Layer/native top-layer host, or a migration gap.
  Unclassified values fail the migration.
- **IR2 — New leaks fail mechanically.** Repository checks MUST reject a new
  local z-index relationship that has no component-owned isolation surface.
  The check MAY use source metadata or a narrow allowlist for relationships
  whose owner cannot be derived statically, but an allowlist entry MUST name
  its owner and verification.
- **IR3 — Layer bypasses fail mechanically.** Repository checks MUST reject a
  new menu, dialog, popover, tooltip, hover card, sheet, command surface, or
  equivalent overlay that uses page-level z-index instead of the shared Layer
  or native top-layer path.
- **IR4 — Migrations remove obsolete bands.** When a surface is contained or
  moved into the top layer, obsolete page-wide z-index comments, constants,
  tokens, tests, and compensating neighbor values MUST be removed in the same
  change.

### Platform support

- Supported feature/engine floor: `isolation: isolate` defines local stacking in
  every browser supported by Astryx. Floating-surface support follows the matrix
  and reduced fallback in `architecture:layer-runtime`.
- Unsupported behavior: a browser below the native Layer feature floor receives
  the documented reduced fallback. It MUST NOT receive an undocumented global
  z-index ladder as an attempted top-layer polyfill.
- Browser evidence: local containment requires real-browser paint and hit-test
  evidence. Popover, modal-dialog, focus/inertness, and top-layer ordering require
  the real-browser matrix named by `architecture:layer-runtime`; Playwright WebKit
  does not substitute for Safari-specific evidence.

## Current-state impact

The repository already contains good examples of the intended local pattern:
Thumbnail, Calendar, ChatComposer, ChatLayout, CodeBlock, CheckboxInput,
RadioList, Switch, Slider, Field's attached-status wrapper, and the touch
DateInput panel place internal z-index relationships beneath a local
`isolation: isolate` owner. They are migration references, not blanket proof
that every descendant is correct.

The source audit covered published package source and CLI-emitted template source:
`packages/{core,lab,charts,richtext}/src` and
`packages/cli/assets/templates`, excluding tests and stories. It found 56
production declarations across 40 files and these current gaps:

| Surface                                                                                                | Current mechanism                                                                                                                                                                                                                       | Impact under this spec                                                                                                                                             | Required migration or proof                                                                                                                                      |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Shared Field input wrappers, including TextInput, FileInput, TextArea, NumberInput, and RichTextEditor | `inputWrapperStyles.base` places the painted input surface itself at `z-index: 1`; only the attached-status composition adds an isolated wrapper; RichTextEditor also places its status icon at local `1` inside the unisolated surface | **Confirmed leak.** Detached and tooltip Field compositions compete directly with AppShell and later page content                                                  | Put isolation on the Field-owned surface for every variant, keep wrapper and status-icon layers beneath it, and remove the painted input surface's external rank |
| Production docsite component pages                                                                     | A live Chromium probe on `/components/TextInput` found a rendered input and the sticky AppShell header both at `z-index: 1` with `isolation: auto`; after the input was scrolled into the header, it won browser hit-testing            | **Confirmed user-facing leak.** A real component example covers navigation on the production route                                                                 | Preserve that route and overlapping-control relationship as a Chromium regression fixture; prove the contained field passes below unrelated sticky chrome        |
| AppShell header and skip link                                                                          | The sticky header uses `1`, the focused skip link uses `9999`, and the shell root is not isolated; only transparent content establishes a local isolation boundary                                                                      | **Confirmed page-level coupling.** AppShell exposes numeric bands for ordinary shell parts                                                                         | Isolate the AppShell-owned surface and use only local depths inside it; the skip link remains local AppShell content rather than a reusable global band          |
| ChatComposerDrawer and ChatDictationButton                                                             | The separately exported drawer root and dictation bars use `z-index: 1` without owning isolation; placement inside ChatComposer is only a consumer convention                                                                           | **Confirmed implicit-containment gaps.** Either export can be composed outside ChatComposer's isolated root                                                        | Give each exported part a component-owned isolation surface and keep its internal ordering local; do not rely on a consumer ancestor                             |
| InputGroup focus ordering                                                                              | A focused connected child uses `z-index: 1`; InputGroup composes Field through the detached path                                                                                                                                        | **Confirmed leak.** Focus ordering can escape the group                                                                                                            | Isolate the complete painted group owner and verify connected-border focus ordering against neighboring components                                               |
| SideNav sticky content                                                                                 | The sticky top region uses `z-index: 1`; the SideNav root is not isolated                                                                                                                                                               | **Confirmed leak.** Sticky navigation internals can compete with siblings                                                                                          | Add isolation to the SideNav surface; collapsed menus continue to use Popover/Layer                                                                              |
| Outline active indicator                                                                               | The positioned indicator uses `z-index: 1` beneath an unisolated root                                                                                                                                                                   | **Confirmed leak.** The visual indicator can compete outside Outline                                                                                               | Isolate the Outline root and keep the indicator relationship local                                                                                               |
| Table sticky columns and resize handle                                                                 | Sticky header/body cells use `3` and `1`, the row overlay uses `-1`, and the resize handle uses `1`; `TableScrollWrapper` is not isolated                                                                                               | **Confirmed leak.** Table internals can meet unrelated shell content                                                                                               | Isolate `TableScrollWrapper`; retain header/body/overlay/resize ordering beneath it and verify sticky states under shell chrome                                  |
| Resizable handles and local overlays                                                                   | Overlay root, hit area, and pill use `1` and `2`; no resized surface owner is isolated                                                                                                                                                  | **Confirmed leak.** Resize affordances can escape their region                                                                                                     | Isolate each resized panel or region owner, not only the handle descendant; verify drag and focus states against adjacent content                                |
| Lab Schedule and ChartZoom overlays                                                                    | Schedule uses `1`, dynamic event levels, and `20`; ChartZoom uses `1`; neither painted root is isolated                                                                                                                                 | **Confirmed leaks.** Visualization internals create page-level ranks                                                                                               | Isolate the Schedule and Chart painted roots; Chart also establishes the containing block required by its absolute toolbar                                       |
| ChartTooltip                                                                                           | The published Charts package renders the tooltip with `position: fixed` and `z-index: 9999` instead of Layer                                                                                                                            | **Confirmed Layer bypass and page-level leak.** It uses exactly the substitute prohibited by FR7–FR8                                                               | Route ChartTooltip through Layer's fixed mode and the browser top layer; keep tooltip-internal ordering inside its surface                                       |
| Lab ListInput and TransferList reorder indicators                                                      | Shared reorder styles use `z-index: 2`; ListInput's detached Field path is unisolated, while TransferList relies incidentally on `container-type`                                                                                       | **Confirmed gap for ListInput; implicit containment for TransferList is not the explicit contract**                                                                | Add explicit isolation to both painted owners and verify reorder hit-testing in Chromium                                                                         |
| CLI-emitted page templates                                                                             | Dashboard alert rail and Settings dialog use local `1`; Table filter uses reorder `2`; Kanban renders a fixed drag clone at `1000`                                                                                                      | **Generated leaks.** New consumer apps inherit implicit local boundaries and, for Kanban, a page-level floating band                                               | Add explicit surface isolation to local template relationships; route the Kanban drag clone through a visual-only Layer/top-layer host                           |
| Popover, Lightbox, and modal panel internals                                                           | Small values order arrows, controls, handles, panels, and tints inside native Popover or dialog hosts                                                                                                                                   | **Correct cross-page mechanism; local ownership still requires proof.** The browser host, not the number, places the surface above page content                    | Preserve top-layer hosting and add or identify an explicit isolation owner for each internal relationship                                                        |
| ToastViewport                                                                                          | The viewport enters `popover="manual"` but also carries `z-index: 500`                                                                                                                                                                  | **Redundant page-band value plus the AST-003 hosting gap.** Native promotion, not `500`, owns page-content order; current Toast can still sit behind a later modal | Complete AST-003's global host first or in the same change, then remove `500` and keep any internal ordering beneath an isolated viewport surface                |
| BottomSheet and BottomSheetSwitcher non-modal paths                                                    | Non-modal `dialog.show()` paths use static or dynamic values beginning at `1000`                                                                                                                                                        | **Confirmed global leaks.** A page band cannot provide dialog-layer semantics                                                                                      | Route non-modal hosting through Layer/native top-layer infrastructure; keep panel, handle, tint, action, and stack ordering locally isolated inside the host     |

Current source already routes Tooltip, HoverCard, Popover, DropdownMenu and its
submenus, ContextMenu, CommandPalette, modal Dialog, Drawer, Lightbox,
MobileNav, Tour, Date-input layers, and RadialTooltip through Layer, native
Popover, or `showModal()`. Their cross-page mechanism is directionally
conforming. The confirmed exceptions are ChartTooltip and the non-modal
BottomSheet and BottomSheetSwitcher paths. Toast is promoted but still has the
global-host limitation already owned by AST-003.

This table is an impact inventory, not an implementation approval. Each row still
requires its named browser evidence before migration lands. The exhaustive IR1
audit may add rows; a row may be cleared only with named ownership and evidence.

## Verification

| Contract | Verification                                                   | Representative states                                                                   | Mutation or failure expectation                                                                                                  |
| -------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR6  | Source classification plus real Chromium paint/hit-test matrix | positive, zero, negative, sticky, focused, dragged, selected, and animated local layers | Removing the isolation owner lets a descendant cover an unrelated sibling; changing a local value never changes page-level order |
| FR2, FR3 | DOM ownership and computed-style assertions                    | painted surface root, nested local surface, transparent surface, consumer wrapper       | Isolation appears on a distant page ancestor, or the isolation owner itself competes through a page-level z-index                |
| FR5      | Before/after regression fixtures                               | docsite Field under sticky AppShell header; Table under shell chrome                    | A neighbor must be raised to make the fixture pass, or the leaking surface still wins hit-testing                                |
| FR7–FR10 | Layer unit tests and real browser top-layer matrix             | menu, submenu, dialog, popover, tooltip, hover card, sheet, CommandPalette, Toast       | Positioned DOM or a body portal replaces Layer, or a numeric band determines cross-surface order                                 |
| FR11     | Component and browser tests                                    | label/status overlap, sticky region, focus ring, banner                                 | In-flow UI is unnecessarily promoted, or its local paint state escapes its surface                                               |
| IR1      | Generated shipped z-index inventory checked in CI              | every published package and emitted template declaration                                | A declaration is missing, duplicated, or has no owner/classification                                                             |
| IR2, IR3 | Static-rule fixtures with valid and invalid examples           | isolated local component, leaking field, hand-built menu, native Layer surface          | A leak or Layer bypass passes, or a valid isolated relationship is rejected                                                      |
| IR4      | Changed-file and source scans                                  | migrated Field, AppShell, Table, Toast, BottomSheet, Drawer                             | Obsolete bands, comments, tests, or compensating values remain after migration                                                   |

### Completion criteria

This spec moves to `shipped` only when:

- every z-index in published package source and CLI-emitted template source has
  an owner and classification;
- every ordinary local relationship is contained by its component-owned painted
  surface;
- the real docsite Field/AppShell failure is reproduced before the fix and passes
  through local containment afterward;
- menus, dialogs, popovers, tooltips, hover cards, sheets, command surfaces, and
  equivalent overlays use the Layer/native top-layer path;
- numeric bands no longer determine cross-component, page-shell, or overlay
  order;
- mechanical checks reject a representative local leak and Layer bypass;
- the browser matrices for local containment and Layer hosting pass; and
- `architecture:layer-runtime` plus affected family/component records describe
  the shipped ownership without relying on this impact inventory.

## Decision log

### DEC-1 — z-index expresses local relationships only

**Reference:** `spec:AST-027/DEC-1`
**Decider:** `cixzhang`, `2026-09-04`

A z-index value belongs to one component-owned stacking relationship. Reusing a
number elsewhere creates no relationship between the components.

Rejected: a global scale that ranks inputs, sticky navigation, page chrome, and
overlays against one another. That scale turns every local change into a page-wide
compatibility problem.

### DEC-2 — Isolation lives on the component surface

**Reference:** `spec:AST-027/DEC-2`
**Decider:** `cixzhang`, `2026-09-04`

The element that owns the painted surface also establishes `isolation: isolate`.
Descendant z-index values can then order that surface's parts without escaping to
unrelated content.

Rejected: placing isolation on an arbitrary page wrapper or raising the surface
root itself. Both preserve hidden coupling between the component and its caller.

### DEC-3 — Floating interactions use the browser top layer through Layer

**Reference:** `spec:AST-027/DEC-3`
**Decider:** `cixzhang`, `2026-09-04`

Menus, dialogs, and equivalent floating interactions route through Astryx's Layer
system and applicable native top-layer host. Their cross-surface order comes from
the browser host and Layer contract, not a number.

Rejected: fixed positioning, a body portal, or a large z-index as a substitute for
Layer. These mechanisms do not provide native top-layer, modal, association,
positioning, or dismissal behavior.

### DEC-4 — Leaks are repaired at their owner

**Reference:** `spec:AST-027/DEC-4`
**Decider:** `cixzhang`, `2026-09-04`

When a Field or another component covers unrelated shell chrome, contain the
component at its own surface. Do not raise AppShell or add another global band.

Rejected: escalating the AppShell header to a larger page-level value. It can
pass a known comparison only by outranking today's values and leaves the next
larger value to recreate the same defect.

## Open questions

None.

---

### Specs AST 028 Spec (`docs/specs/AST-028/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-028
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-04
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [templates]
---

# Template local fixtures and preview fallback contract

## Intent

People should be able to preview an Astryx template with realistic media, copy
it into their project, and then replace the demonstration content without
inheriting Astryx's preview infrastructure or mistaking sample media for product
assets.

Templates are starting points. They are intended to be copied and modified by
the consumer. Images and videos included with a template demonstrate the
composition, crop, density, and state the template was designed for; they are
not production content or a supported consumer asset library.

## Scope

This contract owns static demonstration assets used by templates, including
images, posters, and videos. It covers their authored references, maintainer
previews, copied output, and fallbacks.

It does not own icons. Icons may be component props, registry values, imported
modules, inline symbols, or product assets, and their conversion and portability
concerns require a separate contract.

## Non-goals

- Publishing Astryx's template media as a consumer asset package.
- Copying every demonstration image or video into the consumer's project.
- Defining icon resolution, icon imports, SVG-component conversion, favicons, or
  application icons.
- Guaranteeing that demonstration content is appropriate for a consumer's brand,
  product, locale, or production license needs.
- Defining product data loaders or the broader template data boundary.
- Making templates immutable after copying.

## Requirements

### Template and asset ownership

- **FR1 — A template is editable source.** A copied page or block template MUST
  be ordinary consumer-owned source that can be changed, split, connected to
  product data, or deleted without retaining a runtime dependency on Astryx's
  preview host.
- **FR2 — Static template media is demonstration content.** Images, posters, and
  videos shipped for a template MUST be treated as fixtures that demonstrate the
  intended composition and state. Their presence in a template MUST NOT imply
  that consumers should ship those files, URLs, subjects, copy, or brands in a
  product.
- **FR3 — Fixture ownership is explicit and local.** Astryx-owned static template
  fixtures MUST use the repository's designated template-fixture namespace and
  MUST be available to maintainers without private network access. Product-owned
  paths and explicit third-party URLs are not Astryx fixture references and MUST
  NOT be rewritten merely because they occur in template source.

### Preview behavior

- **FR4 — Maintainer previews show the real fixture.** Local development, the
  docsite, the sandbox, and pull-request previews MUST resolve each Astryx-owned
  static fixture to the representative image or video. A preview MAY rewrite the
  deployment base path, but that rewrite MUST leave the portable template source
  unchanged.
- **FR5 — One canonical fixture may serve many previews.** Preview deployment MAY
  share one published fixture set rather than duplicate media into every preview.
  The owning deployment MUST keep references deterministic and prevent a preview
  from silently resolving to an unrelated file.
- **FR6 — Preview failure is visible and bounded.** A missing or unsupported
  fixture MUST produce an actionable authoring or preview failure naming the
  reference. It MUST NOT silently render a fallback of the wrong media kind and
  claim the template preview is representative.

### Copy behavior

- **FR7 — Copied source drops the preview dependency.** Copying or scaffolding a
  template MUST remove every Astryx-owned static fixture reference from the
  generated source. The output MUST NOT fetch from the Astryx docsite, sandbox,
  GitHub Pages preview, private CDN, or repository-only `/template-assets/`
  namespace at runtime.
- **FR8 — Fallbacks preserve source usability, not demo content.** The copy path
  MUST produce source that still parses and renders without setup. Image and
  poster references MAY become a neutral self-contained visual placeholder.
  Video references MAY become an explicit empty or non-playing replacement point
  when no verified portable video placeholder exists. In either case, the output
  MUST make no claim that the original demonstration content was copied.
- **FR9 — Fallbacks are media-kind safe.** An image fallback MUST NOT be supplied
  as a video source, and video bytes MUST NOT be supplied as an image fallback.
  Classification MUST use the complete final filename suffix and be
  case-insensitive. If the copy path cannot safely classify an Astryx-owned
  fixture, it MUST fail with the fixture path and unsupported format rather than
  guess or leave a preview-only reference behind.
- **FR10 — Mixed static media transforms independently.** A template containing
  an image poster and a video source MUST transform each reference according to
  its own media kind. One unsupported reference MUST NOT cause another reference
  to receive the wrong fallback.
- **FR11 — Copy receipts disclose replacement.** A successful copy operation MUST
  tell the consumer when Astryx demonstration media was replaced and identify the
  generated file or replacement points they need to customize. The receipt MUST
  not imply that production-ready media was installed.

### Boundary with icons

- **FR12 — Icons are excluded by role, not file extension.** Component icons,
  registry icons, imported SVG icon components, and application icons MUST NOT be
  classified as static template fixtures under this contract merely because
  their source representation is an image format. Any copying, import rewriting,
  registry conversion, or fallback behavior for icons requires its own accepted
  contract.

## Current-state impact

Astryx templates currently use repository-local `/template-assets/*` references
for representative images and video. The docsite and sandbox own those files for
preview, and deployed previews can rewrite only the executable bundle to a shared
published location. The portable source keeps the root-relative fixture path.

The CLI copy path currently recognizes Astryx's fixture namespace and replaces
images with a self-contained SVG placeholder. It strips recognized video sources
to an empty string rather than place image data in a video element. This behavior
established the core preview-versus-copy split, while this record adds the
previously unwritten requirements for why assets are fixtures, which references
may be transformed, what a safe fallback means, how unsupported formats fail,
and why icons are separate.

This specification-only change alters no package, template, asset, preview,
runtime, or public CLI behavior. Remaining implementation and verification
follow separately.

## Verification

| Contract | Verification                                   | Representative cases                                                                       | Failure expectation                                                                   |
| -------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| FR1–FR3  | Template-source and copy-boundary tests        | built-in page, block, integration template; Astryx fixture; consumer path; third-party URL | Copied source retains a preview runtime dependency or rewrites consumer-owned content |
| FR4–FR6  | Real preview request and rendered-media checks | local, docsite, main sandbox, PR preview; image and video; missing fixture                 | Representative media 404s, resolves to another file, or silently uses the wrong kind  |
| FR7–FR8  | End-to-end CLI copy tests                      | image-only, video-only, and no-media template                                              | Generated source retains `/template-assets/` or does not parse/render without setup   |
| FR9–FR10 | Classifier and mixed-source mutation tests     | upper/lower case; multi-dot filename; image poster plus video; unknown suffix              | Last suffix is misread, image data enters video, or an unsupported kind is guessed    |
| FR11     | Human and structured copy-receipt tests        | no replacement; one image; mixed media                                                     | The consumer is not told what must be replaced or is told assets were installed       |
| FR12     | Scope guard tests and review                   | Icon prop, registry icon, imported SVG component, static hero image                        | Icon behavior is silently folded into the static-fixture transformer                  |

### Completion criteria

This spec moves from `accepted` to `shipped` only when:

- preview environments render the same canonical static fixtures without private
  network access or per-preview duplication requirements;
- copied templates retain no Astryx preview-only media reference;
- image, poster, and video fallbacks are kind-safe and covered end to end;
- unsupported static fixture formats fail with an actionable reference;
- copy receipts identify replaced demonstration media; and
- icon sources are proven outside this transform and linked to a separate
  contract before any icon conversion is introduced.

## Decision log

### DEC-1 — Template media demonstrates a composition; consumers replace it

**Reference:** `spec:AST-028/DEC-1`

**Decider:** `cixzhang`, `2026-09-04`

Images and videos in Astryx templates are local fixtures for showing the
intended composition. A copied template is consumer-owned editable source, not a
channel for distributing production media or preserving an Astryx preview
runtime dependency.

Rejected: treating the checked-in demonstration files as a supported consumer
asset library or copying them by default into every project.

### DEC-2 — Preview and copy have different obligations

**Reference:** `spec:AST-028/DEC-2`

**Decider:** `cixzhang`, `2026-09-04`

Maintainer previews show the real fixture so the template can be judged. Copied
source removes the preview dependency and substitutes a media-kind-safe,
portable replacement point so the consumer can customize it.

Rejected: showing placeholders in every maintainer preview, and leaving
repository-only fixture URLs in generated projects.

### DEC-3 — Icons require a separate contract

**Reference:** `spec:AST-028/DEC-3`

**Decider:** `cixzhang`, `2026-09-04`

This contract covers static image and video fixtures only. Icons have distinct
semantic, import, registry, and conversion concerns; grouping them with demo
media would silently decide another public boundary.

Rejected: classifying every SVG or image-like source as a template fixture.

## Open questions

None.

---

### Specs AST 029 Spec (`docs/specs/AST-029/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-029
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-07
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:knowledge-contracts]
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Component audit contract backfill and auto-merge system spec

## Intent

Define only the additional behavior needed to run whole-component audits safely:
which components participate, how an audit proceeds when a component contract is
missing or incomplete, which fixes may travel with the audit, and when a Night
Watch audit may auto-merge.

This spec builds on the existing component-specification system. Component contract
shape, authority, ownership, approval, conflict handling, and semantic API rules
remain owned by:

- [`docs/templates/knowledge/component-spec.md`](../../templates/knowledge/component-spec.md)
  and the matching versioned knowledge schema;
- [`architecture:knowledge-contracts`](../../architecture/knowledge-contracts.md);
- [`spec:AST-002`](../AST-002/spec.md); and
- each component's existing `<Name>.spec.md`.

## Non-goals

- Define a second component-spec format, authoring guide, schema, or approval path.
- Copy component, family, design, architecture, consumer-doc, or audit facts into a
  new record.
- Add, improve, remove, or reinterpret component behavior during observational
  backfill.
- Replace grading, promotion, or review-mode issue and ledger rules.
- Decide the general component-test quality rubric.

## Requirements

- **FR1 — One audit roster.** The audit queue and Component Audits sandbox table
  MUST use one checked-in roster of public components. The target roster includes
  Core, Lab, Charts, Rich Text, and Vega. A package may enter the active audit roster
  only after the existing canonical knowledge system recognizes its component-record
  locations and validates them; audits MUST NOT invent audit-only spec paths.
- **FR2 — Existing component contracts remain canonical.** An audit MUST begin with
  the nearest current component or module contract and follow its links to current
  family, design, architecture, and system authority. The audit MUST use the existing
  component template and schema rather than creating a parallel contract shape.
- **FR3 — Missing contracts use the existing specification flow.** When a component
  contract is missing or incomplete, the auditor MAY prepare or complete a draft
  `<Name>.spec.md` using the existing template, schema, authority rules, and exact-
  head owner approval flow. A missing draft MUST NOT block grading from current
  authority and checkable evidence.
- **FR4 — Backfills are observational only.** An audit-authored component contract
  MUST describe verified shipped behavior and MUST NOT add, improve, remove, or
  reinterpret behavior. Proposed meaning remains an open question or a separate
  owner decision. A draft cannot clear an audit finding as settled policy.
- **FR5 — Audit completeness is recorded outside the component contract.** The audit
  PR MUST carry a closed evidence receipt. Its finite inventory comes from public
  exports and types, documented concepts and variants, states and transitions
  reachable through the public API, and implementation branches reachable from
  those surfaces. Equivalent inputs MAY share a row only when evidence shows they
  have the same observable contract. Each row names the relevant source, existing
  tests, consumer docs, rendered evidence, applicable current authority or objective
  standard, and any conflict or gap. This receipt is audit evidence; it does not add
  a required section to the component-spec schema.
- **FR6 — Existing authority resolves conflicts.** The auditor MUST apply
  `architecture:knowledge-contracts` and `spec:AST-002`. Current shared authority
  and objective standards govern their scope. A conflict between current records,
  or a question requiring new API, compatibility, ownership, or design judgment,
  stops objective remediation and routes to the owner.
- **FR7 — Objective remediation may share the audit PR.** One Night Watch PR MAY
  combine an observational component-contract edit with missing behavior tests,
  stable visual-regression coverage, consumer-doc drift fixes, and implementation
  bugs whose required outcome is already settled. Baseline evidence and each fix's
  before/after proof MUST remain distinct.
- **FR8 — Manual boundaries fail closed.** Public API meaning or shape, defaults,
  compatibility or migration promises, ownership boundaries or conflicts, and
  subjective representation, proportion, density, or interaction-feel decisions
  MUST remain human-reviewed and MUST NOT auto-merge.
- **FR9 — Audit modes remain distinct.** Night Watch retains no ordinary per-finding
  issues and records only its post-fix ledger result. Grading and promotion retain
  their existing per-BLOCK issue and recording procedures. The shared audit prompt
  MUST preserve the selected mode.
- **FR10 — Auto-merge requires an exact-head eligibility report.** The Night Watch
  auditor MUST emit a versioned, machine-readable report containing the component
  and package, audit mode and rubric version, audited repository and component-
  contract heads, FR5 inventory closure, unresolved objective and manual gaps,
  remediations and their before/after evidence, required approval states, required
  check states, and a fail-closed eligibility verdict with reasons. A trusted check
  MUST validate that report against the current PR and head, publish every unmet
  condition in its summary, and project the required `audit-eligibility` status.
  The report MAY live in trusted PR/check metadata; it MUST NOT require a second
  checked-in audit record. Any missing, stale, inconsistent, or ineligible report
  leaves the PR open for human review.
- **FR11 — Activation is separate, explicit, and atomic.** Auto-merge remains
  unavailable until this record is both `authority: current` and `phase: shipped`,
  and the activation change has landed the audit prompt, wiki procedure, canonical
  package/spec coverage, roster consumers, eligibility checks, and focused tests
  together. Approval without that complete activation does not change runtime
  behavior; every audit PR remains manual-review-only. This spec does not require a
  component-template or schema migration because FR5 keeps audit-only evidence
  outside component contracts.

- **FR13 — Audit data follows the existing storage authority.** Durable product
  behavior stays in the component `.spec.md`. Per
  [`architecture:knowledge-contracts`](../../architecture/knowledge-contracts.md),
  automated wiki `component-scores.json` remains the operational datastore for
  current post-fix scores and unresolved findings; unactivated per-component audit
  files are not a migration target. The audit PR stores the reviewable run evidence,
  and the trusted Check Run stores the FR10 exact-head machine report. Any future
  datastore change requires a separate automated migration and cutover contract.

- **FR14 — Scoring uses the assembled applicable contract.** Each audit section MUST
  score the requirements that apply from current global authority and objective
  standards, current family contracts, and current component/module contracts. Each
  requirement maps to one rubric section and one evidence result so one defect is not
  counted twice. `N/A` requirements are excluded; unavailable evidence uses the
  rubric's existing `not_measured` behavior. Existing severities, weights, grade
  bands, and BLOCK ceilings remain unchanged unless the rubric explicitly versions a
  scoring change.
- **FR15 — Authority changes invalidate affected scores, not the global scale.** A
  change to a current component/module contract invalidates that component's audit.
  A current family or global-authority change invalidates every linked applicable
  component. The rubric version changes only when scoring methodology, weights,
  severities, or evidence treatment changes; an authority change alone triggers
  targeted re-audit on the same scale.

### Platform support

- Supported feature/engine floor: repository-supported Node, browsers, and CI.
- Unsupported behavior: an unresolvable component or missing evidence remains an
  explicit audit gap rather than disappearing or passing.
- Browser evidence: visible and interaction claims use the real-browser evidence
  required by the audit rubric and applicable current records.

## Current-state impact

This accepted spec changes no audit automation or component-contract schema.
Follow-up implementation will update the shared audit roster and its sandbox view,
the audit prompt and mode handling, Night Watch procedure, and fail-closed
eligibility checks. Those changes remain separate from this specification PR.

## Verification

| Contract  | Verification                                                           | Representative states                                                                                     | Mutation or failure expectation                                                                                                  |
| --------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| FR1       | Canonical knowledge-path coverage plus roster and sandbox parity tests | all five target packages; unsupported package; new component; private helper                              | A package is audited before its component records are canonical, queue and sandbox diverge, or an eligible component disappears. |
| FR2–FR6   | Audit fixtures using existing component records                        | current; draft; missing; finite public-state inventory; equivalent inputs; local drift; conflict          | Audit invents a second contract shape, omits a reachable public state, draft becomes policy, or a conflict is canonized.         |
| FR7–FR10  | Mode, report-schema, trusted-validation, and eligibility tests         | safe objective fix; API/default/ownership/design boundary; receipt with gap; stale head; missing approval | Unsafe or incomplete work auto-merges, a stale report clears the status, or one mode receives another mode's lifecycle.          |
| FR11      | Authority/phase and activation tests                                   | draft; current/proposed; current/accepted; current/shipped with partial wiring; fully activated           | Auto-merge activates before the explicit state and all required surfaces land together.                                          |
| FR12      | TDD trace and behavior-test review                                     | settled seam; unsettled seam; red failure; minimal green slice; implementation-coupled test               | Production changes precede red, an unsettled seam is invented, or a test passes without observing the contract.                  |
| FR13      | Storage-boundary tests                                                 | wiki ledger; PR evidence; Check Run report; attempted per-component log                                   | Audit data is duplicated, written under the wrong owner, or read from an unapproved datastore.                                   |
| FR14–FR15 | Scoring and freshness tests                                            | global/family/component requirements; N/A; not measured; local and shared authority changes               | A defect is counted twice, a requirement is omitted, or affected scores remain falsely current.                                  |

## Decision log

### DEC-1 — Audits reuse the existing component-specification system

**Reference:** `spec:AST-029/DEC-1`
**Decider:** `cixzhang`, `2026-09-06`

Audit backfills use the existing component template, schema, authority model, and
component records. Audit-only completeness evidence stays in the audit receipt.

Rejected: creating a second component-spec guide or adding audit workflow fields to
every component contract.

### DEC-2 — Backfill and settled remediation may travel together

**Reference:** `spec:AST-029/DEC-2`
**Decider:** `cixzhang`, `2026-09-06`

One audit PR may describe verified shipped behavior and fix objective defects with
before/after proof. New judgment remains outside that automatic path.

Rejected: splitting a safe fix only because the same audit found a missing contract.

### DEC-3 — Auto-merge uses a structured report without a duplicate checked-in record

**Reference:** `spec:AST-029/DEC-3`
**Decider:** `cixzhang`, `2026-09-07`

Night Watch emits the versioned eligibility format in FR10. A trusted check binds it
to the exact PR head, validates it against repository state, and publishes the
required status and reasons. The report lives in trusted PR/check metadata rather
than a second checked-in audit file. Grading and promotion keep their existing issue
and ledger rules.

Rejected: unstructured prose as an auto-merge input, a second repository audit
record, or eligibility based on green CI alone.

### DEC-4 — Audit-authored behavior tests use Matt Pocock's TDD skill

**Reference:** `spec:AST-029/DEC-4`
**Decider:** `cixzhang`, `2026-09-07`

Night Watch uses the pinned public `tdd` skill for behavior-test remediation. Current
contracts or established public interfaces determine the seam; the auditor does not
invent one. Each cycle proves red before production changes and implements one
minimal vertical slice through observable behavior.

Rejected: writing tests after implementation, testing internals, or generating all
tests before learning from the first vertical slice.

### DEC-5 — Audit data remains split by ownership

**Reference:** `spec:AST-029/DEC-5`
**Decider:** `cixzhang`, `2026-09-07`

The automated wiki ledger remains the operational datastore for current component
audit state. The audit PR stores one run's human-reviewable evidence, and the trusted
Check Run stores the exact-head machine decision. Component contracts store durable
product behavior only. Any future datastore change requires an explicit automated
migration and cutover contract.

Rejected: per-component shadow ledgers, storing screenshots or run matrices in
component specs, or treating transient CI artifacts as the only review record.

### DEC-6 — Scoring stays stable while applicable authority is assembled dynamically

**Reference:** `spec:AST-029/DEC-6`
**Decider:** `cixzhang`, `2026-09-07`

The rubric keeps its existing sections, weights, severities, grade bands, and
unmeasured treatment. Each audit fills those sections from applicable current global,
family, component, and module requirements. Authority changes invalidate the linked
component audits; only a scoring-method change versions the rubric scale.

Rejected: copying every local requirement into the wiki rubric, silently changing
weights when a component has more requirements, or treating a spec edit as a global
scoring-version change.

## Open questions

None.

---

### Specs AST 030 Spec (`docs/specs/AST-030/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-030
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-07
phase: accepted
owners: [cixzhang]
affects_architecture: []
affects_families: []
affects_contributing: []
affects_consumer_docs: []
---

# Positive CI surface routing system spec



```json
{
  "scope": "global",
  "triggers": {
    "testing": ["FR7", "FR11"]
  }
}
```



```json
{
  "paths": [
    ".github/workflows/**",
    ".github/scripts/change-scope.*",
    ".github/scripts/*routing*.test.mjs",
    ".github/scripts/pr-scope-touches.*"
  ]
}
```

## Intent

Route pull-request checks from the repository surfaces a change can affect rather
than from an expanding list of work the change appears not to affect. Contributors
should get the checks that can observe their change, while a narrow operational
Node-tooling change should not wait for unrelated component, browser, Storybook,
or application builds.

The classifier produces a set of touched surfaces. A lane may skip work only when
every changed path is classified from trusted base-branch policy and the selected
checks cover every surface in that set. Mixed, incomplete, or unknown scope uses
the broad lane.

A **lane** is one independently routed logical test or build owner for one
surface. A lane may dispatch multiple workflow jobs or steps and may feed a
historical required-check join. Jobs, workflows, required contexts, and projected
statuses are execution or reporting mechanisms—not additional lanes merely because
they are separately named.

## Non-goals

- Rewrite every CI job or specialize every package lane in one change.
- Add a test-specific or build-specific lane for work already owned by an
  existing surface.
- Apply changed-surface routing to post-merge `main` CI.
- Define compatibility semantics or release-pair delta classes. Those belong to
  [`spec:AST-017`](../AST-017/spec.md); this record routes their checks and owns the
  exact-main release-check execution boundary.
- Remove required check names, branch protection, merge-queue coverage, or
  exact-head owner approval.
- Infer safety from file extensions, directory names, or the absence of a known
  risky path.
- Treat tests, specifications, generated files, or changesets as proof that the
  public or operational surface beside them is untouched.

## Requirements

- **FR1 — Classification is positive and set-based.** Every changed path MUST map
  to one or more named surfaces. Lane selection MUST use the union of those
  surfaces, not exclusion predicates over unrelated paths. A path with no exact
  rule MUST add `shared-or-unknown`.
- **FR2 — The taxonomy follows check ownership.** The classifier MAY name only a
  surface with a concrete check owner and dependency boundary. The initial
  taxonomy and current owners are:

  | Surface             | Positive path ownership                                                                                                            | Current check owners                                                                                                                                 |
  | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `knowledge`         | canonical system, family, design, theme, component, and module records                                                             | knowledge validation and exact-head spec-owner approval                                                                                              |
  | `docsite`           | the docsite application                                                                                                            | docsite generation and tests                                                                                                                         |
  | `node-tooling`      | admitted operational Node tooling whose consumers are covered by Node contract tests                                               | Node Vitest, repository guardrails, and ESLint                                                                                                       |
  | `runtime:<package>` | public source and package contract for Core, Lab, Charts, Rich Text, Vega, CLI, and Build                                          | the package's unit/type checks plus current broad build and downstream consumer checks; Build's owner includes production CSS-layer browser behavior |
  | `theme-build`       | shipped theme packages and theme compilation outputs                                                                               | theme tests, theme package builds, theme-family browser behavior, and stable visual evidence where applicable                                        |
  | `storybook-visual`  | Storybook stories/configuration and visual, accessibility, or RTL audit infrastructure                                             | Storybook build, preview/visual-report publication, and the applicable browser, visual, accessibility, and RTL checks                                |
  | `shared-or-unknown` | shared configuration, dependency graphs, workflows, classifiers, generated ownership, ambiguous paths, and every unclassified path | all applicable pull-request CI checks                                                                                                                |

  A category does not earn a specialized lane merely by existing in this table.
  Until every selected surface has an admitted, complete specialized owner route,
  that surface set uses the broad check set. `shared-or-unknown` always uses the
  broad check set.

- **FR3 — Classification uses trusted base policy.** Pull-request code MUST NOT
  choose its own lane. The workflow MUST load the classifier and every classifier
  dependency from the trusted base ref, then apply that policy to the merge-base
  three-dot path set. Workflow changes use the broad lane unless they are an
  admitted tool's non-PR dispatch workflow. Classifier,
  classifier-dependency, and routing-test changes use the broad lane.
- **FR4 — Uncertainty fails closed.** A missing merge base, missing trusted
  classifier dependency, failed classifier, empty or incomplete file list,
  ambiguous rename, unknown path, or merge-group event without a trusted PR path
  set MUST select the broad lane. No fallback may grant a specialized lane.
- **FR5 — Mixed scope keeps every owner.** A change touching multiple classified
  surfaces MUST run the union of their checks. It MUST NOT run specialized lanes
  for untouched surfaces. A set containing `shared-or-unknown`, a fail-closed
  condition from FR3–FR4, or any selected surface without a complete admitted
  specialized owner route MUST use broad CI. A specification beside runtime code
  does not hide the runtime surface.
- **FR6 — Node-tooling admission is explicit.** A Node program may enter
  `node-tooling` only when dependency analysis proves that package runtime,
  component UI, theme/build output, Storybook/visual evidence, and browser
  behavior are not changed directly, and every operational consumer has a Node
  contract test. Directory-wide admission is allowed only for a directory
  dedicated to non-shipped tooling; other directory- or extension-wide
  admission is prohibited.
- **FR7 — Required check names remain stable.** `test`, `build`, `docsite-test`,
  and `lint` MUST continue to report on every pull request through their existing
  jobs or join jobs. A specialized lane skips owned steps inside those jobs; it
  does not remove historical required contexts. Any owned lane failure MUST fail
  its join. Required lint does not compare main's package declaration with the newest
  stable release; AST-017 assigns prerelease ordering to the canary publisher and keeps
  immutable release merge-back validation in required lint.
- **FR8 — Initial tooling admissions.** `node-tooling` covers
  `scripts/score-ledger.mjs`, `scripts/score-ledger.test.mjs`,
  `internal/scripts/`, and `.github/workflows/crowdin-upload.yml`. It runs the
  Node project and the required lint
  workflow, including repository guardrails. It skips the UI Vitest project,
  component analysis, docsite generation, production package, Storybook and
  Sandbox builds, preview/visual-report publication, and browser/theme/visual/
  a11y/RTL jobs. The trusted post-CI workflow MUST explicitly report that visual
  checks are not applicable and remove stale preview links without enqueueing the preview
  publisher. The Sandbox score-ledger projection MUST have a Node contract test
  for the exports it consumes before this lane can be enabled.
- **FR9 — Routing tests are mutation-sensitive.** Tests MUST prove both the
  intended fast path and the unsafe near misses. At minimum they cover pure
  tooling; pure spec; component spec plus component code; module spec plus Table
  plugin code; tooling plus component code; workflow or classifier self-change;
  unknown paths; shared infrastructure; rename history; incomplete input; and
  public source in every component-bearing package. Removing any fail-closed edge
  MUST make a test fail.
- **FR10 — New surfaces follow one admission convention.** A new surface MUST be
  admitted by an owner-approved amendment to this record before implementation.
  The amendment MUST name its positive path ownership, dependency boundary, test
  commands, one independently routed test owner, build commands and one
  independently routed build owner when a build applies, mixed-scope behavior,
  required-check projection, and mutation-sensitive routing tests. A new suite,
  feature, component, theme family, or test type is not a new surface by itself.
- **FR11 — Each surface owns one test and applicable build lane.** A surface MAY
  define multiple test or build commands, jobs, or steps, but exactly one
  independently routed test lane and, when applicable, one independently routed
  build lane MUST own them. New coverage inside an existing surface MUST join
  those owners and MUST NOT create a separately routed lane or required context.
  Existing historical joins remain stable. A selected surface whose dedicated
  owner is not yet admitted continues through broad CI. The Build package's test
  owner includes its production CSS-layer cascade behavior. Theme-family
  compilation remains `theme-build` and does not become Build merely because both
  behaviors concern CSS.
- **FR12 — Visual regression has one shared pull-request workflow.** Every
  visual-regression test from every surface MUST use the existing Storybook
  framework and the single shared owner in `.github/workflows/ci.yml`
  (`pr-visual`). That workflow MAY use multiple jobs, steps, and artifacts, but
  visual capture and regression comparison MUST NOT run in another workflow,
  including scheduled, post-CI, post-merge, or manual replacement owners.
  Publication of the canonical run's reports is not another test owner: it MUST
  consume that run's artifacts without recapturing or recomparing pixels.
  Explicit full-plan baseline capture MUST remain in `ci.yml`; it is a maintenance
  operation, not a release gate. A reviewed baseline update MUST consume that
  capture, update the versioned repository baseline through a normal pull request,
  and remain separate from automatic merge or release work. GitHub Pages MUST NOT
  be baseline or report storage.
  Release evidence MUST come from an explicit `operation=release-check`
  `workflow_dispatch` in this same workflow, dispatched from `main` and bound to
  its exact event SHA. It MUST run the canonical `pr-visual` (**Stable visual
  regression**), `pr-a11y`, and `pr-rtl` owners with full scope: the closed stable
  visual plan and unfiltered accessibility/RTL component rosters. Changed-file,
  focused, smoke, and no-op paths MUST NOT narrow release checks. PR a11y MUST
  remain scoped to changed components plus fast interaction guards; full a11y
  sweeps, whole-repository spec-test contracts, and their evidence uploads MUST
  run only for `release-check` in this workflow. Missing,
  failed, cancelled, or skipped required work MUST block the release-check join.
  The request and join MUST verify that the checked SHA is the dispatch event SHA
  and remains an ancestor of current remote `main` at the start and completion of
  the run. Release callers MUST fetch and recheck that same ancestry immediately
  before creating the release branch. Later fast-forward movement of `main` does
  not invalidate that receipt: the release MUST cut from the exact checked SHA, and
  later commits remain next-release input. A rewrite or divergence that removes
  the checked SHA from current `main` history invalidates the receipt.
  This replaces constituent-PR-only release gating, not the canonical checks'
  existing finding policies. Release checks MUST NOT run on every push, capture
  baseline candidates, publish baselines, or use a separate daily workflow or
  retroactive acceptance status.
  Deployment, accessibility, RTL, and vibe evidence MAY retain their workflows
  and artifacts when they do not duplicate visual regression. A new or existing
  surface, component, package, theme family, or visual suite MUST join the
  canonical workflow. A repository guard MUST reject independent visual owners
  while preserving legitimate non-visual artifact use.

### Platform support

- Supported feature/engine floor: GitHub pull-request and merge-group workflows
  on the repository's supported Node and runner versions.
- Unsupported behavior: incomplete or unavailable classification receives no fast
  path and runs broad CI.
- Browser evidence: routing itself has no visual claim; browser checks remain
  mandatory whenever their owning surface is present or scope is uncertain.

## Current-state impact

Current CI has singleton fast paths for canonical specification records, the
docsite, and admitted Node tooling. All other changes run broad Node/UI, build,
browser, and application checks. This accepted record changes no workflow by
itself.

Implementation begins by preserving those existing singleton surface sets and
making their ownership model reusable. Existing package, theme/build, and
Storybook/visual surfaces remain on broad CI until their dedicated owners are
admitted. The current broad workflow may dispatch surface-owned jobs; their names
describe implementation, but they do not receive a specialized route until the
classifier and join contract are complete. New surfaces may add lanes only through
FR10. New coverage joins its current surface owner; visual regression remains in
the shared Storybook PR owner.

## Verification

| Contract | Verification                                                                | Representative states                                                                                    | Mutation or failure expectation                                                                                                                                                    |
| -------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR2  | classifier unit tests expose the surface set                                | knowledge; docsite; admitted tool; Core/Lab/Charts/Rich Text/Vega source; shared config; unknown         | an unclassified path receives a specialized lane or a category has no check owner                                                                                                  |
| FR3–FR4  | workflow contract tests execute an isolated trusted-base classifier         | valid base; missing merge base; missing matcher/registry; classifier self-change; merge group            | PR-controlled policy grants a lane, or missing trust data skips checks                                                                                                             |
| FR5      | mixed-surface and incomplete-owner routing tests                            | spec+component; tooling+component; named surface without admitted lane; docsite+shared                   | one surface hides another owner or a named-but-incomplete surface skips broad CI                                                                                                   |
| FR6–FR8  | Node-tooling dependency, CI workflow, and trusted post-CI workflow tests    | both admitted score-ledger paths; Sandbox projection imports; test/build joins; preview/visual publisher | UI/browser/build work or preview publication runs for the singleton tool set, an operational consumer is untested, a visual status stays pending, or a required context disappears |
| FR9      | mutation-sensitive classifier and workflow fixtures                         | unknown path; rename from unknown; truncated list; package-specific public paths                         | weakening a fail-closed rule leaves the suite green                                                                                                                                |
| FR10     | surface-admission and routing contract tests                                | existing surface; proposed surface with and without the full admission contract                          | a new surface adds lanes without its approved paths, commands, ownership, projections, and routing tests                                                                           |
| FR11     | surface owner, internal-job, and required-join contract tests               | Build package browser behavior; CLI; theme-family broad fallback; mixed Core+CLI                         | a second owner is routed for one surface, an internal job masquerades as a lane, an owned failure misses its join, or an unrelated owner runs                                      |
| FR12     | Storybook visual-plan, canonical workflow, and repository-owner guard tests | PR scope; full-plan maintenance; report-only publication; deployment/a11y/RTL/vibe artifacts             | another workflow captures or compares pixels, a renamed or aliased owner escapes the guard, or legitimate non-visual artifacts are rejected                                        |

## Decision log

### DEC-1 — Surfaces form a union; lanes are projections

**Reference:** `spec:AST-030/DEC-1`
**Decider:** `cixzhang`, `2026-09-07`

A mixed change retains every touched surface and therefore every check owner. A
specialized lane is safe only for an exact surface set whose checks are complete.

Rejected: mutually exclusive labels where `spec-only`, `docs`, or `tooling` can
win over runtime code elsewhere in the same pull request.

### DEC-2 — Unknown and self-modifying scope uses broad CI

**Reference:** `spec:AST-030/DEC-2`
**Decider:** `cixzhang`, `2026-09-07`

Classification policy comes from the base branch, and every inability to prove a
specialized set selects broad CI. This keeps a pull request from weakening the
classifier or its dependencies to skip its own checks.

Rejected: head-branch classification, two-dot fallback, directory heuristics, or
a bootstrap fallback that grants a fast path.

### DEC-3 — Start with one explicit Node-tooling ownership group

**Reference:** `spec:AST-030/DEC-3`
**Decider:** `cixzhang`, `2026-09-07`

The score-ledger program and tests form the first narrow operational tool group.
Its implementation is validated by Node tests, repository guardrails, ESLint, and
a Node contract for the Sandbox projection; unrelated UI, browser, and production
build work does not observe that change.

Rejected: admitting all of `scripts/`, all JavaScript files, or every Node test by
pattern. Those sets contain generators and build/release inputs with different
owners.

### DEC-4 — CI grows only at a surface boundary

**Reference:** `spec:AST-030/DEC-4`
**Decider:** `cixzhang`, `2026-09-12`; clarified `2026-09-15`

Each surface may expose the test and build commands it needs, but pull-request CI
has one independently routed test owner and, when applicable, one independently
routed build owner for that surface. One owner may dispatch multiple jobs or
steps and feed a stable required-check join; those mechanics do not create more
lanes. Existing-surface coverage joins those owners. A genuinely new surface may
add owners only after the complete FR10 admission contract is approved. A named
surface without a complete admitted owner remains on broad CI.

Visual regression is the exception to per-surface routing: every surface
registers its cases with the one shared Storybook-backed PR visual owner in
`ci.yml`. Its jobs, steps, artifacts, and report publication are not additional
visual lanes; a second workflow that captures or compares those pixels is.
The single-workflow wording clarifies the existing shared-system requirement;
it does not admit a new owner.

Build's production CSS-layer cascade is a Build package integration contract, so
it belongs to Build's test owner. Theme-family compilation remains owned by
`theme-build`; sharing CSS concepts does not collapse those surfaces.

Rejected: per-suite, per-component, per-feature, per-theme-family, or separate
PR visual owners; counting internal jobs or required status projections as
lanes; and implicit expansion under an undefined “additional specialized
surface” exception.

### DEC-5 — Release checks reuse the canonical owners on an exact main commit

**Reference:** `spec:AST-030/DEC-5`
**Decider:** `cixzhang`, `2026-09-23`; clarified `2026-10-07`

Run release evidence against an exact commit in current `main` history, not only
constituent PR heads. Use an explicit release-time dispatch in `ci.yml` so full
visual, accessibility, and RTL checks do not run on every main push. Reuse
existing test/build owners, keep their finding policies, and fail closed on
incomplete scope, missing work, or when the checked commit is no longer in
current `main` history. Baseline capture and the reviewed pull request that
versions an accepted candidate remain separate operations.

PR accessibility remains a scoped, fast check: audit only explicitly resolved
changed component owners and retain the fast modal-close, theme-var, and story-play
browser guards. Never widen a PR to the exhaustive accessibility sweep because a
shared path changed or the component set is empty. Missing or approximate analysis
fails the scoped check rather than silently claiming no work. Multi-component
folders expand only to their own canonical component owners or existing umbrella
Storybook routes; an exported name without either route cannot be passed to axe. The full
accessibility roster and the whole-repository spec-test contracts and their
evidence uploads run only on `release-check` in this workflow.
The release path retains the complete suite and cannot substitute a scoped result.

Rejected: a separate workflow, per-push full audits, and treating a maintenance
capture, skipped check, a checked SHA no longer in current `main` history, or
retroactive status as release evidence.

## Open questions

None.

---

### Specs AST 031 Spec (`docs/specs/AST-031/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-031
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: proposed
owners: [josephfarina, cixzhang]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [authoring]
---

# Runtime integration feature composition system spec

## Intent

Define reusable composition rules for runtime integration features — CLI
capabilities that an integration can contribute alongside its default manifest.
Each feature is a named export from the integration module and an optional
matching field in `astryx.config`. Multiple handlers compose additively: every
configured eligible handler runs in isolation, failures are reported per handler, and
the feature defines one aggregate command outcome without letting one handler
silence the others.

This spec captures the general composition contract first, then records the
requirements specific to `gapReport`, the first feature built on it.

## Non-goals

- Replace the default-export manifest or its contribution model (components,
  templates, codemods, docs). Those are loaded by the `Project` seam and are
  outside this scope.
- Define transport protocols, network destinations, or persistence formats for
  any handler.
- Provide a plugin system with lifecycle hooks, dependency ordering, or
  inter-handler communication.

---

## Requirements

### General composition rules

The following rules apply to every runtime integration feature that follows
this spec. A feature is identified by a named export from
`astryx.integration.{ts,mjs,js}` and optionally a matching field in
`astryx.config`.

#### Named export + matching project config field

A feature is declared as a **named export** from the integration module.
The same feature may also appear as a field in the consumer's
`astryx.config` for project-level handlers. Older CLI versions that do not
recognize the named export ignore it and continue loading the default manifest.

#### Additive composition

Every handler in the effective set receives an ordered delivery outcome. The
project handler comes first, then each integration handler in the order the
integrations appear in `astryx.config`. An eligible handler runs; a public
handler without consent records `consent_required` without being called. No
handler overrides or displaces another.

#### Identity dedupe by handle function

When the same `handle` function reference appears more than once in the
effective set (e.g. a project config re-exports an integration's handler), it
runs exactly once, at the position of its first occurrence.

#### Per-handler structuredClone copy

Each handler receives its own `structuredClone` copy of the event. A handler
that mutates its input cannot affect any other handler.

#### Failure isolation + aggregate response

A handler that throws, times out, or misbehaves produces a failed delivery for
that handler. Later handlers still run. The command's aggregate response
collects a per-handler delivery record so the caller can see exactly which
handlers succeeded, which failed, and why.

#### Lifecycle / async

- **Timeout:** each handler has a 30-second wall-clock timeout and receives an
  `AbortSignal` that fires when that budget expires.
- **Async:** handlers may be async; the CLI awaits each one.
- **Execution order:** handlers run sequentially in effective-set order. A
  handler's failure does not skip subsequent handlers.

#### Per-handler consent metadata

Each handler declares an `audience` of `'internal'` or `'public'`. The CLI
enforces consent per handler: a `public`-audience handler is not invoked
unless the caller has explicitly confirmed the public write. An `internal`
handler requires no additional confirmation.

#### Inspectable consent (consent_required delivery)

When the caller has not confirmed consent and a handler requires it, the CLI
records a `consent_required` delivery for that handler without calling it. The
caller can inspect which handlers would need consent before re-running with
confirmation.

#### Fallback behavior

A built-in fallback (e.g. opening a GitHub issue via `issuesUrl`) activates
**only** when the effective handler set is empty after deduplication. The
presence of any configured handler — project or integration — suppresses the
fallback entirely.

#### issuesUrl is target metadata

`issuesUrl` on an integration manifest or project config describes the
destination for human-readable issue links. It is metadata about the target
package, not a handler implementation. Handlers use it as context; the CLI
uses it for fallback routing.

#### Mutation-sensitive tests

Tests for a feature built on this composition model MUST cover the fan-out
order, isolation, deduplication, timeout, consent gating, consent-required
inspection, fallback suppression, and aggregate response shape. Removing a
fail-closed guard MUST break a test.

---

### Gap-report-specific requirements

`gapReport` is the first feature built on the general composition rules above.

- **FR1 — Handler type.** A gap-report handler is a plain object conforming to
  `GapReportHandler`: `{ audience: 'internal' | 'public', handle: (event: GapReport, context: { signal: AbortSignal }) => GapReportHandlerReceipt | Promise<GapReportHandlerReceipt> }`.
  `GapReportHandler` replaces `GapReportWriter`.

- **FR2 — Event shape.** The `GapReport` event carries `schemaVersion: 1`,
  plain camelCase field names, normalized values, and a `target` object with
  `{ package, version, issuesUrl }`. No snake_case, no abbreviations, no
  nested debug context.

- **FR3 — Receipt contract.** A handler MUST return a `GapReportHandlerReceipt`:
  `{ status: 'filed' | 'routed_only' | 'skipped', url?: string, message?: string }`.
  No other status values are accepted. `url` and `message` are optional and
  carry human-readable context.

- **FR4 — Handler isolation.** Each handler runs in isolation:
  - Each handler runs in its own worker. `stdout` is redirected to `stderr`, and
    `process.exit`, `process.exitCode`, and late promise continuations are
    contained in that worker.
  - A throw or timeout (30 s) produces a failed delivery for that handler. The
    CLI aborts the handler's signal, then terminates its worker before starting
    the next handler.
  - A failed handler does not affect the command's own exit code or other
    handlers.

- **FR5 — No inherited-handler opt-out.** There is no opt-out mechanism for
  gap-report handlers analogous to `inheritDebug`. Every internal handler in the
  effective set runs, and every public handler receives an outcome subject only
  to the explicit consent gate.
  The `inheritDebug` field in `package.json` is a compatibility exception specific
  to the `debug` feature (see Decision log DEC-2); it does not set a precedent
  for other features.

- **FR6 — Effective set.** The effective handler set is: project config's
  `gapReport` handler first (if present), then each integration's `gapReport`
  named export in `astryx.config` integration order. Deduplication is by
  `handle` function identity.

- **FR7 — Target selection.** The target package for the gap report is selected
  by: explicit `--package` flag → the unique component owner when only one
  package owns the component → Core as the default. Target selection is not
  based on handler count or handler source.

- **FR8 — Built-in GitHub fallback.** When the effective handler set is empty
  (zero handlers after deduplication), and the target has a GitHub `issuesUrl`,
  the CLI offers to file via `gh issue create`. This fallback is consent-gated
  (`public` audience). Any other `issuesUrl` scheme produces a `routed_only`
  receipt. The fallback is suppressed entirely when at least one handler exists.

- **FR9 — Project config field.** `astryx.config` gains a `gapReport` field
  accepting a `GapReportHandler`. This is the project-level handler and runs
  first in the effective set.

- **FR10 — Integration named export.** An integration declares its handler as
  `export const gapReport: GapReportHandler` from its integration module. The
  named export is deliberately not a default-manifest field, consistent with the
  `debug` feature's export convention.

- **FR11 — Per-handler consent.** Each handler's `audience` is evaluated
  independently. A fan-out with mixed audiences invokes `internal` handlers
  unconditionally and gates `public` handlers on caller consent. Ungated
  handlers still run even when a gated sibling is blocked.

- **FR12 — Aggregate response.** The command's response includes a `deliveries`
  array with one entry per effective handler or fallback. Each delivery records
  `handlerType`, `handler`, `audience`, `status`, `url`, and `message`. The
  response shape is the same in `--json` and human output modes.

- **FR13 — A destination the caller names.** When the caller names a
  destination with `--to`, that destination replaces the handlers and the FR8
  fallback for that report. `architecture:cli-surface` INV36 governs the option
  and who chooses the destination.

#### Platform support

- Supported: Node >= 22.13, any OS the CLI supports.
- Unsupported: handlers that depend on network access, native binaries, or
  platform-specific APIs are the handler author's responsibility; the CLI
  provides isolation but not capability detection.

---

## Current-state impact

`debug` already composes one project function with every integration named export,
in project-first/config-order delivery, with identity dedupe and per-handler copies.
Its synchronous process-exit lifecycle and existing `inheritDebug` compatibility
switch remain unchanged.

PR #6200 initially selected one executable gap writer. This specification replaces
that model before release: `gapReport` becomes one project handler plus every loaded
integration handler, and each handler returns its own receipt. Existing consumers
without handlers keep the consent-gated `issuesUrl` fallback. No released config or
integration API requires migration because the single-writer shape has not shipped.

---

## Verification

| Contract | Verification                                           | Representative states                                                                                                      | Mutation or failure expectation                                                                         |
| -------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| FR1–FR3  | gap-report handler type and receipt contract tests     | valid handler; missing audience; wrong status value; non-object return                                                     | an invalid handler shape or receipt is accepted silently                                                |
| FR4      | handler isolation tests                                | handler writes stdout; handler calls process.exit; handler throws; handler exceeds 30 s                                    | stdout corruption reaches the JSON envelope, or exit/throw stops later handlers                         |
| FR5      | no-opt-out assertion test                              | project sets `inheritDebug: false` in package.json; gap-report handlers still run                                          | an opt-out mechanism suppresses a gap-report handler                                                    |
| FR6      | effective-set ordering and deduplication tests         | project + two integrations; duplicate handle ref; project only; integrations only                                          | wrong order, duplicate execution, or missing handler                                                    |
| FR7      | target selection tests                                 | explicit package; unique owner; ambiguous owner requiring `--package`; no component match                                  | handler count influences target, ambiguity is guessed, or an unknown component fails to default to Core |
| FR8      | fallback activation and suppression tests              | zero handlers + GitHub issuesUrl; zero handlers + non-GitHub issuesUrl; one handler present                                | fallback fires with a handler present, or non-GitHub issuesUrl claims it filed                          |
| FR9–FR10 | config field and named-export loading tests            | project config with gapReport; integration with named export; both present; neither present                                | config field ignored, named export ignored, or old CLI breaks on named export                           |
| FR11     | mixed-audience consent tests                           | all internal; all public with consent; all public without consent; mixed with partial consent                              | a public handler runs without consent, or an internal handler is blocked by consent                     |
| FR12     | aggregate response shape tests                         | all succeed; one fails; one times out; consent_required entries; empty set (fallback)                                      | missing delivery entry, wrong source attribution, or response shape differs between --json and human    |
| FR13     | named destination tests                                | `--to` naming an integration with a GitHub issuesUrl, with and without consent; a non-GitHub issuesUrl; an unknown package | a handler runs with `--to`, or an unknown destination sends anything                                    |
| General  | mutation-sensitive composition tests (structuredClone) | handler mutates event fields; handler adds properties to event                                                             | mutation leaks to a subsequent handler                                                                  |

---

## Decision log

### DEC-1 — Handlers compose additively; no single-writer selection

**Reference:** `spec:AST-031/DEC-1`
**Decider:** `josephfarina`, `2026-09-10`

Every configured handler runs. The previous model selected one writer based on
target ownership, which meant an integration could not observe reports about
components it did not own. Additive composition lets every interested party
receive every report while isolating failures to individual deliveries.

Rejected: single-writer selection by target ownership; priority-based override
where a project handler silences integration handlers; "first match wins"
routing.

### DEC-2 — No general opt-out mechanism for runtime features

**Reference:** `spec:AST-031/DEC-2`
**Decider:** `josephfarina`, `2026-09-10`

The `debug` feature's existing `inheritDebug` switch remains a compatibility
exception owned by that feature. It does not establish a general
`inherit<Feature>` convention. Gap reporting is an explicit command whose purpose
is to notify every configured handler, so loading an integration opts into its
handler. Any future opt-out requires a separate owner decision.

Rejected: automatically cloning `inheritDebug` into a general package.json
pattern; adding a gap-report opt-out that silently prevents a loaded integration
from receiving an explicit report.

### DEC-3 — Target selection uses component ownership, not handler count

**Reference:** `spec:AST-031/DEC-3`
**Decider:** `josephfarina`, `2026-09-10`

The target package for a gap report is the package that owns the component. When
multiple packages claim the same component name, the CLI requires an explicit
`--package` instead of guessing. A name with no exact owner defaults to Core.
Handler count and handler order never change target attribution.

Rejected: selecting the target by handler count or integration order; silently
choosing one ambiguous owner; distributing target ownership to every package that
ships a handler.

## Open questions

None.

---

### Specs AST 032 Spec (`docs/specs/AST-032/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-032
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-22
phase: accepted
owners: [cixzhang, imdreamrunner]
affects_architecture: [architecture:icon-resolution-and-component-slots]
affects_families: []
affects_contributing: []
affects_consumer_docs: [FileInput, ChatSendButton, Icon]
---

# Upload and send icon theming

## Intent

Let themes choose the artwork for FileInput's upload affordance and
ChatSendButton's send action without repainting any other icon. Upload is a
shared semantic icon: any surface that uploads can render it, and each theme
draws it in its own style. Send is a ChatSendButton role that a theme maps to a
shared icon.

## Non-goals

- Adding a shared `send` name.
- Changing the meaning or artwork of the shared `arrowUp` icon.
- Changing FileInput or ChatSendButton interaction, layout, sizing, or
  accessibility.
- Removing or changing ChatSendButton's existing `sendIcon` prop.
- Changing ChatSendButton's stop-state icon.
- Prescribing a particular upload, send, or directional drawing.
- Choosing which other components render `upload`; each component's own
  contract decides that.

## Requirements

- **FR1 — Upload is a shared icon.** `IconName` MUST include `upload`, the
  shared meaning for sending files or content from the person's device.
  FileInput's upload affordance MUST render `upload` in both input and dropzone
  modes.
- **FR2 — Send is a ChatSendButton role.** `ComponentIconSlotMap` MUST include
  `chat-send-button-send`. ChatSendButton's send state MUST resolve that slot
  with `arrowUp` as its fallback.
- **FR3 — Existing instance configuration keeps precedence.** An explicit
  `ChatSendButton.sendIcon` MUST continue to win over the active theme's
  component-slot mapping. FileInput exposes no instance prop for its upload icon.
- **FR4 — Themes draw upload and map send independently.** A theme MAY draw
  `upload` through its `icons` map, as it draws any shared name, and MAY map
  `chat-send-button-send` to an existing shared `IconName` or to `null` through
  `componentIcons`. Drawing `upload` MUST NOT change `arrowUp` or any other
  shared name, and mapping the send slot MUST NOT change `upload`.
- **FR5 — Defaults keep geometry and accessibility.** With no theme artwork for
  `upload`, FileInput MUST render the default `upload` artwork with its existing
  size, placement, color, and accessible behavior. That default MUST depict
  uploading and MUST be distinguishable from `arrowUp`. With no slot mapping and
  no `sendIcon`, ChatSendButton MUST render `arrowUp` with its existing size,
  placement, and accessible behavior. Sortable Table and every other `arrowUp`
  consumer MUST remain unchanged.
- **FR6 — Every shipped registry draws upload.** The default registry, each
  bundled theme, and each CLI theme template MUST supply `upload` artwork in
  that registry's own style. The public `IconRegistry` type describes a complete
  registry and MUST require `upload` like every other shared name. A consumer
  migrates by adding an `upload` entry drawn in its registry's style.
- **IR1 — Owner surfaces stay synchronized.** For upload: `IconName`, the
  default registry, every bundled theme and CLI theme template, the documented
  lists of shared icon names, FileInput source and docs, and focused registry
  and FileInput tests MUST change together. For send: the public
  component-slot map, ChatSendButton source, its component documentation and
  current contract, and focused resolver and component tests MUST change
  together.

### Platform support

- Supported feature/engine floor: unchanged from the current icon resolver.
- Unsupported behavior: unchanged; a theme without `upload` artwork or a send
  slot mapping uses the default artwork or the component fallback.
- Browser evidence: FileInput's default glyph changes, so real-browser visual
  evidence MUST show both FileInput modes rendering the upload artwork with
  unchanged geometry in the shipped themes. ChatSendButton's default needs none
  because its artwork and geometry do not change; resolver and component tests
  cover slot precedence and fallback.

## Current-state impact

Current `main` renders the shared `arrowUp` icon directly for both roles:

| Owner                       | Instance override | Theme control                | Default                  |
| --------------------------- | ----------------- | ---------------------------- | ------------------------ |
| FileInput upload affordance | none              | `icons.upload`               | default `upload` artwork |
| ChatSendButton send state   | `sendIcon`        | `chat-send-button-send` slot | `arrowUp`                |

FileInput's default glyph changes from a directional arrow to the upload
artwork; its size, placement, color, and accessible behavior stay the same.
FileInput stops following a theme's `arrowUp` artwork: a theme restyles it
through `upload`, and a theme without `upload` artwork shows the default upload
artwork there. ChatSendButton keeps its default artwork and resolves its own
slot once that slot is implemented. Every Astryx-shipped registry gains an
`upload` entry, and complete registries require `upload` (FR6).

## Verification

| Contract | Verification                                                                                                                           | Representative states                                                        | Mutation or failure expectation                                                                                                             |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1      | Icon public-type and registry tests; rendered FileInput tests                                                                          | default registry, theme `icons.upload`, input and dropzone modes             | `upload` missing from `IconName`, FileInput reading another name, or theme `upload` artwork not reaching FileInput fails.                   |
| FR2–FR3  | component-slot type and resolver tests; rendered ChatSendButton precedence tests                                                       | default theme, mapped name, explicit `null`, explicit `sendIcon`             | A missing or misspelled slot, direct `arrowUp` lookup, ignored mapping, or a theme overriding caller content fails.                         |
| FR4      | registry resolution tests with independent theme overrides                                                                             | theme drawing only `upload`; theme mapping only the send slot                | Theme `upload` artwork that changes `arrowUp` or another name, or a send mapping that changes `upload`, fails.                              |
| FR5      | focused FileInput and ChatSendButton tests; existing Table tests; real-browser visual evidence                                         | unthemed and shipped themes; light and dark; input, dropzone, send, and sort | Changed geometry or accessible output, an `upload` default identical to `arrowUp`, or any changed `arrowUp` consumer fails.                 |
| FR6      | public-type tests; registry completeness tests over the default registry, bundled themes, and CLI theme templates; theme fallback test | complete registry; theme without `upload` artwork                            | A shipped registry without `upload`, a complete registry without `upload` that compiles, or an omitted `upload` that renders nothing fails. |
| IR1      | `pnpm check:knowledge`, focused docs/source consistency assertions                                                                     | shared icon-name lists, component docs, current contracts, public types      | A stale owner surface or an undocumented shared name blocks the implementation.                                                             |

## Decision log

### DEC-1 — Send is a ChatSendButton role

**Reference:** `spec:AST-032/DEC-1`
**Decider:** `cixzhang`, `2026-09-22`; amended `2026-10-05`

ChatSendButton's send action is a component-owned role. It receives the
`chat-send-button-send` slot under the current component icon architecture,
with `arrowUp` as the compatibility fallback, and the existing `sendIcon` prop
remains the highest-precedence choice.

Rejected: continuing to read `arrowUp` directly, because themes could not then
choose the send icon without repainting every `arrowUp`.

### DEC-2 — Upload is a shared icon

**Reference:** `spec:AST-032/DEC-2`
**Decider:** `cixzhang`, `2026-10-05`

Uploading is a general action with a conventional glyph distinct from
directional arrows. As a shared name, `upload` is drawn once per theme in that
theme's style, and every surface that uploads renders the same theme-drawn
glyph. A shared name is permanent public vocabulary, so one is added on the
owner's design direction rather than on a count of current consumers. `upload`
meets that bar: designers need to draw a real upload glyph, and no existing
shared name can carry it.

Rejected: a `file-input-upload` component slot, because a slot maps only to an
existing shared name and so cannot give upload artwork of its own.

### DEC-3 — Complete registries require upload

**Reference:** `spec:AST-032/DEC-3`
**Decider:** `cixzhang`, `2026-10-05`

A complete registry lists every shared name, so `IconRegistry` requires
`upload` like the rest: a complete theme registry that type-checks draws upload
in its own style instead of silently borrowing the default artwork.

Rejected: a permanently optional `upload`, because it would be the only shared
name a complete registry could leave out.

## Deprecation and cleanup records

### DEP-0012 — Deprecate complete `IconRegistry` types that omit `upload`

| Field            | Value                                                                                                         |
| ---------------- | ------------------------------------------------------------------------------------------------------------- |
| id               | `DEP-0012`                                                                                                    |
| cleanup          | `CLN-0021`                                                                                                    |
| package          | `@astryxdesign/core`                                                                                          |
| surface          | The public `IconRegistry` type accepting a complete registry that omits `upload`                              |
| old contract     | A complete registry typed as `IconRegistry` may omit `upload`; the omitted entry resolves the default artwork |
| replacement      | Add an `upload` entry in the registry's own style; `IconRegistry` already accepts it                          |
| direct authority | FR6 and DEC-3. Owners: `spec:AST-032`: `cixzhang`, `imdreamrunner`                                            |
| warning          | Consumer docs and the release notes name `DEP-0012`. No runtime warning (`spec:AST-017` FR29)                 |
| migration        | Non-mechanical: draw `upload` artwork in the registry's style                                                 |
| codemod          | None: icon artwork is bespoke to each theme and cannot be synthesized                                         |
| downstream       | The default registry, every bundled theme, and every CLI theme template supply `upload`                       |
| transition       | no prior transition                                                                                           |
| state            | `deprecated`                                                                                                  |
| open decision    | None beyond listing `CLN-0021` in the minor plan                                                              |
| target plan      | `CLN-0021` in the next minor release, as FR6 schedules                                                        |

### CLN-0021 — `IconRegistry` requires `upload`

| Field     | Value                                                                                                        |
| --------- | ------------------------------------------------------------------------------------------------------------ |
| id        | `CLN-0021`                                                                                                   |
| lifecycle | `DEP-0012`                                                                                                   |
| delta     | `IconRegistry` is `Record<IconName, ReactNode>`: a complete registry that omits `upload` fails to type-check |
| rollback  | Restore the transitional optional `upload` from the final-patch baseline                                     |
| state     | `pending` — lands in a declared minor with its `[breaking]` Changeset                                        |

## Open questions

None.

---

### Specs AST 033 Spec (`docs/specs/AST-033/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-033
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-21
phase: accepted
owners: [cixzhang]
affects_architecture: []
affects_families: [family:navigation-destinations]
affects_contributing: []
affects_consumer_docs: [docsite]
---

# Docsite interaction and product-data contract



```json
{
  "scope": "global",
  "triggers": {
    "docsite": [
      "DEC-1",
      "DEC-2",
      "DEC-3",
      "DEC-5",
      "FR3",
      "FR8",
      "FR13",
      "FR16",
      "FR17",
      "FR18",
      "FR19",
      "FR20",
      "FR21",
      "DEC-7",
      "DEC-8"
    ]
  }
}
```



```json
{
  "paths": ["apps/docsite/**"]
}
```

## Intent

People using the Astryx Docsite should see truthful projections of the owning
product catalogs and one current interaction state. Links, browser history, visible
content, analytics, and latest/canary presentation should agree about what the person
selected and what is actually available.

This contract owns shared Docsite interaction and product-data rules. It makes
ordinary fixes that restore those rules reviewable without inventing a specification
for each bug.

## Non-goals

- Defining upstream component, template, theme, package, or catalog authoring
  schemas. This contract begins at the registry boundary the Docsite consumes.
- Defining template fixture preview and copy behavior owned by `spec:AST-028`.
- Choosing visual design, animation, layout, or copy for an individual Docsite page.
- Choosing a telemetry vendor, transport, retention policy, or dashboard.
- Adding a package API, theme target, or consumer runtime.
- Treating one pull request, bug, or current implementation mechanism as authority.

## Requirements

### Canonical product data

- **FR1 — Upstream owners supply product facts.** Canonical component, template,
  theme, package, and catalog owners define item identity, package ownership,
  taxonomy, readiness, release-channel admission, and explicit visibility. Route
  owners define stable destinations. The Docsite pipeline and generated registries
  materialize those facts; they MUST NOT invent a second identity, classification,
  readiness state, availability rule, or visibility rule. When no current upstream
  owner defines a needed fact, the Docsite reports or routes that authority gap
  rather than deciding it in view code.
- **FR2 — Projections preserve catalog facts.** Gallery, sidebar, direct
  routes, detail pages, preview, playground handoff, and Docsite API projections
  MUST derive membership and canonical item facts from the generated registries. A
  surface MAY define local display order, shorten labels, or derive display-only
  groups from canonical fields with a deterministic fallback. Those presentation
  choices MUST NOT change catalog membership or be exported or reused as canonical
  taxonomy. Site search answers with the CLI's own search: the site's search
  endpoint calls `search()` from the CLI package the site builds with and returns
  its results in the CLI's order, so the site and `astryx search` give one answer.
  The generated registries stay the source for the pages and links those results
  open.
- **FR3 — Availability and visibility are separate projections.** Availability
  means that a named Docsite operation has the source, loader, route, or handoff
  required to present an admitted item on the current content line. Visibility means
  that an otherwise available item is listed on a named surface. A visibility field MUST name or
  document the projections it excludes. Hiding an item from an overview MUST NOT
  silently remove its direct route, search result, preview, or playground
  availability, and a projection MUST NOT infer those effects from a similar flag.
  Missing, invalid, or contradictory admission metadata fails closed rather than
  making an unavailable item appear usable.

### Latest, canary, and readiness

- **FR4 — Release channel, readiness, and visibility are distinct.** Package
  `canaryOnly` metadata owns package release-channel admission. Generated components
  currently project that package channel as `isReady: false`; they do not author an
  independent component-readiness field. Template `isReady` remains
  template-authored item readiness. Explicit visibility metadata owns where an
  admitted item is listed. No projection invents another channel, readiness, or
  visibility state.
- **FR5 — Each build identifies its actual content sources.** `latest` pins
  published stable package metadata and package documentation. Canary, local
  development, and pull-request previews may read configured workspace/canary
  packages. Executable CLI templates and blocks, their live examples, long-form CLI
  docs, and Docsite-authored content remain workspace-backed on both targets where
  the pipeline currently declares that boundary. A projection MUST NOT describe
  workspace-backed content as release-pinned merely because the build target is
  `latest`. Site search is workspace-backed on both targets: it runs the CLI the
  site builds with, over the workspace docs and packages that CLI reads, the same
  source as the site's CLI reference pages. On `latest` it leaves out
  integrations without published docs, and a result whose page is not published
  links to the nearest published index page.
- **FR6 — Canary status is projected once at the owning level.** A surface SHOULD
  group canary status at the package or category level when children share it,
  instead of repeating a marker on every item. The install command, search result,
  detail page, and API output MUST remain consistent with the same channel and
  readiness facts. Visibility remains an independent projection.

### Navigation and browser history

- **FR7 — Docsite destination actions use the shared navigation contract.** An
  action whose result is a URL or another addressable Docsite state is a navigation
  destination. `family:navigation-destinations` owns destination acceptance and
  supported native/router behavior, including real `href`/`to` sinks and browser
  affordances. A click handler MAY add analytics but MUST NOT become a second or
  only navigation mechanism.
- **FR8 — Addressable state has one canonical URL per surface.** A component,
  template, theme, tab, or other state intended for direct linking or restoration
  MUST have one canonical URL encoding on its owning surface. Historical or
  convenience aliases MAY redirect or normalize to it. Direct load, in-app open and
  close, soft navigation, Back, and Forward MUST agree. Catalog-backed identities
  resolve through the owning registry; route-local tabs and other view values resolve
  through the route owner rather than embedding or duplicating product data.
- **FR9 — Primary selection pushes; secondary view state replaces.** Selecting a
  different primary catalog item creates a history entry, and Back and Forward
  traverse those selections. URL-encoded tabs, pickers, filters, sort order, and
  canonicalization replace the current entry. Purely local transient presentation
  state need not enter the URL. A surface MUST NOT use replacement merely to avoid
  restoring a primary addressable selection.

### Selection and asynchronous work

- **FR10 — Current selection owns visible content.** Header, body, actions, command,
  accessible name, and analytics context for a selected item MUST resolve from the
  same current committed selection. Opening, reopening, deep linking, URL
  restoration, or direct selection MUST NOT show content from a previously selected
  item.
- **FR11 — Deferred content is scoped to its initiating transition.** A transition
  MAY retain the previous content beneath an explicit pending treatment only while
  the exact navigation that initiated that work remains current. Closing,
  cancellation, a newer selection, a newer navigation, or unmount supersedes that
  work. Closed or superseded work MUST NOT repaint visible content, replace current
  metadata, move focus, update the canonical URL, or emit a success event.
- **FR12 — Selection-affecting async completion commits through current
  ownership.** Async work that can repaint a selected preview or commit
  selection-owned metadata, focus, or URL state MUST verify that its request still
  owns that selection before committing. Request identity or cancellation is
  implementation-owned; global pending state without request ownership is
  insufficient. General Docsite request lifetime and external synchronization remain
  with this record and the owning Docsite component;
  `architecture:react-component-runtime` supplies analogous Core/Lab lifecycle
  constraints but does not own `apps/docsite`. Copy, save, export, and share
  settlement timing remains with FR15.

### Interaction analytics

- **FR13 — One typed module owns custom analytics.** The shared Docsite analytics
  module owns the small custom event vocabulary and property schema. Item, package,
  and category properties use canonical registry identifiers. Every finite
  code-owned dimension—including page, target, source, type, direction, tab, view,
  and toggle value—uses a closed code-owned vocabulary rather than an unrestricted
  caller string. Events MUST NOT include user-authored source, free-form content,
  URL fragments, or other personally identifying or unexpectedly high-cardinality
  data.
- **FR14 — The semantic action owner emits once.** The code that accepts or commits
  a direct user action emits at most one matching custom event. Rendering,
  hydration, effects, URL restoration, router synchronization, prefetch, background
  work, and automatic page loading emit no custom interaction event. Root analytics
  owns automatic page views.
- **FR15 — Event timing follows the claim.** Navigation intent may log when a valid
  user activation is accepted because the real link owns completion. Copy, export,
  save, share, and similar success claims log only after the operation succeeds.
  Cancelled, rejected, failed, ignored, closed, or superseded work MUST NOT emit a
  success event.

### Playground storage and parent-page isolation

- **FR16 — Production playground code is isolated from storage and the parent page.**
  Production previews, including deployed pull-request previews, MUST execute
  user-authored code with an opaque origin, without access to the Docsite's
  origin-bound storage or parent-page DOM. Local storage may be blocked entirely;
  previewed code has no persistence guarantee. The playground MUST explain these
  restrictions to users. Local development MAY differ, but MUST NOT stand in for
  production isolation evidence.

  Reload or hostile navigation may reset preview runtime state. The playground
  MUST recover a trusted preview with the editor's current code and active
  theme/mode. Replacement content MUST NOT receive editor source, theme state, or
  authority to edit the parent merely because it occupies the preview.

  Changes to this boundary require real production-browser evidence for storage
  and parent-DOM denial, trusted recovery after reload and hostile navigation,
  and restoration of current code and theme/mode.

### Published surfaces and deployment identity

- **FR17 — Production has one exact-head owner.** The canonical production
  Docsite origin MUST serve the documentation, `/storybook/`, and `/sandbox/`
  from the same successful main-branch deployment. A build that cannot stage
  either static application MUST fail rather than publish a partial release.
  The Docsite MUST expose real links to both applications.
- **FR18 — Static application routes remain addressable.** Storybook manager and
  iframe URLs, Sandbox route directories and embeds, static assets, query
  parameters, and fragments MUST survive direct load on their canonical paths.
  Route normalization MAY preserve or remove a trailing slash, but MUST retain
  the selected application state and MUST NOT fall through to unrelated Docsite
  content.
- **FR19 — Canonical and indexing metadata match the surface.** Production entry
  and addressable route pages identify their `https://astryx.atmeta.com`
  canonical URL. Pull-request previews, standalone Storybook frames, Sandbox
  embeds, and compatibility pages MUST be `noindex`; a preview MUST NOT claim a
  production canonical URL.
- **FR20 — Deployment identity is exact and generated.** Every Vercel Preview
  and Production build MUST expose its complete 40-character source commit and
  deployment environment at `/version.json`. Missing source identity fails the
  build. Generated identity and staged applications MUST NOT enter source
  control.
- **FR21 — The former Pages origin is one compatibility surface.** GitHub Pages
  MUST serve only a small, accessible, responsive landing page and equivalent
  deep-path recovery page. Documentation is the primary canonical destination;
  Storybook and Sandbox are labeled secondary destinations. An old Storybook or
  Sandbox path MAY be carried into the corresponding link with its query and
  fragment, but the compatibility page MUST NOT automatically redirect, build
  either application, or host CI evidence.

### Authority routing

`architecture:knowledge-contracts` owns public-delta routing and change disposition.
This record supplies the expected Docsite behavior; it does not pre-approve a pull
request because its title says bug fix. Reviewers map every observable delta to the
applicable current owner. A new interaction model, exception, public API, event
meaning, visibility axis, taxonomy, or data owner remains a separate contract delta.

### Platform support

- Supported feature/engine floor: the Docsite's current supported browsers and
  Next.js router integration.
- Unsupported behavior: a client that cannot preserve a real link or canonical URL
  MUST retain a usable full-page navigation; it MUST NOT substitute a click-only
  control or silently lose the destination.
- Browser evidence: real Chromium verifies link affordances, Back/Forward,
  selection restoration, focus, and pending/close/supersession behavior. DOM-only
  evidence does not prove browser history or painted stale-content absence.

## Current-state impact

The current Docsite already generates package, component, template, docs, block,
theme, and component-preview registries from repository metadata. Published package metadata
and package docs use the latest/canary content roots, while executable templates,
blocks, examples, long-form CLI docs, and Docsite-authored content remain
workspace-backed.
Most navigation actions expose real links, and one analytics module owns a small
event vocabulary.

Known conformance gaps remain implementation work:

- some gallery and playground membership/classification data remains in page- or
  script-local lists instead of canonical metadata or generated registries, and
  template live-preview membership remains in a hand-maintained map;
- template readiness and visibility are not projected consistently across gallery,
  search, direct routes, preview, and playground;
- primary theme selection currently replaces history, and theme search results
  collapse every selected theme to bare `/themes`, which restores the default seed
  instead of the searched identity;
- addressable template previews have canonical `?preview=` URLs, but gallery cards
  and Preview buttons remain click-only controls rather than link-backed
  destinations;
- primary template preview selection currently replaces history rather than creating
  a browsing step;
- template preview may retain a deferred prior selection outside the exact pending
  navigation that owns it;
- analytics still permits unrestricted string context for item, package, category,
  source, type, tab, view, and toggle-value fields, and nested template-preview
  activation can reach the same semantic action twice; and
- existing interaction tests do not yet cover the complete primary-selection,
  Back/Forward, close, cancellation, and supersession matrix.

Stable Storybook and Sandbox currently use a split deployment: pull-request
previews are staged inside the Vercel Docsite, while stable main is rebuilt and
published through a separate GitHub Pages branch. That duplicates application
builds, leaves production ownership ambiguous, and makes the former Pages origin
carry unrelated preview and evidence storage.

The accepted target makes Vercel the one production owner for all three surfaces
and reduces GitHub Pages to the FR21 compatibility page. It adds no package or
consumer runtime API; `/version.json` is deployment evidence for verifying the
exact source already serving those public surfaces.

## Verification

| Contract  | Verification                                                                             | Representative states                                                                                                    | Mutation or failure expectation                                                                                                                           |
| --------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR3   | generator/data-extraction tests plus cross-projection inventory; search endpoint parity with `astryx search --json` | package, component, template, theme; gallery, sidebar, search, direct route, preview, playground                         | a local map changes membership or a canonical item fact, or availability and visibility collapse into one projection                                      |
| FR4–FR6   | latest/canary generation and projection tests                                            | stable package, canary-only package, ready/unready item, overview-hidden item, invalid integration metadata              | latest exposes canary package docs, workspace-backed content is called release-pinned, a second readiness state appears, or projections disagree          |
| FR7–FR9   | navigation-family checks plus real-browser navigation/history tests                      | direct link, modified click, new tab, deep link, primary selection, secondary tab/filter, canonicalization, Back/Forward | Docsite misclassifies a destination, canonical identity diverges, primary selections replace one another, or secondary state clutters history             |
| FR10–FR12 | selection/transition tests plus real-browser visible-content checks                      | initial open, reopen, deep link, direct selection, pending previous/next, close, supersede, reject, unmount              | header/body/actions disagree, stale content paints without its owning pending state, or obsolete selection work commits after supersession                |
| FR13–FR15 | analytics schema and interaction tests with transport spies                              | navigation intent, successful/failed copy, cancel, URL restoration, render/effect, duplicate activation                  | event names/properties drift, free-form/high-cardinality data is logged, a passive lifecycle emits, success logs on failure, or one action emits twice    |
| FR16      | production-browser playground isolation and recovery tests                               | direct load, reload, hostile navigation, storage and parent-DOM denial                                                   | preview code reaches parent/storage or trusted recovery restores stale source/theme                                                                       |
| FR17–FR21 | exact-head Vercel build, route/asset probes, metadata checks, and Pages landing contract | production and PR Preview; Storybook manager/frame; Sandbox route/embed; old root/deep path; desktop/mobile; light/dark  | one app is absent or stale, a direct asset/route fails, metadata claims the wrong owner, identity drifts, or Pages hosts more than the compatibility page |

## Decision log

### DEC-1 — Product data has one registry-backed owner

**Reference:** `spec:AST-033/DEC-1`
**Decider:** `cixzhang`, `2026-09-09`

Upstream canonical metadata and catalog owners define product facts. Generated
registries materialize those facts for the Docsite; views project them without
creating another catalog.

Rejected: page-owned item lists, parallel membership/classification maps, and a
generator inventing product policy absent an upstream owner.

Site search is governed by DEC-8.

### DEC-2 — Browser history follows semantic weight

**Reference:** `spec:AST-033/DEC-2`
**Decider:** `cixzhang`, `2026-05-01`

Primary catalog-item selection is navigation and pushes history. URL-encoded
secondary tabs, pickers, filters, sort order, and canonicalization replace the
current entry. Purely local transient presentation need not enter the URL. This lets
Back and Forward follow the person's browsing path without making secondary controls
tedious.

Rejected: replacing primary selections, pushing every secondary adjustment, and
putting non-addressable transient presentation into history.

### DEC-3 — Custom analytics belongs to direct user actions

**Reference:** `spec:AST-033/DEC-3`
**Decider:** `cixzhang`, `2026-06-10`

One typed vocabulary records direct interaction. Root analytics owns automatic page
views; renders, effects, and URL restoration do not manufacture custom engagement.
Success events follow success.

Rejected: page-load effects duplicating automatic analytics and optimistic copy/save
success events that fire before completion.

### DEC-4 — Canary channel and readiness remain separate

**Reference:** `spec:AST-033/DEC-4`
**Decider:** `cixzhang`, `2026-09-06`

Canary-only packages are publicly visible on the canary line for pre-promotion
vetting and absent from latest package documentation. Package channel may project
component `isReady: false`; Docsite views do not invent another unstable state, and
visibility stays independent.

Rejected: parallel unstable flags, hidden canary-only content, and page-local canary
allowlists.

### DEC-5 — Current selection outranks stale async work

**Reference:** `spec:AST-033/DEC-5`
**Decider:** `cixzhang`, `2026-09-09`

The current committed selection owns visible content. Deferred content is allowed
only for the exact pending transition that retains it, with an explicit pending
treatment. Closing or superseding the transition ends that authority.

Rejected: one global pending/deferred value repainting a newly opened, closed, or
newer selection.

### DEC-6 — Trying code does not require persistence or parent-page access

**Reference:** `spec:AST-033/DEC-6`
**Decider:** `cixzhang`, `2026-09-23`

The playground is for trying Astryx code, not storing local data. Production
previews have an opaque origin and no access to the Docsite's origin-bound
storage or parent-page DOM. Previewed code has no persistence guarantee;
restrictions are explained to users. Development may differ. Reload and hostile
navigation recover a trusted preview with current editor code and theme/mode.

Rejected: treating local persistence or parent-page DOM access as a playground
guarantee.

### DEC-7 — Vercel owns every maintained production surface

**Reference:** `spec:AST-033/DEC-7`
**Decider:** `cixzhang`, `2026-09-29`

The production Docsite, Storybook, and Sandbox ship together from the exact main
commit on the canonical Vercel origin. GitHub Pages remains only as one noindex
compatibility landing for old bookmarks, with documentation first and direct
Storybook and Sandbox destinations alongside it. Pull-request previews keep the
same Vercel paths and remain non-canonical.

Rejected: continuing a second stable-app build on `gh-pages`, silently dropping
old bookmarks, automatically redirecting every old deep path without recovery
choice, and using the compatibility origin for visual or test evidence.

### DEC-8 — Site search uses the CLI's search

**Reference:** `spec:AST-033/DEC-8`
**Decider:** `josephfarina`, `2026-10-21`

Maintainer decision: site search uses the CLI's search instead of a separate index
derived from the registries, and reads the workspace docs the site builds with on
every target, so each search improvement reaches the live site. One ranking implementation means every search
improvement reaches the site with no copy to keep in sync. A parity test holds the
endpoint equal to `astryx search --json` for the site's project.

Rejected: a site-owned index or ranking, and a browser copy of the CLI's search
data that has to track every CLI release.

## Open questions

None.

---

### Specs AST 034 Spec (`docs/specs/AST-034/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-034
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-11
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:theme-compilation, architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:cli-conventions]
affects_consumer_docs: [theme]
---

# Theme family build behavior spec

## Intent

A theme package author should be able to compile one related theme family without
shipping each member's repeated resolved declarations. The caller selects the family
and supplies one stable key. The build emits one `<key>.css` containing every member,
one `<key>.js` exporting every complete member, and one matching `<key>.d.ts`.
Consumers load the CSS once and switch themes by identity.

Public [PR #5687](https://github.com/facebook/astryx/pull/5687) demonstrates the user
need and the required cascade result. Its separately loaded base and member files are
not the accepted output: they can be incomplete, depend on load order, omit a
zero-delta member, and produce invalid JavaScript when source bindings collide. A
replacement uses one aggregate output trio so consumers never assemble the family.

## Non-goals

- Implement the compiler, CLI, or generated artifacts in this specification pull
  request.
- Add a new command, response field, error code, or per-member family entrypoint.
- Strengthen the existing theme build's filesystem failure, cleanup, or publication
  guarantees.
- Add or change the portable/global token vocabulary, component APIs, or theme values.

## Requirements

### Inputs and outputs

- **FR1 — Selected themes form one valid lineage.** The selected themes MUST form one
  connected, acyclic `extends` tree with exactly one root. Every selected non-root
  member's parent MUST also be selected. Duplicate names or identities, missing
  ancestors, and cycles MUST fail before family output is published. Descendants at
  any depth are valid; a family is not limited to one root and its direct children.
- **FR2 — The CLI emits one caller-keyed output trio.** The stable command shape is:

  ```sh
  astryx theme build --family <base> <children...> --family-key <key>
  ```

  `--family-key <key>` is required with `--family` and is refused without it before any
  output is written. It names only `<key>.css`, `<key>.js`, and `<key>.d.ts`. The family
  key is only the filename stem: it does not change a member's identity or serialized
  `data.name`, and it establishes no second output-directory convention. The key uses
  the existing safe lower-kebab filename rules. Family mode composes with `--check`,
  and is refused with `--watch` and `--out`; other options retain the owning build's
  existing semantics. It adds no response field or error code; any future response
  change follows `spec:AST-017`.

### Observable family behavior

- **FR3 — One stylesheet is complete for every member.** `<key>.css` MUST contain all
  theme-authored CSS needed by every selected member. For each member, every generated
  declaration and resulting computed style MUST match that member's complete standalone
  build across every supported theme surface. Completeness follows `spec:AST-066`: a
  member contributes only its explicitly authored `--color-data-*` overrides; canonical
  defaults come from the consuming StyleX data-variable group, outside theme build. A
  consumer MUST NOT load, order, or discover another family stylesheet.
- **FR4 — Theme identity selects the correct cascade.** After `<key>.css` loads,
  selecting or switching a member requires only changing the theme identity. A
  descendant's differing values MUST win over inherited values without consumer
  specificity work or stylesheet-order requirements. Nested roots follow
  `spec:AST-066`: same and different identities inherit every unspecified data value,
  while a child replaces only the subset it explicitly authors. Sibling roots stay
  isolated, and unrelated same-layer stylesheets produce the same winners in either
  physical order.
- **FR5 — Deterministic output preserves zero-delta members.** Equivalent selected
  sets MUST produce byte-identical CSS, JavaScript, and declarations regardless of
  argument order. A member with no visual delta still has its selectable identity,
  complete JavaScript export, and declaration.
- **FR6 — JavaScript and types expose every complete member.** `<key>.js` MUST be a
  CSS-free standard ESM module with one complete resolved export for every selected
  member. It MUST parse and import when separate theme sources reuse the same local
  binding name. `<key>.d.ts` MUST describe every member export under supported
  TypeScript module resolution. CSS, JavaScript, and type readiness remain independent.

### Failure, check, and compatibility

- **FR7 — Success means one complete keyed result.** An invalid lineage, key, or
  member MUST fail before the command reports success. A successful build MUST expose
  all three expected keyed files with current bytes. A failed build MUST NOT report
  success for a mixed, missing, or stale expected set. This defines no stronger
  durability guarantee than the existing theme build.
- **FR8 — `--check` verifies the expected keyed outputs.** With the same family
  selection and key, `--check` MUST regenerate the expected CSS, JavaScript, and
  declaration output and compare those three files using the existing theme-build
  check semantics. It MUST report missing or outdated expected files without writing
  generated output. This contract does not add detection or cleanup of unrelated or
  extra files.
- **FR9 — Existing command and artifact paths remain compatible.** Standalone and
  ordinary multi-file commands, output names, generated JavaScript/types, theme names,
  inheritance, and consumer import paths keep their meaning. `spec:AST-066` intentionally
  removes canonical data defaults from every theme-build mode and leaves only authored
  scoped data overrides; family mode MUST NOT preserve the superseded raw global-data
  block. Family selection itself remains opt-in.

### Evidence and implementation relationship

- **FR10 — PR #5687 is evidence, not the replacement implementation.** Its duplication
  measurements, lineage evidence, and cascade experiments may inform replacement work.
  Its public base-plus-member consumption model is superseded. The replacement MUST
  satisfy the completeness, load-order, zero-delta, and binding-safety behavior above
  through one aggregate CSS/JS/d.ts output.

## Current-state impact

Current `main` builds one complete CSS, JavaScript, and declaration set per theme.
Implementing this accepted amendment adds the opt-in family command behavior above.
`spec:AST-066` separately removes canonical data defaults from standalone and family
theme CSS, leaves only each member's authored scoped data overrides, and assigns
canonical default emission to the public StyleX consumer graph. It does not change the
keyed trio, member exports, declarations, or selection command.

This specification changes no runtime, compiler, CLI, generated artifact, or package by
itself and adds no Changeset.

## Verification

| Contract | Required evidence                                                       | Failure signal                                                                                                                                                                                |
| -------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR2  | graph and CLI composition tests                                         | a missing ancestor or cycle builds; the key changes identity; family mode emits anything other than the keyed trio                                                                            |
| FR3–FR5  | standalone-equivalence fixtures plus real Chromium                      | a component, prose, adaptation, on-media, authored data subset, nested same/different identity, sibling-root, reversed-order, or zero-delta state differs; argument order changes bytes       |
| FR6      | ESM parse/import, TypeScript, and binding-collision fixtures            | an export is incomplete or absent; reused local names break parsing; declarations disagree with exports                                                                                       |
| FR7–FR9  | invalid-input, write-failure, check, and standalone regression fixtures | invalid input reports success; a successful build exposes mixed, missing, or stale bytes; check falsely passes them; standalone output changes beyond the `spec:AST-066` data-default removal |

Real-browser verification MUST load only `<key>.css` in addition to the ordinary
consumer StyleX output and exercise every selected identity, representative components
and prose, on-media behavior, each member's authored data subset, a parent-authored data
override across nested same and different identities, sibling roots, a zero-delta member,
and an unrelated same-layer stylesheet in both orders.
Structural tests MUST prove deterministic bytes, zero canonical data defaults in family
CSS, complete ESM exports, matching types, and collision-safe imports. Existing
standalone fixtures MUST retain every behavior outside `spec:AST-066`.

### Completion criteria

This specification remains `accepted` until one replacement implementation proves:

- one deterministic keyed CSS/JS/d.ts trio for direct and nested descendants;
- complete standalone-equivalent theme behavior from that single stylesheet, including
  `spec:AST-066` authored sparse data overrides, zero canonical defaults, and nested
  inheritance;
- selectable zero-delta members and collision-safe aggregate JavaScript/types;
- build and `--check` cannot report success for mixed, missing, or stale expected
  keyed files; and
- real Chromium behavior plus standalone builds unchanged outside the
  `spec:AST-066` canonical-data-default removal.

## Decision log

### Amendment — Adopt StyleX data-token ownership

**Decider:** `cixzhang`, `2026-10-01`

`spec:AST-066` changes the standalone behavior against which family members are
measured: canonical data defaults come from a retained public StyleX variable group,
while every theme identity emits only its authored data subset. This amendment updates
FR3, FR4, FR9, verification, and DEC-2 without changing the family command, keyed output
trio, graph contract, or complete-member principle.

### Amendment — Keep the specification at the observable boundary

**Decider:** `cixzhang`, `2026-09-12`

The accepted 2026-09-11 text coupled the family output contract to a second internal
architecture. This amendment removes that design from the behavior contract. No
earlier family-specific internal architecture requirement remains in force; only the
observable requirements in this amended record govern.

The amendment retains the one-file family result, deterministic order, nested lineage,
zero-delta identity, complete aggregate exports, binding safety, correct cascade, and
real-browser proof.

### DEC-1 — Emit one keyed family output trio

**Reference:** `spec:AST-034/DEC-1`
**Decider:** `cixzhang`, `2026-09-11`; amended `2026-09-12`

The family command emits `<key>.css`, `<key>.js`, and `<key>.d.ts`. The CSS contains all
members, the ESM exports every complete member, and the declaration describes those
exports. The required `--family-key` changes filenames only. Per-member family output
and consumer runtime assembly are rejected because they return graph and load-order
work to the consumer.

### DEC-2 — Preserve complete member behavior in one stylesheet

**Reference:** `spec:AST-034/DEC-2`
**Decider:** `cixzhang`, `2026-09-11`; amended `2026-09-12`

Every selected identity produces the same observable theme CSS behavior as its complete
standalone build. Descendant differences win without a consumer-managed load order or
specificity escalation, including for components, prose, adaptations, and on-media
behavior. Under `spec:AST-066`, canonical data defaults come from the retained public
StyleX group; same and different nested identities inherit every unspecified value and
override only their authored subset. Family CSS contains no canonical data defaults.

## Open questions

None.

---

### Specs AST 035 Spec (`docs/specs/AST-035/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-035
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-09
phase: accepted
owners: [josephfarina, cixzhang]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:templates, contributing:api-conventions]
affects_consumer_docs: [authoring]
---

# Integration template and component replacement system spec

## Intent

Let an installed integration provide the project-specific implementation of a Core
template or a Core component without making the common lookup ambiguous or removing
access to the Core original.

## Non-goals

- Replacing reference-doc topics. That surface owns its own contract.
- Rewriting template or component source that a consumer already copied into an app.
- Making an integration template or component available when its source or metadata
  is invalid.
- Selecting an integration that the project neither configures nor autolinks.

## Requirements

- **FR1 — A template declares its own replacement.** An integration template MAY
  set `replaces` in its own metadata to an existing Core template id. No
  package-level map declares replacements (`spec:AST-039/FR11`). The field is part
  of the strict template metadata object, so stable CLIs before 0.6.4 reject it;
  an integration that uses it MUST declare an `@astryxdesign/cli` peer range whose
  lowest admitted version is at least 0.6.4, the first stable release that applies
  it (DEC-7).
- **FR2 — A valid replacement owns default discovery.** When one valid declaration
  applies, unqualified template lookup and default template projections MUST use the
  integration template for the Core id. The integration template MUST remain
  addressable by its own id. List, search, build suggestions, Project discovery, and
  block-layout lookup MUST project the same winner.
- **FR3 — Explicit package selection preserves alternatives.** Selecting
  `@astryxdesign/core` MUST address the original Core template. Selecting an
  integration package MUST retain access to its shadowed or rejected template by its
  own id.
- **FR4 — Invalid declarations fail closed.** A missing Core target, template-kind
  mismatch, a declaration on a template that cannot be used, or more than one
  declaration for one target inside a package MUST be an error. An invalid set MUST NOT replace Core. Valid integration
  templates remain available by their own ids when replacement metadata alone is
  invalid.
- **FR5 — Precedence is deterministic.** When multiple explicitly configured
  integrations validly replace one target, the integration configured later wins and
  the CLI reports a warning. An explicitly configured replacement MUST win over an
  autolinked replacement. When only autolinked integrations validly replace one
  target, the dependency listed later in package.json wins with a warning; naming
  the intended package in `astryx.config` makes precedence explicit. Invalid
  autolinked declarations remain reportable but MUST NOT disable a valid explicit
  replacement.
- **FR6 — Omission preserves selection behavior for valid integrations.** Without
  `replaces`, valid template selection and package-aware ambiguity remain
  unchanged. FR9 intentionally changes failure isolation with or without the field.
- **FR7 — Diagnostics preserve the released contract until support begins.**
  Existing same-id conflicts MUST keep the released warning-only response shape
  throughout 0.6.x. Replacement-specific diagnostics MAY be implemented for
  pre-publication validation, but their `relationship`, optional `replaces`, and
  `severity: 'info'` fields do not enter the stable conflict API until 0.7.0.
  Reaching 0.7.0 MUST NOT expand the schema by version alone: one deliberate
  projection change MUST update the response type, generated reference, terminal
  output, consumer documentation, and tests together. From that supported boundary,
  `astryx doctor integration templates` MUST report intentional replacements, missing targets, declarations on templates that cannot
  be used, kind mismatches, and same-package ambiguity. Error findings MUST produce
  exit code 1 and MUST NOT be followed by a false success message.
- **FR8 — Public schema projection is complete at its supported boundary.**
  Effective `template.list` entries MAY include optional `replaces`; adding that
  optional field is nonbreaking. At or after 0.7.0, an explicit projection update
  MUST change the response type, generated inventory, terminal projection, consumer
  documentation, and tests together; a package-version bump alone MUST leave the
  warning-only response unchanged.
- **FR9 — Failure isolation preserves valid contributions.** An error in one
  contribution kind MUST remain reportable without removing the integration's other
  valid contribution kinds. Within template and component discovery, one unusable file
  MUST NOT remove valid siblings. Other kinds keep their existing per-kind atomicity. A
  manifest load failure still withdraws the integration because no contribution roots
  are trustworthy. This rule applies whether or not any template declares `replaces`.
- **FR10 — A component declares its own replacement, and the CLI floor turns it on.**
  An integration component MAY set `replaces` in its own ComponentDoc to the exact
  `name` of a component in the Core component catalog. The replacement applies only
  when the component's package declares a `@astryxdesign/cli` peer range whose lowest
  admitted version is at least the first stable CLI release that applies component
  replacement (`COMPONENT_REPLACES_CLI`). That range is the package's opt-in. Stable
  CLIs before that release accept the field and keep the component under its own name.
- **FR11 — An applied component replacement owns unqualified component lookup.** The
  replacing component answers to the Core component's name in component detail and
  batch selectors, takes the Core component's slot at every component-list detail
  level, is the component search result for that name, is the component an unqualified
  `swizzle <Name>` copies, and is where gap-report routing sends a report for that name. It
  remains addressable by its own name. Every one of those results names the replacing
  component's own package (`architecture:cli-surface` INV28).
- **FR12 — Explicit Core selection preserves the original component.** Selecting
  `@astryxdesign/core` in component detail, batch selectors, and swizzle MUST address
  the original Core component. `swizzle --list` lists Core's components, a replaced one
  included.
- **FR13 — Invalid component declarations fail closed for packages that opt in.** For a
  package with the FR10 range, a `replaces` that names no Core catalog component, a
  value that is not a non-empty string, a component whose own name is a different Core
  component, and more than one declaration for one target inside the package MUST each
  be an error in `astryx doctor integration components`, which then exits 1. An invalid
  set MUST NOT replace Core. Valid components remain available by their own names
  (FR9).
- **FR14 — Component precedence is deterministic.** When several packages with the
  FR10 range validly replace one Core component, the FR5 order decides the winner: an
  explicitly configured package wins over an autolinked one, the package configured
  later wins among configured packages, and the dependency listed later wins among
  autolinked packages. Doctor warns whenever more than one package contends. An invalid
  autolinked declaration MUST NOT disable a valid explicit replacement. An integration
  component from another package whose own name is the replaced Core name is shadowed
  for unqualified lookup, stays addressable through its package, and Doctor warns.
- **FR15 — A package without the floor keeps its released behavior.** Its component
  `replaces` declarations never apply, and an app that loads the package sees no new
  output from them. Every finding about them, including the one that names the FR10
  range, is a warning reported to the package's author by
  `astryx doctor integration components`, so no command's result or exit code changes
  because of them. `integration pack --check` warns, and never fails, when a component
  sets `replaces` and the package's range admits an earlier stable CLI.

### Platform support

- Minimum supported CLI for an integration whose template declares `replaces`:
  `@astryxdesign/cli >=0.6.4`. Earlier stable CLIs parse template metadata strictly:
  they reject the field, print one warning, drop that template, and withhold the
  package's doc topics, while its other templates and its components still load.
- Stable 0.6.x conflict responses retain their warning-only shape. The
  replacement-specific conflict schema becomes eligible for an explicit, complete
  projection update at 0.7.0; version alone does not activate it.
- Component replacement: `@astryxdesign/cli >=0.6.7` is both the minimum supported CLI
  and the opt-in (FR10). Earlier stable CLIs accept a component's `replaces` and keep
  the component under its own name, with the Core component still selected.
- Browser evidence: not applicable to catalog selection. A generated consumer app
  MUST still build or run when its selected template renders integration-owned
  navigation.

## Current-state impact

The template metadata type and parser accept one optional field, `replaces`. Shared
template resolution owns replacement validation, precedence, aliases, and the default
discovery view. Template commands, search/build, Project, layout, Doctor,
response documentation, and the integration-authoring guide project that result.
Project reports invalid contributions while retaining other valid contribution kinds
and valid template or component siblings.

The field is optional, and valid templates keep their selection behavior when it is
absent. Stable releases from 0.6.4 apply a declared replacement, and an integration
package that uses one requires `@astryxdesign/cli >=0.6.4`, a range that excludes
every stable release that rejects the field. Template winners are mutable catalog
data, so replacement selection does not create a released victim. The optional
`TemplateListEntry.replaces` field is additive.

The released `IntegrationTemplateConflict` response is different: existing same-id
conflicts are already stable. Final 0.6.x preserves their warning-only shape. The
required `relationship` field and `severity: 'info'` value remain staged for a
deliberate complete projection update at or after 0.7.0. A package-version bump
alone leaves the warning-only projection in place. Under `spec:AST-017/FR1`, FR2,
FR5, FR7, and FR9–FR13, the capability plus its compatibility gate is a `[feat]`
patch, not a breaking minor.

Component discovery reads `replaces` from each valid component doc, and one resolver
decides component replacement, precedence, and findings. Component detail, batch
selectors, every list detail level, search, swizzle copy, gap-report routing, Project
issues, and `astryx doctor integration components` read its result. Stable CLI
releases before `COMPONENT_REPLACES_CLI` accept a component `replaces` and ignore it. A
package whose range admits one of them keeps its components' names, the Core
components stay selected, an app that loads it sees no new output, and its author sees
warnings in the package checks. A package whose range already starts later, such as
the `>=0.7.0` that earlier `integration add theme` and `integration add doc --parent`
wrote, opts in unchanged; its range excludes every CLI that ignores the field, so no
supported combination changes. Under `spec:AST-017/FR1`, FR5, and FR12, component
replacement is a `[feat]` patch.

## Verification

| Contract   | Verification                                                                             | Representative states                                                                                                 | Mutation or failure expectation                                                                                     |
| ---------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| FR1, FR6   | Template parser and discovery tests, floor-table test, released-CLI consumer run         | field present, field absent, published 0.6.3 and 0.6.4 CLIs, peer range at and below the floor                        | a replacement is honored from anywhere but the template's own metadata, or verify accepts a range below the floor   |
| FR2, FR3   | Template API/CLI and real consumer-app tests                                             | target id, own id, Core package, integration package                                                                  | lookup becomes ambiguous or the Core original is unreachable                                                        |
| FR4, FR7   | Doctor and discovery tests plus a 0.6.x response-shape fixture                           | missing local/source, missing target, wrong kind, duplicate, same-id rejection, pre-publication replacement           | invalid metadata activates a replacement, Doctor reports success, or a 0.6.x conflict gains staged fields           |
| FR5        | Multi-integration and autolink tests                                                     | configured order, explicit versus autolinked, invalid loser                                                           | file/display order chooses the winner or invalid autolinking disables explicit intent                               |
| FR2, FR8   | List, search/build, Project, layout, response, and generated-doc checks                  | alias plus undeclared exact-id collision, page and block, explicit 0.7 schema projection                              | two projections disagree, a public field is undocumented, or version alone expands the conflict schema              |
| FR9        | Project discovery and issue-order tests                                                  | invalid component/template siblings, valid contribution kinds and siblings                                            | one bad contribution removes unrelated valid output                                                                 |
| FR10, FR15 | Floor-table test, resolver tests, and a published-CLI consumer run                       | range at the floor, no range, lower range; latest stable CLI with the field                                           | a package without the range gets a replacement, an error, or a changed exit code                                    |
| FR11, FR12 | Component, batch, list, search, swizzle, and gap-report tests plus a packed consumer run | target name, own name, Core package selector, every list detail level                                                 | a surface selects a different owner than component detail, a result omits the owner package, or Core is unreachable |
| FR13       | Resolver and `doctor integration components` tests                                       | missing target, invalid value, own name is another Core component, two declarations in one package                    | invalid metadata replaces Core, or Doctor exits 0 with an error finding                                             |
| FR14       | Multi-integration resolver tests                                                         | configured order, explicit versus autolinked, autolinked only, invalid autolinked loser, shadowed same-name component | file or display order chooses the winner, or an invalid autolinked declaration disables explicit intent             |

## Decision log

### DEC-1 — Replacement declarations live on the integration manifest

**Reference:** `spec:AST-035/DEC-1`
**Proposed by:** `josephfarina`, `2026-09-11`
**Superseded by:** `spec:AST-035/DEC-3` and `spec:AST-039/DEC-4`, `2026-09-24`

Use `templateReplacements: Record<integrationTemplateId, coreTemplateId>` on the
integration manifest. This makes intent explicit. CLIs from 0.5.3 onward ignore the
field when it is unknown without rejecting understood contributions; integrations
that use it must not claim compatibility with 0.5.2 or earlier. Supported integration
use starts in 0.7.0; the implementation may ship earlier for forward validation.

Rejected: adding `replaces` directly to `TemplateDoc`. A CLI released before that
field would reject the strict metadata object and make the integration template
unavailable instead of degrading cleanly.

### DEC-2 — Explicit project configuration owns replacement precedence

**Reference:** `spec:AST-035/DEC-2`
**Proposed by:** `josephfarina`, `2026-09-11`

Use configuration order when more than one explicitly configured package replaces a
target, and prefer any explicit replacement over autolinking. This keeps the
consumer-authored config authoritative while making valid conflicts deterministic and
visible.

Rejected: making every cross-package replacement ambiguous. That would prevent a
consumer from intentionally composing integrations in a declared order. Also
rejected: letting autolinking override explicit config merely because it is appended
to discovery later.

### DEC-3 — A template declares its own replacement

**Reference:** `spec:AST-035/DEC-3`
**Proposed by:** `josephfarina`, `2026-09-24`

Put `replaces` in the integration template's own metadata. Every integration item
keeps its per-item metadata in its own descriptor (`spec:AST-039/FR11`), and doc
topics already declare replacement with the same field. The cost: a stable CLI
before 0.6.4 rejects the unknown field, drops that template, and withholds the
package's doc topics, where the manifest map let it fall back to own-id access.
Integration themes, section ids, and namespace docs already need a 0.6.4 CLI, so
this adds no new kind of break; integrations that use `replaces` declare the CLI
floor in DEC-7.

Rejected: `templateReplacements` in the manifest (DEC-1). It is a central catalog of
per-item data, which `spec:AST-039/FR11` forbids for every kind.

### DEC-4 — Pre-publication replacement support is patch-compatible

**Reference:** `spec:AST-035/DEC-4`
**Decider:** `josephfarina`, `2026-09-29`
**Superseded in part by:** `spec:AST-035/DEC-7`, `2026-10-09`

Integration-template replacement is not a fully published API before the supported
0.7.0 integration-package boundary. Shipping its implementation in final 0.6.x is
therefore additive and patch-compatible: there is no valid latest-stable
integration consumer whose supported behavior changes. Template winners are mutable
catalog data, and the optional list field is additive.

This does not relax the stable CLI schema around the unpublished capability. Existing
same-id `IntegrationTemplateConflict` responses keep their warning-only shape through
0.6.x. At or after 0.7.0, replacement-specific `relationship`, `replaces`, and
`severity: 'info'` require one deliberate projection change across runtime, types,
generated reference, terminal output, documentation, and tests; version alone does
not activate them. Integration packages using `replaces` still require
`@astryxdesign/cli >=0.7.0`, protecting older strict metadata readers.

Rejected: calling the unpublished replacement behavior breaking merely because its
future schema differs; exposing the expanded conflict schema in 0.6.x; or gating all
replacement implementation when the supported package boundary and stable-schema
adapter already protect released consumers.

### DEC-5 — A component declares its own replacement, and the CLI floor is the opt-in

**Reference:** `spec:AST-035/DEC-5`
**Decider:** `josephfarina`, `2026-10-07`

A component's `replaces` lives in its own ComponentDoc, as a template's does (DEC-3).
Stable releases before the floor accept the field and document replacement without
applying it, so applying every declaration at once would change selection for packages
whose range admits those releases. The CLI applies a package's component replacements
only when its `@astryxdesign/cli` peer range starts at the first release that applies
them. A package whose range admits an earlier CLI keeps its behavior; a range that
already starts later, such as `>=0.7.0`, excludes every CLI that ignores the field, so
no supported combination changes. New packages get one documented switch they already
manage for other features.

Rejected: applying every declared `replaces` immediately — it changes released selection without the package asking.
Rejected: an app configuration key — the package already states its intent, and `spec:AST-017/FR19` admits configuration only on evidence.

### DEC-6 — Component replacement uses template replacement's precedence and failure rules

**Reference:** `spec:AST-035/DEC-6`
**Decider:** `josephfarina`, `2026-10-07`

One resolver applies FR4, FR5, and FR9 to components with the component-specific
checks of FR13, and every component surface reads its result. An integration author
learns one set of rules for both kinds, and no two component surfaces can disagree
about the winner.

Rejected: per-surface resolution — component, search, and swizzle would drift apart.

### DEC-7 — The template replacement floor is the first release that applies it

**Reference:** `spec:AST-035/DEC-7`
**Decider:** `josephfarina`, `2026-10-09`

An integration whose template sets `replaces` declares an `@astryxdesign/cli` peer
range that starts at 0.6.4, in place of DEC-4's `>=0.7.0`. Published 0.6.4 through
0.6.7 apply FR2 and FR3 to a declared replacement, and published 0.6.3 and earlier
reject the field. A floor that no published release satisfies makes npm leave the
CLI uninstalled in an app that installs the package. From 0.6.4, the `replaces`
declaration, replacement selection, and the optional list projection are published
surfaces under `spec:AST-017/FR24`; the expanded conflict fields stay
pre-publication until the projection change of FR7 and FR8 at or after 0.7.0.

Rejected: keeping `>=0.7.0` until 0.7.0 ships, because npm removes the CLI from apps on 0.6.x that install such a package.

## Open questions

None.

---

### Specs AST 036 Spec (`docs/specs/AST-036/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-036
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-03
phase: accepted
owners: [cixzhang]
affects_architecture:
  [
    architecture:public-component-api,
    architecture:react-component-runtime,
    architecture:component-test-sufficiency,
  ]
affects_families: [family:navigation-destinations]
affects_contributing: []
affects_consumer_docs: [Markdown, Outline]
---

# General Markdown plugin system spec

## Contract at a glance

| Area                 | Contract                                                                                                                                                                                                                                                                                                                                             |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public contract      | One additive `plugins` prop and `createMarkdownPlugin()`. A plugin exposes only `syntax`, immutable `transform`, and `renderers`. Text matching, semantic fences, and source decoration are helper-authored transforms, not separate protocol phases.                                                                                                |
| Behavior             | Parsing, transformation, Markdown rendering, and Outline observe one stable, strictly typed, MDAST-aligned tree. Ordered transforms return validated replacement trees while Core retains built-in semantics and readable fallback.                                                                                                                  |
| Remark compatibility | A separate adapter may run synchronous transform-only Remark plugins over the documented supported MDAST subset. Parser extensions, async plugins, compiler plugins, arbitrary `VFile` state, and unsupported node kinds are rejected rather than approximated.                                                                                      |
| End-user impact      | Readers may receive additional syntax and transformed document structure while ordinary Markdown, copyable fallback, heading and Outline identity, accessibility, navigation, images, lists, and tables retain canonical ownership.                                                                                                                  |
| Builder impact       | Existing builders do nothing. Opt-in builders pass one stable plugin list. Simple text, fence, and decoration use cases use helpers that return transforms.                                                                                                                                                                                          |
| Compatibility        | Omitted plugins and `plugins={[]}` are semantically the same empty pipeline. Core may skip empty preparation and allocation, but no separate behavior model exists. Existing `components`, `inlinePlugins`, math, citations, autolinks, and parser signatures retain their meaning.                                                                  |
| Review checks        | Reject a second plugin prop, public lifecycle-specific phases, mutable shared AST, raw-markup nodes, plugin override of Core semantics, unbounded parser hooks, silent Remark incompatibility, transform-driven reparsing, or work proportional to every plugin at every source character.                                                           |
| Governing rules      | [`architecture:public-component-api`](../../architecture/public-component-api.md); [`family:navigation-destinations`](../../families/navigation-destinations.md); [AST-002 FR4](../AST-002/spec.md); [AST-002 FR15](../AST-002/spec.md); [AST-002 FR17](../AST-002/spec.md); [AST-002 FR18](../AST-002/spec.md); [AST-002 FR20](../AST-002/spec.md). |

This section is a review projection; the body below is authoritative.

## Intent

Application authors should be able to add reusable Markdown behavior without rewriting source text, forking the parser, or replacing the whole renderer. One understandable model must cover new source syntax, document-level semantic transformation, and typed rendering.

The public concepts should remain fewer than the use cases. Prose replacement, semantic code fences, source decoration, callouts, frontmatter, TOCs, footnotes, and compatible Remark transforms all use the same immutable transform boundary rather than becoming independent protocol phases.

## Ownership boundary

AST-036 owns shared plugin admission, syntax and transform ordering, compatibility, validation, failure behavior, parse identity, Remark-adapter limits, and observable resource constraints. Markdown owns its concrete AST declarations, parser integration, aggregate rendering, and helpers. Outline owns its projection of Markdown heading identity. A first-party plugin owns its specific semantics and evidence in its own module record.

## Non-goals

- Adopt Unified, mutable MDAST, `VFile`, or Remark as Core runtime dependencies.
- Break the released `parseMarkdown` result shape before a separately approved
  migration.
- Promise compatibility with arbitrary Remark parser, transform, compiler, async, or raw-HTML plugins.
- Expose mutable shared AST, unrestricted visitors, DOM access, package discovery, or a registry.
- Replace generic CodeBlock highlighting or Core-owned document, heading, navigation, image, list, or table semantics.
- Make a zero-plugin document a separate semantic mode.

## Platform support

- **Feature and engine floor:** unchanged from current Markdown/Core package support; the protocol adds no browser, React, Node, or TypeScript floor.
- **Unsupported behavior:** unsupported syntax/transform/Remark behavior fails closed to the last valid readable document and cannot silently approximate output.
- **Browser evidence:** interactive stories verify rendered output, keyboard/focus order, copyable fallback, navigation ownership, and unchanged no-plugin behavior in real Chromium; server-render fixtures cover non-browser use.

## Current-state impact

| Current seam              | Preserved behavior                                                                     | New role                                                                                                      |
| ------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `components`              | Replaces supported built-in renderers, including the all-fence code renderer.          | Wins before plugin rendering, except that a semantic-fence proposal claims its fence first (FR17).            |
| `inlinePlugins`           | Replaces matched prose with released traversal, overlap, callback, and error behavior. | Remains compatible; a later migration may adapt it through a helper-backed transform only after exact parity. |
| Parser and streaming APIs | Produce the released AST and reuse settled incremental output.                         | Accept syntax plugins and apply live transforms without making transforms part of parse identity.             |
| Markdown and Outline      | Share built-in heading text and slug behavior.                                         | Also share transformed heading projection and collision allocation.                                           |

## Semantic model

A plugin entry is an opaque value created by Markdown's public `createMarkdownPlugin()` factory and accepted only through the ordered `plugins` array. Its public shape is `{name, apiVersion: 1, syntax?, transform?, renderers?}` subject to these rules: `syntax` contains ordered bounded inline/block tokenizer declarations and requires `parseKey`; `transform` is one synchronous immutable root-to-root function; `renderers` is a node-name-keyed map whose entries provide one pure render callback and one deterministic `toText` projection. A plugin exposes no other lifecycle phase. A plain object cannot masquerade as an entry, and heterogeneous lists preserve the union of their extension-node kinds under strict TypeScript.

The public construction surface is `createMarkdownPlugin()`, `createMarkdownTextTransform()`, `createMarkdownFenceTransform()`, and `createMarkdownSourceDecoration()`. Limited Remark compatibility is a separate tree-shakeable `createMarkdownRemarkTransform()` export from `@astryxdesign/core/Markdown/remark`. These helpers return or annotate the one `transform` capability; they never accept React render callbacks and are not additional lifecycle phases. In particular, `createMarkdownFenceTransform()` maps eligible built-in code nodes to an owning plugin's typed extension data, while that plugin's standard `renderers` map owns presentation and `toText`. Detailed TypeScript signatures and examples live in the Markdown consumer docs, while these names and responsibilities are part of this contract.

The canonical document is an immutable, discriminated, Unist-shaped tree aligned with the supported MDAST subset below. **MDAST is a public community specification maintained by the syntax-tree/unified ecosystem, not a browser or standards-body API.** Astryx's public contract is its own exported, versioned TypeScript node union: it uses the listed MDAST node names and field meanings where they align, documents every deviation, and does not promise nominal or structural interchangeability with arbitrary `mdast`, Unified, or third-party plugin versions. `parseMarkdownAst()` and `parseInlineAst()` expose that tree additively from the server-safe `@astryxdesign/core/Markdown/parser` subpath; the component barrel also re-exports them for client code. Parsing, transforms, Markdown rendering, and Outline projection observe the same node meanings. Released parser entrypoints retain their current result types and values through a compatibility boundary; adopting plugins requires no migration.

The Markdown owner exposes stable node unions and node-kind-narrowed traversal. Plugin-defined extension kinds augment the generic union without widening unrelated built-in callbacks. A transform receives readonly input and returns the original root or a replacement root. Equivalent validation and immutability implementations are permitted.

A syntax contribution declares non-empty literal prefixes, a finite pending bound, and a synchronous deterministic tokenizer. It may emit only its owning typed extension nodes. Syntax-bearing plugins declare stable parse identity; only ordered syntax identity participates in incremental parse identity.

A transform is synchronous and deterministic. It may replace, insert, remove, or annotate representable nodes within the permitted-edit matrix. It cannot mutate input, author source provenance, introduce raw markup or React values, or bypass Core-owned semantics. Transform changes refresh transformed output without changing syntax parse identity.

Renderers receive typed extension-node data only. Every extension node introduced through syntax or transform has a renderer and deterministic perceivable-text projection. A source-backed leaf falls back to exact authored source; a synthetic leaf falls back to its required text projection; a container keeps and renders its validated children. Sibling content continues after failure.

Each extension kind declares `content: 'none' | 'phrasing' | 'flow'` or a finite allowlist that narrows the category implied by `display`. Core validates children at every transform boundary. Syntax-created containers declare an inner source span that Core parses; plugins never turn raw source into child nodes. A plugin may read or remove another plugin's nodes, but it may create, edit, or duplicate only its own. Cross-plugin reads are declared through `dependsOn` and validated against list order.

Plugin admission and every failure phase use one source-free, rate-limited `onPluginDiagnostic` channel in development and production. Malformed entries and protocol-version mismatches fail locally rather than throwing into the caller. Plugin-authored reports use stable codes, never document-derived text.

Text matching, semantic fences, and source decoration are optional transform helpers, not additional public phases. Equivalent optimized execution is allowed when it preserves ordered transform semantics and observable results.

## Canonical MDAST compatibility matrix

| Node or field                               | Supported contract                                                                                                                                                                               | Rejected or constrained behavior                                                                                                    |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `root`, `children`                          | One root; ordered typed children.                                                                                                                                                                | Removing/retyping the root; cycles; shared mutable children.                                                                        |
| `text`, `value`                             | Text replacement, splitting, insertion, and removal.                                                                                                                                             | Functions, React values, or non-string `value`.                                                                                     |
| `paragraph`, `strong`, `emphasis`, `delete` | Ordered phrasing children.                                                                                                                                                                       | Block children inside phrasing containers.                                                                                          |
| `heading`, `depth`                          | Phrasing children may transform; transformed text participates in shared Markdown/Outline identity.                                                                                              | Changing a source-backed heading's depth; renderer-defined IDs.                                                                     |
| `inlineCode`, `break`                       | Standard MDAST meaning.                                                                                                                                                                          | Transforming protected inline-code contents through text helpers.                                                                   |
| `link`, `url`, `children`                   | Phrasing children; every Astryx-owned destination is revalidated by the navigation owner.                                                                                                        | Nested links; bypassing destination policy; non-string URLs.                                                                        |
| `image`, `url`, `alt`                       | Revalidated resource URL and text alternative.                                                                                                                                                   | Bypassing resource policy or removing required alternative text.                                                                    |
| `blockquote`                                | Ordered block children.                                                                                                                                                                          | Invalid child kinds.                                                                                                                |
| `list`, `listItem`                          | `ordered`, optional `start`, `spread`, optional `checked`, and block children. Astryx also preserves the authored ordered-list `delimiter` for exact released projection.                        | Invalid task/list structure or non-finite starts.                                                                                   |
| `code`, `lang`, `meta`, `value`             | MDAST semantics: `lang` is `null` when absent and `meta` is optional. Ordinary code remains copyable; fence helpers annotate the original node.                                                  | Replacing/removing source through the fence helper; bypassing `components.code` for a fence no proposal claims.                     |
| Astryx flow image                           | Preserves released standalone-image block behavior with `url` and `alt`; phrasing images retain normal MDAST placement.                                                                          | Treating the flow extension as portable MDAST without adapter evidence.                                                             |
| `table`, `tableRow`, `tableCell`, `align`   | Rectangular rows, phrasing cells, normalized alignment.                                                                                                                                          | Replacing Core table semantics or producing ragged/invalid structure.                                                               |
| `thematicBreak`                             | Standard MDAST meaning.                                                                                                                                                                          | Children or renderer slots.                                                                                                         |
| `math`, `inlineMath`                        | Present only when the released math opt-in is enabled.                                                                                                                                           | Enabling math through transformation alone.                                                                                         |
| `position`                                  | Core-authored optional start/end UTF-16 offsets on source-backed nodes; line and column remain optional until available without changing released range behavior. Synthetic nodes omit position. | Authoring, shifting, or forging source positions.                                                                                   |
| `data`                                      | Finite JSON-like plugin data under the owning plugin key; Core bookkeeping uses one reserved `astryx` namespace with no rendering effect by itself.                                              | `hName`, `hProperties`, `hChildren`, functions, DOM/React values, raw-markup channels, or plugin-authored Core-reserved data.       |
| Astryx extension node                       | Stable plugin/name/display/data discriminants; declared `none`, `phrasing`, `flow`, or narrowed allowlist content; renderer and text projection.                                                 | Foreign minting/editing/duplication, undeclared children, missing projection/renderer, or changing another plugin's discriminants.  |
| Astryx citation node                        | Preserved as an owned typed extension to the MDAST subset.                                                                                                                                       | Rewriting it as a link to bypass citation ownership.                                                                                |
| Other MDAST nodes                           | Unsupported unless a later current contract adds them.                                                                                                                                           | `html`, definitions/references, footnotes, frontmatter, directives, MDX, and other unknown nodes fail closed in the Remark adapter. |

## Permitted transform edits

| Surface                         | Permitted                                                                                                   | Rejected                                                                                                     |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Document root                   | Return the same root or a structurally valid replacement root.                                              | Removing/retyping the root or adding an independently rendered document shell.                               |
| Source-backed text and phrasing | Replace, split, insert, remove, or annotate while preserving protected contexts.                            | Text-helper matches inside protected code, links, images, citations, math, or accepted opaque syntax.        |
| Headings                        | Transform phrasing children; remove or reorder headings; shared identity updates from transformed text.     | Changing a source-backed depth, forging/duplicating provenance or identity, or supplying a renderer-only ID. |
| Links and images                | Insert or update values that pass their current navigation/resource owners.                                 | Bypassing destination, protocol, alternative-text, or trusted-resource policy.                               |
| Lists and tables                | Insert, remove, or transform valid descendants while retaining Core-owned semantics.                        | Changing roles, ownership, or producing invalid structure.                                                   |
| Code fences                     | A semantic-fence helper may attach owned typed extension data to the original code node.                    | Replacing source through the helper, rendering inside the helper, or claiming a fence it does not annotate.  |
| Source decoration               | Attach non-semantic, non-interactive presentation metadata to source-backed ranges.                         | Changing AST meaning, focus order, accessible name, navigation, selection, or copyable text.                 |
| Plugin extension nodes          | Insert/edit the transform owner's node; read or remove foreign nodes/subtrees; preserve validated children. | Minting, editing, duplicating, or internally reordering another plugin's node; undeclared child content.     |
| Inline entrypoints              | Return exactly one paragraph-equivalent root containing phrasing nodes.                                     | Block nodes, multiple top-level blocks, or a changed container shape.                                        |

## Limited Remark compatibility profile

A Remark adapter accepts a synchronous transform-only plugin confined to the matrix above. Each invocation receives a fresh mutable tree copy and an isolated constrained file object. The file exposes readonly source text, per-invocation finite JSON data, and diagnostic reporting; it exposes no processor registration, cross-run state, compiler, I/O, or async completion.

The adapter's input type is structurally compatible with conventional Unified/Remark `Plugin` values, including their broad declared transformer return union, so a compatible package does not need a type wrapper. Runtime acceptance remains narrower: an invocation that actually returns a promise or uses an unsupported capability fails closed. Astryx exports its own supported-subset tree and file types rather than re-exporting or pinning the complete `mdast`/Unified type surface, so upstream type evolution cannot silently widen Astryx's contract.

Remark compatibility is content-only, not presentation compatibility. Adapted plugins may return only the supported structural nodes and fields; Astryx still renders those nodes through its own components, tokens, navigation/resource policies, and accessibility semantics. `html`, `hName`, `hProperties`, `hChildren`, custom compiler output, CSS assumptions, and third-party renderer conventions are rejected rather than approximated. A plugin that needs new presentation must use an Astryx-owned typed extension node and renderer outside the Remark adapter.

The adapter validates the plugin's returned or mutated tree against the matrix before it becomes observable. Unsupported nodes, raw HTML, promises, shared processor state, invalid structure, forged positions, and unrepresentable metadata preserve the last valid tree and report a source-free diagnostic.

Compatibility is explicit per plugin and tested version. “Remark-compatible” means equivalent supported-subset fixtures pass the adapter matrix; package shape or ecosystem membership alone is not evidence. Plugins using processor data, micromark extensions, async work, unrestricted `VFile` behavior, unsupported nodes, renderer metadata, or compiler hooks are outside the profile.

## Requirements

### Admission and compatibility

- **FR1 — One public plugin model.** `plugins` and `createMarkdownPlugin()` are the canonical extension seam. The only protocol capabilities are `syntax`, `transform`, and `renderers`; helper APIs may compile common use cases into transforms.
- **FR2 — One empty-pipeline behavior.** Omitted plugins and an empty list have identical AST, DOM, styling, targets, IDs, errors, and streaming behavior. Core may avoid empty normalization and allocation, but no behavior may depend on whether the empty list was explicit.
- **FR3 — Zero breaking changes.** Existing `components`, `inlinePlugins`, citations, opt-in GFM autolinking, math, sources, parser signatures, fields, optionality, mutability, no-plugin result types, DOM, accessibility, heading/Outline identity, streaming reuse, and renderer precedence retain their released meaning and behavior. Opting into plugins adds behavior only for that callsite.
- **FR4 — `inlinePlugins` migrates only after parity.** No deprecation occurs until a helper-backed transform preserves every released traversal context, overlap rule, callback result, `null`, throw, migration-doc, and codemod requirement.
- **FR5 — Entries are durable package values.** First-party plugins are colocated under Markdown and exported normally; third-party plugins are explicit package imports with a compatible Core peer range. No registry or discovery mechanism exists.

### Parsing and immutable transformation

- **FR6 — Fixed order.** Built-in and extension syntax parse first; transforms then run in plugin-array order; rendering runs last. A transform observes the validated result of every earlier transform.
- **FR7 — Built-in lexical shields win during syntax.** Escapes, inline and fenced code, links, images, citations, math, and accepted opaque syntax remain protected according to their current owners. Block extension syntax remains top-level unless a current owner explicitly broadens it.
- **FR8 — Ordered syntax claims.** Duplicate plugin names fail validation. Syntax contributions resolve in plugin and declaration order; first match wins, defer reserves the bounded candidate, and terminal parsing resolves all pending source.
- **FR9 — Canonical AST is MDAST-aligned, strictly typed, and reachable.** Parsing, transforms, rendering, and Outline projection use one stable Unist-shaped subset with complete available source positions. `parseMarkdownAst()` and `parseInlineAst()` return that canonical tree from the server-safe `@astryxdesign/core/Markdown/parser` subpath and the Markdown component barrel. Public node-kind traversal narrows callbacks by discriminant, including generic plugin extensions. Released parser functions retain their existing result types and values until a separately approved migration.
- **FR10 — Transform input is immutable.** A transform returns the same root or a replacement root. Attempted mutation cannot change any observed input node, array, data object, source position, prior snapshot, or later plugin input.
- **FR11 — Transform output is representable and validated.** Output is finite, acyclic, typed data that satisfies both matrices above. Core rejects raw HTML, React values, DOM nodes, functions in node data, invalid built-in structure, foreign extension ownership, authored/shifted provenance, and block-shaped output from inline entrypoints.
- **FR12 — Transform failure is local to the plugin.** A throw, promise, rejected thenable, invalid result, or unsupported adapter output reports one source-free diagnostic and passes the last valid root to later plugins and rendering. Core attaches a rejection sink before refusing an asynchronous tokenizer or transform so a rejected promise cannot escape through the host's unhandled-rejection channel. Source-backed nodes preserve authored-source fallback; synthetic extension leaves use their required perceivable-text projection; extension containers preserve their validated children.
- **FR13 — Live transforms do not reparse or mutate retained output.** Adding, removing, reordering, or updating transforms refreshes transformed output without rerunning syntax tokenization. Only current parser options and ordered syntax identity invalidate syntax parse identity. Prior snapshots stay unchanged; unchanged settled legacy output retains its released reuse and remount behavior.

### Rendering and cross-surface identity

- **FR14 — Extension rendering is complete and local.** Every extension kind a plugin may introduce has an owning renderer and perceivable-text projection. Missing or failed rendering preserves exact authored source for source-backed leaves, the required projection for synthetic leaves, or validated Markdown children for containers; siblings continue running.
- **FR15 — Core semantics remain authoritative.** Plugins cannot replace the document root or override Core-owned heading level/ID, navigation, image policy, list semantics, table semantics, or built-in accessibility behavior. Trusted renderer-created links and resources remain renderer-owned.
- **FR16 — One selected heading projection.** Markdown and Markdown-derived Outline use the same syntax list, transform list, text projection, and installed heading projection. The released projection remains top-level; `module:Markdown/headingLinks` may opt into its separately owned projection through the same opaque plugin list without changing Outline selection. Renderer output cannot change identity.
- **FR17 — A semantic fence claims its fence before the code override.** A semantic-fence helper may annotate an eligible original code node but cannot replace its source. Rendering resolves the helper proposal first: when the owning plugin's renderer renders it, that output replaces the fence. `components.code` renders every fence no proposal claims, and is the local fallback for a claimed fence whose renderer is absent, returns nothing, throws, or suspends. Without `components.code`, ordinary copyable code is that fallback.

### Remark compatibility

- **FR18 — Adapter scope is explicit.** The adapter supports only synchronous transform-only plugins over the documented MDAST subset. It does not emulate Unified parser/compiler registration, async execution, processor state, or unrestricted `VFile` behavior.
- **FR19 — Translation is lossless or rejected.** Each adapted node and metadata field has a documented round trip. Unsupported input or output fails closed with readable source; the adapter never silently drops or approximates content.
- **FR20 — Compatibility is tested per plugin.** A package is called compatible only after fixtures prove equivalent supported-subset output, protected-context behavior, diagnostics, source preservation, and server rendering.

### Resources

- **FR21 — Unclaimed work is bounded.** A helper callback runs only for an actual claim; unmatched source cannot invoke it. Stable and recreated semantically equivalent plugin lists must both satisfy FR23, without requiring any particular preparation or dispatch implementation.
- **FR22 — Existing parser budgets remain the floor.** The canonical [parser performance fixture](../../../packages/core/src/Markdown/parser.perf.test.ts) remains green: full parse stays below 20/50/100/400/1000 ms at 10/50/200/500/2000 sections; full and incremental 50-character streaming stay below 5000 ms at 50 sections and 30000 ms at 500; incremental time stays at most 1.1× full reparse; and its exact settled-tail work, object-reuse, source-range, median/worst-block, and tracked whole-prefix assertions remain unchanged.
- **FR23 — Focused overhead budgets cover both transform paths.** Under the evidence protocol below, each budget names the path it measures. First-party helper transforms: five zero-work helpers add at most 15 percent and the representative three-helper set adds at most 25 percent, at both fixture sizes. Plugin-authored transforms, which pay full immutability and validation guards: five zero-work transforms add at most 30 percent and the representative three-transform set adds at most 50 percent, at both fixture sizes. Streaming the representative plugin-authored set stays within FR22's streaming budgets and at most 1.25× the same stream with an empty pipeline. Per-transform guard work is bounded by one traversal of that transform's output; whole-document invariant collection happens once per pipeline run, not once per transform. Core may skip a guard for a helper-authored transform only where the helper's own construction already guarantees the property, and the published budget must say which path a number describes.
- **FR24 — Optional work stays optional.** The server-safe `@astryxdesign/core/Markdown/parser` subpath contains no client directive or component dependency. Remark adapters, the conformance kit, and heavy renderers are tree-shakeable and absent from parser-only/server bundles unless imported.

### Extension containers, ownership, and node data

- **FR25 — Extension nodes declare their content.** Every extension kind declares `'none'`, `'phrasing'`, `'flow'`, or an explicit allowlist with optional finite cardinality bounds. An allowlist narrows the category its `display` already implies and never widens it, and an extension kind's name may not collide with a supported built-in node name. Core validates the declaration at admission and the actual children at every transform boundary. A container produced by syntax declares an inner source span that Core parses; no plugin supplies children built from raw source. Containers count toward the built-in container-depth bound, neither restart nor independently scope the heading projection selected under FR16, and keep their children when their renderer fails.
- **FR26 — Ownership is minting and editing, not existence.** A transform may read another plugin's extension nodes and may remove them, including removing a subtree that contains them. It may not create, edit, reorder the internals of, or duplicate a node owned by another plugin. A source heading may be removed or inserted but its depth may not change and its identity may not be forged or duplicated. Every rejection under this rule reports a diagnostic that names the ownership rule and the owning plugin rather than a generic invalid-document code.
- **FR27 — Plugins declare the plugins they read.** `dependsOn` lists plugin names whose extension nodes a plugin reads. Preparation validates that each named plugin is present and ordered earlier. An unmet or misordered dependency is a failure local to the declaring plugin: its transform does not run, its syntax and renderers remain active, and one diagnostic distinguishes missing from misordered. Declaring no dependency keeps array-order-only behavior.
- **FR28 — Core-internal per-node state lives in one reserved data namespace.** Core's own per-node bookkeeping is a documented reserved key of node `data`, not a hidden, non-enumerable, or signature-matched channel. Plugin data lives under the owning plugin's key. Plugins may read reserved data; authoring, editing, forging, or removing it is rejected with a diagnostic.

### Diagnostics and failure visibility

- **FR29 — One diagnostic channel, including in production.** `onPluginDiagnostic` is available on the component and in parser options and receives one event per failure or advisory: the plugin name, phase (admission, syntax, transform, render, or adapter), stable enumerated code, and severity. Events are source-free by construction — no document text, node values, destinations, or positions — and are delivered in development and production. Delivery is rate-limited per document and plugin/phase/code, and a suppression code reports when the bound is reached. With no handler, development surfaces each unique code once and production stays silent. `context.report()` accepts a plugin-authored stable code rather than document-derived text and is delivered as an advisory, never as a failure. Any condition that silently degrades a document to reduced plugin behavior, including exceeding a validation size bound, emits a diagnostic.
- **FR30 — Admission and version failures are uniform and soft.** A malformed entry, duplicate name, unsupported protocol version, or otherwise unadmissible list behaves identically through the component and every public parser entrypoint: the call succeeds with the last valid configuration — an empty pipeline when nothing is admissible — reports one diagnostic, and never throws into the caller. A protocol-version mismatch is never an import-time or module-scope throw. Plugin identity created by a compatible duplicate Core copy remains valid through a stable cross-package brand whose frozen shape and version are verified.

### Authoring, canonical APIs, and packaging

- **FR31 — Extension types are inferred.** `createMarkdownPlugin()` infers the plugin's extension-node union from its declaration. No callsite requires explicit type arguments or a hand-written node alias to obtain correct narrowing in `renderers`, `transform`, traversal, and the aggregated list union. A declaration that produces extension nodes without a matching renderer, or that resolves to an empty extension union while extension kinds are used, is a type error rather than a silently permissive `never`.
- **FR32 — The canonical AST has public parse entrypoints.** `parseMarkdownAst()` and `parseInlineAst()` return the canonical tree these types describe, accept the same options and ordered plugin list as the component, and sit beside the released `parseMarkdown` family, which keeps its projected result shape.
- **FR33 — A server-safe parser entry exists.** `@astryxdesign/core/Markdown/parser` exports parsing, canonical AST types, and plugin admission with no client-boundary directive, so a server importer does not pull a client reference. It is registered in the generated export map and covered by an import fixture, exactly as the adapter entry is.
- **FR34 — A conformance kit produces per-plugin evidence.** `@astryxdesign/core/Markdown/testing` exports fixtures a plugin author runs to produce the evidence FR20 requires: protected contexts, immutability, validation rejection, failure fallback and diagnostics, container content validation, streaming idempotence, and server rendering. It is tree-shakeable and absent from runtime bundles.
- **FR35 — Compatibility and limits are consumer-documented.** Consumer documentation publishes the supported and rejected categories with the Astryx answer for each: transform-only content rewriters are supported; anything that extends the grammar, emits markup or hast, depends on processor or file state, or only reports messages is not. It states plainly that no raw-markup stage exists, names the native capabilities that make specific plugin categories unnecessary, and documents that a renderer is arbitrary application code, so the trust boundary is the plugin package rather than the document.

### Streaming, theming, and stability

- **FR36 — Transforms are idempotent under streaming.** A transform runs again for every streamed update. Given the same source prefix it must return the same result, and its output must converge rather than oscillate as the prefix grows. Transforms whose effect is valid only on complete input use the final-input signal. Evidence includes a no-oscillation fixture across chunk boundaries.
- **FR37 — Extension output has an opt-in theme target.** An extension renderer may opt into one Markdown-owned extension theme target so themes can reach plugin output. Opting out leaves output untargeted. The target carries no default styling or anatomy beyond block spacing and content width Core already applies, and it does not make plugin output part of Markdown's default anatomy.
- **FR38 — Prepared work is reused for equal lists.** Semantically equal plugin lists reuse prepared syntax and transform work across renders whether or not the array reference is stable, so preparation cost is not proportional to render count. Where reuse cannot be established, development reports one diagnostic identifying the recreated list, and consumer documentation states the hoisting guidance.
- **FR39 — Protocol stability is published.** The first public release publishes what `apiVersion` covers — entry shape, capability names, extension-node discriminants, declared content shapes, diagnostic codes and phases, and canonical node union — and what it does not — helper internals, unlisted AST fields, Core-reserved node data, and diagnostic text. A change that invalidates an existing conforming plugin bumps `apiVersion`; purely additive capability does not. The policy states which Core versions accept a given `apiVersion` and how skew fails, consistent with FR30.

### Planned first-party scope

- **FR40 — First-party plugins use the public protocol.** Astryx ships a small first-party set — soft breaks, heading links, configurable entity references, and callout containers — through the public opaque plugin list, each owning its semantics and evidence in its own module record and serving as a canonical documentation example. No first-party plugin receives a public capability, validation exemption, ordering privilege, or type affordance unavailable to a third party; an internal composition seam or fast path is permitted only where its module record owns the behavior and the empty pipeline stays unchanged, per FR3.
- **FR41 — Footnotes, definitions, and references stay Core scope.** They are not a plugin surface: their identity is document-level ID allocation, collision handling, and back-reference navigation that Core owns under FR15 and FR16, and a plugin owning them would fork the identity system Markdown and Outline share. Until a separately approved contract adds them as Core syntax, they are unsupported, the adapter rejects them, and consumer documentation names the gap explicitly rather than implying a plugin workaround.

## Performance evidence protocol

- **Fixture:** a deterministic 200- and 500-section document; each section contains one heading, prose with an identifier and mention, a two-item list, a block quote, one table row, and an ordinary fenced code block.
- **Helper zero-work set:** five prepared first-party helper transforms with valid selectors that match no fixture node or source span.
- **Helper representative set:** three helpers that respectively match prose identifiers, annotate eligible code fences, and decorate a known source range.
- **Plugin-authored zero-work set:** five plugin-authored transforms that return their input root unchanged, taking the full immutability and validation path.
- **Plugin-authored representative set:** three plugin-authored transforms that respectively rewrite matched prose text, wrap a section in an extension container, and read a preceding plugin's nodes under a declared dependency.
- **Streaming set:** the plugin-authored representative set applied to the 200-section fixture streamed in 50-character chunks, compared against the same stream with an empty pipeline.
- **Comparison:** omitted/empty pipeline versus the same parse-and-transform call with the stable plugin list, in one isolated process and runner.
- **Sampling:** ten untimed warmups, then nine alternating paired rounds; each side averages 20 iterations per round. The reported ratio is the median of the nine paired ratios. Both 200- and 500-section ratios must satisfy FR23. An A/A control outside the measurement-validity bound invalidates that sample; the harness retries rather than certifying noisy output. After a first valid sample over a budget, the stable and recreated lists are each decided separately, per budget and fixture size: a list fails when five of up to nine fresh-process valid samples, the first included, are over the budget.
- **Cost-shape evidence:** guard traversal counts are asserted directly, so adding a transform may add at most one output traversal and may not repeat whole-document invariant collection.
- **Regression signal:** invoking a helper callback for unclaimed source, invoking a claimed helper more than once, regressing parse/result reuse, or publishing a number without naming its path must fail work-count or threshold evidence.

## Public parser integration

Block, inline, incremental, canonical-AST, Markdown, and Outline entrypoints accept the same ordered plugin configuration and infer the same extension-node union while retaining their released signatures and projected result shapes. The canonical entrypoints return the canonical tree directly; released entrypoints keep their projection. The server-safe parser entry exposes the same behavior without a client boundary, and the Markdown component barrel re-exports it for client code. Inline transforms must satisfy the one-paragraph phrasing constraint. Transform-only updates cannot increase syntax tokenizer calls, mutate prior snapshots, or remount unchanged settled output beyond current behavior.

### Public API admission evidence

| Caller-owned distinction       | Before / unchanged default                                                                                    | Opt-in callsite                                                                                                                                                                        | Why Core cannot derive it                                                                                                                        |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| No plugin behavior             | `<Markdown>{source}</Markdown>` and `parseMarkdown(source)` retain released output.                           | `<Markdown plugins={plugins}>{source}</Markdown>`                                                                                                                                      | Only the application knows which extension packages and ordering it trusts for this document.                                                    |
| Novel source grammar           | Preprocess source or fork parsing; `components` and `inlinePlugins` cannot claim new block/inline grammar.    | `createMarkdownPlugin({name, apiVersion: 1, parseKey, syntax, renderers})`                                                                                                             | The application owns the literal grammar, bounded tokenizer, parse-version key, and extension semantics.                                         |
| Semantic text/fence/range work | Existing `inlinePlugins` remains available; `components.code` remains the code override for unclaimed fences. | Put `createMarkdownTextTransform()`, `createMarkdownFenceTransform()`, or `createMarkdownSourceDecoration()` in the plugin's one `transform`; render typed output through `renderers`. | Only the plugin knows which prose, language, or source range has domain meaning; helpers cannot infer product semantics and never own rendering. |
| Rich extension container       | No custom container syntax or renderer is active.                                                             | Declare `content: 'phrasing'                                                                                                                                                           | 'flow'                                                                                                                                           | {allow, min?, max?}` and render the Core-parsed children through the owning renderer. | Core can validate and parse children but cannot infer whether a product concept is a callout, card, tab, or leaf. |
| Cross-plugin observation       | Array order alone has no declared dependency.                                                                 | Add `dependsOn: ['earlier-plugin']` before reading its nodes.                                                                                                                          | Only the later plugin knows that its transform requires another plugin's output; Core validates presence and order.                              |
| Diagnostics                    | With no handler, development reports unique failures and production stays silent.                             | Pass `onPluginDiagnostic={event => report(event)}` or the parser equivalent.                                                                                                           | The host owns telemetry destination and retention; Astryx owns a source-free bounded event shape, not the logging system.                        |
| Canonical/server parsing       | Released parser calls keep their projected unions.                                                            | Import `parseMarkdownAst` from `@astryxdesign/core/Markdown/parser` when a server or transform tool needs the canonical tree.                                                          | Core cannot infer whether a caller needs released compatibility data or canonical extension-aware structure.                                     |
| Markdown-derived Outline       | Existing calls omit options and retain released headings.                                                     | Pass the same `plugins` and finality to Markdown and `parseOutlineFromMarkdown`/`useOutlineFromMarkdown`.                                                                              | The caller owns which document/plugin configuration both surfaces represent; Core then owns shared text/slug identity.                           |
| Theme reachability             | Renderer output is untargeted by default.                                                                     | Set the renderer's opt-in Markdown extension theme target.                                                                                                                             | Only the renderer author knows whether its application-owned output should participate in design-system theming.                                 |

The new decisions are additive and opt-in. `components` swaps supported built-in rendering but cannot add source grammar or document transforms. `inlinePlugins` performs released prose matching but cannot add block syntax, canonical whole-tree transforms, or typed extension ownership. Existing parser options cannot express application-defined grammar, renderer ownership, declared cross-plugin dependencies, or host diagnostics. These surviving distinctions therefore belong to the caller and cannot be derived safely by Core.

## Verification

| Contract             | Verification                                                                                                                                                  | Representative states                                                                                                                 | Mutation or failure expectation                                                                                                  |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR5              | Public type/export fixtures, DOM/AST snapshots, duplicated-Core-copy fixture, and the callsite matrix above                                                   | Omitted, explicit empty, stable/recreated lists, every released prop and parser overload, two Core copies in one graph                | Removing empty parity, changing a released signature, or rejecting an entry created by a compatible second Core copy fails.      |
| FR6–FR13             | Syntax claim matrix; typed narrowing; transform replacement/insertion/removal/annotation; inline-shape, mutation, cycle, raw, async, and provenance rejection | Full, inline, terminal, incremental partial/final, syntax-only update, transform-only update, prior snapshots                         | Reordering transforms changes ordered evidence; adding tokenizer calls, mutating a prior snapshot, or leaking a rejection fails. |
| FR14–FR17            | Renderer/fallback matrix, semantic-owner matrix, heading/Outline identity, code override precedence, server rendering                                         | Source-backed and synthetic extensions, containers, duplicate headings, cross-base headings, custom code override present/absent      | Failed render preserves exact source, required projection, or children; `components.code` never pre-empts a claimed fence.       |
| FR18–FR20            | One real compatible transform plus rejected async, parser-extension, raw-HTML, unsupported-node, forged-position, processor-state, and metadata fixtures      | Every supported matrix row, constrained file messages/data, protected contexts, server rendering                                      | Silent field loss, unsupported-node acceptance, source loss, or an escaping rejection fails closed fixtures.                     |
| FR21–FR24            | Canonical linked parser budgets, callback work counts, guard-traversal counts, bundle inspection, and the exact paired protocol above                         | 200/500 sections, stable/recreated lists, helper and plugin-authored zero-work and representative sets, streaming set                 | Any unmatched callback, duplicate claim callback, repeated whole-document guard, parser-budget regression, or breach fails.      |
| FR25–FR28            | Content-declaration admission/tree validation, ownership matrix, dependency preparation, reserved-data rejection                                              | Leaf, phrasing, flow, allowlist; nested containers at depth bound; foreign read/remove/mint/edit; unmet/misordered dependencies       | Plugin-built raw-source children, invalid content, foreign mint/edit, or generic ownership diagnostics fail.                     |
| FR29–FR30            | Diagnostic fixtures in development/production and admission parity across entrypoints                                                                         | Every phase/code, advisories, rate suppression, no-handler default, malformed list, duplicate Core, version skew                      | Silent production failure, document content in diagnostics, module-scope throw, or entrypoint divergence fails.                  |
| FR31–FR35            | Inference fixtures without explicit type arguments, canonical/server imports, conformance-kit self-test, documentation checks                                 | Syntax-only, transform-only, renderer-only, heterogeneous lists, server import, third-party kit run                                   | Explicit-type workarounds, client references in server entry, or undocumented rejected categories fail.                          |
| FR36–FR39            | Streaming no-oscillation, theme reachability, prepared-reuse counts, stability-policy docs                                                                    | Chunk boundaries, final-only effects, themed/unthemed output, equal new-identity lists, version skew                                  | Oscillation, unreachable opted-in output, per-render re-preparation, or unpublished policy fails.                                |
| FR40–FR41            | First-party module records/evidence and unsupported-node fixtures                                                                                             | Soft breaks, heading links, configurable entity references, callouts; footnote/definition/reference source through parser and adapter | First-party public privilege, changed empty-pipeline behavior, or plugin-owned footnote/definition support fails.                |
| Repository integrity | Knowledge validation, public-content checks, typecheck, formatting, and changed-file review                                                                   | Specification and every implementation PR                                                                                             | Internal content, stale owner records, unrelated paths, or formatting/type failures block merge.                                 |

## Related owner prerequisites

Before implementation acceptance, current owner clauses must cover:

1. a stable canonical MDAST-aligned AST, typed node-map/visitor APIs, released-parser compatibility projection, public canonical parse entrypoints, and Core-owned semantic validation;
2. Markdown/Outline shared transformed heading identity and the unchanged top-level traversal both surfaces use;
3. observable invalidation identity that distinguishes syntax from live transforms;
4. navigation/resource conformance for transformed nodes;
5. exact `inlinePlugins` compatibility before any migration;
6. the diagnostic channel on the component and parser options;
7. the opt-in extension theme target in Markdown's theming anatomy;
8. server-safe parser and conformance-kit package entries;
9. first-party plugin module owners for soft breaks, heading links, configurable entity references, and callouts.

## Decision log

### DEC-1 — Expose syntax, transform, and renderers only

**Reference:** `spec:AST-036/DEC-1`
**Direction owner:** `cixzhang`, `2026-09-15`

The public protocol has three concepts. Text matching, semantic fences, and decorations are transform helpers, not parallel lifecycle APIs, and they never render — presentation always comes from the owning plugin's `renderers`. This keeps one mental model while permitting specialized internal execution.

Rejected: five independent public capability phases; a second transform prop; helpers that accept render callbacks; replacing simple local syntax with an unbounded document hook.

### DEC-2 — Make immutable AST transformation canonical

**Reference:** `spec:AST-036/DEC-2`
**Direction owner:** `cixzhang`, `2026-09-15`

Transforms receive the canonical MDAST-aligned tree observed by parsing, rendering,
and Outline. Public node-kind traversal preserves strict discriminated narrowing,
including plugin extension nodes. Inputs are immutable and transforms return
validated roots. Core owners retain provenance, structural validity, fallback, and
semantic authority. Released parser results remain unchanged until a separately
approved migration.

Rejected: a second transform tree, in-place mutation of canonical state,
transform-driven reparsing, raw markup, weakly typed string visitors, and
renderer-controlled heading identity.

### DEC-3 — Support a limited Remark compatibility profile

**Reference:** `spec:AST-036/DEC-3`
**Direction owner:** `cixzhang`, `2026-09-15`

A tree-shakeable adapter may run synchronous transform-only Remark plugins over an explicit MDAST subset. Compatibility is per-plugin evidence, never inferred from package identity, and supported and rejected categories are published for consumers.

Rejected: adopting Unified/MDAST as Core's runtime, claiming arbitrary Remark compatibility, or silently approximating unsupported behavior.

### DEC-4 — Separate syntax identity from live transforms

**Reference:** `spec:AST-036/DEC-4`
**Direction owner:** `cixzhang`, `2026-09-15`

Only syntax and existing parser options determine parse identity. Transform and renderer updates reuse parsed output and rerun only their owning post-parse work.

Rejected: object identity as parse identity, transform-driven reparsing, a public render key, or repeated preparation for semantically equal inputs.

### DEC-5 — Extension nodes may contain validated Markdown

**Reference:** `spec:AST-036/DEC-5`
**Direction owner:** `cixzhang`, `2026-09-16`

Callouts, admonitions, details, tabs, cards, and columns require real Markdown children. An extension therefore declares leaf, phrasing, flow, or narrowed allowlist content, and Core validates children. Core parses syntax-container inner spans; plugins never build children from raw source, so provenance and protected contexts stay Core-owned. A container never selects or independently scopes heading identity; it participates only in the projection already selected under FR16. A failed container renderer still exposes its validated children.

Rejected: leaf-only extension nodes; independent nested document shells; plugin-authored parsed children; unbounded nesting.

### DEC-6 — Ownership means minting and editing, not existence

**Reference:** `spec:AST-036/DEC-6`
**Direction owner:** `cixzhang`, `2026-09-16`

A transform may read or remove another plugin's nodes, including a removed subtree, but may not create, edit, internally reorder, or duplicate them. Source headings may be inserted, removed, or reordered but never re-levelled or given forged/duplicated identity. `dependsOn` makes cross-plugin reads explicit and validates presence and order.

Rejected: foreign-node immortality; immortal headings; silent missing/misordered dependencies; generic ownership diagnostics.

### DEC-7 — Failure is visible in production

**Reference:** `spec:AST-036/DEC-7`
**Direction owner:** `cixzhang`, `2026-09-16`

Astryx owns one source-free, rate-limited `onPluginDiagnostic` channel covering admission, syntax, transform, render, and adapter phases in development and production. Plugin advisories carry stable codes, not document-derived text. Admission failures are uniform across component and parser entrypoints; version skew never throws at import time. Rejected tokenizer and transform thenables are defused before refusal.

Rejected: development-only reporting; free-text document-derived messages; silent bounded-validation fallback; one entrypoint throwing where another recovers.

### DEC-8 — The canonical AST, server parser, and conformance kit are public

**Reference:** `spec:AST-036/DEC-8`
**Direction owner:** `cixzhang`, `2026-09-16`

Exported canonical node types have public parse producers. A server-safe parser entry avoids a client reference. Shared conformance fixtures let third parties produce required evidence. Plugin declarations infer extension types without hand-written aliases or multiple explicit type arguments.

Rejected: canonical types with no public producer; parser APIs reachable only through a client barrel; evidence requirements with no shared kit; inference deferred to examples.

### DEC-9 — Publish the cost of the path plugins actually take

**Reference:** `spec:AST-036/DEC-9`
**Direction owner:** `cixzhang`, `2026-09-16`

First-party helpers may skip guards their construction proves, but their numbers do not describe third-party cost. Budgets therefore name helper and plugin-authored paths separately, include streaming, and constrain guard work to one traversal of each transform output with whole-document invariants collected once per pipeline run.

Rejected: publishing helper-path numbers as protocol overhead; unmeasured streaming; repeated whole-document invariant collection per transform.

### DEC-10 — Footnotes, definitions, and references stay Core scope

**Reference:** `spec:AST-036/DEC-10`
**Direction owner:** `cixzhang`, `2026-09-16`

These concepts require document-level ID allocation, collision handling, and back-reference navigation shared with Markdown identity. They remain unsupported and adapter-rejected until a separate Core syntax contract adds them; documentation names the gap.

Rejected: first- or third-party footnote plugins; adapter translation into ordinary links; leaving the limitation undocumented.

### DEC-11 — First-party plugins are ordinary plugins

**Reference:** `spec:AST-036/DEC-11`
**Direction owner:** `cixzhang`, `2026-09-22`

Soft breaks, heading links, configurable entity references, and callout examples use the public opaque plugin list, own their semantics and evidence in module records, and receive no public capability, exemption, ordering privilege, or type affordance unavailable to third parties. An internal composition seam or fast path is allowed only when its module record owns the result and the empty pipeline remains observationally unchanged.

Rejected: privileged first-party capabilities; demos that exercise private paths; a public protocol with no real first-party consumers.

### DEC-12 — A semantic fence outranks the host code renderer

**Reference:** `spec:AST-036/DEC-12`
**Direction owner:** `cixzhang`, `2026-10-09`

A host that supplies `components.code` usually does so to highlight ordinary code, and that host is the one most likely to also install fence plugins for diagrams, charts, or diffs. A fence proposal is the more specific claim: it names one language and the plugin that owns it, so it renders first and `components.code` renders every fence no plugin claims. A claimed fence whose renderer declines or fails falls back to the host's code renderer, so a reader still sees the code the way the host draws it. A product feature that gives a fence language meaning ships as a fence plugin, not as a branch inside a host's code renderer. Only a host that supplies both `components.code` and a fence plugin claiming the same language changes behavior: that language's fences render through the plugin. A host with only one of the two renders exactly as before.

Rejected: passing the proposal to `components.code` for each host to re-dispatch, which puts plugin rendering back inside a host component.

## Open questions

None. The contract is current; implementation and compatibility evidence remain pending.

## Content boundary

This record does not duplicate consumer signatures, examples, private dispatch/cache design, repository layout, first-party plugin behavior, or current audit results. Those belong to their canonical owners.

---

### Specs AST 037 Timer Spec (`docs/specs/AST-037-timer/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-037
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-22
phase: accepted
owners: [cixzhang]
affects_architecture:
  [
    architecture:public-component-api,
    architecture:component-theming-surface,
    architecture:react-component-runtime,
  ]
affects_families: []
affects_contributing: []
affects_consumer_docs: [Timer]
---

# Non-rendering elapsed Timer system spec

## Contract at a glance

| Area                                                                                                                     | Contract                                                                                                                                                                                                                                                                                                                        |
| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public contract                                                                                                          | Add `Timer`, `TimerProps`, `TimerFormat`, optional `startTime` in Unix milliseconds, closed `format` values `elapsed` and `clock`, Timestamp-equivalent `type`, `size`,                                                                                                                                                         |
| `color`, and `weight` typography, the root `HTMLTimeElement` ref, and the standard `BaseProps<HTMLTimeElement>` surface. |
| Behavior                                                                                                                 | Render a standardized elapsed duration from the chosen origin, update the owned `<time>` node without React tick renders, and schedule only when the selected format can visibly change.                                                                                                                                        |
| End-user impact                                                                                                          | People watching active work see a stable duration that scales from seconds through minutes and hours without unnecessary timer work competing with the surrounding interface.                                                                                                                                                   |
| Builder impact                                                                                                           | Builders use the zero-config elapsed format, supply an earlier operation start, or choose the standardized stopwatch-like clock format. They use the same                                                                                                                                                                       |
| optional typography props and defaults as Timestamp, and do not write duration                                           |
| formatters or choose timer cadence.                                                                                      |
| Compatibility                                                                                                            | Additive, not yet released. Runtime, accessibility, documentation, and theming evidence must land before release.                                                                                                                                                                                                               |
| Review checks                                                                                                            | Reject custom formatter or cadence APIs, React state updates for ticks, callback-count drift, one-second wakeups after elapsed output drops seconds, leaked timers, negative output, lost root passthrough/ref behavior, or more than one visible anatomy part.                                                                 |
| Governing rules                                                                                                          | [`architecture:public-component-api`](../../architecture/public-component-api.md); [`architecture:react-component-runtime`](../../architecture/react-component-runtime.md); [`architecture:component-theming-surface`](../../architecture/component-theming-surface.md); [AST-002 DEC-1, DEC-2, and DEC-5](../AST-002/spec.md). |

This table is a review projection; the body below is authoritative.

## Intent

Products need a shared elapsed-time primitive for operations whose duration
changes while visible. Its defining value is ownership of a clock-driven DOM
update that does not schedule a React render on each tick. Its formatting remains
consistent as a duration grows from seconds through minutes and hours, and its
resource cadence follows the precision a person can actually see.

Timer is distinct from Timestamp: Timestamp describes an instant and may present
relative calendar language, while Timer measures duration from an origin. It is
also distinct from Text: Text presents caller-owned content, while Timer owns the
changing duration value, standardized representation, and resource lifecycle.

### Component ledger

| Source requirement                                                                                         | Proposed target                | Why it belongs                                                                                                                                                                     | Done criteria                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Show elapsed time during active work without periodic React render work or product-local formatting drift. | Stable Core `Timer` component. | The behavior repeats across loading, processing, recording, and activity surfaces; products should not rebuild formatting, lifecycle, drift handling, or cadence at each callsite. | Closed formats, format-aware scheduling, direct owned-node updates, clock-derived duration, cleanup, ref and passthrough behavior, theming target, consumer docs, stories, and focused tests conform to this contract. |

## Ownership boundary

AST-037 owns Timer's public elapsed-origin and format concepts, representation
ladder, format-aware cadence, non-rendering tick requirement, semantic duration
output, resource lifecycle, and initial Core admission. The Timer component record
owns the concrete component projection and evidence after implementation.
`architecture:public-component-api` owns export, BaseProps, styling, ref, and
caller-choice rules. `architecture:react-component-runtime` owns Effect and
resource lifecycle correctness. `architecture:component-theming-surface` owns
target qualification.

## Non-goals

- Loading indicators, waiting messages, status labels, or when a product chooses to
  show or hide elapsed time.
- Wall-clock dates, relative calendar phrases, time zones, or absolute instants.
- Pause, resume, deadlines, alarms, lap history, or imperative timer controls.
- Countdown is deferred to a later contract; standardized duration formats and
  cadence remain reusable if Timer later admits an end-time origin.
- Sub-second display precision, arbitrary formatter callbacks, or caller control of
  the internal scheduling cadence.
- Announcing every tick to assistive technology.
- A new global token or component-specific theme variable.

## Public concepts

| Concept           | Closed values or states                     | Meaning                                                                       | Default                                                          | Invalid-value behavior                                     |
| ----------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------- |
| Elapsed origin    | Mount time or `startTime` Unix milliseconds | The instant from which elapsed duration is derived                            | Mount time                                                       | A non-finite value falls back to mount time                |
| Duration format   | `elapsed`, `clock`                          | Standardized visible representation and its implied precision                 | `elapsed`                                                        | Types reject other values; runtime falls back to `elapsed` |
| Tick presentation | Current elapsed value on one `<time>` root  | Visible text and machine-readable duration at the selected format's precision | `0s` for elapsed; `0:00` for clock before client synchronization | Clock values before the origin clamp to zero               |

`startTime` is caller-owned because Timer cannot derive when an operation began
before Timer mounted. `format` is caller-owned because compact prose and a
stopwatch readout are distinct presentation needs. Formatting details and
scheduling cadence remain Timer-owned: callers choose meaning, not machinery.

## Standard formats

| Elapsed duration              | `elapsed` | `clock`   |
| ----------------------------- | --------- | --------- |
| zero                          | `0s`      | `0:00`    |
| 34 seconds                    | `34s`     | `0:34`    |
| 2 minutes, 8 seconds          | `2m 08s`  | `2:08`    |
| 1 hour, 2 minutes, 33 seconds | `1h 02m`  | `1:02:33` |

`elapsed` displays at most two units, largest first. Seconds are unpadded when
they are the only unit, padded after minutes, and omitted after hours. `clock`
uses colon-separated stopwatch notation: `m:ss` below one hour and `h:mm:ss`
from one hour onward.

## Requirements

- **FR1 — One semantic root.** Timer MUST render one `<time>` root whose visible
  text uses the selected standard format.
- **FR2 — Derivable origin default.** With no `startTime`, elapsed time MUST begin
  from the component's mount lifetime. With a finite `startTime`, elapsed time MUST
  derive from that Unix-millisecond origin.
- **FR3 — Clock-derived, non-negative duration.** Timer MUST subtract the resolved
  origin from the current clock, clamp negative duration to zero, and derive output
  from that result. It MUST NOT accumulate elapsed time from callback count, so a
  delayed callback catches up without cumulative drift.
- **FR4 — No tick renders.** Clock ticks MUST update the owned `<time>` node
  directly and MUST produce zero React update commits for Timer and its surrounding
  subtree. Parent renders caused by unrelated work remain outside Timer's control.
- **FR5 — Machine-readable parity.** Timer MUST update `dateTime` to an ISO 8601
  duration representing the same precision as the visible value: whole seconds for
  `clock` and for `elapsed` below one hour, whole minutes for `elapsed` at or above
  one hour.
- **FR6 — Current props without duplicate resources.** A changed `startTime` or
  `format` MUST take effect without remounting and without creating more than one
  active timer resource.
- **FR7 — Complete lifecycle ownership.** Each mounted Timer owns at most one active
  browser timer resource. Every Effect setup MUST release the resource it acquired
  during dependency change, node replacement, unmount, and StrictMode replay. No
  tick may update a detached or replaced node.
- **FR8 — Stable public DOM and typography surface.** The root MUST expose
  `Timer`, `TimerProps`, `TimerFormat`, its `HTMLTimeElement` ref, Text-family
  `type`, `size`, `color`, and `weight`, and the standard DOM, data, ARIA, style,
  class, event, and `xstyle` inputs admitted by `BaseProps<HTMLTimeElement>`.
  Typography resolution and defaults MUST match Timestamp: `type="supporting"`,
  `size="supporting"`, `color="secondary"`, and `weight="normal"`.
- **FR9 — No unsolicited live announcements.** Timer MUST NOT add a live-region
  role or `aria-live` value by default. A caller may deliberately provide an ARIA
  attribute through the standard DOM surface for a context that needs it.
- **FR10 — One themeable anatomy part.** The visible root MUST carry the `timer`
  theming target. No subpart, format, timing state, or scheduling mechanism receives
  a separate target.
- **FR11 — Initial render is deterministic.** Initial markup MUST show the selected
  format's zero value with `dateTime="PT0S"` without reading the client clock into
  server-visible markup. Client synchronization then applies the chosen origin.
- **FR12 — Format-aware cadence.** `clock` and `elapsed` below one hour MUST schedule
  at the next whole-second boundary. `elapsed` at or above one hour MUST schedule at
  the next whole-minute boundary because its visible output omits seconds. A future
  origin MUST schedule directly toward the first visible change rather than waking
  every second while output remains zero. A callback that runs late MUST schedule
  from the current clock, not from the missed deadline.
- **FR13 — Bounded DOM writes.** Timer SHOULD skip visible-text and `dateTime` writes
  when the selected format's represented value has not changed.

### Transformation and precedence

Resolve the finite caller origin or mount origin, subtract it from the current
clock, clamp to zero, floor to the selected format's current precision, format the
value, update visible and machine-readable output on the same owned node, then
schedule the next boundary where that format can change. Timer owns `dateTime`;
standard consumer attributes and styling otherwise reach the root.

### Platform support

- Supported feature/engine floor: unchanged from the current Core package support.
- Unsupported behavior: environments without Effects retain the selected format's
  deterministic zero value and `PT0S`, and acquire no timer resource.
- Browser evidence: real-browser stories verify both format ladders, the hour
  boundary, stable inline geometry, semantic duration parity, and absence of
  component update commits.

## Current-state impact

- Core gains one stable elapsed-duration component and type, two closed formats,
  Timestamp-equivalent typography props, one `timer` theming target, consumer
  documentation, Storybook coverage, and a showcase block.
- Timestamp remains unchanged and continues to own instants, calendar-relative
  language, time zones, and absolute formatting.
- The default path adds no builder choice. Only operations that began before mount
  supply `startTime`; only stopwatch-like surfaces choose `format="clock"`.
- Custom formatting and timer cadence are intentionally not public API.
- No existing component, export, DOM structure, theme, token, or callsite changes.

## Verification

| Contract           | Verification                                                                    | Representative states                                                                                                                             | Mutation or failure expectation                                                                                                                                                                                                |
| ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR1–FR5, FR11–FR13 | Focused Timer tests with a controlled clock and fake timers                     | Initial, default/earlier/future origin and future scheduling, delayed callback, each format ladder, 59:59→1:00:00 boundary, elapsed hour boundary | State-driven ticks, callback-count accumulation, negative values, needless pre-origin wakes, wrong padding/unit ladder, stale `dateTime`, wrong initial markup, redundant writes, or one-second post-hour elapsed wakeups fail |
| FR4                | React Profiler commit-count test                                                | Several elapsed ticks and a composed sibling                                                                                                      | Replacing owned-node mutation with React state produces extra update commits                                                                                                                                                   |
| FR6–FR7            | Resource spy tests under rerender, unmount, and StrictMode                      | Origin change, format change, replay, cleanup                                                                                                     | Duplicate or leaked timer resources fail setup/cleanup counts and detached-node guards                                                                                                                                         |
| FR8                | Public import, DOM passthrough, typography, styling merge, event, and ref tests | Root import, Timestamp defaults, each typography override, and representative BaseProps                                                           | Missing exports; divergent defaults; or dropped typography, attributes, handlers, class/style inputs, or root ref fail                                                                                                         |
| FR9                | Accessibility attribute tests                                                   | Default and caller-declared live behavior                                                                                                         | Timer adds unsolicited live semantics or drops deliberate caller ARIA                                                                                                                                                          |
| FR10               | Theming-target and knowledge checks                                             | One root target across formats                                                                                                                    | Missing, extra, or misplaced target mapping fails repository validation                                                                                                                                                        |
| Browser contract   | Storybook browser evidence                                                      | Default elapsed, clock, earlier origin, hour-scale elapsed, composed wait message                                                                 | Visible output, semantic duration, geometry, or update-commit behavior differs from the contract                                                                                                                               |

## Decision log

### DEC-1 — Timer owns non-rendering elapsed-time updates

**Reference:** `spec:AST-037/DEC-1`  
**Direction owner:** `cixzhang`, `2026-09-22`

Timer is a stable shared primitive for elapsed time. It owns one clock-derived
duration and writes changing text directly to its owned DOM node so clock ticks do
not schedule React renders. Builders may supply an earlier start instant or choose
a standardized format; they do not supply a formatter or control scheduling.

Rejected: implementing each tick through React state. That makes every Timer a
periodic render source and increases contention in trees already processing live
application updates.

Rejected: exposing an interval, tick frequency, or arbitrary formatter. Those
choices prevent the component from maintaining consistent duration language and
from reducing work when visible precision drops.

### DEC-2 — Timer starts in Core

**Reference:** `spec:AST-037/DEC-2`  
**Direction owner:** `cixzhang`, `2026-09-22`

Timer starts in Core because its purpose is a stable cross-product runtime
primitive, its first contract is intentionally narrow, and the requested behavior
is already operationally proven. Core admission requires the complete evidence in
this spec before release; it does not waive normal component quality gates.

Rejected: shipping independent product copies or making the non-rendering behavior
an application recipe. That would duplicate representation, resource ownership,
and cadence decisions across callsites.

### DEC-3 — Formats own their visible precision and cadence

**Reference:** `spec:AST-037/DEC-3`
**Direction owner:** `cixzhang`, `2026-09-22`

Timer exposes two standard representations: `elapsed` for compact prose and row
metadata, and `clock` for stopwatch-like readings. Each format owns its precision,
so the runtime wakes only at the next boundary capable of changing visible output.
In particular, elapsed durations at or above one hour omit seconds and wake on
minute boundaries.

Rejected: scaling cadence from elapsed age while continuing to display seconds.
That would leave visible output stale. Precision changes first; cadence follows it.

## Open questions

None. The contract is current; implementation evidence remains pending.

## Content boundary

This record does not duplicate consumer examples, implementation code, private
scheduling mechanics, current measurements, or system runtime/theming rules. Those
belong to their canonical owners.

---

### Specs AST 038 Layer Text Boundary Spec (`docs/specs/AST-038-layer-text-boundary/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-038
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-26
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:layer-runtime]
affects_families: []
affects_contributing: []
affects_consumer_docs: [Layer, useLayer, Dialog]
---

# Layer inherited-text and grouping boundary system spec

## Intent

People opening a dialog, menu, tooltip, or other floating reading surface must
not receive centered, italic, uppercase, indented, or unwrappable body text merely
because its trigger lives in a formatted paragraph, heading, table cell, or code
region. A layer starts a new reading task while retaining the surrounding theme
and language context. Native top-layer promotion changes painting, not CSS
inheritance, so the boundary must be explicit. Nor is the layer another member
of an ancestor visual group: buttons and inputs inside it must not inherit that
group's connected borders, sizing, density, or other presentation defaults.
The trigger retains its group styling; the newly opened surface starts its
own scope. Surface/group membership stops as a whole under FR7/FR8; unrelated
contexts and explicitly authored props and accessibility semantics are preserved.

The original contract was **approved by `cixzhang` on 2026-09-23** (DEC-1–DEC-3).
On the same date, `cixzhang` corrected its scope to **text/layout/visual isolation
only** (DEC-4). Approved DEC-5 removes the requirement to separate visual fields:
surface/group membership stops as a whole under FR7/FR8. Unrelated ARIA, focus,
keyboard ownership, dismissal, semantic/data/interaction contexts, and authored
DOM/accessibility-tree semantics remain unchanged. `cixzhang` approved DEC-4 and
authorized its spec-only merge on 2026-09-23. [Implementation PR #6457](https://github.com/facebook/astryx/pull/6457)
may align to this accepted contract. Neither this record nor test success
authorizes implementation merge. Architecture
projects the decision rather than independently approving it.

### Ownership

AST-038 owns the closed inherited-text baseline, the ancestor visual-group
boundary, participation rule, precedence, and compatibility decision. It is distinct from
[AST-003](../AST-003/spec.md), whose Non-goals explicitly exclude visual treatment.
AST-003 remains the owner of accepted hosting and interaction changes; this spec
neither duplicates those requirements nor makes its unimplemented changes current.

[Layer runtime](../../architecture/layer-runtime.md) describes shipped hosting
mechanisms and will project the approved text boundary when implemented. Its
existing approval cannot authorize this new cross-component styling decision.
[Component style authoring](../../architecture/component-style-authoring.md),
[theme application](../../architecture/theme-application.md), and
[public component API](../../architecture/public-component-api.md) continue to
own lowering, live theme context, and existing override seams respectively.
Current records and open work for the affected layer paths, `layerTextReset`,
`inherited text`, and `AST-038` were checked; the overlapping implementation is
PR #6457, not an independent approved contract.

## Non-goals

- Redesigning hosting, portals, positioning, modal semantics, focus, dismissal,
  selection, scrolling, motion, or which layers appear above native dialogs.
  Stopping ancestor visual styling and whole membership under FR8 is in scope;
  unrelated interaction, collection membership, disabled/selected state,
  labels/descriptions, and accessibility semantics are not changed. In particular, do not neutralize `aria-disabled`, change
  accessibility-tree disabled inheritance, add semantic roles, or introduce
  corrective portal hosting to satisfy this styling contract.
- Standardizing foregrounds, backgrounds, geometry, component type roles, or
  deliberate typography inside a layer; changing global document resets or
  non-Astryx elements; neutralizing ancestor text-decoration propagation.
- New component props, reset opt-outs, public reset utilities, theme targets,
  tokens, wrappers, or runtime style-copying. An existing explicit styling seam
  remains the way to request deliberate formatting.
- New accessibility contracts, visual harness products, or test infrastructure.
  Verification proves this styling contract; it does not authorize workflow
  additions or modifications, or changes to unrelated integration evaluators.
- Equivalent internal implementations remain valid. Private module names and
  sharing mechanisms belong in architecture or implementation, not this contract.

## Requirements

### Closed baseline

**FR1 — Neutral reading baseline.** Every participating content boundary MUST
establish exactly these inherited-property defaults before component and caller
styles. Unlisted inherited text properties are not reset by FR1.

| Property          | Baseline                   | Reason                                                        |
| ----------------- | -------------------------- | ------------------------------------------------------------- |
| `font-family`     | `var(--font-family-body)`  | Do not borrow a code or heading face.                         |
| `font-size`       | `var(--text-body-size)`    | Start ordinary content in the active theme's body role.       |
| `line-height`     | `var(--text-body-leading)` | Preserve the theme's body reading rhythm.                     |
| `font-weight`     | `var(--text-body-weight)`  | Do not borrow trigger emphasis.                               |
| `font-style`      | `normal`                   | An emphasized ancestor must not italicize the task.           |
| `text-align`      | `start`                    | Start at the reading edge, including RTL.                     |
| `text-align-last` | `auto`                     | Do not inherit last-line justification.                       |
| `text-indent`     | `0`                        | Do not inherit paragraph or icon-replacement indents.         |
| `text-transform`  | `none`                     | Preserve authored case.                                       |
| `letter-spacing`  | `normal`                   | Do not inherit heading tracking.                              |
| `word-spacing`    | `normal`                   | Do not distort reading or intrinsic size.                     |
| `text-shadow`     | `none`                     | Do not borrow decorative text shadows.                        |
| `white-space`     | `normal`                   | Permit ordinary wrapping under preformatted/nowrap ancestors. |
| `word-break`      | `normal`                   | Leave exceptional word breaking to the content owner.         |
| `overflow-wrap`   | `normal`                   | Leave long-token wrapping to the content owner.               |
| `hyphens`         | `manual`                   | Do not inherit automatic hyphenation.                         |

**FR2 — Theme and writing context survive.** The boundary MUST preserve
`direction`, `writing-mode`, `text-orientation`, language, theme/custom-property
values, `color-scheme`, font feature/variation settings, and accessibility
preferences. Scoped layout/presentation values fall under FR7 even when carried
by custom properties; this does not authorize clearing properties by prefix or
resetting semantic theme tokens. Theme is not an ancestor layout provider.
Theme body tokens MUST remain live when a local theme changes. This does not
promise more context recovery than the existing hosting contract provides.

**FR3 — Surface, behavior, and accessibility ownership survive.** Color/background pairs,
geometry, display, positioning, animation, opacity, visibility, cursor,
`pointer-events`, `touch-action`, and `user-select` MUST retain their existing
owners. ARIA attributes, focus, selection, disabled/read-only state, callbacks,
keyboard ownership, dismissal, semantic/data/interaction contexts, and DOM/AX
semantics MUST remain unchanged except for membership-owned values that stop
with their whole context under FR8. Do not otherwise change event routing or
roving focus because content is visually independent. Inverted Tooltip and Toast colors, Lightbox captions, and click-through
notification/non-modal hosts must not be changed by the text baseline. The
[container-padding boundary](../../architecture/container-padding.md) is separate.
`text-decoration` is not inherited; descendant `none` cannot remove decoration
propagated from an ancestor and is not part of FR1.

### Participation ledger

**FR4 — A reading surface, not a dismissal role.** Inclusion requires a
content-bearing Astryx surface presented outside its owner's normal flow through
Layer, native Dialog, or an equivalent layer host. Modal, non-modal, fallback, and
inline-preview presentations of the same surface MUST share the baseline.
Compositions receive it at their existing content boundary, not their trigger.
A nested layer establishes its own boundary.

| Surface family                 | Participating paths                                                                                                                                                                                                                                                                                         | Why / done criteria                                                                                                      |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Layer-backed content           | Context/anchor, context/custom, and fixed Layer; Popover/usePopover; Tooltip/useTooltip; HoverCard/useHoverCard; ContextMenu; DropdownMenu submenus                                                                                                                                                         | The common boundary neutralizes ambient formatting in every renderer without changing geometry or hosting.               |
| Popover compositions           | DropdownMenu, MoreMenu, NavHeadingMenu, TabMenu, TopNav menus, collapsed SideNav items and heading menus, BreadcrumbItem menus; Selector/MultiSelector/ComplexSelector, Typeahead/BaseTypeahead, date inputs, PowerSearch, ChatComposerInput menus, Table filtering; Lab TourStep, ChatEmojiPicker, InfoTip | Inherit the common reset; no duplicate reset or trigger/API changes.                                                     |
| Layer utility surfaces         | Keyboard hints, Carousel controls, Tokenizer overflow, chart/radial tooltips, ListInput/TransferList drag previews                                                                                                                                                                                          | Shared host supplies the baseline; authored hint/chart nowrap and component typography still win.                        |
| Dialog family                  | Modal and inline Dialog, AlertDialog, imperative Dialog/AlertDialog, CommandPalette                                                                                                                                                                                                                         | Native and preview paths agree; component and caller styling stays stronger.                                             |
| Independent viewers/navigation | Lightbox, MobileNav including AppShell, Lab Drawer in modal and non-modal modes                                                                                                                                                                                                                             | Each independent root participates; media/caption treatment and interaction stay unchanged.                              |
| Sheets                         | Standalone and switched BottomSheet, including adaptive menu/input presentations                                                                                                                                                                                                                            | The painted content panel owns the boundary, not its transparent shell or each child.                                    |
| Notifications                  | Promoted and non-top-layer ToastViewport; LayerProvider and imperative fallback                                                                                                                                                                                                                             | The viewport supplies default/custom toast text without resetting page children or changing colors and pointer behavior. |

Consumers of Tooltip/HoverCard (including Button, Link, Text, Timestamp, Avatar,
StatusDot, and field explanations) require no independent trigger change.
Exclusions: in-flow ChatComposerDrawer, Overlay/OverlayScrim, local chart
crosshairs and zoom controls, empty Tour highlight/scrim, and portal-only Token
or Stepper mounts. The Tour Popover callout participates. Moving DOM alone does
not turn ordinary content into a reading layer.

### Precedence and compatibility

**FR5 — Explicit styling wins.** Apply the baseline on the content-bearing root
only, before component styles and supported theme/consumer overrides. Never
reset descendants. Deliberate headings, code, centered captions, bold emphasis,
nowrap hints, and Tooltip/Toast word-breaking rules MUST remain effective.
Existing `xstyle`, `className`, and `style` precedence MUST not be reordered.

**FR6 — No new choice or public surface.** There is no reset opt-out prop, public
reset helper/export, new theme target, or token. Callers that intentionally relied
on inherited formatting must express it on an existing root styling seam or on
their content (including an explicit `inherit` where supported). This is a visible
compatibility change for those callers and must be stated in consumer guidance
and release notes. Normal theme-body content must not acquire an unrelated visual
change; selected component type roles remain component-owned.

### Ancestor layout and grouping boundary

**FR7 — Ancestor layout and presentation stop at the layer boundary.** All
providers' scoped layout, visual, density, sizing, grouping, or presentation
adjustments MUST stop at the content boundary by default; a surface/group
membership context stops as a whole under FR8. This
includes general SizeProvider, not only size supplied by ButtonGroup/InputGroup,
and applies to **future providers by responsibility**, not a fixed allowlist of
names or today's evidence ledger. This direction is approved in DEC-2.

The trigger/control remains in its ancestor scope. Layer content must not gain
ancestor-group connected corner/border/separator treatment, visual orientation,
density, or sizing merely from DOM or React ancestry. The boundary applies
whether presentation is carried by context, selectors/markers, or scoped
structural custom properties. A CSS text reset alone does not satisfy it.
Theme and writing context remain covered by FR2. Semantic roles and orientation,
disabled/selected state, accessible naming, collection membership, callbacks,
and interaction behavior MUST retain their existing ownership and values,
except for whole surface/group membership that stops under FR8.

**FR8 — Whole surface/group membership; unrelated ownership survives.** Unrelated
semantic, data, interaction, and accessibility providers MUST retain their
existing inheritance and owner protocols.

Layout/visual/presentation providers intentionally targeting content inside the
layer MUST be established separately inside/across its content boundary. Those
providers govern their descendants up to the next layer boundary. Explicit child
props continue to work; theme and writing context remain preserved by FR2.
These rules apply to future providers by responsibility, not name.

When FR7 applies to surface/group membership carried by one context, that
existing whole value stops; visual fields are not separated. Membership-owned
state, including disabled/read-only state, stops with it. Unrelated contexts,
explicitly authored props, and existing public Context/Provider shapes remain
unchanged. No public opt-out or particular private mechanism is implied.

A context's field types alone do not establish that it is presentation-only.
[AvatarGroup](../../../packages/core/src/AvatarGroup/AvatarGroupContext.ts)
stores visual size/shape/overlap values, but its presence also determines
[Avatar's](../../../packages/core/src/Avatar/Avatar.tsx) tooltip `tabIndex` and
focusability. That group membership stops as a whole under FR7/FR8.
[LayoutArea](../../../packages/core/src/Layout/LayoutAreaContext.ts) selects
semantic DOM elements as well as dividers and spacing; its semantic element
selection remains unchanged.

The layer does not clear all React context or overwrite every descendant's
geometry. A grouped trigger stays visually grouped and an explicit inner visual
group works normally. Unrelated semantic ownership remains unchanged.

These examples illustrate the accepted generic distinction, not a closed list
of exceptions:

| Surface owner                                                                                                                                           | Explicit owner protocol that survives                                                                                                                                                                                                                         | Ancestor presentation that stops                                                                                                                                                        |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [DropdownMenu](../../../packages/core/src/DropdownMenu/DropdownMenu.tsx) and [submenu](../../../packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx) | Radio selection for that menu, its close chain, and menu-owned sizing explicitly established for its item/submenu surface. The current menu establishes its own context around the rendered content; the submenu composes its close callback with the parent. | Outer ButtonGroup visual styling and ambient SizeProvider/visual defaults. Menu-owned sizing is deliberately established by the surface owner, not a general sizing-provider exemption. |
| [Selector](../../../packages/core/src/Selector/Selector.tsx) / [Typeahead](../../../packages/core/src/Typeahead/BaseTypeahead.tsx)                      | The owner's options/results, active option, search query, and selection callbacks remain connected to its own popup. Current code carries these through local state/closures as well as component props; no new context is required by this example.          | Outer InputGroup sizing, connected borders/radii, and other group visuals do not style popup controls. The input/trigger itself remains grouped.                                        |
| [Table filter](../../../packages/core/src/Table/plugins/filtering/useTableFiltering.tsx)                                                                | Column identity, filter value/draft, and apply/clear behavior belong to the filter surface. Current code establishes a draft FilterStoreContext inside the popover and passes the column key explicitly.                                                      | Outer table/row density, dividers, alignment, and other scoped presentation do not become filter-panel defaults. The filter surface may establish its own local layout.                 |

The table filter's explicit inner store is a content-provider example. A
SizeProvider or ButtonGroup deliberately placed inside a layer likewise applies
to that content, but the same provider around its trigger does not. A nested
layer starts another boundary and requires its own explicit content provisioning.
Chart data, form models, and layer dismissal coordination retain their existing
inheritance and owner protocols; whole surface/group membership follows FR8.

### Grouping evidence and scope ledger

The audit inspected grouping providers/consumers on the proposed text-only
implementation, with source cross-checks against base
`1d940e032cab3c45d404a40f0ce8502db5b98059`. ToggleButton's newer action handling
was inspected separately and retains the same group membership path.
Read-only static-render probes used the text-only implementation at
`12991d841df03c6455ba669a026373d55c68bc45` plus its scope-removal working changes.
Static rendering proves the stated props/associations, **not browser geometry**.
No group-boundary implementation is claimed by this audit; implementation follows
the accepted rules below. The ledger records evidence, not an allowlist limiting
FR7's application to future layout or presentation providers. Observations are
historical; the proposed boundaries follow the whole-membership rule in FR8.

| Owner / evidence seam                                                                                                                                                                                                                                                                                                                                                                              | Actual mechanism and observation                                                                                                                                                                                                                                                                                                                                                                                                         | Proposed boundary / verification                                                                                                                                                                                                                               |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [ButtonGroup](../../../packages/core/src/ButtonGroup/ButtonGroup.tsx), [Button](../../../packages/core/src/Button/Button.tsx)                                                                                                                                                                                                                                                                      | React context supplies orientation/disabled; shared SizeContext supplies size. Button applies its own sibling-sensitive radius/separator rules and suppresses standalone elevation/press treatment. Static-render probes under inline Dialog and fixed Layer produced an inner button with `disabled` and `data-size="lg"` from the outer group. No group ancestor marker or group-specific inherited CSS variable supplies these rules. | When FR7 applies, whole group membership stops with visual orientation, size, connected corners/separators, elevation treatment, and group-owned disabled state. Preserve the grouped trigger and explicit inner ButtonGroup.                                  |
| [InputGroup](../../../packages/core/src/InputGroup/InputGroup.tsx), [groupStyles](../../../packages/core/src/InputGroup/groupStyles.ts), [TextInput](../../../packages/core/src/TextInput/TextInput.tsx)                                                                                                                                                                                           | Context carries membership and label/description IDs; SizeContext carries size. Consumers apply 100% height/flex, negative border overlap, sibling corners, and skip their normal Field wrapper. Both probes gave inner TextInput `lg` and an accessible label composed from **outer and inner** labels. The context has no disabled/status boolean: do not invent a blanket state reset.                                                | When FR7 applies, outer sizing, connected-border/layout presentation, and group-owned label/description associations stop with whole membership. Preserve explicitly authored labels, Field semantics, status/disabled/read-only props, and local InputGroups. |
| [AvatarGroup context](../../../packages/core/src/AvatarGroup/AvatarGroupContext.ts), [Avatar](../../../packages/core/src/Avatar/Avatar.tsx)                                                                                                                                                                                                                                                        | Context carries size/shape/overlap; Avatar adds ring/negative overlap and changes its tooltip tab stop. Both probes produced `lg`/`square` inside the layer without local group ownership.                                                                                                                                                                                                                                               | When FR7 applies, whole group membership stops, including size/shape/ring/overlap and membership-owned tooltip focusability. Preserve explicit props and unrelated interactions; geometry still needs browser proof.                                           |
| [ToggleButtonGroup](../../../packages/core/src/ToggleButton/ToggleButtonGroup.tsx), [ToggleButton](../../../packages/core/src/ToggleButton/ToggleButton.tsx)                                                                                                                                                                                                                                       | Context carries selected values, toggle callback, size, and disabled state. Both probes produced a pressed `lg` inner toggle from the ancestor group. Source routes activation to that group's callback when `value` is present.                                                                                                                                                                                                         | When FR7 applies, whole group membership stops, including size, selected values, disabled state, and group callback ownership. Preserve explicitly authored props/callbacks and local groups.                                                                  |
| [SizeContext](../../../packages/core/src/SizeContext/SizeContext.ts)                                                                                                                                                                                                                                                                                                                               | `sizeProp ?? inherited ?? default`; providers include ButtonGroup/InputGroup and Toolbar/SideNav. The value has no provenance distinguishing an explicit general SizeProvider from a group-supplied default.                                                                                                                                                                                                                             | All ambient SizeProvider defaults stop, regardless of origin (DEC-2, OQ2 resolved). Explicit control props and providers established inside the layer continue to work there.                                                                                  |
| [Collapsible group contexts](../../../packages/core/src/Collapsible/CollapsibleGroupContext.tsx)                                                                                                                                                                                                                                                                                                   | Separate state and presentation contexts. Collapsible already clears presentation around its children; state remains deliberately separate.                                                                                                                                                                                                                                                                                              | Preserve that existing guard. Verify any bypass route before changing it; do not treat the state context as merely a divider style.                                                                                                                            |
| [ListContext](../../../packages/core/src/List/ListContext.tsx), [MetadataListContext](../../../packages/core/src/MetadataList/MetadataListContext.tsx)                                                                                                                                                                                                                                             | Source consumers inherit density/dividers/list markers and metadata orientation/label layout, respectively; no generic layer guard was found. No pixel or interaction reproduction is claimed for these paths.                                                                                                                                                                                                                           | Equivalent presentation candidates: confirm a supported independent-layer composition before adding implementation work. Existing groups inside a layer remain owners.                                                                                         |
| [RadioList](../../../packages/core/src/RadioList/RadioList.tsx), [CheckboxList](../../../packages/core/src/CheckboxList/CheckboxListContext.tsx), [SegmentedControl](../../../packages/core/src/SegmentedControl/SegmentedControlContext.ts), [TabList](../../../packages/core/src/TabList/TabListContext.ts), [menu radio group](../../../packages/core/src/DropdownMenu/DropdownMenuContext.tsx) | These contexts own collection values, callbacks, availability, roles/names, and sometimes sizing. Several child APIs require their provider and throw without it. Source reachability is not evidence that every cross-layer composition is supported or accidental.                                                                                                                                                                     | Preserve unrelated or content-local ownership, and ownership where FR7 does not apply. Under FR7, the whole ancestor surface/group membership value stops; required consumers need their complete provider established inside the layer.                       |

ButtonGroup's roving-focus boundary already excludes nested groups/popovers;
Button/InputGroup trailing-edge selectors skip `[popover]`/`template` siblings.
They protect the **outer trigger's** membership, not descendant controls inside
the popup. Keep those pre-existing fixes unchanged. Generic
[`useListFocus`](../../../packages/core/src/hooks/useListFocus.ts) item filtering
or event-ownership changes based on a new layer-content marker are outside this
visual-reset scope and MUST be reverted from the implementation. Keyboard
ownership, including existing nested-group/popover exclusions, remains unchanged.
Source searches also inspected FormLayout,
Table/Stepper, and navigation contexts/markers. Their scoped layout/presentation
responsibilities fall under FR7, whether or not named in this audit; their
semantic/data/interaction/accessibility responsibilities must remain unchanged.
SegmentedControl also uses inherited private
`--_segmented-control-radius`/`--_segmented-control-padding` values for item
corners; Tab/TabMenu consume `--_tab-indicator-bottom` from strip/container
geometry. AvatarGroupOverflow's private overlap value is authored on its own
root. These are concrete structural channels to inspect, not reasons to erase
every custom property. RadioList indicator and Tab hover/active markers style
local interaction feedback. Scoped indicator/row markers are not group context
barriers; no observed marker leak is claimed without a matching descendant and
browser-state reproduction.

### Existing normalization: consolidate only equivalents

**FR9 — Prior fixes are evidence, not a deletion list.** A normalization may move
into the shared boundary only when its value, target, cascade precedence, and
supported states are equivalent. Local type roles, wrapping, colors, geometry,
and ownership guards MUST remain component-owned when that equivalence is absent.

| Existing code                                                                                                                                          | Finding and disposition                                                                                                                                                                                                                                                                                           |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [useLayer](../../../packages/core/src/Layer/useLayer.tsx) base typography                                                                              | Existing body family/size/leading are the one equivalent baseline moved into the private shared reset by the text-only implementation. No token/value or precedence change for those three declarations is intended.                                                                                              |
| [Tooltip](../../../packages/core/src/Tooltip/useTooltip.tsx) painted surface                                                                           | Explicit body type, inverted colors, and content `wordBreak: break-word` are deliberate. Keep them; moving inner declarations outward can change supported overrides even where values match.                                                                                                                     |
| [Toast](../../../packages/core/src/Toast/Toast.tsx)                                                                                                    | Uses body **defaults** for size/leading, surface inversion, and content `overflowWrap: anywhere`. This is not equivalent to replacing them with inherited live body variables. Keep them.                                                                                                                         |
| [Lightbox](../../../packages/core/src/Lightbox/Lightbox.tsx), menu rows, keyboard hints, chart/radial tooltips                                         | Centered large captions, menu label/supporting type and start alignment, smaller nowrap keyboard hints, and compact chart wrapping remain intentional local presentation. Do not delete them as redundant resets.                                                                                                 |
| [useLayer portal writing context](../../../packages/core/src/Layer/useLayer.tsx), [padding reset](../../../packages/core/src/Layout/padding.stylex.ts) | Corrective portals preserve lost direction/writing mode without snapshotting tokens. The existing padding boundary resets its own structural variables. Preserve both; neither is a generic group or text barrier.                                                                                                |
| Button/InputGroup end-cap rules and Collapsible/BottomSheetSwitcher context guards                                                                     | Already solve distinct membership/ownership problems. Preserve them; the text boundary does not replace sibling classification, nested presentation isolation, or sheet registration scope.                                                                                                                       |
| `all: unset` on Item/TreeListItem/Collapsible/Stepper/Token/Thumbnail/ChatComposer controls                                                            | Local UA/control resets, not layer-root inheritance fixes. No new `all`/`unset`/`revert` layer reset is proposed. ToastViewport's `inset: unset` is geometry; menuWidth's `unset`/`revert` strings are caller-supplied width keywords routed to inline-size rather than `min()`, not a general inheritance reset. |

The audit found no existing general ButtonGroup/InputGroup context barrier in
Layer or the independent native roots. React portals preserve context, and
native top-layer painting does not break selector ancestry. The grouped-control
leaks above therefore survive the proposed text-only reset.

### Implementation requirements

- **IR1 — Bounded styling, not a sandbox.** Use supported component-local CSS
  declarations. Do not use `all`, the `font` shorthand, universal/descendant
  resets, global `[popover]`/`dialog` selectors, or copying computed ancestor styles.
- **IR2 — Minimum boundary wiring.** Share the baseline internally where package
  boundaries permit; ensure equivalent declarations at independent roots without
  creating public API solely to share styles. Do not change runtime effects,
  wrappers, portals, interaction logic, or composed callsites unnecessarily.
- **IR3 — Scope and authority stay separate.** Architecture and concise consumer
  documentation project this accepted decision; they do not repeat an independent
  policy. Acceptance does not claim shipped behavior or authorize a merge.
  Verification must not be used to broaden implementation scope.
- **IR4 — Inclusive responsibility, bounded implementation.** Provider isolation
  must be private, ownership-aware, and consistent across FR4 content roots.
  Apply FR7 by responsibility, including future scoped layout/visual providers,
  not a fixed reset allowlist copied from this audit. Do not wipe unrelated
  contexts or custom properties. When FR7 applies, whole surface/group membership
  stops under FR8 without separating visual fields. No DOM wrapper, public boundary
  API, semantic role, ARIA neutralization, portal, or general CSS reset is implied;
  verification must cover visual isolation and unchanged unrelated behavior/accessibility.

### Platform support

- Supported feature/engine floor: the existing
  [browser support policy](../../../packages/cli/assets/docs/browser-support.doc.mjs)
  applies; this spec raises no engine floor. Native hosting still follows its
  own support contract.
- Unsupported behavior: the existing display fallback must retain the same text
  baseline. It is not upgraded to native top-layer, positioning, or dismissal
  equivalence by this change.
- Browser evidence: actual CSS inheritance, theme cascade, wrapping, and pixels
  require real Chromium; emitted declarations in a DOM unit environment are not
  sufficient visual proof. No Safari-specific claim is made from Chromium output.

## Current-state impact

After implementation, `architecture:layer-runtime` will project the
baseline ownership and link this spec. Layer, useLayer, and Dialog consumer
guidance will explain intentional formatting at the root/content, and the
changeset will describe the compatibility impact. No family membership,
contributor policy, theme-application semantics, or AST-003 claim changes.

## Verification

| Contract     | Verification                                                                                               | Representative states                                                                                                                           | Mutation or failure expectation                                                                                                                                              |
| ------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR4     | Existing real-browser visual regression and computed-style evidence over all supported layer content roots | Both Layer renderers, Dialog native/inline, independent native roots, sheets standalone/switched, Toast promoted/hosted, Drawer modal/non-modal | Removing a boundary's baseline restores inherited formatting in the browser comparison.                                                                                      |
| FR2, FR5     | Unit override controls plus real-browser cascade evidence                                                  | Nested/light/dark themes, LTR/RTL and vertical writing, live body tokens; authored child emphasis/code and direct root overrides                | A broad reset, descendant rule, frozen tokens, or reversed precedence fails the preserved-context/style control.                                                             |
| FR1, FR6     | Real-browser before/after pixels retained with the implementation PR                                       | Normal theme-body DOM and a deliberately hostile centered/italic/uppercase/indented/spaced/shadowed/preformatted ancestor; parent and submenu   | Restoring inherited alignment produces visibly centered hostile text; normal baseline remains unchanged. Label a mutation as a regression control, not as historical source. |
| FR3, FR4     | Existing component behavior tests and browser inspection                                                   | Positioning, focus/dismissal, caption and inverted colors, click-through viewport/non-modal host                                                | Removed or reordered component styles fail existing controls; no broader interaction contract is inferred.                                                                   |
| FR6, IR1–IR3 | Diff review, existing lint/type/build/knowledge checks                                                     | Export map and barrels, reset declarations, architecture projection, changeset                                                                  | New public reset API, broad selectors, workflow changes, or an approval claim without owner approval fails review.                                                           |

Use existing visual regression for presentation and established component tests
for FR7/FR8 behavior.
Check visual size, density, connected corners/separators, overlap, and layout
under hostile outer groups and SizeProvider defaults. Include native/inline
Dialog, existing inline/correctively-portaled context Layer paths, fixed Layer,
and independent roots without adding hosting paths. Keep the outer trigger
visually grouped and explicit inner visual/layout providers working. Verify a
future-style scoped presentation provider by responsibility, not only today's
names; nested layers stop ambient presentation again.

Behavior and accessibility checks preserve unrelated behavior and explicit props,
except for membership-owned values that stop under FR8. Compare ARIA,
labels/descriptions, focus/tab stops, keyboard
ownership, disabled/read-only and selected/pressed state, callback identities
and results, dismissal, and DOM/AX semantics with the existing behavior. Keep
unrelated semantic/data/interaction contexts live and unchanged. Preserve
LayoutArea's semantic DOM elements and `useListFocus`'s existing item/event ownership.
Preserve menu radio selection/close, Selector/Typeahead active
option/search/selection, and Table filter draft/apply behavior. Verify that
membership-owned state stops with its context while explicitly authored disabled
or selected props remain effective; no new accessibility-tree boundary is required.

Real-browser checks establish presentation and preserved behavior; static
rendering cannot prove pixels. Removing a visual isolation barrier must restore
the matching visual leak. Changing unrelated semantic fields, ARIA, keyboard routing,
hosting, or callbacks must fail preservation controls. FR9 retains local
text/wrapping and existing group-tail/keyboard guards unchanged.

Browser
screenshots and their provenance belong in PR evidence; this styling decision
does not require a dedicated accessibility spec, committed story matrix, or new
workflow/artifact wiring. Test success is evidence, not owner approval.

## Decision log

### DEC-1 — Accepted neutral reading boundary

**Reference:** `spec:AST-038/DEC-1`
**Decider:** `cixzhang`, `2026-09-23`

Approved the closed FR1 baseline at FR4 boundaries, with FR2/FR3 preservation,
FR5 precedence, and FR6's compatibility/no-new-API contract. This makes the same
reading task independent of incidental trigger formatting without erasing its
theme or intentional content styling.

Rejected: Dialog-only treatment (other layers still inherit); resetting all CSS
(destroys unrelated style/context); resetting document-wide dialog/popover
selectors (changes non-Astryx UI); preserving all ambient typography (retains the
reported failure); an opt-out prop or public reset utility (adds a permanent
caller choice where existing styling seams suffice).

### DEC-2 — Accepted inclusive layout-provider boundary

**Reference:** `spec:AST-038/DEC-2`
**Decider:** `cixzhang`, `2026-09-23`

Approved FR7: all surface-level layout, visual, density, sizing, grouping, and
presentation providers stop at the layer content boundary by default, including
general SizeProvider and future providers with those responsibilities. This is
not a fixed allowlist. The trigger stays in its surrounding group; independent
layer content does not implicitly join its visual styling. DEC-4 clarifies that
semantic membership and behavior remain unchanged. Theme and writing context survive.
OQ2 is resolved: no distinction between group-origin and general ambient size
permits either to leak across the boundary.

Rejected: text CSS alone (does not stop context membership); portals alone
(preserve React context and change hosting); limiting isolation to today's named
groups (future presentation providers would leak); resetting every descendant's
borders/radii (destroys valid content-owned groups).

### DEC-3 — Accepted explicit surface/content protocol

**Reference:** `spec:AST-038/DEC-3`
**Decider:** `cixzhang`, `2026-09-23`

**Historical record, narrowed by DEC-4:** The original FR8 wording required
explicit owner protocols for semantic/data/interaction providers and separately
provided layout/visual content contexts. It also applied that rule to mixed
providers. That wording conflated presentation and semantic membership.
DEC-4 removes the semantic-state reset requirement; it preserves existing
semantic inheritance and narrows explicit content provision to visual/layout
ownership. FR9's preservation of non-equivalent local styling still applies.

### DEC-4 — Visual reset only; behavior and accessibility unchanged

**Reference:** `spec:AST-038/DEC-4`
**Decider:** `cixzhang`, `2026-09-23`
**Status:** Approved by `cixzhang` on 2026-09-23, with authorization to merge
this spec correction only. That approval covered DEC-1–DEC-4; the historical
mixed-provider rule below is amended by approved DEC-5.

Corrected the scope: **VISUAL RESET ONLY**. Preserve ARIA, focus, selection,
disabled/read-only state, callbacks, keyboard ownership, dismissal,
semantic/data/interaction contexts, and DOM/AX semantics exactly. The visual
boundary isolates text, size, density, visual grouping, borders/radii/separators,
and scoped layout properties only. Its requirement to separate visual ownership
from mixed providers is superseded by approved DEC-5.

This supersedes any semantic-reset interpretation of DEC-2/DEC-3 and the earlier
FR7/FR8 evidence ledger. `aria-disabled` neutralization, new AX-disabled isolation
assertions, semantic group roles, and corrective portal hosting are excluded.
Verification proves visual isolation plus unchanged behavior/accessibility;
it must not invent a new a11y contract. The FR1 theme-body metrics remain
unchanged; this is not a revival of the rejected neutral-document-metric proposal.

Rejected: clearing a mixed context wholesale and rebuilding selected semantics
at popup callsites; changing disabled/selected state to demonstrate isolation;
adding roles/ARIA/portals to make a styling boundary an accessibility boundary;
changing focus or keyboard ownership because a surface is visually independent.

### DEC-5 — Remove the visual-context separation requirement

**Reference:** `spec:AST-038/DEC-5`
**Decider:** `cixzhang`, `2026-09-24`
**Status:** Approved by `cixzhang` on 2026-09-26.

When FR7 applies to surface/group membership carried by one context, that
existing whole value stops; visual fields are not separated. Membership-owned
state, including disabled/read-only state, stops with it.
Unrelated contexts and explicitly authored props remain unchanged.
This supersedes conflicting separation and membership-preservation
clauses in DEC-1–DEC-4; all other scope and the FR1 baseline remain unchanged.

## Open questions

None. OQ1–OQ3 remain historical decisions. Approved DEC-5 amends the requirement
to separate visual context; neither spec approval nor validation authorizes
implementation merge.

---

### Specs AST 039 Spec (`docs/specs/AST-039/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-039
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: implementing
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:cli-conventions]
affects_consumer_docs: [authoring]
---

# Integration contribution descriptor system spec

## Intent

Every discoverable item that an Astryx integration contributes has one local,
strongly typed description beside the item it describes. A contributor can
understand, author, validate, move, and package one item without editing a
second catalog elsewhere in the package.

The canonical descriptor is `<source-stem>.doc.mjs`. It is the only source of
per-item metadata. The integration manifest locates contribution roots and
holds integration-level capabilities; it does not catalog the items below a
root. There is no central catalog of items anywhere, in any form (FR11): 0.6
shipped one for themes by mistake, and it had to be removed as a breaking
change.

## Non-goals

- Remove a released compatibility reader before the normal breaking-change and
  migration process permits it.
- Make runtime integration features such as `debug` or `gapReport` into item
  contributions. Their typed named-export contract remains owned by
  `spec:AST-031`.
- Put build receipts, generated output manifests, or consumer runtime data into
  authoring descriptors.
- Require a separate executable source file for a reference doc whose
  `.doc.mjs` is itself the complete payload.
- Execute contributed source during discovery to learn its metadata.

## Requirements

- **FR1 — Every discoverable item owns one canonical descriptor.** A new or
  newly authored component, template, theme, doc topic, codemod, or future item
  contribution MUST have exactly one `<source-stem>.doc.mjs` descriptor. When
  the item has a primary source or payload file, the descriptor MUST be beside
  it and MUST use the same stem. A reference doc is both descriptor and payload.
- **FR2 — Descriptors are strongly typed authoring modules.** Every descriptor
  kind MUST export a public TypeScript type from
  `@astryxdesign/cli/authoring`, use that type in a JSDoc annotation, and
  expose its value through the kind's canonical export (the stamped default
  export for a new descriptor kind). Discovery MUST parse the loaded value through the kind's public authoring parser at the load
  boundary. A new kind is incomplete without its type, parser, self-doc, and
  parser tests.
- **FR3 — The descriptor is the sole item-metadata authority.** Per-item names,
  labels, descriptions, readiness or maintenance state, source relationship,
  and copy metadata MUST NOT be duplicated in a root JSON catalog, an array in
  `astryx.integration.*`, or another package-level registry. The integration
  manifest MAY locate roots and declare integration-level metadata or runtime
  capabilities. It MUST NOT enumerate the items under a root. Per-item options,
  such as `replaces` and `extends`, belong in the item's own descriptor.
- **FR4 — Pair identity is structural.** Discovery MUST derive the primary
  source path and any source-bound runtime export identity from the shared
  descriptor/source stem when that relationship is structural. The descriptor
  owns the stable public item identity. A component descriptor's `name` is the
  runtime export it documents: when the same-stem source has no runtime export
  named after the stem, that `name` MAY name another runtime export of the
  source, and discovery records the component under that name. Each full entry a
  component descriptor lists in `components` is a component under its own name
  when the module the descriptor's import names exports it. When the pack
  lifecycle writes the file that import names, `integration verify` compares
  that descriptor's members as discovery lists them after the pack. Every
  surface (FR7) lists, finds, and verifies these components by those names, and
  a source that exports its stem keeps the stem. A name discovery records only
  this way MUST NOT take an exact unscoped lookup that Core, a legacy docs
  package, or a component named after its source answers, and the stem of a
  descriptor recorded under another name yields the same way to Core and to a
  component named after its source: such a lookup returns that owner's component
  unless a declared replacement claims the name, a lookup scoped to the package
  returns the package's component, and Doctor reports a recorded name Core
  shares. An exact name, including one discovery records this way, takes
  precedence over Core's keyword fallback. Discovery MUST reject a missing
  source, more than one eligible same-stem source, an invalid inferred export
  name, or a descriptor whose authored identity conflicts with its required
  directory identity. A template answers to its display name too when that
  name holds every word of the component it shows; a variant label alone does
  not.
- **FR5 — Multi-file items have one confined ownership boundary.** A kind whose
  item needs nested source, token, palette, asset, or receipt files MUST define
  one item-local copy and pack boundary. Discovery and authoring MUST enumerate
  that boundary without a second catalog, keep every path inside it, and copy
  the complete owned set. A missing local dependency, an escaped path, and an
  unexpected symlink target MUST fail before consumer writes.
- **FR6 — Discovery does not execute contributed payload source.** Loading a
  descriptor follows the established doc-module boundary. Executable component,
  theme, template, and codemod payloads MUST be inspected or resolved without
  running them during discovery. Static validation MUST reject type-only or
  missing runtime exports where a runtime binding is part of the contract.
- **FR7 — One discovery seam serves every downstream surface.** Listing,
  package ownership, ambiguity handling, authoring verification, Doctor,
  package inventory, packed-consumer verification, and materialization MUST
  consume the same normalized discovered record. A writer MUST verify its output
  through that seam rather than reimplementing the format.
- **FR8 — Authoring emits only the canonical form.** `astryx integration add`
  and its typed APIs MUST write `.doc.mjs`, the public JSDoc type annotation,
  and the conventional export. They MUST NOT create a new legacy alias, central
  item catalog, untyped object, or duplicated metadata source.
- **FR9 — Released aliases are compatibility inputs, not authoring precedent.**
  Readers MAY retain a released alternate suffix or combined module while the
  compatibility policy requires it. New writers and examples MUST use the
  canonical descriptor. Each retained alias MUST be isolated, tested as a
  compatibility path, and excluded from the design of new contribution kinds.
  Canary-only formats have no compatibility claim.
- **FR10 — Theme registration is the first clean application.** A theme root
  contains one directory per lower-kebab slug. That directory contains a theme
  source and mandatory same-stem `.doc.mjs`. `ThemeDoc` owns `name`,
  `displayName`, `description`, and `maintained`; the source entry and named
  runtime export come from the shared stem. The directory is the recursive copy
  and pack boundary. `themes/manifest.json` MUST NOT remain as a fallback
  reader. The catalog shipped in stable `0.6.3`, so its removal is a breaking
  change: it ships in a minor release with a codemod that turns each catalog
  entry into that theme's descriptor, and a themes root that still holds the
  catalog fails with a message that names the migration.
- **FR11 — Never catalog items centrally.** No change MAY add, for any kind, a
  file under a root that lists that root's items (`themes/manifest.json`, a
  `components/index.json`, or similar), a per-item map or list in
  `astryx.integration.*` (for example a map of template replacements or a list
  of themes), or a registry that authors keep in sync by hand. Adding one item
  MUST NOT change a file its siblings share; only the integration manifest and
  `package.json` may change, to declare a new root. Review MUST reject a change
  that adds a central catalog, and writer tests MUST fail on one.
- **FR12 — Documented component props agree with the source.** Governed by
  `architecture:cli-surface` INV33.
- **FR13 — Every descriptor satisfies its published authoring type.** Governed by
  `architecture:cli-surface` INV34.
- **FR14 — One command checks an integration, and can gate its build.** Governed by
  `architecture:cli-surface` INV35.

### Compatibility transition

The final rule applies to every kind. Existing released inputs move to it
without pretending they were never released:

- components and reference docs already use `.doc.*`; new authoring narrows to
  strongly typed `.doc.mjs` while released suffixes remain readable;
- templates keep their released `.template.*` reader, but new authoring and
  examples use the already-supported `.doc.mjs` pair;
- themes replace the JSON catalog, released in `0.6.3`, as a breaking change:
  no dual reader, and a codemod converts each package;
- combined codemod modules and inline `agentDocs` remain released compatibility
  inputs until their paired-descriptor migrations ship; they do not permit
  another contribution kind to omit a descriptor.

## Current-state impact

This specification owns a distinct boundary that no current system spec covered.
`spec:AST-031` explicitly excludes default-manifest item contributions, while
`architecture:cli-surface` describes the implementation but cannot authorize a
new cross-kind authoring rule.

This specification replaces the catalog claim in
`architecture:cli-surface/INV19` with one mandatory per-theme descriptor. It
adds a general descriptor invariant to the CLI architecture and contributor
checklist. The theme change updates public authoring types, discovery, bundled
assets, integration authoring, package inventory, pack verification, consumer
docs, and real provider-to-consumer tests.

The contract does not change `DefineThemeInput` or `DefinedTheme`. Theme package
metadata stays outside Core runtime authoring, consistent with
`architecture:theme-authoring-contract/INV11`.

## Verification

| Contract | Verification                                                                                                                       | Representative states                                                                                                                                      | Mutation or failure expectation                                                                                                                                                                                                                |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1–FR4  | authoring parser, discovery, and writer tests                                                                                      | valid pair; missing/duplicate source; bad identity; malformed/missing doc                                                                                  | a source without its typed same-stem descriptor becomes visible, or duplicated metadata can win                                                                                                                                                |
| FR4      | discovery, lookup, search, Doctor, and pack-check tests                                                                            | a doc naming another export of its file; a file exporting its stem; a family member the import exports; one it does not; a recorded name Core also has     | one surface misses a component's name, a doc named after its file reads differently, or an unscoped lookup Core answers returns another package's component                                                                                    |
| FR5–FR7  | contribution inventory, pack-check, materialization, and real-world integration tests                                              | nested files; symlink/escape; missing import; local vs packed; duplicate package slug                                                                      | a nested file is omitted, an escaped file is copied, or two surfaces disagree on ownership                                                                                                                                                     |
| FR6      | static source-graph and runtime-export tests                                                                                       | direct export; re-export; type-only export; source side effect                                                                                             | discovery executes source or accepts no runtime binding                                                                                                                                                                                        |
| FR8–FR9  | integration-add snapshots and compatibility fixtures                                                                               | new writer output; released template/codemod/agent guidance input                                                                                          | a writer emits an untyped or non-`.doc.mjs` item, or a compatibility reader becomes the new canonical form                                                                                                                                     |
| FR10     | bundled-theme, integration-theme, and provider-to-consumer tests                                                                   | all bundled themes; multi-theme package; list/add/build; no JSON catalog                                                                                   | removing a theme doc still passes, or restoring `manifest.json` becomes necessary                                                                                                                                                              |
| FR11     | integration-add writer tests; catalog codemod tests                                                                                | second item of a kind; leftover 0.6 catalog                                                                                                                | adding an item writes a shared file, or a leftover catalog is read instead of refused                                                                                                                                                          |
| FR12     | prop-drift unit tests; `doctor integration components` tests; a repository test over every shipped component descriptor            | each finding kind; a type written differently but equal; props declared in another package; no TypeScript installed                                        | a removed or renamed prop or a changed union passes unreported, or a missing compiler reads as a pass                                                                                                                                          |
| FR13     | doc type unit tests; `doctor integration validate` tests; a repository test over every shipped descriptor and translation          | each finding kind; a field hidden behind an unknown field; a descriptor in another kind's root; an annotation naming another type; no TypeScript installed | an unknown, missing, or mistyped field passes unreported, or a missing compiler reads as a pass                                                                                                                                                |
| FR14     | `integration check` text and JSON tests, with and without `--strict`; a test that lists every finding that fails it; a search test | a clean package; a warning only; an error in one check; no manifest; a skipped comparison; a search for ci                                                 | the default exit differs from a check's own, a warning or a skipped comparison passes `--strict`, text and JSON exit differently, a new failing finding is added without the list changing, or a search for ci does not find the command first |

## Decision log

### DEC-1 — Use one typed same-stem descriptor per contribution item

**Reference:** `spec:AST-039/DEC-1`
**Decider:** `joeyfarina`, `2026-09-12`

A contribution's description belongs beside the source or payload it describes.
One typed module gives editors and the CLI the same contract, makes the item
movable as a unit, and prevents a root catalog from drifting away from source.
The integration manifest remains a root locator and integration-capability
boundary.

Rejected: a central JSON catalog. It creates a theme-only authoring language,
duplicates source identity, and allowed the CLI to accept a format that does not
follow the repository's `.doc.mjs` convention.

Rejected: an optional descriptor layered over the JSON catalog. Two metadata
sources need precedence and drift rules and preserve the design defect.

### DEC-2 — Infer theme source identity and own the whole theme directory

**Reference:** `spec:AST-039/DEC-2`
**Decider:** `joeyfarina`, `2026-09-12`

A theme source and descriptor share one stem. That stem identifies the entry and
required named runtime export. The lower-kebab parent directory and `ThemeDoc.name`
agree on the CLI slug. The complete theme directory, including its descriptor, is
the copy and pack boundary, so nested files do not require an authored
allowlist.

Rejected: carrying `entry`, `exportName`, and `files` forward into `ThemeDoc`.
Those fields restate structure that discovery can prove directly and recreate
the catalog inside each file.

### DEC-3 — Preserve released readers without weakening new authoring

**Reference:** `spec:AST-039/DEC-3`
**Decider:** `joeyfarina`, `2026-09-12`

Stable `0.6.0` already reads template, codemod, and inline agent-guidance forms
that predate this rule. Compatibility readers remain until their normal
migration path permits removal. All new writers and new contribution kinds use
the canonical descriptor immediately. The theme catalog, which shipped in stable
`0.6.3` by mistake, receives no compatibility reader; it is removed as a
breaking change with a codemod (DEC-4).

Rejected: treating every historical form as a permanent equal convention. A
reader kept for compatibility does not define what the authoring system should
create next.

### DEC-4 — No central catalog or manifest map, for any kind

**Reference:** `spec:AST-039/DEC-4`
**Decider:** `joeyfarina`, `2026-09-24`

The theme catalog that `integration add theme` wrote in 0.6 was a bug: it put
per-item details in one central file, against the rule every other kind
follows. It reached stable `0.6.3` before anyone caught it. It is removed as a
breaking change, with a codemod that converts each package, and FR11 now
forbids the pattern outright so that no kind repeats it.

This supersedes `spec:AST-035/DEC-1`, a draft that keeps template replacements
as a map in the integration manifest: a template's `replaces` belongs in its own
descriptor.

Rejected: a manifest map for replacements only. It is the same central catalog,
one field at a time.

## Open questions

None.

---

### Specs AST 040 Consumer File Modification Spec (`docs/specs/AST-040-consumer-file-modification/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-040
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-20
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:cli-conventions]
affects_consumer_docs: [authoring]
---

# Evidence-based consumer file modification system spec

## Intent

People run Astryx commands such as `astryx upgrade --apply` on projects they
own. Those commands edit existing files in place. A consumer must be able to
trust that each edit targets content Astryx can prove it may change, that
generated, vendored, and dependency content is regenerated rather than edited,
and that anything Astryx could not change is reported instead of hidden.

This record owns what an operation may modify in an existing consumer file, what
counts as evidence, and how skipped or blocked changes are reported. It applies
to every operation that modifies or replaces an existing file in a consumer
project, including core, integration, and configuration codemods. For
`astryx upgrade`, it also owns which project the operation works on, which
installed Core it migrates to, how its result names files, and how it reports a
CLI too old to finish the migration.

## Non-goals

- Creating new files, such as template copies or scaffolding. Their write
  confinement stays with `architecture:cli-surface`, and template copy behavior
  stays with `spec:AST-028`.
- Defining individual migrations or their mappings. The
  [Release compatibility and versioning spec](../AST-017/spec.md) owns when a
  deprecation, planned cleanup, or incompatible fix requires migration evidence;
  this record owns how a consumer-file transform edits safely.
- Choosing a command for inspecting effective settings; `spec:AST-017` FR20
  requires one.
- Where other commands look for the project, including where `init` writes
  agent docs. FR9–FR13 govern `astryx upgrade` only.
- Equivalent internal implementations remain valid when they satisfy this contract.

## Requirements

- **FR1 — Every edit needs affirmative evidence.** An operation MUST modify
  existing consumer content only when the file is not protected (FR2–FR4) and the
  edited construct is proven in scope. For a source transform, proof is a
  resolved binding to the migrated export: a named, aliased, default, or
  namespace import, or a re-export, that is not shadowed at the edit site. A
  type-only import proves only type positions and never authorizes an edit to a
  runtime value. A matching name, string, or shape alone is not proof, and a
  file-level import does not authorize edits elsewhere in the file. When proof is
  missing, the content MUST stay unchanged and the candidate MUST be reported
  under FR7.
- **FR2 — Generated, vendored, and dependency content is protected.** An
  operation MUST NOT modify a file that is inside an installed dependency or
  version-control metadata, is outside the operation root, is reached through a
  symbolic link, is marked generated or vendored by a recognized convention
  (FR3), or is excluded by the project's ignore rules. A file or directory name
  alone neither protects nor unprotects a file.
- **FR3 — Recognized conventions are read from the checkout.** The CLI MUST
  recognize these declarations from files present in the working tree, without
  running a version-control program:
  - `linguist-generated` and `linguist-vendored` in `.gitattributes` files,
    including nested files;
  - a generated marker in the file's leading comment block: `@generated`,
    `@partially-generated`, or a line matching the standard
    `Code generated … DO NOT EDIT.` form. A marker after authored code does not
    protect the file, and `@partially-generated` protects the whole file;
  - exclusions in `.gitignore` files and in `.hgignore`.

  Each convention uses its own standard semantics, including later-rule
  precedence, explicit unset or false attribute values, and ignore-rule negation.
  The effective result for the file decides protection; Astryx configuration and
  integration contributions can only add protection to it (FR4). The result MUST
  be the same in Git, Sapling, and Mercurial checkouts and in a directory with no
  version control. The recognized set is public and discoverable under
  `spec:AST-017` FR14. It grows by amendment when a convention is established
  across ecosystems.

- **FR4 — Protection is additive and fails closed.** Project configuration and
  integration contributions MAY add protection under `spec:AST-017` FR19 and
  FR20. A protection setting requires a reproduced case that FR3 conventions
  miss. No configuration or contribution can remove protection established by
  FR2, FR3, or another source. When a protection source cannot be read or
  parsed, the operation MUST fail before writing, with a stable error that names
  the source.
- **FR5 — Generated output is regenerated, not edited.** When a required change
  would alter a protected generated file, the operation MUST leave that file
  unchanged, complete the in-scope edits to owned sources, and then run the
  regeneration the project declares. The CLI MUST regenerate output of its own
  generators itself, or report the exact command that does. After regeneration,
  it MUST evaluate the protected files again.
- **FR6 — A blocked required change is an incomplete result.** If a protected
  file still requires a change after FR5, the operation MUST exit nonzero with a
  stable error code and list every such file with the declaration that protects
  it. Completed edits to owned files remain. A protected file that needs no
  change never blocks. Running the same operation again after the project
  regenerates MUST NOT apply a completed edit twice. A dry run MUST report the
  same classification without writing.
- **FR7 — Every skipped candidate is reported.** The machine-readable result
  MUST list modified files, protected files that required a change (FR6), and
  candidates declined for lack of proof (FR1) with their location and reason.
  Human-readable output MUST show the same facts. Declined candidates are
  reported as needing manual review; they do not by themselves fail the
  operation. A result MUST NOT look complete while a required change is blocked.
- **FR8 — Every modification path follows this contract.** Core, integration,
  and configuration codemods, and any other operation that modifies existing
  consumer files, MUST apply FR1–FR7 identically. A new modification path cannot
  opt out, consistent with `spec:AST-017` FR16. The managed agent-docs block is
  governed by `architecture:cli-surface` INV31.
- **FR9 — An upgrade works on its project.** `astryx upgrade` MUST operate on
  the project of the folder it runs in: the nearest folder at or above that
  folder whose `package.json` declares an Astryx package, or that its workspace
  lists as a member when the workspace root's `package.json` declares one. A
  `package.json` declares an Astryx package when any of its dependency fields,
  peer dependencies included, names an `@astryxdesign/*` package or a legacy
  `@xds/*` one. A folder's workspace root is the one `spec:AST-048` FR15 finds
  from it, and the workspace lists the folder when a pattern in the root
  `package.json` `workspaces` field or in `pnpm-workspace.yaml` `packages`
  matches the folder and no exclusion pattern removes it. A folder that installs
  Core but qualifies neither way is not a project: an upgrade run there works on
  the project above it, if any. The search MUST NOT continue past a folder that
  holds version-control metadata, and MUST end at the home folder without
  counting it as a project or as a workspace root. The project root is the
  operation root (FR2): the upgrade reads the project's configuration, agent
  docs, and protection sources (FR3, FR4) there. When no folder qualifies, the
  folder the command runs in is the operation root.
- **FR10 — A named source folder is read where the command runs.** By default,
  an upgrade MUST scan the `src` folder of the operation root. A source folder
  the caller names MUST be resolved from the folder the command runs in, and
  MUST be inside the operation root; one outside it fails with
  `ERR_PATH_TRAVERSAL` before any write.
- **FR11 — A workspace member migrates to the Core its workspace installs.**
  The version an upgrade migrates to MUST be that of the Core
  (`@astryxdesign/core`, or the legacy `@xds/core`) the project installs: in the
  `node_modules` of the project root; for a member its workspace lists, in the
  nearest `node_modules` from the project root up to the workspace root; and for
  a project installed without `node_modules`, the copy its package manager
  resolves for it. A Core above a project that no workspace lists MUST NOT
  count. When no folder qualifies under FR9, the target is the Core in the
  `node_modules` of the folder the command runs in. With no installed Core, the
  upgrade fails with `ERR_VERSION_DETECT` before any write.
- **FR12 — The result names files from where the command runs.** The
  machine-readable result of an upgrade MUST name its modified, protected, and
  declined files (FR7), the files its errors name, its agent-doc files, and the
  files of its copied-composition summary by paths relative to the folder the
  command runs in, so each resolves from there. When the command runs at the
  operation root, the copied-composition summary instead names its files
  relative to the source folder the upgrade scanned. When the operation root is
  another folder, the human-readable output MUST name it.
- **FR13 — A CLI older than the installed Core reports an incomplete upgrade.**
  When the installed `@astryxdesign/core` is newer than both the running CLI and
  the version the upgrade starts from, the upgrade MUST run the codemods the
  running CLI has for the range, and its human-readable output MUST report the
  upgrade as incomplete and MUST NOT say the code is up to date. The
  machine-readable result MUST carry the optional `cliBehindCore` field, present
  only in this case, with the running CLI's version and one command that
  finishes the upgrade with the CLI release matching the installed Core. That
  command MUST work from any folder it is run in, upgrading the same project; it
  MUST repeat the run's options, MUST name the source folder the run scanned,
  and MUST quote each argument for the shell it targets. The human-readable
  output MUST print the command on a line of its own, and MUST name that shell
  unless it is a POSIX shell. This case does not change the exit status, or what
  `complete` and `errorCode` report under FR6.

### Platform support

- Supported feature/engine floor: every supported CLI runtime on Linux, macOS,
  and Windows; Git, Sapling, and Mercurial checkouts; directories with no
  version control; and the workspaces and install layouts of npm, pnpm, Yarn
  (Plug'n'Play included), and Bun.
- Unsupported behavior: a version-control declaration that exists only in
  repository metadata outside the working tree is not read; the project adds
  protection under FR4 when a reproduced case needs it.
- Browser evidence: not applicable.

## Current-state impact

Current codemod scanners skip a fixed list of directory names and never follow
symbolic links. They read no generated markers, attributes, or ignore files, so a
codemod can edit a generated file. Some transforms treat a file-level import as
permission to edit matching literals anywhere in that file. Both behaviors are
non-conforming with FR1–FR3; this record does not choose their implementation.

Post-codemod hooks already exist and serve as the declared regeneration for FR5.
The `authoring` guides and the codemod authoring guidance must
explain protection, how to mark generated files, and how transforms prove scope.

This specification-only change alters no runtime behavior or published package
and needs no Changeset.

## Verification

| Contract | Verification                                               | Representative states                                                                                                                                                                                                                | Mutation or failure expectation                                                                                                  |
| -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| FR1      | Transform fixtures                                         | named, aliased, default, namespace, re-exported, type-only in a type position and a value position, shadowed, wrong-package, unrelated literal                                                                                       | A same-named local or unrelated literal changes, or a proven usage is not edited                                                 |
| FR2–FR4  | Protection fixtures in each checkout kind                  | nested attributes, unset or false attribute, negated ignore, marker after code, partial marker, ignored path, symlink, config and integration additions, attempted removal, bad source                                               | A protected file changes, a result differs by version control, or a bad source allows writes                                     |
| FR5–FR6  | Upgrade runs with a generated file and a regeneration hook | hook succeeds, hook fails, dry run, second run                                                                                                                                                                                       | Protected bytes change, exit is zero with a blocked change, or a second run edits twice                                          |
| FR7      | Result snapshots in JSON and text                          | modified, blocked, declined                                                                                                                                                                                                          | A blocked or declined candidate is missing from either output                                                                    |
| FR8      | Inventory of every operation that modifies existing files  | core, integration, config codemods, new path                                                                                                                                                                                         | A path bypasses FR1–FR7                                                                                                          |
| FR9      | Upgrade runs from folders inside and around projects       | a subfolder; a listed and an unlisted workspace member; a folder whose `package.json` declares no Astryx package; a folder with no `package.json` inside a home folder that installs Astryx; a nested checkout; no qualifying folder | The upgrade operates on another project, the home folder, or a folder past version-control metadata                              |
| FR10     | Upgrade runs with a named source folder                    | the default from a subfolder; one folder named from a subfolder in each spelling; a folder outside the operation root                                                                                                                | A named folder read from another folder than the one the command runs in, or a folder outside the operation root that is scanned |
| FR11     | Upgrade target detection                                   | a member whose workspace forces Core, or that declares another range or only a peer range; an unlisted project below an installed Core; a Core in a parent folder; an install without `node_modules`; no qualifying folder           | A member that misses the Core its workspace installs, or a Core above an unlisted project that counts                            |
| FR12     | Result snapshots from a subfolder and from the root        | modified, protected, declined, error, agent-doc, and copied-composition paths                                                                                                                                                        | A path that does not resolve from the folder the command runs in, or a root run whose copied-composition paths change            |
| FR13     | Upgrade runs with a CLI older than the installed Core      | no codemods left; part of the range covered; each option; the printed command run inside the project, above it, and in an unrelated folder; a folder name with shell metacharacters in each targeted shell                           | An up-to-date or complete report, a dropped option, a command that misses the project elsewhere, or an unnamed non-POSIX shell   |

## Decision log

### DEC-1 — Edit only with affirmative evidence

**Reference:** `spec:AST-040/DEC-1`
**Decider:** `josephfarina`, `2026-09-23`

An upgrade rewrites a person's source without review of each edit, so every
edit needs proof. A resolved binding shows that the construct is the export being
migrated. Uncertain candidates are left alone and reported.

Rejected: matching by name, string, or shape, and treating a file-level import as
permission for the whole file. Both rewrite unrelated code silently.

### DEC-2 — Use established, VCS-neutral conventions

**Reference:** `spec:AST-040/DEC-2`
**Decider:** `josephfarina`, `2026-09-23`

Projects already mark generated and vendored content through attributes, file
headers, and ignore files. Reading those from the working tree gives the same
answer in any checkout and needs no new annotation.

Rejected: an Astryx-only marker, a skip flag as the main mechanism, running a
version-control program, filename conventions, and configuration without a
reproduced gap.

### DEC-3 — Regenerate, then report what remains

**Reference:** `spec:AST-040/DEC-3`
**Decider:** `josephfarina`, `2026-09-23`

Generated files follow their sources, so the operation edits sources, runs the
declared regeneration, and checks again. Anything still blocked makes the result
incomplete and names each file.

Rejected: editing generated output, silently skipping it, and stopping before
any edit, because regeneration needs the source edits.

## Open questions

None.

---

### Specs AST 041 Consumer Docs Audience Spec (`docs/specs/AST-041-consumer-docs-audience/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-041
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-09
phase: accepted
owners: [josephfarina]
affects_architecture: []
affects_families: []
affects_contributing: []
affects_consumer_docs: [is-my-code-good]
---

# Consumer documentation audience boundary system spec



```json
{
  "paths": [
    "packages/cli/assets/docs/**",
    "packages/**/*.doc.mjs",
    "packages/**/*.doc.*.mjs"
  ]
}
```

## Intent

Documentation that Astryx ships to callers serves people and agents building
products with Astryx. It reaches them through `astryx docs`, `astryx search`,
component, hook, and template docs, the `--json` API, generated agent guidance,
and the doc site. A caller must be able to act on everything they read there.
Material about building Astryx itself belongs in contributor knowledge.

This record owns the audience boundary for every doc delivered through those
consumer surfaces, including docs that integrations contribute.

## Non-goals

- Contributor documentation, knowledge records, and repository instructions.
- Doc structure, graph identity, and the quality bar for caller docs; the
  documentation contract owns those.
- Equivalent internal implementations remain valid when they satisfy this contract.

## Requirements

- **FR1 — Shipped docs are for callers.** Every section of a shipped doc MUST
  describe something a caller can use or rely on: purpose and alternatives, API
  and defaults, observable behavior and its guarantees, composition, costs and
  obligations the caller inherits, theming seams, migration, or quality criteria
  they can apply to a product surface or artifact they create with Astryx from
  public evidence.
- **FR2 — Maintainer process is excluded.** A shipped doc MUST NOT ask its reader
  to produce evidence for Astryx maintainers, meet Astryx's internal grading or
  promotion criteria, follow Astryx's contribution lifecycle, or run Astryx's own
  test and CI tooling. That material belongs in the contributor surface that owns
  the topic, or in the contributing guide when no specific surface exists. It
  never defaults back to a shipped doc.
- **FR3 — System guarantees stay, as guarantees.** A fact about the system's
  behavior that a caller can rely on MUST stay in shipped docs, stated as a
  guarantee to the caller. For example, "theme targets are stable once
  published" is caller-facing; "reviewers must check that theme targets are
  stable" is not. The fix for maintainer phrasing of a caller fact is a rewrite,
  not a move.
- **FR4 — Distribution audience is not reader role.** A doc's distribution
  audience selects which compiled bundle includes it, such as a public bundle or
  an internal bundle. It MUST NOT admit maintainer-process material into any
  bundle. An internal bundle is still for people building with Astryx.
- **FR5 — Enforcement measures the reader addressed.** An automated check that
  gates FR2 MUST classify content by the reader it addresses, not by the presence
  of a word. Before it gates, it MUST show zero findings on the shipped corpus
  and catch at least one real violation. A caller instruction that contains a
  flagged term, such as "animate transforms to keep compositor promotion", MUST
  pass. Each finding MUST name the doc and section and state where the material
  belongs. The check MUST offer an explicit, reviewable exemption for a
  legitimate caller-facing use.
- **FR6 — Contributed docs follow the same boundary.** Docs that an integration
  contributes to consumer surfaces MUST meet FR1–FR4. The integration authoring
  reference MUST state this rule.
- **FR7 — The shared caller rubric has one owner.** The shipped `is-my-code-good`
  guide MUST own the shared criteria for whether caller-created product code uses
  Astryx well. Specialized rubrics MAY add weights and artifact-specific checks,
  but MUST link to the shared criteria instead of redefining them. A caller rubric
  MUST use public inputs and MUST report missing evidence as uninspected, not as a
  pass.

### Platform support

- Supported feature/engine floor: every consumer doc surface listed in Intent.
- Unsupported behavior: none.
- Browser evidence: not applicable.

## Current-state impact

The shipped topic directory has a README that states the caller-action test,
common signs of maintainer writing, and where that material goes. Repository
instructions state the same audience. FR7 assigns the shared caller-facing
rubric to `is-my-code-good`; its guide and the specialized links are companion
implementation outside this specification-only change. No automated check
enforces the audience boundary yet. The authoring types declare a distribution
audience field with public and internal values for the docs graph; nothing reads
it yet. FR4 keeps that field separate from the reader boundary.

This specification-only change alters no runtime behavior or published package
and needs no Changeset.

## Verification

| Contract | Verification                                        | Representative states                                                                 | Mutation or failure expectation                                                                 |
| -------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| FR1–FR3  | Review of every shipped doc the compiler enumerates | component, hook, template, topic, agent guidance; caller fact phrased for maintainers | A section addresses maintainers, or a caller guarantee is removed instead of rewritten          |
| FR4      | Compiled public and internal bundle tests           | public doc, internal doc, maintainer material marked internal                         | Maintainer material appears in any bundle                                                       |
| FR5      | Check fixtures and a run over the shipped corpus    | clean corpus, real violation, caller instruction with a flagged term, exemption       | The corpus fails, the violation passes, the caller instruction fails, or an exemption is silent |
| FR6      | Integration doc fixtures                            | contributed topic that addresses maintainers                                          | A contributed doc bypasses the boundary                                                         |
| FR7      | Docs graph and rubric review                        | shared code rubric, specialized template scoring, missing rendered evidence           | Shared criteria have two owners, a specialization does not link, or missing evidence passes     |

## Decision log

### DEC-1 — The caller-action test defines the boundary

**Reference:** `spec:AST-041/DEC-1`
**Decider:** `josephfarina`, `2026-09-23`

Shipped docs reach agents and people who build with Astryx, and each extra
section costs their attention. The test is whether a caller acts on the
content. System guarantees pass that test even when they sound like process, so
they are rewritten for the caller, not moved.

Rejected: keeping maintainer material in shipped docs because it is accurate,
and moving caller guarantees out because they are phrased as process.

### DEC-2 — Enforce by the reader addressed, with measured precision

**Reference:** `spec:AST-041/DEC-2`
**Decider:** `josephfarina`, `2026-09-23`

A noisy check gets suppressed instead of acted on. A gate must show that it
catches real violations and passes caller instructions that share vocabulary
with maintainer process.

Rejected: a bare word list as a gate, and a distribution audience used as a
place for maintainer material.

### DEC-3 — Caller-owned quality review stays in shipped docs

**Reference:** `spec:AST-041/DEC-3`
**Decider:** `josephfarina`, `2026-10-09`

A person or agent building with Astryx can act on public criteria that evaluate
its own product code. The `is-my-code-good` guide owns the shared criteria;
artifact-specific guides link to it and add only their own checks or scoring.
Missing evidence remains visible instead of becoming a pass.

Rejected: treating every quality rubric as maintainer process, and copying the
shared criteria into each artifact-specific guide.

## Open questions

None.

---

### Specs AST 042 Cli Command Admission Spec (`docs/specs/AST-042-cli-command-admission/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-042
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-09-24
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:cli-conventions]
affects_consumer_docs: []
---

# CLI command admission and programmatic parity system spec



```json
{
  "paths": ["packages/cli/clients/cli/**", "packages/cli/api/**"]
}
```

## Intent

Agents learn the Astryx CLI from its help, its manifest, and their own notes, and
they script the same operations through the programmatic API. Each command,
subcommand, and option that ships becomes surface that agents depend on, and
removing one is a breaking change. Before writing code, a contributor needs to
know whether new behavior belongs in an existing command, a new option, a new
subcommand, or a new top-level command, and what each of those needs.

This record owns what each kind of CLI surface needs before it is added, the rule
that every command is a thin layer over one documented and tested programmatic
function, and the vocabulary that human-readable output may use.

## Non-goals

- Where command, API, adapter, and formatter code lives, and which modules may
  read the environment. `architecture:cli-surface` INV20–INV23 own that
  structure.
- Discoverability, environment variables, global options, and configuration.
  `spec:AST-017` FR14–FR20 own those rules.
- Classifying a removal, rename, behavior change, deprecation, or incompatible fix.
  [`spec:AST-017`](../AST-017/spec.md) owns compatibility and release lifecycle.
- The JSON envelope, error codes, and generated help. `architecture:cli-surface`
  INV2, INV3, and INV7 own them.
- Equivalent internal implementations remain valid when they satisfy this contract.

## Requirements

- **FR1 — Every command is a thin layer over one programmatic function.** Every
  command or subcommand that performs an operation MUST perform it by calling one
  function exported from `@astryxdesign/cli/api`. Called with the same inputs,
  that function MUST perform the same operation and return the same result that
  the command emits under `--json`. The command adds only argument parsing,
  rendering, and exit status; rendering MAY use how the CLI was invoked, such as
  the package manager's run prefix, to phrase hints. It MUST NOT read project
  state, change files, contact the network, or produce a result field or effect
  that the function does not produce. A command that only groups subcommands is exempt, and so is a
  command that only describes the CLI itself, such as help, the version, or the
  capability manifest. This requirement names `@astryxdesign/cli/api` because
  automation depends on it under `spec:AST-017` FR14.
- **FR2 — The programmatic API is documented and tested.** Every function
  exported from `@astryxdesign/cli/api` MUST have reference documentation that
  states its parameters, each option and its default, its result types, the error
  codes it can return, and at least one example. Tests MUST exercise each
  documented option, each result type, and each documented error code, and MUST
  fail when that behavior is removed. Every command MUST also have tests that run
  the CLI and check its `--json` result, its human-readable output, and its exit
  status, for at least one success and one failure.
- **FR3 — Human-readable output uses one shared vocabulary.** Human-readable
  output MUST use only the block kinds that generated help documents: Record,
  Section, List, Text, and Code. A Text block carries prose. Items and field
  values MUST use Record, Section, or List blocks, never columns or tables that a
  command builds from characters itself. Content authored in a documentation
  source, such as a heading or a table in a reference doc, follows the same rule.
  A command MUST NOT define an output format of its own. When no existing block
  can express an output without losing information a reader needs, such as a
  table too large to read as records, the answer is a new shared block kind, not
  a command-specific renderer. A new block kind changes the output of every
  command, so it MUST be justified by that evidence, MUST be available to every
  command, and MUST appear in generated help in the same change.
- **FR4 — New surface is admitted by tier.** Each addition MUST pass the test for
  its tier before it merges.
  - **Top-level command.** A new `astryx <command>` MUST cite a current system
    specification that authorizes it, approved by an approval owner (an owner listed in the knowledge schema's `approvalOwners`). That specification MUST state the
    question a consumer or agent has that no existing command answers; the
    existing command closest to answering it, and why extending that command is
    wrong; the one job the command does, stated without "and"; why its result is
    data rather than prose, which belongs in a docs topic; and the function that
    FR1 requires.
  - **Subcommand.** A new subcommand MUST stay inside its parent command's one
    job. Its pull request MUST state the question it answers, why an option on
    the parent or on an existing subcommand cannot answer it, and the function
    that FR1 requires. A code owner of the CLI package MUST approve it.
  - **Option.** A new option on an existing command MUST meet FR5 and
    `spec:AST-017` FR14–FR15. Normal review is enough.

  Behavior that fails the test for its tier is not admitted at that tier. It
  becomes an option, a subcommand, a docs topic, or an improvement to an existing
  command instead.

- **FR5 — Options change what a command produces, predictably.** An option MUST
  narrow or redirect work that its command already does, and MUST NOT give the
  command a second job. Its default MUST be the right result for most callers. A
  boolean option MUST default to off; behavior that is on by default gets a named
  opt-out, not a flag that defaults to true. An option that changes only how the
  same result is presented is a global presentation option and MUST meet
  `spec:AST-017` FR15. An option name MUST mean the same thing, spelled the same
  way, on every command that has it, and no command must carry an option because
  a sibling has it. An option MUST NOT exist to work around a defect. Every pair
  of options on a command MUST have a decided result: they compose and a test
  proves it, they are refused together with a stable error code, or another rule
  already prevents the pair.
- **FR6 — Moving surface is an admission.** Renaming a command, moving behavior
  into a new command, or promoting a subcommand to a top-level command is an
  admission at the new tier and MUST pass that tier's test. Removal and renaming
  also follow `spec:AST-017` FR1–FR8 and FR31: a removal lands in a declared minor
  with its `[breaking]` Changeset, and when the next release is a patch the command is
  deprecated first (FR28).
- **FR7 — Exact-lookup admission classifies batching first.** Before code is written,
  every new command or subcommand with an exact selector position MUST classify that
  position against `spec:AST-053` FR11 E1-E5. An eligible surface MUST use AST-053's
  batch contract in its first public release. An ineligible top-level command MUST cite
  a current command-owning record that names the failed condition; a draft does not
  admit the exclusion. An ineligible subcommand MUST name the condition in its
  admitting pull request and still follows FR4's code-owner approval bar. After typed
  admission metadata lands, its CommandDoc MUST retain the same classification.
  Existing eligible commands follow AST-053's incremental migration rule. A hidden
  option MUST NOT make batching opt-in or bypass this classification.

### Platform support

- Supported feature/engine floor: every supported CLI runtime.
- Unsupported behavior: none.
- Browser evidence: not applicable.

## Current-state impact

`architecture:cli-surface` INV20–INV23 carry FR1–FR3 into the code: the layout of
an API subject, the adapter as a subject's only access to the environment, thin
command handlers, and the closed formatter kit. `contributing:cli-conventions`
restates FR4 and FR5 for contributors. It does not yet restate FR7, and CommandDoc
has no typed AST-053 eligibility field; projecting the rule there and adding the
repository check are known enforcement gaps that land separately.

The CLI has 17 top-level commands. The command docs of 28 commands and
subcommands name the function they call. Known gaps, for which this record does
not assign migrations:

- the component, discover, layout, search, template, and theme handlers read
  project state or files themselves (FR1);
- the theme handler draws its target table from characters, and the docs
  handler renders authored headings and tables with its own code (FR3); both
  are candidates for one shared block kind under FR3, not two command
  renderers;
- several API subjects read or write the environment outside an adapter, and the
  `integration` subject keeps flat modules without leaves
  (`architecture:cli-surface` INV20–INV21);
- the reference docs of 14 exported functions, most of them in the
  `integration` subject, do not state the error codes they can return (FR2);
- the structure check confirms that each API subject has at least one reference
  doc, typedef file, and test, but not that each exported function or command is
  covered (FR2).

No mechanical check enforces FR1, the per-function part of FR2, FR3, FR4, FR5,
FR7, `architecture:cli-surface` INV21, or most of INV22 and INV23 yet. Review
applies them to every new change; until mechanical checks land, a new violation
fails only in review. The checks and the closure of the gaps above land as separate
changes.

This specification change alters no runtime behavior or published package and
needs no Changeset.

## Verification

| Contract | Verification                                                                                                          | Representative states                                                                        | Mutation or failure expectation                                                                                                  |
| -------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| FR1      | Command inventory from the manifest, matched to `@astryxdesign/cli/api` exports; paired CLI and API runs on one input | each executable command and subcommand; a group; the manifest                                | A command has no exported function, or emits a field or effect that its function does not                                        |
| FR2      | Reference-doc inventory for every export; per-function tests; command-level CLI tests                                 | each option, result type, and documented error code; one success and one failure per command | Removing a documented behavior leaves every test passing                                                                         |
| FR3      | Text-output snapshots per command; the help snapshot                                                                  | each block kind; a list of items; a result shaped like a table                               | A command prints a layout outside the documented blocks, or a new block kind is missing from help                                |
| FR4, FR6 | The admitting pull request and the authority it cites                                                                 | top-level command, subcommand, option, rename, promotion                                     | A top-level command lands without a current specification, or a subcommand lands without its stated case and code-owner approval |
| FR5      | Option composition tests per command                                                                                  | each option pair; a boolean default; a shared option name                                    | A pair has no decided result, a boolean defaults to on, or one name has two meanings                                             |
| FR7      | Admission review against AST-053 E1-E5 and the evidence for its tier                                                  | eligible new lookup; top-level and subcommand exclusions; draft                              | An eligible surface is single-only, or an exclusion lacks the evidence its tier requires                                         |

## Decision log

### DEC-1 — Commands are thin layers over the programmatic API

**Reference:** `spec:AST-042/DEC-1`
**Decider:** `josephfarina`, `2026-09-24`

One function owns each operation. An agent that runs the command and an agent
that scripts the API get the same result, and the behavior is tested once, where
it lives. The command only turns arguments into a call and a result into output.

Rejected: behavior implemented in the command handler, and features that only the
command offers. Both drift from the API, and automation cannot reach them.

### DEC-2 — One output vocabulary for every command

**Reference:** `spec:AST-042/DEC-2`
**Decider:** `josephfarina`, `2026-09-24`

Agents grep the text output and people read it. One small set of blocks keeps
every command's output predictable and keeps it a view of the JSON result.

Rejected: per-command renderers and tables drawn from characters. They drift from
the JSON result and from each other, and each one is a format that agents must
learn separately.

### DEC-3 — Admission bars scale with permanence

**Reference:** `spec:AST-042/DEC-3`
**Decider:** `josephfarina`, `2026-09-24`

A top-level command is a permanent concept in every agent's working set, so it
needs a current specification from an approval owner. A subcommand lives inside
one command's job, so a stated case and a code owner's approval are enough. An
option narrows work a command already does, so the option rules and normal review
are enough.

Rejected: one bar for every addition, which either blocks small options or lets
top-level commands in too easily; and adding a top-level command to make behavior
easier to find, because help and docs make behavior discoverable under
`spec:AST-017` FR14.

### DEC-4 — Exact lookups classify batching during admission

**Reference:** `spec:AST-042/DEC-4`
**Decider:** `josephfarina`, `2026-10-01`

Batch shape is part of a lookup's public surface, so admission applies AST-053 E1-E5
before implementation. The evidence still follows DEC-3's permanence tiers: a
current record for a top-level command, and the pull request plus code-owner approval
for a subcommand; CommandDoc retains the result after typed metadata lands.

Rejected: letting each eligible command remain single-only or hide batching behind an
option, which gives equivalent read-only lookups different automation contracts; and
requiring a new system record for every subcommand exclusion, which defeats the tiered
bar.

## Deprecation and cleanup descriptions

These tables describe deprecations and removals for readers; the gate for a removal
is the declared minor and its `[breaking]` Changeset (`spec:AST-017` FR31).

### DEP-0006 — Deprecate the `astryx layout` command group

| Field            | Value                                                                                                                                                                                                                                                          |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id               | `DEP-0006`                                                                                                                                                                                                                                                     |
| cleanup          | `CLN-0006`                                                                                                                                                                                                                                                     |
| package          | `@astryxdesign/cli`                                                                                                                                                                                                                                            |
| surface          | CLI command group: `astryx layout` (`expand`, `check`, `grammar`), its three JSON response types (`layout.expand`, `layout.check`, `layout.grammar`), and the programmatic API exports `layoutExpand`, `layoutCheck`, `layoutGrammar` from `@astryxdesign/cli` |
| old contract     | Compressed XLE/XLO expressions parsed, validated, and expanded into XDS TSX; expression validation with canonical-surface echo; grammar cheatsheet with alias table                                                                                            |
| replacement      | `astryx build` chooses the closest template to start from. `astryx template <name> <path>` scaffolds it. `astryx docs layout` teaches the layout principles (scaffold, structure, spacing, breakpoints).                                                       |
| warning          | Human mode: one stderr line per invocation naming DEP-0006 and the replacement commands. JSON mode: `meta.deprecations` array in the response envelope, each entry `{id, replacements}`, matching the documented envelope schema.                              |
| migration        | Non-mechanical. The XLE/XLO expression language has no source-level equivalent in the replacement commands. Use `astryx build` to find the right template, `astryx template <name>` to scaffold it, and edit the scaffolded code directly.                     |
| codemod          | None — vacuous: expressions are ad-hoc input, not persisted source that a codemod can rewrite.                                                                                                                                                                 |
| downstream       | Maintained agent-docs teach `build` as the front door. The layout guide (`astryx docs layout`) is unaffected.                                                                                                                                                  |
| direct authority | `spec:AST-042` (CLI command admission and programmatic parity)                                                                                                                                                                                                 |
| state            | `deprecated`                                                                                                                                                                                                                                                   |

### CLN-0006 — Remove the `astryx layout` command group

| Field     | Value                                                                                                                                                            |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id        | `CLN-0006`                                                                                                                                                       |
| lifecycle | `DEP-0006`                                                                                                                                                       |
| delta     | Remove command registration, API exports (`layoutExpand`, `layoutCheck`, `layoutGrammar`), CLI bindings, command docs, tests, and the `layout.*` response types. |
| rollback  | Re-register the command group with the same API, response types, and tests from the final-patch baseline.                                                        |
| state     | `pending` — lands in the declared minor with its `[breaking]` Changeset                                                                                          |

## Open questions

None.

---

### Specs AST 043 Input Presentation Spec (`docs/specs/AST-043-input-presentation/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-043
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-09-24
phase: accepted
owners: [imdreamrunner]
affects_architecture: [architecture:public-component-api]
affects_families: [family:input-fields]
affects_contributing: []
affects_consumer_docs: []
---

# Date and time input presentation system spec

## Intent

One prop question — which surface collects the value — currently has two
answers:

- `ContextMenu`, `DropdownMenu`, `MoreMenu`, `Selector`, and `MultiSelector`
  use `presentation` (`'popover' | 'bottom-sheet' | 'adaptive'`).
- `DateInput`, `DateTimeInput`, and `TimeInput` use `nativePicker`
  (`'touch' | 'always' | 'never'`), which names only the native surface and
  hides that Astryx-on-desktop, Astryx-on-touch (bottom sheet), and — for
  `TimeInput` — a plain text field with no picker are different surfaces.

This spec gives the three date/time inputs a `presentation` prop whose values
distinguish every surface, preserves today's default and deprecated-prop
behavior exactly, and deprecates — but does not remove — `nativePicker`. No
existing record owns this cross-component vocabulary (searched: component
contracts, `architecture:public-component-api`, `family:input-fields`,
`nativePicker` / `presentation` / `AdaptivePresentation` in code and records).

## Non-goals

- Any surface's appearance, anatomy, or interaction — unchanged and owned by
  each component, except the new surfaces DEC-3 approves. Eligibility/fallback
  behavior changes only as FR2 states (for `native`).
- Renaming menu/selector `presentation` or their `adaptive` (OQ1).
- Removing `nativePicker` — its concrete mapping remains owned here, while the
  deprecation record, cleanup id, Changeset, paired-minor treatment, and migration
  evidence follow [`spec:AST-017` FR28–FR44](../AST-017/spec.md) and the codemod
  follows `spec:AST-040`.

## Requirements

- **FR1 — Prop, values, and surfaces.** `DateInput`, `DateTimeInput`, and
  `TimeInput` MUST accept optional `presentation`, default
  `'adaptive-native'`:

  | Value                   | Fine pointer   | Coarse pointer |
  | ----------------------- | -------------- | -------------- |
  | `text-input`            | Text field     | Text field     |
  | `popover`               | Astryx desktop | Astryx desktop |
  | `bottom-sheet`          | Astryx sheet   | Astryx sheet   |
  | `native`                | Native         | Native         |
  | `adaptive-bottom-sheet` | Astryx desktop | Astryx sheet   |
  | `adaptive-native`       | Astryx desktop | Native (FR2)   |

  | Surface        | `DateInput`                    | `DateTimeInput`                   | `TimeInput`                                                                       |
  | -------------- | ------------------------------ | --------------------------------- | --------------------------------------------------------------------------------- |
  | Text field     | — (not accepted)               | — (not accepted)                  | Typed field (today's `never`)                                                     |
  | Astryx desktop | Typed field + calendar popover | Typed fields + date/time popovers | — (fine resolves via Text field; `popover` is not a distinct `TimeInput` surface) |
  | Astryx sheet   | BottomSheet calendar           | BottomSheet date/time wheels      | BottomSheet time wheels (DEC-3)                                                   |
  | Native         | Browser/OS date picker         | Browser/OS date + time pickers    | Browser/OS time picker                                                            |

  `text-input` is `TimeInput`-only (maintainer-confirmed: imdreamrunner, 2026-09-25) — it exists solely for its released `never` (FR3); a picker-less date field has no released need and would silently drop the calendar affordance: `DateInput` and `DateTimeInput`
  MUST NOT accept it — their exported `presentation` types exclude it, so it
  is a compile-time error, with no runtime surface defined for it. Their five
  values are `popover | bottom-sheet | native | adaptive-bottom-sheet |
adaptive-native`; only `TimeInput` accepts all six.

  Coarse uses the inputs' existing pointer test, no width condition. Forced
  values (`popover` on coarse, `bottom-sheet` where not reached today, and
  `text-input` on `TimeInput`) MUST render the named surface, never silently
  substitute another.

- **FR2 — Fallback split for native.** Explicit `presentation="native"` MUST
  always show the native surface, with no eligibility fallbacks — a state the
  native control cannot express (seconds, non-default step/increment, preset
  time options, no usable native control) is the caller's explicit choice.
  `presentation="adaptive-native"` — including the default and
  `nativePicker="touch"` — MUST keep the released fallbacks: in those same
  states it falls back to the component's Astryx surface (per-segment for
  `DateTimeInput`: date may go native while time falls back), exactly as
  released today. Deprecated `nativePicker="always"` without an explicit
  `presentation` is the third case (FR3): it MUST keep its own released
  fallbacks, so its behavior does not change — only an explicit
  `presentation="native"` opts into no-fallback.

- **FR3 — Default and `nativePicker` preserved exactly.** Omitting both props
  MUST behave as today's `nativePicker="touch"`. `nativePicker` and its
  exported types MUST remain, marked `@deprecated`, resolving exactly as
  released for unmigrated and migrated callsites alike:

  | `nativePicker` | `DateInput` / `DateTimeInput` | `TimeInput`            |
  | -------------- | ----------------------------- | ---------------------- |
  | `touch`        | `adaptive-native`             | `adaptive-native`      |
  | `always`       | `native` (legacy, FR2)        | `native` (legacy, FR2) |
  | `never`        | `adaptive-bottom-sheet`       | `text-input`           |

  `always` maps to the `native` surface on every pointer but retains the
  released eligibility fallbacks (FR2): `hasSeconds`, non-default
  step/increment, preset time options, or no usable native control keep the
  Astryx surface, exactly as released. It is therefore not identical to
  explicit `presentation="native"`, which never falls back; migration
  (codemod or by hand) to `presentation="native"` is what changes those
  states, deliberately. (`TimeInput`'s released `never` is the typed field on
  every pointer; a sheet for `TimeInput` comes only from explicit
  `presentation="bottom-sheet" | "adaptive-bottom-sheet"`.) Preserving the
  prop with equivalent meaning is nonbreaking (`spec:AST-017` FR4).

- **FR4 — Precedence.** If both props are set, `presentation` MUST win and
  `nativePicker` MUST be ignored, regardless of order or pointer. A
  development-only warning SHOULD name the FR3 replacement whenever
  `nativePicker` is used, and MUST NOT fire in production.

- **FR5 — Docs and types agree.** Each component's consumer docs (en + zh),
  exported types, and Storybook controls MUST show FR1 values/default (six
  values on `TimeInput`, five on `DateInput`/`DateTimeInput`) and
  `nativePicker` only as deprecated with its FR3 replacement.

### Platform support

- Floor: unchanged (`spec:AST-013`, each component); no value renders no
  picker (FR2's `adaptive-native` fallback guarantees an Astryx surface).
- Browser evidence: each FR1 value on fine/coarse browsers, plus FR2 fallback
  states, because jsdom reports neither pointer type nor native pickers.

## Current-state impact

- `packages/core/src/{DateInput,DateTimeInput,TimeInput}`: add `presentation`,
  deprecate `nativePicker` (FR1–FR4); docs/types (FR5); DEC-3 new surfaces;
  implementing PR adds the `spec:AST-017` FR8 migration + nonbreaking
  Changeset. Menu/selector components: unchanged.

## Verification

| Contract | Verification                               | Representative states                                                                                                                 | Mutation or failure expectation                                                       |
| -------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| FR1      | Unit tests per input + real-browser check  | Per-component values × fine/coarse, incl. forced + `TimeInput` sheet; `text-input` type-rejected outside `TimeInput`                  | Wrong/substituted surface fails                                                       |
| FR2      | Unit tests, native requested               | Seconds, step≠default, presets, no usable native: explicit `native` stays native; `adaptive-native` and deprecated `always` fall back | Any fallback under explicit `native`, or none under `adaptive-native`/`always`, fails |
| FR3–FR4  | Unit tests: default, deprecated, dual-prop | Omitted; `touch`/`always`/`never` per component; both props, any order                                                                | Mapping drift (incl. `TimeInput:never→sheet`) or `nativePicker` winning fails         |
| FR5      | Doc/type consistency tests                 | All 3 components, en + zh                                                                                                             | Documented-but-untyped value/default fails                                            |

## Decision log

### DEC-1 — Unify on `presentation`; values name the surface

**Reference:** `spec:AST-043/DEC-1`
**Decider:** imdreamrunner, 2026-09-24

Builders already meet `presentation` on menus/selectors for this question.
Rejected: literal rename to `'touch'|'always'|'never'` (hides desktop vs
sheet vs text field), one `astryx` value (same loss), shared `'adaptive'`
(hides sheet vs native), two coexisting props with overlapping meaning.

### DEC-2 — Default `adaptive-native`; deprecate, don't remove

**Reference:** `spec:AST-043/DEC-2`
**Decider:** imdreamrunner, 2026-09-24

`adaptive-native` is today's default renamed; `nativePicker` stays working
(FR3/FR4) — removal is the breaking event, not the rename. Rejected:
defaulting to `adaptive-bottom-sheet`, and `nativePicker` winning over
`presentation`.

### DEC-3 — Forced surfaces and `TimeInput` sheet approved

**Reference:** `spec:AST-043/DEC-3`
**Decider:** cixzhang, 2026-09-24

Forced `popover` on coarse, `bottom-sheet` on fine, and a new `TimeInput`
bottom sheet (the time half of `DateTimeInput`'s sheet) are approved new
surfaces. This closes the former open question on FR1's forced reach;
implementation may rely on FR1 as written.

## Open questions

- **OQ1 — Menu/selector rename** (`human-api`): later rename their `adaptive`
  to `adaptive-bottom-sheet` under this deprecation pattern? Out of scope.

---

### Specs AST 046 Docs Tree Spec (`docs/specs/AST-046-docs-tree/spec.md`)

---
schema_version: 4
template_version: 2
kind: system-spec
id: spec:AST-046
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-22
phase: implementing
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [authoring, guides, foundations]
---

# Documentation tree system spec



## Intent

A reader who opens the Astryx docs should find every doc in one tree and move
through it one level at a time: `astryx docs cli` lists the CLI's guides and
reference, `astryx docs cli/api` lists the API's functions, schemas, and enums,
and `astryx docs cli/api/functions/search` prints one function. Each doc keeps
its own file and becomes one node with one route. The compiler builds the tree
from the typed docs that already exist; no one keeps a list of children by hand.
Search leads into the same tree: a hit names the smallest part that answers, so
a reader never reads a whole guide to find one fact.

The tree is the backbone of one docs graph. Every provider's docs are its nodes,
each with one home in the tree; the links between docs (a command and its API
function, a token reference, a replacement) are its other edges, named by doc
identity. Phase 1 builds the backbone for the CLI's own docs and each
integration's, and `spec:AST-047` turns the edges into moves a reader can
follow.

This record owns how a doc gets its one home in the tree, how a route is formed,
how `astryx docs` reads the tree, and what `astryx doctor` checks. It rolls out
in phases: phase 1 places the CLI's own docs and the namespace docs and guides
each integration ships, and gives every other doc a home in a generated
level named for its category, Guides or Foundations; later phases may move
those docs into real sections.

A long flat topic can become a namespace of short guides, so a reader loads only
the guide that answers. The split keeps the unambiguous reads a released
command makes inside the topic's own package: the name, each section read by
its key or by a title only one guide matches, each reference, and each address
on the docsite still lead to the same content (FR13, FR6, FR14). A title
fragment that matches sections in two guides asks instead of guessing (FR6),
and references from other packages are an open question (OQ2).

## Non-goals

- How a docsite page looks, and how the docsite shows flat topics. FR14 states
  which page holds a namespace's content and where each of its addresses leads;
  the page design is the docsite's.
- A top-level browse of every provider. It is a later phase.
- Aliases and audiences. `aliases` and `audience` stay reserved fields that a
  doc may not set.
- Rendering `collection` blocks. A topic that uses one fails to load.
- Equivalent internal implementations remain valid when they satisfy this
  contract. The compiler's modules, the docsite's build scripts, and the files
  a split creates are implementation.

## Requirements

- **FR1 — Identity is separate from the route.** A node with an authored doc
  MUST be identified by that doc's identity: its DocId, as the provider-identity
  contract defines it, built from its provider's ProviderId (which can differ from its
  package name), its kind, and its name. The identity MUST NOT change when the node
  moves. A generated level has no authored doc and no identity: its `id` is
  `null`. A node's route is its parent's route, `/`, and its own segment; a
  top-level namespace's route is its name, and a flat topic keeps its own name
  (FR12). A segment is the node's name as
  lowercase letters and digits joined by single hyphens. The route is the
  readable name; `docs.node` carries both.
- **FR2 — Each doc has one home, decided in a fixed order.** A doc's home MUST
  be decided once, in this order: its explicit `placement`; otherwise the one
  adoption rule in its own package that matches its discovery group and kind;
  otherwise, for a reference topic, the generated level for its category (FR12). A placement MUST name a namespace of the doc's own package
  (`namespace:<name>`), a slot that namespace declares, and a slot that accepts
  the doc's kind. A placement that fails MUST withdraw the doc with a
  diagnostic; it MUST NOT fall back to adoption. A doc that two rules adopt MUST
  be withdrawn with a diagnostic that names both namespaces.
- **FR3 — Namespaces never list or scan their children.** A namespace doc
  declares its slots and its adoption rules. It MUST NOT enumerate its
  children, and the compiler MUST NOT infer a parent from a folder. A child
  names its parent with `placement`, or a namespace adopts a discovery group: a
  CLI typed doc's group is the `namespace` it declares (`cli/commands`,
  `cli/api`). An adoption rule with `groupBy: 'kind'` MUST add one generated
  namespace per kind (`functions`, `schemas`, `enums`), in the order the rule
  lists the kinds.
- **FR4 — Routes are unique.** Two nodes MUST NOT share a route. When they
  would, the compiler MUST keep one by a deterministic order and withdraw the
  other with a diagnostic that names both. The same inputs MUST build the same
  tree, whatever order they arrive in. Children sort by `placement.order`, then
  title, then identity.
- **FR5 — `astryx docs` reads the tree one level at a time.** `astryx docs
<route>` MUST resolve a flat topic first, then a tree route, except that a
  topic whose route the tree gives to another doc (FR11) opens that doc. A namespace MUST
  print its title, its summary, and each slot's children one level down, each
  with its summary and the command to open it; it MUST NOT inline its
  grandchildren. When a child's title is not its route name
  (`assertResponse()`, `search()`), the text view MUST show the title before
  the summary. A typed doc MUST print its content. Both MUST end with the way
  back up. A guide the tree places MUST read like a topic, by
  its route (`spec:AST-047` FR3). `--json` MUST return `docs.node` for a
  namespace or typed doc: its identity, route, kind, package, title, summary,
  breadcrumb, and either its slots with their children or its content.
- **FR6 — A section read names one section.** A section argument on a typed
  doc MUST fail with `ERR_UNKNOWN_SECTION` and name the doc. On a namespace,
  the read `astryx docs <namespace> <section>` MUST take, from each guide below
  the namespace at any depth, the section that guide's own section read
  returns for the query; within one guide, that read decides between the
  guide's sections, as it does on any topic. Across guides the read MUST then
  decide in two steps: when the section returned by exactly one guide has the
  query as its key (its `id`, or the key its title derives), that section
  answers, even when other guides return a section too; otherwise, when
  exactly one guide returns a section, that section answers. The response MUST
  be the one a section read of that guide returns (`docs.detail.section`), so
  an unambiguous section read that worked on a topic before it became a
  namespace (FR13) returns the same section in the same shape. When no guide
  returns a section, or more than one does at the step that decides, the read
  MUST fail with `ERR_UNKNOWN_SECTION`, the error a topic's unknown section
  gives; its suggestions MUST name the route of each guide that returned a
  section, or of every guide below the namespace when none did. An unknown
  route MUST fail with `ERR_UNKNOWN_TOPIC` and suggest the children of the
  deepest namespace the route reaches.
- **FR7 — The topic list names the tree.** `astryx docs --json` MUST list each
  top-level namespace in `meta.namespaces`, so `data` stays the topic list, and
  every entry in it reads as a topic. The text view MUST show the namespaces first,
  under their own heading, because that is where the CLI's own docs start. A
  package whose docs did not load MUST be named, in `meta.notLoaded` and under
  its own heading in text, so its author knows why its docs are missing.
- **FR8 — Doctor proves the tree.** `astryx doctor` MUST warn when the tree has
  an error diagnostic, when a CLI typed doc whose group the tree reads has no
  route, and when a link between docs names no doc (`spec:AST-047` FR9). These
  checks warn, so a project that passed without them keeps passing. The progressive-disclosure check MUST hold each guide the tree
  places to the same size budget as every topic.
- **FR9 — Phase 1 places the CLI's own docs.** The CLI MUST ship the `cli`
  namespace with the `commands` and `api` namespaces under it, and the
  `authoring` namespace, the one home for integration authors. Every command
  doc MUST have a route under `cli/commands`, and every function, schema, and
  enum doc in the `cli/api` group a route under `cli/api/<kind>s`. The
  integration guides MUST live under `authoring`, one short guide per task,
  beside the field reference whose sections `astryx docs authoring <name>`
  reads, and no flat topic is named `cli-integrations` or `authoring`. An old
  `cli/integrations` route MUST stay unknown, and its not-found error MUST name
  the guide's new route under `authoring`. Under `spec:AST-017/FR45`, a CLI route or name MAY
  change like this when every reference changes with it: links name docs by
  identity (`spec:AST-047` FR9), and the graph walk fails on a reference
  left behind (`spec:AST-047` FR11, FR12).
- **FR10 — Search finds the smallest part that answers.** `astryx search` MUST
  index each section of each topic and placed guide, and each namespace and
  typed doc of the tree. A section result MUST carry `section`, and its command
  MUST read only that section. A tree result's command MUST read its route. A
  topic result's command MUST list the topic's sections when it has more than
  one. A typed doc MUST also match by its own name, and a doc part by each
  identifier it defines or names in code (`assertResponse`,
  `ERR_UNKNOWN_SECTION`). A docs-only search (`--type doc`) MUST NOT need
  `@astryxdesign/core`, because `astryx docs` does not.
- **FR11 — Integrations join the tree.** A configured integration MAY ship
  namespace docs, and guides with `placement`, in its docs directory. The tree
  MUST read them beside the CLI's own, identify each node by the integration's
  provider id (FR1) and name its `package` by the integration's npm name, and
  hold them to FR2–FR8. A placed guide MUST NOT also `replaces` or `extends` a
  topic. A flat topic MAY name a placed guide by its route, or by a route it
  moved from, in `replaces` or `extends`; this claims no route, so the guide
  keeps its place and route, and a read of a moved route still fails as FR9
  states. A replacement MUST be read at that route as its own package's doc,
  and an extension's sections MUST merge onto the guide as they merge onto a
  topic; docs that name one guide by its old and its new route apply in
  configured order. An extension's section MAY set `before` or `after` to the
  key of a section of the doc it extends, and MUST be placed there, in
  authored order; with neither, or with a key that names no section, it MUST
  be added at the end, and neither field is read back. A replaced topic or
  guide MUST name the package it replaced as `replacedPackage`, beside
  `replaces`. A `replaces` or `extends` that names no topic and no guide,
  such as a namespace, changes nothing.
  When two providers claim one route, the CLI's own docs MUST keep it,
  including its flat topics' names and the generated topic levels, then integrations
  in configured order; between integrations a namespace keeps its route over a
  topic, and a name a topic answers to through `replaces` is that topic's route.
  The claim that loses MUST be a `duplicate_route` diagnostic filed against the
  provider that lost it, and the topic list, reads, and search MUST agree with
  the tree. `astryx doctor integration docs` MUST run the
  same tree and link checks on one integration's docs, so an author finds a
  broken placement or link before the package ships. It MUST fail on a tree
  error diagnostic filed against the package, since that hides a doc, and warn
  on a link that names no doc; `astryx doctor` keeps FR8's warnings. And
  `astryx integration add doc <name> --parent <namespace>` MUST write a guide
  placed in that namespace, found by its name, and the namespace doc when the
  package has none. A CLI release that does not read the docs tree can hide every doc topic of a
  package that ships a namespace doc or a placed guide, with nothing saying
  why, so such a package MUST declare a `@astryxdesign/cli` peer
  range that admits only CLIs that read it: `integration add doc --parent` MUST declare it,
  and `astryx integration verify` MUST fail without it. The same check
  covers a template that sets `replaces` (`spec:AST-035`), a doc section that
  sets `id`, and a theme, whose typed descriptor an older CLI rejects along
  with the package's doc topics; `integration add theme` declares the peer.
  A doc section that sets `before` or `after` needs the same range from the
  CLI that reads those fields; `integration verify` warns without it.
- **FR12 — Every doc has a named home.** Every flat topic, the CLI's and
  each integration's, MUST sit in the generated level for its category, in the
  order the topic list reads: `foundations` (Foundations) for a foundations
  topic, and `guides` (Guides) for any other. A level has no authored doc, so
  its `id` is `null`, and it lists its topics one level down with the command
  that opens each. A topic keeps its own name as its route, so no name
  changes; a read of it offers Up to its level and Previous and Next among its
  level's topics, as any tree node does. Placing a topic in a real section
  takes it out of its level. Every flat topic the CLI ships MUST declare the
  `guide` or `foundations` category, and the repository's tests MUST fail on
  one that does not.
- **FR13 — A flat topic becomes a namespace without breaking an unambiguous
  read.** A
  provider's flat topic MAY become a namespace of guides only when every rule
  below holds for the result:
  - The namespace takes the topic's name, so its route and `astryx docs <name>`
    are unchanged, and no flat topic keeps the name, so the name has one owner
    (FR4).
  - Every section of the topic lands in exactly one guide, with its title, its
    `id`, and the key a reader addresses it by unchanged; when regrouping would
    change a section's derived key, the section's `id` pins the old key. No
    section is dropped, merged into another, or copied into two guides. No
    guide below the namespace shares a key with one of the topic's own
    sections, so every section read that worked on the topic still names one
    guide (FR6). Guides placed below the namespace from elsewhere MAY share
    other keys with each other; a namespace read of such a key fails as FR6
    states and names those guides.
  - Each guide answers one task or one decision a reader brings, and its title
    and summary name it. A guide is not a fragment cut at a size, and each
    meets the size budget of FR8.
  - Every link, `{@link}` target, and token reference the provider's docs make
    to the topic still resolves: each names the namespace, or the guide that
    holds what it pointed at, and the graph walk fails on one left behind
    (`spec:AST-047` FR11). A token reference that names the namespace reads its
    guides as one topic, their sections in tree order.
  - A read in another language (`--dense`, `--zh`) of a guide, or of one of its
    sections, returns the text the topic's read in that language returned for
    each section the guide holds.
  - Each piece of content appears once: the topic list names the namespace in
    `meta.namespaces` and no topic in `data` by that name (FR7), search returns
    each section once, from the guide that holds it (FR10), and no flat topic
    keeps a copy of guide content for references or pages to read.
- **FR14 — The docsite shows a root namespace as one page.** The docsite MUST
  read the tree through the CLI's public docs API. A root namespace with a
  guide below it MUST be one page at the namespace's slug: the namespace's own
  content, then every guide below it in tree order, as the read
  `astryx docs <namespace> --depth all --detail full` returns it. A nested
  namespace's own content opens its guides on that page, and typed docs below
  the namespace open only in `astryx docs`. A topic that becomes a namespace (FR13) therefore keeps its
  address, its sidebar group, and its text. The slug of each guide and nested
  namespace below it (its route with `/` as `-`) MUST be a temporary redirect
  to where its text starts on the page. A root namespace whose slug is a
  package's page (`cli` is the CLI package's page) keeps one page per guide,
  at the guide's slug, and has no namespace page. A namespace page's sidebar
  group MUST be the namespace's own `category`; without one, `foundations`
  when every guide below it is a foundations doc, and `guide` otherwise. A
  namespace with no guide below it, such as a generated topic level (FR12), has
  no page. The docsite build MUST fail when a slug is both a page and a
  redirect, or both a flat topic and a namespace page.

### Platform support

- Supported feature/engine floor: every supported CLI runtime.
- Unsupported behavior: none.
- Browser evidence: none beyond the docsite's page and redirect tests (FR14);
  the record makes no browser-specific claim.

## Current-state impact

Phase 1 changes these surfaces:

- the CLI ships its namespace docs and the guides they place in
  `assets/docs/tree/`, each named after its doc; the flat topic list does not
  read that directory;
- the compiler builds the tree from those files and the CLI's typed docs, and
  `collectDocInputs` lists the tree files as a `tree` root;
- the four tree diagnostics (`invalid_namespace`, `invalid_placement`,
  `overlapping_adoption`, `duplicate_route`) join the compiler's codes;
- `astryx docs`, `docs()`, and `astryx doctor` read the tree as FR5–FR8 state,
  and a section read on a namespace resolves through its guides (FR6);
- `astryx search` finds sections and tree nodes, as FR10 states;
- an integration's namespace docs and placed guides join the tree, and
  `astryx doctor integration docs` checks them, as FR11 states;
- `cli-integrations` moves to `cli/integrations`, and every reference moves
  with it;
- the integration guides then move from `cli/integrations` to `authoring`,
  beside the field reference, and every reference moves with them; an old
  route's not-found error names its new one (FR9);
- every flat topic sits in the generated level for its category, Guides or
  Foundations, as FR12 states;
- a flat topic that becomes a namespace keeps its unambiguous reads inside
  its own package, as FR13 and FR6 state;
- the docsite reads the tree through `docs()`: each root namespace is one page
  and its guides' slugs redirect into it (FR14); the `cli` namespace's guides
  keep their own pages, and `/docs/cli-integrations`, every
  `/docs/cli-integrations-…` guide slug, and `/docs/cli-writing-docs` redirect
  into `/docs/authoring`.

`architecture:cli-surface` INV25 and INV26 carry this record into the code.

## Verification

| Contract      | Verification                                                                  | Representative states                                                                                                                                                                                                          | Mutation or failure expectation                                                                                                                                                                             |
| ------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR3, FR4 | Tree builder fixture tests                                                    | three authored levels; adoption with and without `groupBy`; reversed input order; order and title sort                                                                                                                         | A route that ignores the parent, a folder that implies a parent, or a tree that depends on input order                                                                                                      |
| FR2           | Tree builder failure fixtures                                                 | no slot; unknown slot; a slot that refuses the kind; another package's parent; not a reference; two adopters; a cycle                                                                                                          | A failed placement that falls back to adoption, or a failure without a diagnostic                                                                                                                           |
| FR5, FR6, FR7 | `docs()` dispatcher tests and CLI runs                                        | `cli`, `cli/api`, one function, the placed guide and its sections, the old name, a typo route; a section on a namespace that one guide, no guide, and two guides hold                                                          | A namespace that inlines grandchildren, a lost section read, a section read that picks one of two guides, or a wrong error code                                                                             |
| FR8           | Doctor tests                                                                  | this repo; a fixture tree with a broken placement                                                                                                                                                                              | A broken tree or an unplaced CLI doc that passes                                                                                                                                                            |
| FR9           | Real-tree tests and the route inventory                                       | every command, function, schema, and enum doc; every exported API function                                                                                                                                                     | A CLI typed doc without its route, or a route inventory row the tree contradicts                                                                                                                            |
| FR10          | Search tests and CLI runs                                                     | a guide section; a typed doc by its own name; an error code; a topic hit; no core installed                                                                                                                                    | A section hit whose command reads the whole topic, or a docs-only search that needs core                                                                                                                    |
| FR11          | Integration tree tests; guide route target tests                              | a namespace and two placed guides from a package whose provider id differs from its name; a broken link; a claim on the `cli` route; a flat topic that replaces or extends a CLI guide by its route, with `before` and `after` | A node named by the package name, a broken link that passes either doctor, an integration that takes a CLI route, a replaced guide or added section that names the wrong package, or a section out of place |
| FR12          | `docs()` tests, the graph walk, and search tests                              | the level and its children in list order; a topic's Up, Previous, and Next; a flat topic hit's parent; an integration's flat topic                                                                                             | A flat topic without a home, a topic whose name changes, or a level that lists a placed guide                                                                                                               |
| FR13          | Split tests over the topic before and after, the graph walk, and search tests | each old section key read through the namespace; a link and a token reference to the old topic; a `--dense` read; the topic list and a search for one section                                                                  | A dropped, merged, or copied section; a changed title or `id`; a reference left on the old identity; a name listed twice; a section found twice                                                             |
| FR14          | Docsite page-generation and route-resolution tests                            | a split namespace; a nested namespace; a package-page namespace; a namespace with a `category`, and one whose guides are all foundations docs; a generated topic level                                                         | An old address that stops resolving or lands in another sidebar group, a permanent guide redirect, a guide page on a non-package namespace, or a slug that is both a page and a redirect                    |

## Decision log

### DEC-1 — Roll out the tree in phases, CLI first

**Reference:** `spec:AST-046/DEC-1`
**Decider:** `josephfarina`, `2026-09-22`

The compiler, the CLI reader, and Doctor come first; the docsite follows the
tree only as far as FR14 states, and every current docsite address keeps
working. Starting with the CLI's own docs tests the contract on docs one team owns.

Rejected: one change that moves every doc and the docsite at once. It is too
large to review, and every route changes before the contract is proven.

### DEC-2 — A CLI typed doc's `namespace` is its adoption group

**Reference:** `spec:AST-046/DEC-2`
**Decider:** `josephfarina`, `2026-09-24`

Every CLI typed doc already declares the `namespace` that reads it, and Doctor
already enforces it. The tree adopts by that group, so a new command or
function appears in the tree with no other edit.

Rejected: a second field for the tree, and a list of children in each namespace.
Both repeat what the doc already says and drift from it.

### DEC-3 — Rename, and move every reference with it

**Reference:** `spec:AST-046/DEC-3`
**Decider:** `josephfarina`, `2026-09-28`

`cli-integrations` becomes `cli/integrations`, with no alias. A CLI name or
route may change when every reference changes with it: links name docs by
identity, so they follow the doc, and the graph walk fails on any reference
left behind. An alias would keep a second name for one doc.

Rejected: keeping `cli-integrations` as an alias, and removing it before typed
links and the graph walk could prove no reference was left.

### DEC-4 — Integrations join the tree in the first phase

**Reference:** `spec:AST-046/DEC-4`
**Decider:** `josephfarina`, `2026-09-28`

The tree, its moves, and its checks work the same for every provider. Holding
integrations back would leave their docs on a weaker path and delay the checks
their authors need, so an integration's namespace docs and placed guides join
the tree in the same change as the CLI's.

Rejected: a later phase for integration namespaces.

### DEC-5 — A split keeps the topic's name and its unambiguous reads

**Reference:** `spec:AST-046/DEC-5`
**Decider:** `josephfarina`, `2026-10-08`

A long topic gets short guides without a breaking change. The `docs` command's
response schemas are a contract (`spec:AST-017/FR45`), and a split moves no
content, so it keeps the topic's unambiguous released reads working inside its
own package, even where FR45 would let a route stop resolving. The one
deliberate exception is a title fragment that matches sections in two guides,
which asks instead of guessing (DEC-6); references from other packages are an
open question (OQ2). The namespace takes the topic's name, so its
route and every command that opens it stay the same;
sections keep their titles and ids, so section reads and links keep resolving;
each piece of content appears once, so a reader never weighs two copies. A
guide is shaped by the task or decision it answers, which is what search and a
reader's next step look for.

Rejected: keeping the flat topic beside the namespace, as an alias or a data
copy; the name has two owners and lists and search show the content twice.

### DEC-6 — A section read on a namespace resolves through its guides

**Reference:** `spec:AST-046/DEC-6`
**Decider:** `josephfarina`, `2026-10-08`

A released `astryx docs <topic> <section>` command keeps working after its
topic becomes a namespace when its section is unambiguous: its exact key, or a
title fragment that only one guide's read matches. Each guide's own read
decides between that guide's sections, as a topic's read does. Any other case fails with the error an unknown section already
gives, naming the guides, so the reader is one step from the answer.

Rejected: failing every section argument on a namespace; it breaks released
section reads of each split topic.
Rejected: the first match in tree order; it picks a guide silently when two
match.

### DEC-7 — The docsite shows a root namespace as one page

**Reference:** `spec:AST-046/DEC-7`
**Decider:** `josephfarina`, `2026-10-08`

A person reading the docsite keeps one full page per topic, at the same address
and in the same sidebar group, however the CLI splits it; an agent reads the
guides one at a time. Guide addresses redirect temporarily, so a later site
version can give guides their own pages without breaking a saved link. A
namespace whose slug is a package's page keeps one page per guide, because that
slug already belongs to the package.

Rejected: one docsite page per guide; it moves each split topic's address and
thins its page.

### DEC-8 — Authoring owns integration authoring, and every doc has a named home

**Reference:** `spec:AST-046/DEC-8`
**Decider:** `josephfarina`, `2026-10-21`

Maintainer decision: everything an integration author reads lives under one
top-level home, `authoring`, which holds the integration guides and the field
reference. No doc sits in a catch-all:
a flat topic's home is named for what it is, Guides or Foundations, after the
docsite's sidebar groups. Routes move with every reference, as DEC-3 allows,
and topic names do not change.

Rejected: keeping the integration guides under `cli` beside a separate
reference, which gave authors two top-level homes; and a generated level a
reader sees as "Unorganized".

## Open questions

- **OQ2 — What another provider's doc reads when it links to, extends, or
  replaces a topic that becomes a namespace.** (`human-api`) FR13 moves every
  reference inside the topic's own provider; a reference in another package
  cannot move with it.

---

### Specs AST 047 Navigable Output Spec (`docs/specs/AST-047-navigable-output/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-047
authority: draft
archive_reason: null
superseded_by: null
approved_by: null
approved_at: null
phase: implementing
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [cli]
---

# Navigable CLI output system spec

## Intent

Every read the CLI prints is a node in a graph. A reader who lands anywhere,
from a search hit, a link, or a guess, must see where they are and every move
they can make next, as commands they can run: up to the level the node sits
in, down to its children, across to its neighbors, and out to the nodes it
names. Nobody has to know a route in advance or read a long page to find one
fact: they search, land on the smallest node that answers, and move from there.

This record owns the moves a read offers and how they appear in text and JSON.
`spec:AST-046` owns the docs tree those moves walk first.

## Non-goals

- What the nodes of each command are. The docs tree is `spec:AST-046`'s; each
  command that adopts this record decides its own nodes.
- Interactive navigation. There are no prompts, pagers, or state kept between
  runs; each move is its own command.
- Links out of the CLI, such as docsite URLs.

## Requirements

- **FR1 — A read names where it is.** A read below the top MUST name its
  place: a docs-tree node its breadcrumb, a section its topic, and a search hit
  the path of titles above it (`Astryx CLI › API › Functions › search()`).
- **FR2 — Up.** Every read except the top MUST offer the command that opens the
  level it sits in: for a section, its topic's section list; for a node or a
  topic, the level it sits in (a guide's namespace, or the Guides or Foundations
  level for a flat topic, `spec:AST-046` FR12); for a top-level namespace, the
  topic list.
- **FR3 — Down.** A read that has children MUST list them, each with its
  summary, and give the command that opens one: a namespace its children, a
  topic's index its sections, and the topic list its topics and namespaces. In text,
  a topic with more than one section is read one level at a time: a bare read
  lists its sections, and `--full` prints the whole doc; the dense variant is
  written to be read whole, so `--dense` prints it whole. The JSON contract is
  unchanged: `docs(topic)` and `--json` return the whole doc, and `--index` its
  sections.
- **FR4 — Across.** A read with a place in an ordered level MUST offer the
  commands that open the item before it and the item after it, when they exist:
  a section's neighbors in its topic, and a docs-tree node's neighbors in its
  parent's slot.
- **FR5 — Out.** A read that names another node MUST give the command that
  opens it: a command doc its API function, a function doc its command, and a
  typed doc each doc it lists as related.
- **FR6 — Search lands on nodes.** Each search hit MUST be one node, the
  smallest that answers (`spec:AST-046` FR10), with the command that reads it
  and the command that opens the level above it.
- **FR7 — Moves are commands, and JSON carries them.** In text, each move MUST
  be a whole command that starts with the command name `spec:AST-081` FR5
  picks. In JSON, a read MUST carry its moves as data: `links`, with `up`,
  `previous` and `next` where FR4 applies, and
  `related` where FR5 applies to a typed doc, each an `astryx ...` command; a search hit carries `command` and
  `parent`. `links` MUST be present on every read but the topic list, as a field
  added to each existing response, and the published types MUST type each move
  as an `astryx docs` command. The text view
  MUST stay a projection of the JSON.
- **FR8 — A wrong move says where to go.** A route, topic, or section that does
  not exist MUST fail with a stable error code and suggest the nodes a reader
  can reach from the nearest level that does exist (`spec:AST-046` FR6).
- **FR9 — Moves are derived, not written.** The CLI MUST derive Up, Down, and
  Across from the docs tree and a topic's sections, and Out from a typed doc's
  own fields, so no doc lists its own moves and every provider's docs, from
  Core, the CLI, or an integration, get the same moves. A link from one doc to
  another MUST name its target by doc identity, never by route or title. A bare
  name in a typed field (`fn`, `command`, `related`) names a doc of its own
  provider, so it is a doc identity: it resolves among the kinds its field
  allows, and a name that matches docs of more than one allowed kind is an
  error, never a silent pick. Inside a doc's text, a link MUST be written
  `{@link <target>}` and read as the command that opens its doc; a link that
  names no doc MUST print as written. A topic's section MAY hold a `reference`
  block, which includes the doc it names instead of a copy: a read MUST inline
  it as that doc's content (a schema's content narrowed to the fields its
  projection names), then a line naming that doc and the command that opens
  it, so a read still returns only the stable content blocks. A target, field,
  or projection the block cannot include MUST fail the authoring check.
  `workflow` and `collection` blocks (GraphContentBlock) belong to a namespace
  doc's `blocks`, which a later phase renders. A target without a
  provider names a doc of the provider that wrote the link: in a topic that
  merges sections from several providers, each section resolves against its
  own. A component doc's text fields (`description`, `summary`, `label`, at any
  depth, an example's label included; never code or defaults) and a topic's description and
  summary hold links too, and a component doc's bare target names a doc of
  the component's provider, where a Core component's provider is the CLI's
  docs, which hold Core's guides. A search hit's description reads the same.
  The CLI MUST resolve
  every link when it reads a doc, and Doctor MUST warn on a link that is
  unresolved or ambiguous. A link names a doc identity, not a provider instance,
  so it survives version changes.
- **FR10 — Phase 1 covers docs and search.** `astryx docs` and `astryx search`
  MUST meet FR1–FR9, FR11, and FR12. A command a doc shows as an example, in a code
  block or in prose, is not a link: FR11 keeps it true. Other commands adopt
  this record in later phases, each as its own change.
- **FR11 — Nothing a read shows can go stale.** A move or a cross-link MUST be
  derived when the read is made, never stored in a doc. Every other `astryx`
  command that a doc, a hint line, or the repository's agent prompt shows MUST
  name a real command and only options that command takes, and every
  `astryx docs` command it shows MUST open; a test MUST fail the build
  otherwise. A command a doc shows wrong on purpose, as an error-code example,
  MUST be listed as such, and the list MUST fail when an entry is no longer
  shown.
- **FR12 — Every doc is one walk away.** Every doc a provider ships MUST be
  reachable from `astryx docs` by following the moves reads offer: Down from the
  topic list and each namespace, then Across and Out. Neighbors MUST agree: the
  read an item's Next opens names that item as its Previous. A test MUST walk
  the whole graph from the top and fail on a doc file it cannot reach, a move
  that does not open, or a link that names no doc.

### Platform support

The moves are plain commands that start with the command name `spec:AST-081`
FR5 picks, so they run in any shell the CLI runs in. JSON carries them without
that name.

## Current-state impact

Before this record, only docs-tree namespaces and typed docs offered a way up,
and only in text; a section, a topic, and a topic's index were dead ends.

Phase 1 changes:

- in text, a bare topic read lists the topic's sections when it has more than
  one; `--full` and `--dense` print it whole; `docs(topic)` and `--json` still
  return the whole doc;
- `docs.node`, `docs.index`, `docs.detail`, and `docs.detail.section` carry
  `links`: `up` on every read, and `previous` and `next` on sections and
  docs-tree nodes;
- a section read, a whole-topic read, and a topic's index end with their moves;
- a docs search hit carries `parent` and the path of titles above it;
- links between docs are typed (`{@link <target>}` in text, and the typed
  fields), resolved on every read, and checked by Doctor, which warns; the
  hand-written `astryx docs` links in the CLI's own docs become typed links;
- a test proves every command a doc or hint shows is a real command with real
  options, and every docs command opens;
- a test walks the whole graph from the top and reaches every doc file the CLI
  ships, as FR12 states.

`architecture:cli-surface` INV27 carries this record into the code.

## Verification

| Contract     | Verification                                                                                      | Representative states                                                                                                        | Mutation or failure expectation                                                                     |
| ------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| FR1–FR5, FR7 | `docs()` dispatcher tests, CLI runs, and the graph walk (`packages/cli/test/docs-graph.test.mjs`) | a top-level namespace; a typed doc in the middle of its slot; the first and the last section of a guide; a flat topic, whole | A read with no way up, a move that does not run, or a text move with no `links`                     |
| FR6          | Search tests                                                                                      | a section hit; a docs-tree hit; a topic hit                                                                                  | A hit without `parent`, or a `parent` that does not open the level above                            |
| FR8          | `spec:AST-046` FR6 tests                                                                          | an unknown route, topic, and section                                                                                         | A dead end without suggestions                                                                      |
| FR9          | Link tests, the doctor docs-tree check, and the graph walk                                        | an inline link, a link in a list and a table, and a typed field; a link to another provider; a link in code ticks            | A link that names no doc and still passes, a link resolved by guess, or a stored route              |
| FR11         | The graph walk's shown-commands check                                                             | commands in doc prose and code blocks, CLI hint lines, and the agent prompt; the wrong-on-purpose list                       | A command or option the CLI does not have, a docs command that does not open, or a stale list entry |
| FR12         | The graph walk (`packages/cli/test/docs-graph.test.mjs`)                                          | every tree node; every doc file the CLI ships; Next and Previous on sections, topics, guides, and tree nodes                 | A doc file no walk reaches, a move that does not open, or a Next whose read names another Previous  |

## Decision log

### DEC-1 — A move is a command, not a route

**Reference:** `spec:AST-047/DEC-1`
**Decider:** `josephfarina`, `2026-09-28`

Each move is a whole `astryx ...` command that a reader can run as is.

Rejected: bare routes. They make the reader rebuild the command and know which
argument a section key goes in.

### DEC-2 — Across names the neighbors, not the whole level

**Reference:** `spec:AST-047/DEC-2`
**Decider:** `josephfarina`, `2026-09-28`

FR4 names the item before and the item after; the whole level is one move up.

Rejected: listing every sibling on every read, which makes each leaf as long as
its parent.

### DEC-3 — Adopt per command, docs and search first

**Reference:** `spec:AST-047/DEC-3`
**Decider:** `josephfarina`, `2026-09-28`

Readers get lost in the docs first, and search is how they arrive. Other
commands adopt the moves as their own changes.

Rejected: one change for every command, which is too large to review and
decides every command's nodes at once.

## Open questions

- Which command adopts this record next, and what its nodes are.
- Whether top-level namespaces offer `previous` and `next` among each other.

---

### Specs AST 048 Build Recommendation Spec (`docs/specs/AST-048-build-recommendation/spec.md`)

---
schema_version: 4
template_version: 2
kind: system-spec
id: spec:AST-048
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-09
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [cli]
---

# Build recommendation system spec



## Intent

A builder, a person or a coding agent, describes the UI they want in words and
asks `astryx build` where to begin. A page template already carries the frame,
the spacing, and the section rhythm the system decided on; a page composed from
components has to rediscover all of that. So build's answer always starts from
a template: it names one page template to scaffold, the command that scaffolds
exactly that template, the next best templates, and the blocks and components
that fill it.

A scaffolded template builds only when the project has what its source needs. So
the `template.copy` receipt from `astryx template`, and the start and each
alternative build recommends, also state that template's scaffold readiness: the
packages its source imports that the project does not declare, one command that
installs them, and whether the project lacks a StyleX compiler the source needs.
Scaffold readiness is separate from a template's ready mark (FR1, FR4): a ready
template can still need packages a project lacks.

This record owns what build recommends, how the recommendation reads in text
and JSON, and the scaffold readiness that the scaffold receipt and build's
recommended templates report. `spec:AST-017` owns compatibility and
response-field rules, `spec:AST-042` owns command admission, search owns its
own ranking and output, and template owners own what each template is.

## Non-goals

- How candidates are ranked. Word matching, weights, thresholds, and the
  ranking algorithm are implementation; this record states what the
  recommendation must be and how a reader can act on it.
- Which templates exist, how they look, or what they import.
- Writing files. Build recommends; `astryx template` scaffolds. This record
  owns only what the scaffold receipt says the project lacks.
- Installing packages or setting up a compiler. Readiness names what is
  missing and how to install it; neither command installs or configures
  anything.
- New commands, options, flags, or exit codes for build or template;
  readiness never sets an exit status (FR22).
- Equivalent internal implementations remain valid when they satisfy this
  contract.

## Requirements

- **FR1 — Always one start.** A build that can return pages MUST name exactly
  one page template to start from, chosen among the ready page templates the
  project can scaffold, including the ones its integrations provide. A build
  narrowed to components or hooks names no start, and neither does a project
  that offers no page template.
- **FR2 — A shell when nothing leads.** When no template has the evidence to
  lead, the start MUST be the neutral app shell, or a blank page template when
  the project has no shell. The start never falls back to "compose from
  components".
- **FR3 — A part starts where it lives.** A request for a part of a page (a
  control, a state, a widget, a section) MUST start from the page template the
  request places that part in, when the request gives that page, and from the
  app shell (FR2) when it gives no page. The blocks and components in the kit
  carry the part itself. A request that changes a page the builder already has
  starts from the app shell (FR2): the page is the builder's to keep (FR9), and
  no template scaffolds it.
- **FR4 — Never an unready start.** A template marked not ready MUST NOT be the
  start or an alternative. When search matches one directly, the start's
  reason MUST name it and say the start is the closest ready template or the
  app shell instead.
- **FR5 — The start is exact and runnable.** The start and every alternative
  MUST carry the scaffold command that selects exactly that template in the
  project where build ran. A template an integration provides in place of a
  Core one is selected the way `astryx template` addresses it, and the command
  names the page kind so a block with the same id cannot make it ambiguous.
- **FR6 — The start says why.** The start MUST carry a basis and a one-line
  reason. The basis is `direct` when the start is also search's direct match,
  `closest` when no template is exactly this page, and `fallback` for the
  shell.
- **FR7 — The builder can overrule.** Beside every start, build MUST offer the
  next best page templates, at most two, each with its scaffold command.
- **FR8 — The kit fills the template.** Build MUST list the blocks that fill
  the start before the components, and name the always-on frame and
  foundation components. The order of the listed blocks follows
  `spec:AST-083`.
- **FR9 — An existing page is kept.** Build's text output MUST tell a builder
  who is changing a page they already have to keep it and use only the blocks
  and components.
- **FR10 — The recommendation is data.** The JSON response MUST carry the
  recommendation itself (the start with its name, command, basis, reason, and
  alternatives), so no caller derives it from other fields.
- **FR11 — Additive JSON.** Changes to the recommendation MUST keep the build
  JSON additive under `spec:AST-017`: new data arrives as new fields, and
  every existing field keeps its shape and meaning. A ranking change may move
  values (which templates, blocks, or components appear, and in what order);
  it does not change a field's shape or meaning.
- **FR12 — Text leads with the start.** Build's text output MUST lead with the
  start, then the alternatives, then the blocks, then the components. Every
  response field keeps a text projection as `spec:AST-017` requires: short by
  default, and whole under `--verbose`.
- **FR13 — Every agent-facing surface agrees.** The build playbook, the
  generated agent docs, and the consumer guides that describe building a page
  MUST describe the same workflow: scaffold the named template, fill it with
  blocks, and compose only what is left. No surface tells builders to compose
  a page from primitives first.
- **FR14 — The written source names what it needs.** The packages a template
  needs MUST be the packages named by the imports in the source the command
  writes: its import declarations, type-only ones included, its export-from
  declarations, and its `import()` and `require()` calls. An import that sits
  inside a comment, a string, or a template literal is text and names nothing. A
  bare specifier names one package, by its scope and name (`@scope/name`) or by
  its first segment; a Node built-in module, a relative or absolute path, a `#`
  subpath import, and a URL name none.
- **FR15 — The project declares what its package.json files list.** The packages
  a project declares MUST be the union of the `dependencies`, `devDependencies`,
  `peerDependencies`, and `optionalDependencies` of every package.json from the
  target directory up to the workspace root. The target directory is the
  directory the command writes into, or for build the directory where it ran
  (FR21). The workspace root is the nearest directory at or above the target
  directory that declares a workspace, by a `pnpm-workspace.yaml` or a
  package.json `workspaces` field; outside a workspace it is the directory of
  the nearest package.json, and with no package.json the project declares
  nothing. `missingPackages` MUST list each needed package (FR14) that the
  project does not declare, once, and is empty when none is missing.
- **FR16 — Astryx packages count like any other.** An Astryx package MUST count
  under FR14 and FR15 like any other package: a source that imports one the
  project does not declare lists it in `missingPackages`, and in the install
  command when there is one (FR17). No package is exempt by name.
- **FR17 — One install command, in the project's package manager.** When
  `missingPackages` is not empty and the package manager is not ambiguous
  (FR19), `installCommand` MUST be one command that installs every missing
  package with the project's package manager; otherwise it is null. The nearest
  directory, from the target directory up to the workspace root, that has a
  package.json `packageManager` field or a lockfile decides the package manager.
  The field names it. Without the field, the lockfiles name the package manager
  they belong to; when they belong to several, the directory names the one of
  those that its files read by only one package manager (such as
  `pnpm-workspace.yaml` or `.yarnrc.yml`) point to, when they point to exactly
  one of them. When no directory there has a field or a lockfile, the project's
  package manager is npm, which ships with Node.
- **FR18 — The command runs as printed.** Each package in the install command
  MUST carry the version range declared for it by the package that ships the
  template's source file, which is not always the package the receipt names as
  the template's owner: its `peerDependencies` range, else its `dependencies`,
  `optionalDependencies`, or `devDependencies` range, in that order. The
  shipping package itself, when the source imports it, carries the version the
  template was read from. Any other package it declares no version range for, by
  declaring nothing or only a protocol value such as `workspace:` or `catalog:`,
  is named without a range. Run as printed, from the directory where the command
  ran, in any common shell (Platform support), the command MUST hand every
  argument to the package manager unchanged and MUST add every missing package
  to the package.json nearest the target directory, so FR15 then finds it
  declared.
- **FR19 — No command when the package manager is ambiguous.** When the
  directory that decides the package manager (FR17) has no `packageManager`
  field and holds lockfiles of several package managers, and its files read by
  only one package manager point to none of those or to more than one, the
  package manager is ambiguous. `installCommand` MUST then be null,
  `missingPackages` still lists every missing package, and `notes` MUST name
  them and say why there is no command: the directory and the lockfiles that
  disagree.
- **FR20 — A missing StyleX compiler gets a note.** When the source imports
  StyleX (`@stylexjs/stylex`) by an import that is not type-only, and no package
  the project declares (FR15) is a StyleX compiler, a package that compiles
  StyleX at build time, `notes` MUST say that the project needs a StyleX
  compiler and name the docs that set one up. The compiler stays out of
  `missingPackages` and the install command, because setting it up takes
  configuration as well as a package.
- **FR21 — Every recommended template carries the same readiness.** The
  `template.copy` receipt MUST carry `notes`, `missingPackages`, and
  `installCommand`, and build's start and each alternative MUST carry the same
  fields, with the values the receipt carries when that template is scaffolded
  into the directory where build ran, from the source its scaffold command (FR5)
  writes. `notes` holds one line for each thing the project lacks: the missing
  packages with their install command or the reason there is none (FR19), and a
  missing StyleX compiler (FR20); the receipt's `notes` is empty when the
  project lacks nothing. One exception keeps build's response additive (FR22):
  on build's start and alternatives, `notes` is present only when it holds a
  line. Text shows each template's readiness beside it, as FR12 and
  `spec:AST-017/FR13` require.
- **FR22 — Readiness reports and never fails.** Readiness MUST NOT change either
  command's exit status: with packages or a compiler missing, `astryx template`
  writes its file and `astryx build` returns its recommendation, each with the
  exit status it has without them. JSON changes MUST stay additive under
  `spec:AST-017`: every existing field keeps its name and type, and new data
  arrives as new fields. FR14–FR21 decide the values that `notes`,
  `missingPackages`, and `installCommand` carry (which packages, which ranges,
  and whether there is a command), as ranking decides values under FR11;
  `spec:AST-017` classifies a change in those values.

### Platform support

- Supported floor: every shell and package manager the CLI already supports.
  Commands are printed in text with the command name `spec:AST-081` FR5
  picks, and without it in JSON, as the CLI does for every command it prints;
  the install command
  (FR17) is the package manager's own and reads the same in both. The common
  shells an install command runs in as printed (FR18) are sh-compatible shells
  such as bash and zsh, fish, PowerShell (Windows PowerShell 5.1 and PowerShell
  7), and the Windows command prompt.
- Unsupported behavior: none; build writes nothing, and readiness only reports
  (FR22), so there is nothing to degrade. An ambiguous package manager gets a
  note instead of a command (FR19).
- Browser evidence: not applicable; the recommendation and the receipt are CLI
  output.

## Current-state impact

This record changes these surfaces:

- the `build.kit` response carries the start, with its command, basis, reason,
  and alternatives; every existing field keeps its shape and meaning;
- build's text leads with the start and the alternatives, then the blocks and
  the components;
- the build playbook, the generated agent docs, and the consumer guides that
  describe building a page start from a template;
- the `template.copy` receipt reports scaffold readiness as FR14–FR20 state,
  and build's start and each alternative carry the same fields (FR21);
- the canonical response types and the response docs for `template.copy` and
  `build.kit` describe `notes`, `missingPackages`, and `installCommand` as
  FR14–FR22 state.

`architecture:cli-surface` keeps the command envelope and its rules unchanged.

## Verification

| Contract   | Verification                                    | Representative states                                                                                                                                                                                                        | Mutation or failure expectation                                                                                                                                        |
| ---------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR2   | Build kit tests                                 | a request that names a template; a loose request; a request no template fits                                                                                                                                                 | A page build with no start, or a start that is not a page template                                                                                                     |
| FR3        | Build kit tests                                 | a part placed in a page; a part with no page; a change to an existing page                                                                                                                                                   | A part with no page starting from a page template, or a change to an existing page starting from that page's template                                                  |
| FR4        | Build kit tests                                 | search's direct match is not ready                                                                                                                                                                                           | An unready template as the start or an alternative, or a reason that hides it                                                                                          |
| FR5        | Template integration tests                      | an integration template that replaces a Core one; a block sharing a page template's id                                                                                                                                       | A start or alternative command that selects another template, or none                                                                                                  |
| FR6, FR7   | Build kit tests                                 | direct, closest, and fallback starts                                                                                                                                                                                         | A start without a basis or reason, or without its alternatives                                                                                                         |
| FR8, FR12  | Text field tests                                | default and `--verbose` text for a build with every field populated                                                                                                                                                          | A response field the text never shows, or text out of the required order                                                                                               |
| FR9        | CLI text tests                                  | any page build                                                                                                                                                                                                               | Text without the keep-your-page line                                                                                                                                   |
| FR10, FR11 | Response type and build tests                   | the canonical response type, the response docs, and existing fields on a page build                                                                                                                                          | A missing recommendation field, or an existing field whose shape changes                                                                                               |
| FR13       | Playbook and agent docs tests                   | the playbook steps and the generated agent docs block                                                                                                                                                                        | A surface that tells builders to compose a page from primitives before a template                                                                                      |
| FR14       | Readiness fixture tests                         | every import form, a type-only import, and a scoped subpath; an import inside a comment, a string, and a template literal; a Node built-in, a relative path, and a URL                                                       | An import inside a comment or a string that counts, or a real import that does not                                                                                     |
| FR15       | Readiness fixture tests                         | a package declared only at the workspace root; one in each dependency field; a target in a nested package; a package.json above the workspace root; no package.json                                                          | A declared package reported missing, or a package.json above the workspace root that counts                                                                            |
| FR16       | Readiness fixture tests                         | a source that imports an Astryx package and a framework package the project does not declare                                                                                                                                 | An undeclared package left out of `missingPackages` because of its name                                                                                                |
| FR17       | Readiness fixture tests                         | a lockfile only at the workspace root; a `packageManager` field beside another lockfile; two lockfiles and a workspace file; no lockfile                                                                                     | A command in a package manager the deciding directory does not name                                                                                                    |
| FR18       | Readiness fixture tests and shell quoting tests | ranges with spaces, a caret, comparison operators, a wildcard, and a union; a protocol value; a template an integration ships that imports its own package; a command run outside the target's package                       | A range the shipping package does not declare, an argument a common shell splits, expands, or redirects, or a package not added to the package.json nearest the target |
| FR19       | Readiness fixture tests                         | two lockfiles in one directory: with and without a `packageManager` field, with one file only one package manager reads, with two such files for different package managers, and with only a package.json `workspaces` field | A command printed for an ambiguous package manager, or a note that names no lockfile                                                                                   |
| FR20       | Readiness fixture tests                         | a source that imports StyleX at run time and one that imports only its types, with and without a declared compiler                                                                                                           | A missing compiler with no note, a note beside a declared compiler or for a type-only import, or a compiler in the install command                                     |
| FR21       | Build kit and template copy tests               | the start and each alternative against the receipt for the same template and directory; default and `--verbose` text                                                                                                         | A recommended template without the fields, a value the receipt would not carry, an empty `notes` on a build entry, or a field the text never shows                     |
| FR22       | CLI exit-code and response type tests           | a scaffold and a build with packages and a compiler missing                                                                                                                                                                  | A changed exit status, or an existing field whose name or type changes                                                                                                 |

## Decision log

### DEC-1 — Start from a template, always

**Reference:** `spec:AST-048/DEC-1`
**Decider:** `josephfarina`, `2026-09-28`

Templates carry the spacing judgment and a kit of loose parts does not, so the
recommendation is always a full page template, and blocks and components fill
it.

Rejected: naming a template only on an exact match and a layout reference
otherwise, which leaves most builders composing from primitives.

### DEC-2 — A shell when nothing leads

**Reference:** `spec:AST-048/DEC-2`
**Decider:** `josephfarina`, `2026-09-28`

When no template has the evidence to lead, the start is a neutral app shell,
so even an unusual request starts from a frame the system decided.

Rejected: no start at all, which hands the builder primitives; and the best
weak match, which lets one incidental word pick the page.

### DEC-3 — The builder can overrule the pick

**Reference:** `spec:AST-048/DEC-3`
**Decider:** `josephfarina`, `2026-09-28`

The builder judges meaning better than word matching does, so every start
comes with the next best templates and their commands.

Rejected: a single pick with no alternatives.

### DEC-4 — A part starts where it lives

**Reference:** `spec:AST-048/DEC-4`
**Decider:** `josephfarina`, `2026-10-01`

A part is built inside a page. When the request gives that page, its template
is where the part goes; when it gives none, the app shell is the frame, and the
blocks and components carry the part. A change to an existing page is the same
case: the page is the builder's own, so no template is its start.

Rejected: starting a part from whichever page template shares a word with it,
which scaffolds an unrelated page for a single control; and giving a part no
start, which breaks DEC-1.

### DEC-5 — The response stays additive

**Reference:** `spec:AST-048/DEC-5`
**Decider:** `josephfarina`, `2026-09-29`

Callers read build's JSON, so the recommendation arrives as new data beside
the existing fields, and ranking changes move values rather than shapes. Text
may change with the recommendation.

Rejected: replacing existing fields with the recommendation, which breaks JSON
callers for a policy change they did not ask for.

### DEC-6 — The source and the workspace say what is missing

**Reference:** `spec:AST-048/DEC-6`
**Decider:** `josephfarina`, `2026-10-09`

The source a command writes is the only authority on what it imports, and the
package.json files from the target directory up to the workspace root are the
authority on what the project declares; a workspace keeps shared packages at
its root, so they count for every package below it. What is missing is the
difference, and no package is exempt by name, because an exemption hides a
missing package exactly when the project lacks it. A source that uses StyleX
also needs a compiler, which takes configuration as well as a package, so the
compiler gets a note and stays out of the install command.

Rejected: a list of packages assumed installed, Astryx or framework packages
among them, which hides what a fresh project lacks; and matching text that
looks like an import, which counts imports inside comments and strings.

### DEC-7 — One runnable command, or none

**Reference:** `spec:AST-048/DEC-7`
**Decider:** `josephfarina`, `2026-10-09`

A builder or an agent copies the install command and runs it, so it is one
command in the project's own package manager that reaches that package manager
unchanged from every common shell and leaves nothing missing. Its versions are
the ranges the package that ships the template declares, with that package
itself at the version the template came from, and a peer range comes first
because it states what that package supports beside the project's code.
Installing with the wrong package manager writes a second lockfile and a second
dependency tree, so when the project's package manager is ambiguous the receipt
names what to install and why there is no command, and the builder picks the
tool.

Rejected: falling back to npm when lockfiles disagree, which deepens the
conflict the receipt reports.

### DEC-8 — Every recommended template says what it needs

**Reference:** `spec:AST-048/DEC-8`
**Decider:** `josephfarina`, `2026-10-09`

The builder chooses among the start and its alternatives (DEC-3), and what a
template needs is part of that choice, so each one carries the receipt's fields
with the values its scaffold would report.

Rejected: readiness on the start only, which hides what an alternative needs
until it is scaffolded.

### DEC-9 — Readiness reports and never fails

**Reference:** `spec:AST-048/DEC-9`
**Decider:** `josephfarina`, `2026-10-09`

A missing package is the next step after a scaffold, not a failure of it, so
neither command's exit status depends on readiness, and callers keep reading
the existing fields as they are (DEC-5).

Rejected: failing the scaffold when a package is missing, which blocks a
builder who installs afterwards.

## Open questions

- **OQ1 — How recommendation quality is measured.** Whether changes to what
  build recommends must report their effect on a checked-in set of
  representative requests with accepted templates, separately for requests
  that describe a whole page and requests that describe a part of one.
  (`human-design`)

---

### Specs AST 050 App Themes Spec (`docs/specs/AST-050-app-themes/spec.md`)

---
schema_version: 4
template_version: 2
kind: system-spec
id: spec:AST-050
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-05
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [theme, authoring, cli]
---

# App themes system spec

## Intent

A builder, a person or a coding agent, wants an app that uses one or more themes:
a first-party theme, a theme from an installed integration package, or a theme
the app makes itself. They want to find a theme, add it, choose the default,
let users switch between themes at runtime, and know that the setup is correct.

A theme that an app copies as source stops receiving its owner's updates, loses
its package identity, and must be built again by the app. It also loses any
stylesheet its package loads beside it, such as its fonts, so it can render in
fallback fonts with no error. An app that uses several themes needs one place
that lists them, one way to switch among them, and a check that proves the
setup.

This record owns how an app finds, declares, imports, switches, and checks its
themes through the CLI. The rule is the same for every theme: an app uses a
theme by importing its built form and passing it to `Theme`, and customizes it
by extending that theme. Copying a theme's source is an explicit author fork.
`theme add` reaches that rule in two lifecycle stages (FR12): an opt-in import
ships beside its deprecated copy default, and the import becomes the default in
a minor an owner schedules. `architecture:theme-application` already supports
several built themes on one page, because built theme CSS is scoped to the
theme's name and switching changes only the active identity. `spec:AST-017` owns
compatibility, lifecycle, and response fields, `spec:AST-042` owns command
admission, `spec:AST-040` owns writes to consumer files, and
`architecture:cli-surface` INV19 and INV24 own how integration items are
described.

## Non-goals

- A Core component for switching themes, and saving a user's choice. The
  generated module gives an app everything a switcher needs; a Core component is
  a separate component decision.
- Loading theme stylesheets lazily.
- Finding themes that are not installed.
- Changing how a theme is authored, compiled, or applied at runtime.
- Merging or renaming the first-party theme packages.
- Making theme source CLI-owned. The generated module only imports and lists the
  app's themes; every theme's source stays with its author.
- How a font stylesheet delivers its fonts. Font files in the package and a font
  service both satisfy FR7; the package chooses.
- Reading the 0.6 theme catalog (`themes/manifest.json`). Packages describe
  themes with typed descriptors, no root holds a catalog file
  (`architecture:cli-surface` INV24), and no theme command reads one.
- Equivalent internal implementations remain valid when they satisfy this
  contract. The generated module's exact text, the discovery code, and the
  receipt field order are implementation.

## Requirements

- **FR1 — Importing a theme adds it to the app.**
  `theme add <slug> --import [--package <package>]` MUST make the theme part of
  the app by adding it to the app's theme record (FR3) and regenerating the
  app's theme module (FR2). It MUST NOT copy theme source into the project and
  MUST NOT edit application code. Importing a theme that is already added
  regenerates the module and reports no change. Importing a slug that is already
  added from a different owner switches it to the new owner and reports that
  change. `--import` with a target path or `--overwrite`, which belong to the
  copy (FR12), MUST fail before writing.
- **FR2 — One generated theme module.** The CLI MUST keep one module that
  imports each added theme's built module and stylesheet. It exports `themes`,
  every added theme keyed by its slug, and `defaultThemeSlug`, the default slug;
  a TypeScript module also exports the type `ThemeSlug`, the union of the added
  slugs. These exports are the module's public contract, and its theme record
  (FR3) is not one of them. The module carries a generated marker, and the CLI
  regenerates the whole module instead of editing it (`spec:AST-040/FR5`). The
  module is TypeScript when the project uses TypeScript and JavaScript
  otherwise. It has one fixed home: `astryx-themes.ts` or `astryx-themes.js` in
  the project's `src` folder when the project has one, and otherwise in the
  project root. A module that already exists stays where it is, even when the
  project gains or loses a `src` folder. No configuration chooses the home
  (`spec:AST-017/FR19`). When the home holds a file the CLI did not generate, or
  more than one generated theme module exists, the command MUST fail before
  writing and name the files.
- **FR3 — The module is the app's theme record.** The theme module records each
  added theme's slug and owner, a package name or the project's local themes
  root, and the default slug, in a form the CLI reads without running the
  module. The importing `theme add`, `theme remove`, and `theme use` read that
  record, change it, and regenerate the module. The default MUST be one of the
  added themes. No `package.json` field, configuration key, or environment
  variable records or selects the app's themes (`spec:AST-017/FR14`,
  `spec:AST-017/FR19`). In a project with a theme module, a command that reads
  the app's default theme reads the module's record; in a project with no theme
  module, an existing `astryx.theme` field keeps its released meaning. The CLI
  does not read `ASTRYX_THEME` (DEC-7).
- **FR4 — The set and the default are managed by command.**
  `theme remove <slug>` removes a theme from the app, and `theme use <slug>`
  makes an added theme the default. Removing the default theme MUST fail and
  name `theme use`; `theme use` on a theme that is not added MUST fail and name
  the import command. Each command regenerates the module (FR2).
- **FR5 — One receipt describes the app's themes.** The importing `theme add`,
  `theme remove`, and `theme use` MUST return the `theme.app` response: every
  added theme with its slug, owner, and the module and stylesheet it imports;
  the default slug; the module path; and the change the command made. Text
  output shows the same facts. The first import in a project also shows the
  one-time wiring: import `themes` and `defaultThemeSlug` from the module and
  pass `themes[defaultThemeSlug]` to `Theme`.
- **FR6 — Ejecting is the author fork.**
  `theme eject <slug> [path] [--overwrite] [--package <package>]` MUST copy a
  theme's complete source directory into the project exactly as the copying
  `theme add` does (FR12), with the same path safety, overwrite rule, and
  rollback. It MUST also copy the theme's same-stem descriptor
  (`architecture:cli-surface` INV24), so the ejected theme is a discoverable
  local theme (FR8). The copy MUST NOT describe itself as maintained by the
  theme's original owner. It returns the copy fields of the `theme.add` response
  as the `theme.eject` response, with the descriptor in its file list.
- **FR7 — A package exposes each theme to import.** A package makes a theme
  importable by exporting its built module at `./themes/<slug>` and its
  stylesheet at `./themes/<slug>.css`. A package that owns exactly one theme MAY
  instead export `./built` and `./theme.css`. When a theme needs fonts that are
  not system fonts, the package SHOULD export a font stylesheet at
  `./themes/<slug>.fonts.css`, or `./fonts.css` for a single theme. The font
  stylesheet owns loading its fonts: it may ship the font files with
  `@font-face` rules or import them from a font service. An import MUST fail,
  naming what is missing, when a theme has no resolvable built module and
  stylesheet; it never falls back to copying or to runtime source.
  `integration add theme` MUST write these exports, and `integration verify`
  MUST fail when an exported theme module, stylesheet, or font stylesheet does
  not resolve from the packed tarball, or when a theme exports both its built
  module and its stylesheet and either one does not match its source. The
  exported module matches its source when it is the module `theme build` writes
  from that source, or when it only loads the theme's own styles (DEC-10): each
  of its statements imports the theme's exported stylesheet or font stylesheet
  for its side effects, except one, which re-exports from the module
  `theme build` writes either everything or only its theme, under the theme's
  export name. A theme
  that exports only part of that pair, or none of it, stays packable when every
  path it exports resolves, and `integration verify` warns about what is missing
  or does not match.
- **FR8 — Local themes are added like package themes.** The project's local
  themes root is `src/themes`: the folder the copying `theme add` writes into
  and the folder `theme eject` copies into by default, also in a project with no
  `src` folder. A theme directory there, with the same shape as an integration
  theme (`architecture:cli-surface` INV19), is listed and imported like a
  package theme. Its built module and stylesheet are the files `theme build`
  writes beside its source, and its font stylesheet, when it has one, is
  `<slug>.fonts.css` beside them. An import MUST fail and name the build command
  when the built module or stylesheet is missing. When a local theme and a
  package theme share a slug, an import adds the local theme unless `--package`
  names the package, and `theme list` shows both with their owners. The copying
  `theme add` resolves bundled and package themes only, as released.
- **FR9 — The module imports built themes only.** The generated module MUST
  import built themes, their stylesheets, and their font stylesheets when the
  package exports one, never theme source for runtime style injection, so every
  added theme is present at first paint and a switch never waits for styles.
- **FR10 — Listing shows the app's themes.** `theme list` MUST mark each theme
  as added or not and as the default or not for the project, and name whether it
  is bundled, from a package, or local. These fields are additive, and local
  themes join the list as additional entries.
- **FR11 — Doctor proves the setup.** In a project with a theme module, doctor
  MUST check, and pass each check only on positive evidence:
  1. every added theme's owner is installed and its module and stylesheet
     resolve from the project;
  2. the theme module exists, carries the CLI's generated marker, and is exactly
     what the CLI generates from the record it carries;
  3. project source imports the theme module (a warning when this cannot be
     shown);
  4. no built theme module is imported without its stylesheet;
  5. every added local theme is built, and its outputs match its source;
  6. no added theme sets a private `--_*` variable directly
     (`architecture:theme-compilation` INV6);
  7. every added theme's `@astryxdesign/core` peer range accepts the installed
     Core;
  8. the default theme is one of the added themes;
  9. every font family an added theme names is a system font or is loaded by a
     font stylesheet the module imports (a warning that names the family when
     this cannot be shown, as when the stylesheet imports its fonts from a font
     service);
  10. no two added themes write different rules outside their own theme scope
      for the same selector, because every added stylesheet loads at once.

  Each failure names the exact command that fixes it.

- **FR12 — The copy default leaves through its lifecycle.** The copying
  `theme add`, its options, and its `theme.add` response are released
  contracts. `theme add` reaches FR1 through its `spec:AST-017` lifecycle:
  1. **The copy default is deprecated.** Without `--import`,
     `theme add <slug> [path] [--overwrite] [--package <package>]` copies the
     theme's source and returns `theme.add` as released. It emits at most one
     stderr warning per invocation, naming `theme eject` for a fork and
     `theme add --import` for using the theme, and its machine result
     carries the deprecation id and the replacement (`spec:AST-017/FR28`,
     `spec:AST-017/FR29`). Its exit status, canonical output, and the rest of
     its machine result are the released ones. `theme add --list` keeps its
     machine result at every stage, and its text names the commands of the
     current stage.
  2. **Cleanup lands in a declared minor.** When `main` declares a minor, a
     `[breaking]` Changeset describing the change (`spec:AST-017/FR31`) lets
     `theme add <slug>` import as FR1 states, with or without `--import`, and
     returns `theme.app`, and `theme add` accepts no target path or
     `--overwrite`, which stay on `theme eject`. That minor carries no other
     delta from this record (`spec:AST-017/FR39`).

  Existing projects migrate as follows (`spec:AST-017/FR8`):
  1. A script that copies with `theme add` runs `theme eject` with the same
     arguments; one that wants the app to use the theme runs
     `theme add --import`, which keeps its meaning after the cleanup. No codemod
     rewrites these calls, because the right replacement depends on which of the
     two the caller meant; the deprecation warning names both.
  2. A theme copied by `theme add` is app source. The CLI MUST NOT move, change,
     or delete it, and the app's imports of it keep working. The copy lacks the
     theme's descriptor, so `astryx upgrade` MUST supply a project codemod that
     writes the missing descriptor beside each such copy in the local themes
     root, marked as not maintained, which makes the copy a local theme (FR8).
     The codemod changes nothing else, and a second run changes nothing
     (`spec:AST-040`).
  3. Until that codemod runs, a folder in the local themes root that holds a
     theme source without a descriptor MUST NOT fail a theme command or doctor.
     `theme list` and doctor name it as an unmigrated copy, with the upgrade
     command, and it is not listed or imported as a theme. For the local themes
     root only, this replaces the rule that such a folder fails discovery
     (`architecture:cli-surface` INV19).
  4. A project with no theme module keeps working as it is. Doctor reports that
     the CLI manages none of its themes and names the import command; the FR11
     checks apply once a theme module exists.
  5. A caller that sets `ASTRYX_THEME` chooses the default with `theme use`, or
     with `astryx.theme` in a project with no theme module.

- **FR13 — Every builder-facing surface agrees.** The theme guide, the
  integration guide, the generated agent docs, and `init`'s next steps MUST
  describe the same workflow: import themes, wire the module once, switch
  through the exported themes, extend a theme to customize it, and eject only to
  fork. They name `theme add --import` while the copy default is deprecated and
  `theme add` once it is removed. No surface teaches copying as the way to use a
  theme.
- **FR14 — Search finds the themes the list shows.** `astryx search` MUST index
  each theme `theme list` shows as a `theme` result, which `--type theme`
  selects. A theme result MUST carry its slug as `name`, its display name, its
  description, the package that ships it, and the `theme add` command for the
  CLI's FR12 stage: `theme add <slug>` in a CLI without `--import`,
  `theme add --import <slug>` while the copy default is deprecated, and
  `theme add <slug>` after the cleanup (FR13). A theme's slug and display name
  are its names, and its description is prose (DEC-9): a query word the theme
  shares only through its description ranks it as a description mention, never
  as a name or keyword match, so that word alone never ranks the theme above a
  result that matches it by name, title, or keyword. Searching themes MUST NOT
  need `@astryxdesign/core`, because listing them does not, so an open search
  outside an app includes them (DEC-8). Help, the manifest, and the API
  reference MUST list the `theme` domain and its result fields.

### Platform support

- Supported floor: every runtime and package manager the CLI supports, and every
  toolchain that imports CSS from a JavaScript module.
- Unsupported behavior: a toolchain that cannot import CSS from a module loads
  each added theme's stylesheet itself; the module still exports the themes, and
  doctor check 4 names the stylesheets to load.
- Browser evidence: a consumer app built for production imports a first-party,
  an integration, and a local theme, renders the default on first paint, and
  switches among all three.

## Current-state impact

- `theme add` gains `--import`; its copy default and `theme.add` response are
  deprecated and leave `theme add` in a scheduled minor (FR12); `theme eject`,
  `theme remove`, and `theme use` join the `theme` command; `theme list` gains
  its app fields and lists local themes;
- `astryx search` finds the themes `theme list` shows, as the `theme` domain,
  and ranks a word found only in a theme's description as prose (FR14);
- `architecture:cli-surface` INV19: integration themes are importable packages,
  and editable source is an explicit eject;
- `integration add theme` writes theme exports, and `integration verify` checks
  them;
- doctor's theme check is FR11 in a project with a theme module, and the FR12
  report in a project without one;
- `astryx upgrade` carries the codemod that gives copies their descriptors
  (FR12);
- the CLI stops reading `ASTRYX_THEME` (DEC-7);
- the theme guide, the integration guide, agent docs, and `init` next steps
  describe the FR13 workflow.

`architecture:theme-application` and `architecture:theme-compilation` are
unchanged.

## Verification

| Contract | Verification                                                                                        | Representative states                                                                                                                                                                                                                                                                                            | Mutation or failure expectation                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| -------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR2 | Theme command tests and a real consumer                                                             | first import; repeat import; a slug imported again from another owner; the module's exports; `--import` with a target path or `--overwrite`; hand-written file at the module home; no TypeScript; no `src` folder; `src` added later                                                                             | Source is copied, app code changes, a repeat import changes files, a second owner's import keeps the old owner, `--import` with a copy option writes anything, an export is missing or renamed, the theme record is exported, a user file is replaced, or an existing module moves                                                                                                                                                                                                     |
| FR3, FR4 | Theme record tests                                                                                  | import, remove, use; removing the default; using a theme not added; a hand-edited module; two modules; no module with `astryx.theme`; `ASTRYX_THEME` set                                                                                                                                                         | The default is not an added theme, a hand-edited or second module is overwritten, theme state is written outside the module, `astryx.theme` stops resolving, or `ASTRYX_THEME` changes which theme a command reads                                                                                                                                                                                                                                                                     |
| FR5      | Response type and text field tests                                                                  | every command; first import                                                                                                                                                                                                                                                                                      | An import emits `theme.add`, a field has no text projection, or the first import shows no wiring                                                                                                                                                                                                                                                                                                                                                                                       |
| FR6      | Eject tests against the copy fixtures                                                               | bundled, integration, and nested-file themes; eject then list                                                                                                                                                                                                                                                    | Copied source bytes or copy receipt fields differ from the copying `theme add`, the descriptor is missing, or the ejected theme is not listed as local                                                                                                                                                                                                                                                                                                                                 |
| FR7, FR8 | Integration verify and real provider-to-consumer tests                                              | multi-theme package; single-theme package; missing export; font stylesheet missing from the tarball; stale build; partial export set; a local theme sharing a package theme's slug; local themes in a project with no `src` folder; a module that loads its own styles; a theme whose name differs from its slug | A theme without a resolvable built module is imported, verify passes a stale complete pair, an unresolved export, or a module that adds more than its own stylesheet imports and a re-export of the built module, verify fails a partial export set whose exports resolve or a module that only loads its own styles, a shared slug imports the package theme without `--package`, the copying `theme add` resolves a local theme, or local themes are looked for outside `src/themes` |
| FR9      | Generated module tests                                                                              | package and local themes                                                                                                                                                                                                                                                                                         | The module imports source                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| FR10     | List tests                                                                                          | added, default, bundled, package, local                                                                                                                                                                                                                                                                          | A listed theme lacks its app fields                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| FR11     | Doctor tests, one planted fault per check                                                           | each of the ten faults; a correct app                                                                                                                                                                                                                                                                            | A check passes on its fault, or passes without positive evidence                                                                                                                                                                                                                                                                                                                                                                                                                       |
| FR12     | Lifecycle tests against the latest stable CLI, migration tests on a project it made, and docs tests | plain `theme add`; the cleanup build; a copy made by the latest stable `theme add`; the upgrade codemod run twice; no theme module                                                                                                                                                                               | Before the cleanup, plain `theme add` stops copying, changes its exit status, stdout, or `theme.add` fields, omits the deprecation id, or warns more than once; after it, plain `theme add` copies; a theme command or doctor fails on an unmigrated copy; the codemod changes more than the descriptor or changes anything on its second run; a copy is moved or deleted; or doctor fails a project with no theme module                                                              |
| FR13     | Docs and agent-docs tests                                                                           | theme guide, integration guide, agent block, init next steps                                                                                                                                                                                                                                                     | A surface teaches copying as the way to use a theme, or names a different import command than the current lifecycle stage                                                                                                                                                                                                                                                                                                                                                              |
| FR14     | Search tests and CLI runs                                                                           | bundled and integration themes; `--type theme` and an open search outside an app; a word only in a theme's description, such as `focus`; an exact slug or display name; a CLI with and without `--import`; help and the manifest                                                                                 | A theme `theme list` shows is missing from search, a theme result lacks a field or names another command than its FR12 stage, a word only in a theme's description ranks it as a name or keyword match or above a result matching that word by name, title, or keyword, a themes-only search needs Core, or help, the manifest, or the API reference omits the domain                                                                                                                  |

## Decision log

### DEC-1 — Consumers import; copying is an author fork

**Reference:** `spec:AST-050/DEC-1`
**Decider:** `josephfarina`, `2026-09-30`

A theme an app uses is a dependency, like a component library: it keeps its
owner's updates, its identity, and the stylesheets its package loads beside it.
An app customizes a theme by extending it. Copying is how a template starts a
page and how an author forks a theme, not how an app uses one. Consumer
`theme add` therefore imports, and source copying belongs to the explicit author
fork, `theme eject`.

Rejected: keeping `theme add` as a copy and adding a new verb for importing,
which leaves the obvious command doing the wrong thing for most builders.

### DEC-2 — The CLI owns one generated module and never edits app code

**Reference:** `spec:AST-050/DEC-2`
**Decider:** `josephfarina`, `2026-09-30`

An app wires one module once. After that, adding, removing, and choosing themes
changes only files the CLI generates, so no command needs proof that it may
change application source, and a switcher reads every added theme from one
export. The module has one fixed home that never moves, so the app's import
keeps working and nothing has to be set to find it.

Rejected: editing the app's root component on every import, which needs a
source transform for every framework.

Rejected: printing import lines only, which leaves the app to keep the list of
themes by hand.

Rejected: a configurable module path, which `spec:AST-017/FR19` admits only for
a case where the fixed home fails.

### DEC-3 — The generated module is the record

**Reference:** `spec:AST-050/DEC-3`
**Decider:** `josephfarina`, `2026-10-01`

The module already names every added theme, where it comes from, and the
default. A second record elsewhere would duplicate a value the CLI can read from
its own output, which `spec:AST-017/FR19` forbids, and would add a public field
that every app has to keep in step with the module.

Rejected: an `astryx.themes` field in `package.json`, which duplicates the
module as permanent public surface.

Rejected: a key in `astryx.config`, which released CLIs reject as unknown.

Rejected: detecting themes from application source, which cannot tell a theme
an app uses from one it only mentions.

### DEC-4 — Packages expose themes through their exports

**Reference:** `spec:AST-050/DEC-4`
**Decider:** `josephfarina`, `2026-09-30`

A fixed export path lets any app import a theme with any CLI version, or with no
CLI. It adds no descriptor field, so no released CLI meets an unknown field. The
single-theme form keeps the `./built` and `./theme.css` export names that built
theme packages already use, so an existing package can be imported without a
new release.

Rejected: import paths declared in the theme descriptor, which released CLIs
reject as an unknown field.

### DEC-5 — New subcommands under `theme`

**Reference:** `spec:AST-050/DEC-5`
**Decider:** `josephfarina`, `2026-09-30`

Each stays inside the `theme` command's job, managing an app's themes
(`spec:AST-042/FR4`):

- `theme remove` answers "stop using this theme". An option on `add` would give
  it a second job.
- `theme use` answers "start with this theme". An option on `add` could not set
  the default of a theme that is already added.
- `theme eject` answers "give me this theme's source to fork". It is the copy
  `theme add` makes while its copy default is deprecated, plus the descriptor a
  local theme needs (`spec:AST-042/FR6`); once the copy leaves `theme add`, an
  option on `add` would give it two opposite results.

None of them is eligible for the batch contract (`spec:AST-042/FR7`,
`spec:AST-053/FR11`), so each takes one selector:

- `theme remove <slug>` changes the app's themes: it fails E1 and E4.
- `theme use <slug>` changes the app's themes and picks the one default: it
  fails E1, E2, and E4.
- `theme eject <slug> [path]` writes files and takes a positional after the
  slug: it fails E1, E4, and E5.
- `theme add <slug>` changes the app's themes when it imports: it fails E1 and
  E4.

Each has one API function under `spec:AST-042/FR1`.

Rejected: one `theme set` command with flags for each action, which gives a
single command several jobs.

### DEC-6 — The import default arrives in two steps

**Reference:** `spec:AST-050/DEC-6`
**Decider:** `josephfarina`, `2026-10-05`

Every replacement ships first, in a compatible patch: `theme add --import`,
`theme eject`, `theme remove`, `theme use`, the generated module, the package
exports, and doctor, while the copy default keeps working with its warning. The
import default follows in a minor an owner schedules (`spec:AST-017/FR28`,
`spec:AST-017/FR47`), so every builder can reach the import path before it
becomes the default. The opt-in is an option on `theme add`, so the command
builders learn during the deprecation is the command that imports after it.

Rejected: switching the default in one release, which `spec:AST-017/FR48`
refuses while main targets a patch.

Rejected: opting in by the presence of a theme module, which changes what
`theme add` does without a visible control (`spec:AST-017/FR14`).

### DEC-7 — The CLI reads no theme environment variable

**Reference:** `spec:AST-050/DEC-7`
**Decider:** `josephfarina`, `2026-10-05`

`spec:AST-017/FR14` forbids the CLI from defining or reading any Astryx-owned
environment variable, so `ASTRYX_THEME` is not a supported way to choose a
theme, and the CLI does not read it in either step. An app's default theme lives
in its theme module's record (FR3), or in `astryx.theme` in a project with no
theme module.

Rejected: reading `ASTRYX_THEME` with a warning until the minor, which keeps
reading a variable `spec:AST-017/FR14` forbids.

Rejected: warning when `ASTRYX_THEME` is set, which reads the variable to warn.

### DEC-8 — Searching themes needs no Core

**Reference:** `spec:AST-050/DEC-8`
**Decider:** `josephfarina`, `2026-10-06`

`theme list` reads bundled themes without `@astryxdesign/core`, so a search of
themes does too, and an open search outside an app includes them. A builder who
has not set up an app yet can still find a theme by how it looks.

Rejected: requiring Core for every domain but docs, which fails a themes-only
search where `theme list` works.

### DEC-9 — A theme's description is prose

**Reference:** `spec:AST-050/DEC-9`
**Decider:** `cixzhang`, `2026-10-06`

A theme's description is a sentence written for a person choosing a look. Its
words describe a mood, so a word in it is a passing mention, ranked like any
other description, and not a label the theme's author chose for search. A
theme's names are its slug and its display name, and a theme declares no
keywords.

Rejected: reading every description word as a keyword, which ranks a theme first
for any word its description happens to use.

### DEC-10 — A theme module may load its own styles

**Reference:** `spec:AST-050/DEC-10`
**Decider:** `josephfarina`, `2026-10-20`

A package can make importing a theme's module the whole integration: the module
imports the theme's stylesheet and font stylesheet, then re-exports the built
theme, and consumers rely on that import to load the styles. `integration
verify` accepts that module when those imports and one re-export of the built
theme are its only statements. The re-export may give the built theme the
theme's export name, because `theme build` names the built module and its
export after the theme's own name, which can differ from the slug the package
exports it under. The re-exported module is still exactly what `theme build`
writes, and the stylesheet and font stylesheet stay separate exports, so the
generated module imports all three as before and a bundler loads each
stylesheet once.

Rejected: requiring the exported module to be the built module itself, which
makes such a package choose between passing verify and keeping its consumers'
imports working; and accepting any module that exports the theme, which lets
verify pass a module that no longer matches its source.

## Open questions

None.

---

### Specs AST 051 Discover Integration Manager Spec (`docs/specs/AST-051-discover-integration-manager/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-051
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-20
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: []
affects_consumer_docs: [authoring]
---

# Discover integration manager system spec

## Intent

A person or agent building an Astryx app wants to know which integrations
exist, what each one adds, which ones the app already has, and which versions
are published, without leaving the terminal. `astryx discover` answers that. It
is the read-only manager for integrations: it lists what the project has, the
official Astryx packages, and what its sources offer, shows each package's
versions and what they add, and searches and filters all of it. The project's
package manager installs, updates, and removes packages.

This record owns what discover shows, where the list of available integrations
comes from, the official list the CLI carries, and what happens when a source
cannot be reached. `spec:AST-031` owns how named-export features compose;
discover sources follow it except where this record says otherwise.

## Non-goals

- Installing, updating, or removing packages. The package manager does that,
  and autolink loads a newly installed integration with no discover step.
- Hosting a catalog. The official list ships inside each CLI release and is
  never fetched (FR11).
- How a source signs in. A source runs as the person running the CLI and uses
  whatever credentials its host already has. The CLI never prompts
  (`architecture:cli-surface` INV1) and reads no Astryx-owned environment
  variable for a source (`spec:AST-017` FR14).
- `astryx search`, which stays on installed content.
- Equivalent internal implementations remain valid when they satisfy this
  contract.

## Requirements

- **FR1 — The list shows what the project has and what it could add.** With no
  query, and with any query in a project that loads no integration and has no
  discover source of its own, discover MUST list every integration the project
  loads, configured or autolinked, and every official package the official list
  supplies (FR11) that the project's own `package.json`, the nearest one above
  the folder discover runs in, declares in `dependencies`, `devDependencies` or
  `optionalDependencies`, under its name or an npm alias, each with its
  version, what it adds as one list per kind, and the latest release a source
  knows. It MUST then list, once per integration, every package its sources
  offer that the project neither loads nor declares as a dependency, each with
  the package-manager command that adds it (FR2), and MUST NOT offer a package
  whose integration the project has under another name. Official packages come
  first in both lists. An integration that failed to load is left out, even an
  official one; the integration-issues warning and Doctor report it.
- **FR2 — A package page shows its versions and what they add.** `discover
<package>` MUST show what the shown version adds, grouped by kind, whether
  the project has the package, its latest release, and its releases newest
  first with their dates and any unreadable status, and MUST count its
  prereleases. `discover <package>@<version>` shows any version, prerelease
  included; the default is the installed version, else the latest release. A
  version no source lists MUST fail with `ERR_NOT_FOUND` and suggest releases.
  For a package the project does not have, discover MUST print the
  package-manager command that adds it, as text, and MUST NOT run it; for an
  alias of a package the project has, it names that package instead. For a
  package the official list supplies, that command pins what FR11 names and
  quotes a range for the shell.
- **FR3 — An item page shows one item.** `discover <package>/<Component>` for an
  installed component keeps printing its doc. Any other item a package adds,
  installed or listed by a source, MUST resolve to its kind, name, package,
  version, and whether the project has it.
- **FR4 — Search covers every kind and every source.** `discover <words>` MUST
  match installed components, the project's other installed items, package
  names and descriptions, and every item a source lists for a package the
  project could add, by name, title, summary, and keywords. It MUST answer with
  the list of matches, even when one name matches exactly or only one item
  matches, so the response type depends on the form of the query and never on
  what the project has. Matches are ranked by how closely the name matches,
  with installed items first among equals. Each match states its kind and
  whether the project has its package. One item opens by its package path
  (FR3).
- **FR5 — Filters follow the CLI's existing options.** `--type <kind>` keeps one
  kind (`component`, `template`, `doc`, `theme`, `codemod`, `agent-doc`): in the
  list, packages that add it; in a search, items of that kind. `--installed`
  and `--available` keep one side and MUST be refused together with
  `ERR_INVALID_OPTION`. `--limit <n>` caps search results (default 20) and the
  response reports the total when it cut the list. An unknown kind or a limit
  that is not a positive integer MUST fail with `ERR_INVALID_OPTION`.
- **FR6 — Sources are functions a project, an integration, or a door
  provides.** A source is an async function that receives
  `{signal, package?, version?}` and resolves to a catalog; `package` asks for
  one package with every version, and `version` asks for that version's items.
  A project MAY set one as the `discover` field of `astryx.config`, each loaded
  integration MAY export one as its `discover` named export, and a door MAY
  pass its own (`spec:AST-081` FR1), which discover names as the door's. Every
  source is called, the project's first, then each integration's in load
  order, then the door's, and the same function runs
  once; discover then reads the official list (FR11).
  A source that throws, exceeds 30 seconds (signalled on `signal`), or
  returns an invalid catalog fails alone: the others still count, and discover
  reports which source failed and why. Sources only read, so `spec:AST-031`'s
  consent gate does not apply to them.
- **FR7 — The catalog shape is versioned.** A catalog is `{schemaVersion: 1,
source, packages}`. `source` has `name`, `generatedAt`, and `complete`. Each
  package has `package`, `integration` (shared by every package name that
  publishes the same integration), `aliases`, an optional `description`,
  `latest`, `versions` (each with `version`, `publishedAt`, `prerelease`, and
  `status`), and the `contributions` of the requested or latest version (each
  with `kind`, `name`, and optional `title`, `summary`, and `keywords`). The CLI
  MUST validate every answer, MUST ignore fields and kinds it does not know,
  MUST refuse any other `schemaVersion`, and MUST show versions newest first by
  publish time whatever order the source returned them in. When two sources
  list the same package, the earlier source's entry is used.
- **FR8 — A saved copy covers an unreachable source.** After a source answers,
  discover MUST save that answer, per source and per request, in the per-user
  cache directory of the platform, never in the project. When the source later
  fails, discover MUST use the saved answer for the same request and say when
  it was saved. With no saved answer, the source adds nothing and discover says
  so. Installed integrations always appear, with or without a source.
- **FR9 — JSON stays compatible.** Existing response types and fields keep their
  meaning; new information is additive. `discover.list` `data` lists what the
  project has (FR1), with optional per-kind lists and `latest`;
  `meta.available` lists what the project could add and `meta.sources` reports
  each source, the official list last. `discover.detail` carries
  `installed` and, with a source, `latest`, `aliases`, `source`, `versions`,
  and `install` or `installedAs`. Items other than installed components use the
  `discover.item` type. `discover.search` matches carry `kind` and `installed`,
  `component` holds the item name for every kind, and `data.total` appears when
  `--limit` cut the list. A free-text query returns `discover.search`, never
  `discover.detail.doc` (FR4).
- **FR10 — A package can link its docs site.** An integration manifest MAY
  set `docsiteUrl`, a URL of the package's docs site, so catalogs that list
  the package can link to it. The CLI MUST accept it as a known key, without
  an unknown-key warning, and does nothing else with it.
- **FR11 — The CLI carries the official list.** Each CLI release carries the
  list of official packages: every package that release publishes that adds at
  least one item discover lists. The list is generated from the repository when
  the CLI is built and never edited by hand, and a check fails when it no longer
  matches the repository. It holds no versions. An entry's latest release is the
  newest release the CLI knows: its own version on a release CLI, or the
  project's Core version when that is a newer release. An entry for a package
  that always releases at Core's version lists a release CLI's own version and
  the project's Core version, installed or declared exactly, so either can be
  looked up; its add command pins the project's Core version, else the Core
  range the project declares, else the latest release, and on a canary CLI in a
  project with no Core it pins none. Only a canary CLI lists the canary-only
  packages, as prereleases: at the project's Core version when that is a
  prerelease, at the CLI's own version when the project has no Core, and not
  beside a released Core or a Core declared without an exact version, which they
  cannot be installed with. For a package only the list supplies, a version or
  item the entry does not list fails with `ERR_UNKNOWN_PACKAGE`. Discover reads
  the list after every other source, so a source's entry is used (FR7) for an
  official package it lists and for any official package whose integration it
  publishes under another name; the list adds the official packages no source
  covers. Official packages come first in both lists. An official entry reads
  like any other package, with no section of its own, and names its source
  `Astryx`.

### Platform support

- Supported feature/engine floor: every supported CLI runtime.
- Unsupported behavior: none. What a source needs from its machine is its
  author's responsibility; a source that cannot run there fails under FR6.
- Browser evidence: not applicable.

## Current-state impact

- `astryx discover` listed only configured integrations with a components root
  and matched component names only; its `description` and `displayName` fields
  were never filled. This record replaces that behavior additively (FR9).
- `discover <Name>` opened an installed component's doc when the name matched
  exactly or was the only match. It now lists the matches, and
  `discover <package>/<Name>` opens the doc (FR4, DEC-4).
- `architecture:cli-surface`: the discover adapter calls sources and keeps the
  saved copy (INV21); the saved copy's path passes `assertWithin` under the
  cache directory (INV10).
- `spec:AST-031`: `discover` is a second named-export feature, without the
  consent gate (FR6).
- Consumer docs: `authoring` gains the `discover` named export and config
  field, and `astryx docs authoring` gains the `discover-source` section.
- `astryx discover` with no source configured listed only the project's
  integrations. It also lists the official packages, first, each with the
  command that adds it, and a declared official package the official list
  supplies counts as installed from any folder of the project (FR1, FR11). A
  version or item of a package only the official list supplies that its entry
  does not list fails with `ERR_UNKNOWN_PACKAGE`, the answer for a package no
  source lists (FR11).
  A query in a project that loads no integration and has no source of its own
  answers with the list, as it does without the official list (FR1).
- Release: the CLI build generates the official list from the packages the
  release publishes, and a drift check fails when the committed list is stale
  (FR11). `astryx docs authoring` `discover-source` says the official list comes
  before every source.

## Verification

| Contract | Verification                                                   | Representative states                                                                                                                                                                                                                                                                                         | Mutation or failure expectation                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1      | list leaf, catalog view, adapter, command tests                | installed only; available only; both; latest known; alias of an installed package; declared dependency; declared official package; official and other packages together; a query in a project with no integration and no source of its own                                                                    | an available package is missing, an alias or declared package is offered, latest is absent, a declared official package is not installed, an official package does not come first, or that query does not answer with the list                                                                                                                                                                                                                                              |
| FR2      | detail leaf, adapter, and command tests                        | installed; not installed; `@version`; prerelease; unpublished version; alias; official entry with and without Core                                                                                                                                                                                            | a version is dropped, the default is wrong, the add command runs, an alias is offered, or an official entry's add command is not pinned                                                                                                                                                                                                                                                                                                                                     |
| FR3      | item leaf and dispatcher tests                                 | installed component; installed template; listed item; unknown item                                                                                                                                                                                                                                            | an installed component loses its doc, or a listed item is not found                                                                                                                                                                                                                                                                                                                                                                                                         |
| FR4      | search leaf and command tests                                  | components, templates, docs, packages; installed and available; title and summary matches; an exact installed name; a single match                                                                                                                                                                            | a kind is not searched, installed state is wrong, a weak match ranks first, or a free-text query returns a doc instead of the list                                                                                                                                                                                                                                                                                                                                          |
| FR5      | dispatcher, leaf, and command tests                            | each kind; `--installed`; `--available`; both; `--limit`; bad values                                                                                                                                                                                                                                          | a filter is ignored, the pair is accepted, or a bad value passes                                                                                                                                                                                                                                                                                                                                                                                                            |
| FR6      | adapter source tests                                           | project and two integrations, then the official list; a shared function; a throw; a timeout; invalid data                                                                                                                                                                                                     | a failure hides other sources, a function runs twice, order changes, or the signal never aborts                                                                                                                                                                                                                                                                                                                                                                             |
| FR7      | catalog schema tests                                           | unknown field; unknown kind; `schemaVersion: 2`; duplicate package across sources; misordered versions                                                                                                                                                                                                        | an unknown field fails, another version is accepted, the later source wins, or versions keep the source order                                                                                                                                                                                                                                                                                                                                                               |
| FR8      | adapter saved-copy and command tests                           | fresh answer; failure with a saved copy; failure with none; another request                                                                                                                                                                                                                                   | a stale copy hides a fresh answer, the saved date is missing, or a project file is written                                                                                                                                                                                                                                                                                                                                                                                  |
| FR9      | existing discover JSON tests                                   | each response type with and without a source                                                                                                                                                                                                                                                                  | an existing field changes type or meaning                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| FR10     | manifest parser and validate tests                             | no field; a URL; a value that is not a URL                                                                                                                                                                                                                                                                    | the key raises an unknown-key warning, or a valid URL is refused                                                                                                                                                                                                                                                                                                                                                                                                            |
| FR11     | official list generator, drift test, adapter and command tests | stable and canary CLI; Core older and newer than the CLI; Core declared exactly, as a range, and as a path; canary Core; no Core; private package; package that adds nothing; theme folder that does not match its file; empty folder; subfolder; another source lists an official package or its integration | a private or empty package is listed, an unreadable theme package is skipped, the committed list drifts from the repository, latest is older than a release the CLI knows, the project's Core version cannot be looked up, a declared range is not pinned or not quoted, a canary-only package shows on a stable CLI or beside a released Core, an official entry replaces a source's entry for the same integration, or a version it does not list fails with another code |

## Decision log

### DEC-1 — Discover shows; the package manager installs

**Reference:** `spec:AST-051/DEC-1`
**Decider:** `josephfarina`, `2026-09-30`

Installing is the package manager's job, and autolink already loads a newly
installed integration, so discover only reads. For a package the project does
not have, it prints the command that adds it.

Rejected: `discover add`, `update`, and `remove` subcommands; running the
package manager from discover.

### DEC-2 — Other integrations come from sources

**Reference:** `spec:AST-051/DEC-2`
**Decider:** `josephfarina`, `2026-09-30`

Beyond the official list (DEC-5), a project or an integration provides a source
as a function, and discover merges every source. A catalog host that needs
sign-in works through the function, which runs with the credentials its host
already has, and a project can change what its sources list without a CLI
release.

Rejected: a catalog URL built into the CLI, which ties the public CLI to one
host; a source given as a URL for the CLI to fetch, which cannot sign in to a
private host without the CLI handling credentials.

### DEC-3 — Discover has its own search and filters, in the CLI's existing terms

**Reference:** `spec:AST-051/DEC-3`
**Decider:** `josephfarina`, `2026-09-30`

Discover lists, shows one package, version, or item, and searches with filters,
using the option names the CLI already has (`--type`, `--limit`) plus
`--installed` and `--available`. Its responses follow the existing discover
types, so a reader of `--json` learns nothing new to use it.

Rejected: sending discover's searches through `astryx search`, which answers
about installed content only.

### DEC-4 — A free-text query always answers with a list

**Reference:** `spec:AST-051/DEC-4`
**Decider:** `josephfarina`, `2026-09-30`

`discover <words>` is a search, so it lists every match across all packages,
the ones the project has and the ones it could add, even when a component's
name matches exactly or only one item matches. The response type then depends
only on the form of the query. One item opens by its package path.

Rejected: keeping the earlier shortcut, where an exact or only
installed-component match opened that component's doc. The same query then
returned a doc or a list depending on what the project and its sources held.

### DEC-5 — The official list is built into each release and read last

**Reference:** `spec:AST-051/DEC-5`
**Decider:** `josephfarina`, `2026-10-13`

Official packages change only when Astryx releases, so a list built into each
release is as current as the packages that CLI works with. It works offline and
in an empty folder, and it asks for no more trust than the CLI itself. Discover
reads it after every other source, so a project or an integration keeps the
entry its own source gives an official package, and the list fills in the
rest. Official packages still come first in the lists and read as part of them.
An entry keeps the newest release the CLI knows as its latest, so an update
check still sees it, and also lists the version that fits the project, which its
add command pins; both can be looked up.

Rejected: reading the list before the other sources, which replaces the entry
a project's or an integration's source gives; searching the package registry at
run time, which needs the network and cannot tell what a package adds without
running its code.

## Open questions

None.

---

### Specs AST 053 Multi Selector Lookups Spec (`docs/specs/AST-053-multi-selector-lookups/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-053
authority: current
archive_reason: null
superseded_by: null
approved_by: josephfarina
approved_at: 2026-10-01
phase: accepted
owners: [josephfarina]
affects_architecture: [architecture:cli-surface]
affects_families: []
affects_contributing: [contributing:cli-conventions]
affects_consumer_docs: [cli]
---

# Complete multi-selector lookup receipts system spec

## Intent

An agent sometimes knows several exact subjects it must inspect. Repeating one CLI
process per subject wastes setup work and makes it easy to lose a failure between
successful calls. A lookup that accepts several selectors returns one complete,
ordered receipt: every requested selector has one row, ambiguity stays visible, and
the exit status says whether every lookup resolved.

This record owns the observable batch contract and adoption rule for CLI lookups over
independent exact selectors. FR11 E1-E5 are the only eligibility test. An eligible
read-only lookup adopts batching by default; an ineligible command keeps its existing
grammar. The lookup's existing authority still owns what a selector means, how one
selector resolves, and the data returned for one resolved subject.

## Non-goals

- Turning free-text search into a batch. Words in one search query remain one query.
- Applying this read-only lookup contract to mutations, streaming or watch sessions,
  comparisons, ranges, or commands whose positional arguments form one expression or
  ordered workflow.
- Changing existing CLI responses for zero or one positional selector, or existing
  programmatic responses for omitted and string arguments.
- Ranking candidates or choosing among ambiguous identities.
- Adding a global option, configuration key, environment variable, or network source.
- Changing a pre-existing batch response on a command that fails E1-E5. A `.batch`
  suffix alone does not make that response an AST-053 receipt.
- Equivalent internal implementations remain valid when they satisfy this contract.

## Requirements

- **FR1 — Batch selection is explicit and discoverable.** An adopting command MUST
  document its selector position as variadic in generated help and its manifest entry,
  and as an accepted array in its programmatic API. No hidden control enables the
  behavior.
- **FR2 — Every accepted selector gets one row.** After invocation-level validation, a
  batch receipt MUST contain exactly one result row per supplied selector, in input
  order. Duplicate selectors remain duplicate rows. A command MAY reject the whole
  request before resolution when its aggregate work would exceed a documented finite
  bound; that rejection uses the ordinary top-level `ERR_INVALID_ARGUMENT` error
  envelope, the CLI exits 1, and no batch is emitted. An accepted batch MUST NOT
  truncate or deduplicate selectors.
- **FR3 — Every row states its outcome.** Each row MUST echo the selector exactly and
  identify one state: `found`, `not_found`, `ambiguous`, or `error`.
- **FR4 — Found rows preserve single-lookup meaning.** A `found` row MUST carry under
  `result` the same typed response that the programmatic API returns for that selector
  by itself with the same controls and response-base fields. `spec:AST-047` FR10 decides
  whether a command has adopted navigable output, and its FR7 defines the `links` field
  after adoption. Single-result links stay nested with their row. The outer receipt
  carries links only when the command's own AST-047 adoption authority defines the
  receipt as a node; AST-053 does not synthesize moves. A caller does not reconstruct
  the single result from batch-only fields.
- **FR5 — Ambiguity is data, never a guess.** When several identities can satisfy a
  selector, the row MUST be `ambiguous` and list every effective candidate with the
  stable identity fields that the owning catalog exposes. The lookup MUST NOT choose
  the first candidate or silently prefer one namespace, provider, or package.
- **FR6 — Per-selector failures stay in the receipt.** A `not_found` or `error` row MUST
  carry the stable error code the same one-selector lookup uses, its human-readable
  error, and any structured suggestions. After a request is accepted as a batch, a
  per-selector failure MUST NOT become a top-level error, disappear, or become an empty
  success. Only validation or setup failures that apply to the invocation before any
  selector can resolve use the ordinary top-level error envelope.
- **FR7 — Exact qualifiers do not fall through.** When a selector carries an exact
  qualifier owned by the lookup, such as a package version or revision, it resolves
  only against that exact subject. A near identity with another qualifier is
  `not_found`, may point to the owning discovery or next-action command, and is never
  used as a plausible substitute.
- **FR8 — The full receipt precedes failure.** The CLI MUST emit every row, then exit 1
  when any row is not `found`. Text and JSON modes use the same exit status. The
  programmatic API returns the complete typed receipt and leaves process exit policy
  to its caller.
- **FR9 — Existing cardinalities and positional meaning stay stable.** For an adopting
  command, zero selectors preserve the command's existing zero-selector behavior,
  whether browse or error; one selector preserves its existing success or error
  response; and two or more selectors use the batch discriminator. Only the owned
  selector position becomes variadic. Other positional arguments keep their existing
  relationship and order. In the programmatic API, an omitted argument preserves its
  existing behavior, a string requests one result, and an array requests a batch at
  every array length, including zero and one.
- **FR10 — Text is a projection of the batch.** Human-readable output MUST expose the
  count and every row's selector, status, and state-specific fields through the shared
  formatter vocabulary. Outer response fields stay on the receipt, and fields from a
  nested single result stay with that row; nested links MUST NOT be promoted to the
  receipt. Text MUST NOT invent a command-specific table or omit a field that JSON
  carries.
- **FR11 — Eligibility has one test.** A command is eligible exactly when all five
  conditions hold:
  - **E1:** it is read-only and its selector position accepts one exact stable subject;
  - **E2:** selectors resolve independently, without a relationship or order between
    them, and the same options and other positional arguments apply to each;
  - **E3:** each result is finite and the command can return the complete accepted
    receipt before exit;
  - **E4:** one documented aggregate work bound can safely accept at least two
    selectors in every supported option and projection mode, and resolving the set adds
    no side effect; and
  - **E5:** the selector is the command's final positional argument, so making it
    variadic does not reinterpret a following positional.
    A command admitted after this record became current that satisfies E1-E5 MUST adopt
    the contract before its first public release. An existing command that satisfies
    E1-E5 SHOULD migrate without changing its zero- and one-selector contracts. If any
    condition fails, the command is not eligible.
- **FR12 — Every adopter uses one batch vocabulary.** The discriminator MUST be
  `<subject>.batch`, where `<subject>` is the stable subject family used by the
  command's single-result responses. Under `architecture:cli-surface` INV2, `type`
  carries that discriminator and `{count, results}` is the `data` payload. The shared
  CLI response foundation MUST own and publish `BatchResponse` and `BatchRow` for that
  payload and the four row states; subject API modules specialize those types with
  their candidate and single-result types. An adopter MUST NOT invent synonymous
  discriminator, status, or row field names. A pre-existing batch response on an
  ineligible command keeps its command-owned contract and does not claim AST-053
  conformance merely because its discriminator ends in `.batch`.
- **FR13 — Admission records the classification.** Command admission under AST-042 MUST
  classify the selector position against E1-E5. A top-level command's exclusion MUST
  name the failed condition in a current command-owning record; a draft does not count.
  A subcommand's exclusion MUST name the condition in its admitting pull request under
  AST-042's existing code-owner approval bar. After typed admission metadata lands, its
  CommandDoc MUST retain the same classification. Until that metadata and a repository
  check land, FR11 and this requirement are enforced in review rather than
  mechanically.

### Platform support

- Supported feature/engine floor: every runtime already supported by the owning CLI
  command.
- Unsupported behavior: none. A command that is not eligible keeps its existing
  grammar.
- Browser evidence: not applicable; this is a CLI and programmatic API contract.

## Adoption and rollout

FR11 E1-E5 govern eligibility. Typical adopters are `get`, `show`, `inspect`, and
status-style reads over exact identities. The examples below illustrate a failed
condition; they do not replace the E1-E5 test:

- free-text search or discovery fails E1 because several words form one query;
- create, update, delete, apply, or other mutations fail E1 and E4 because they need
  separate transaction and partial-write semantics;
- compare, diff, range, pipeline, or workflow commands fail E2 because argument
  position defines a relationship rather than independent subjects;
- watch, shell, stream, or interactive commands fail E3 because they do not return one
  finite receipt;
- commands whose selector is followed by another positional fail E5 in the current
  grammar; and
- lookups that cannot safely accept at least two selectors in every supported mode fail
  E4.

Adopt the contract one command at a time:

1. Classify the owned selector position against E1-E5 during AST-042 admission. Record
   any exclusion exactly as FR13 requires.
2. Define and document the aggregate work bound, including selector count and any
   command-specific cost dimension. It must safely accept at least two selectors in
   every supported mode. Reject work beyond it before resolution with top-level
   `ERR_INVALID_ARGUMENT` and CLI exit 1; never truncate an accepted batch.
3. Preserve existing zero- and one-selector behavior and minimum cardinality. Make
   only the owned selector position variadic; do not reinterpret another positional.
4. Make programmatic batch intent explicit with an array at every array length. Use
   the shared batch response and row types, the `<subject>.batch` discriminator, and
   the existing one-selector response under each `found.result`.
5. Preserve outer and nested response-base fields, then apply every existing read
   projection and option to each `found` row. Do not ship a reduced batch-only detail
   surface.
6. Add generated help, manifest/API docs including `exitCodes`, a searchable guide,
   real text and JSON receipts, and deterministic coverage for oversized rejection,
   empty and one-item API arrays, order, duplicates, all-found, mixed, all-failed,
   ambiguity, response-base fields, and every projection.

## Current-state impact

The component lookup remains the designated first adopter. Its runtime implementation
lands separately from this specification amendment and MUST apply FR1-FR12 before it
claims compliance. CLI browse and single-selector response types stay unchanged;
programmatic callers choose the catalog with an omitted argument, one result with a
string, and a batch with an array. `architecture:cli-surface` keeps ownership of the
outer envelope, formatter vocabulary, generated help, and API to CLI parity.

AST-042 projects the E1-E5 classification into command admission for FR13. The next
rollout phase adds typed classification to CommandDoc and the generated manifest, then
classifies every existing exact selector position. Those public command docs and the
manifest become the canonical inventory. Later changes migrate each eligible command
family. External scheduling is not authority and its references do not enter this
public repository. Each command's own current authority continues to own selector
grammar, single-result data, and any recorded exclusion.

## Verification

| Contract | Verification                                                       | Representative states                                                        | Mutation or failure expectation                                                            |
| -------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| FR1      | Generated help and manifest tests; FunctionDoc contract tests      | one selector position becomes variadic                                       | Batch input exists in code but is absent from help, manifest, or API docs                  |
| FR2, FR3 | Programmatic API and CLI batch tests                               | minimum useful bound; oversized rejection; order; duplicates; mixed outcomes | An accepted selector is reordered, deduplicated, dropped, or has no state                  |
| FR4      | Batch versus single-result and response-base comparison            | detail, links, and each supported focused projection                         | A found row differs from the same one-selector result                                      |
| FR5      | Ambiguous installed-identity fixture                               | two effective package owners for one name                                    | One owner is chosen or a candidate is omitted                                              |
| FR6, FR7 | Missing, malformed, unknown-package, and version-mismatch fixtures | suggestions present and absent; another version installed                    | A selector failure escapes the receipt, loses its code, or uses another qualifier          |
| FR8      | Text and JSON process tests                                        | all found; one failure; several failures                                     | Output stops at the first failure or an unresolved batch does not exit 1                   |
| FR9      | CLI cardinality, positional-meaning, and API input-shape tests     | zero browse/error; one; many; empty and one-item arrays                      | Existing cardinality changes, another positional becomes a selector, or array shape drifts |
| FR10     | Text projection and navigable-output tests                         | every row state; outer fields with and without links                         | Text omits a JSON field, moves a nested link, or draws an unregistered output format       |
| FR11     | AST-042 admission review                                           | E1-E5 eligible adopter; top-level and subcommand exclusions                  | An eligible new command is single-only, or an exclusion does not name a failed condition   |
| FR12     | Shared JSON/type contract and manifest tests                       | `<subject>.batch`; every shared row state                                    | An adopter invents a discriminator, status, or row field                                   |
| FR13     | Knowledge-reference, pull-request, and typed-metadata review       | current top-level record; subcommand evidence; draft exclusion               | Admission skips classification or uses the wrong evidence for its tier                     |

## Decision log

### DEC-1 — A batch is a complete ordered receipt

**Reference:** `spec:AST-053/DEC-1`
**Decider:** `josephfarina`, `2026-10-01`

Batch lookup exists to reduce repeated process work without hiding what happened to
any accepted request, so accepted selectors remain one-for-one and in order, including
duplicates. A work-bound rejection happens before batch resolution and returns no
partial receipt.

Rejected: fail an accepted batch on the first miss, which loses later answers; drop
failed selectors, which makes partial work look complete; or truncate an oversized
request after resolution begins.

### DEC-2 — Ambiguity lists candidates

**Reference:** `spec:AST-053/DEC-2`
**Decider:** `josephfarina`, `2026-10-01`

An unqualified identity can belong to several namespaces, providers, or packages. The
receipt exposes every effective candidate so the caller can make the next lookup
exact.

Rejected: precedence or first-match selection, which turns catalog order into an
undocumented answer.

### DEC-3 — An unresolved row makes the CLI fail after output

**Reference:** `spec:AST-053/DEC-3`
**Decider:** `josephfarina`, `2026-10-01`

Automation needs the process status to say whether every requested lookup resolved,
while the receipt is still useful for fixing all failures in one pass, so any
unresolved row exits 1 after output.

Rejected: exit zero because some rows succeeded, which reports incomplete work as
success; and emit no receipt on failure, which forces repeated calls.

### DEC-4 — Input shape makes batch intent explicit

**Reference:** `spec:AST-053/DEC-4`
**Decider:** `josephfarina`, `2026-10-01`

Existing CLI callers keep the zero- and one-selector behavior they already parse.
Programmatic callers choose those paths with an omitted argument or a string, and
choose a complete batch receipt with an array. Empty and one-item arrays remain
batches, so filtering a selector list never changes the response type.

Rejected: making an array's response depend on its length, which makes an explicit
batch call change shape as a caller filters selectors; and always returning a batch
for strings, which changes the stable JSON shape for existing callers.

### DEC-5 — Independent read-only lookups batch by default

**Reference:** `spec:AST-053/DEC-5`
**Decider:** `josephfarina`, `2026-10-01`

Repeated process setup and fragmented failures are common to every lookup that meets
E1-E5, not only component docs. Commands admitted after this record became current
adopt the shared batch vocabulary in their first public release. Existing eligible
commands migrate one at a time without changing zero- and one-selector behavior.

Rejected: leaving adoption entirely optional, which produces a different batching
model for each command; and making every multi-argument command a batch, which breaks
search queries, mutations, comparisons, workflows, and interactive sessions whose
arguments are not independent read-only subjects.

## Open questions

None.

---

### Specs AST 055 Caller Rendered Trigger Spec (`docs/specs/AST-055-caller-rendered-trigger/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-055
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-02
phase: accepted
owners: [cixzhang]
affects_architecture:
  [
    architecture:public-component-api,
    architecture:layer-runtime,
    architecture:interaction-modality,
  ]
affects_families: [family:overlay-dismissal]
affects_contributing: [contributing:api-conventions]
affects_consumer_docs: [DropdownMenu, ComplexSelector, MultiSelector]
review_triggers: [public-api, accessibility, behavior, styling]
verified_by:
  [
    packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
    packages/core/src/ComplexSelector/ComplexSelector.test.tsx,
    packages/core/src/MultiSelector/MultiSelector.test.tsx,
  ]
---

# Caller-rendered overlay trigger system spec



```json
{
  "scope": "global",
  "triggers": {
    "public-api": ["FR1", "FR2", "FR3", "FR7", "FR8", "DEC-1", "DEC-4"],
    "accessibility": ["AR1", "AR2", "AR3", "AR4"],
    "styling": ["FR4", "FR5", "FR6", "DEC-2", "DEC-3"]
  }
}
```

## Contract at a glance

| Area                    | Contract                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public contract         | One shape for every component that lets a caller supply the control opening its overlay: a render prop. The component calls the caller's function with one props object and renders what it returns, unwrapped. The props object's type is exported. The prop is named `renderTrigger` (DEC-5). No existing shipped prop changes.                                                                                                                                                                     |
| Behavior                | The caller owns the element; the component owns the behavior it hands back. Spreading the given props is the whole integration. Both a built-in control and the render prop given, the caller's control wins and a development warning fires. Hover and pressed paint stay the caller's CSS; the open state reaches CSS through `aria-expanded`.                                                                                                                                                      |
| End-user impact         | Someone operating an icon button, a chip, an avatar, or a list row that opens a menu or picker gets the system's keyboard opens, press model, focus return, and disclosure semantics instead of whatever a hand-rolled copy kept. Nothing changes for a caller who keeps the built-in control.                                                                                                                                                                                                        |
| Builder impact          | A builder supplying the control renders one element and spreads one object onto it; no `ref` plumbing, no ARIA to re-derive. New caller responsibility: the control's own hover, pressed, and focus paint, and its open-state style keyed to `aria-expanded`. A control that cannot take focus needs the component's documented alternative path (AR4).                                                                                                                                               |
| Compatibility/readiness | Additive: the render prop is absent by default and every current built-in-control path is unchanged. Authority: `current`; `cixzhang` approved this record on 2026-10-02. One owner question remains open (OQ3), and it does not change FR1–FR8 or AR1–AR4. Real-browser evidence for the focus ring and the overlay's position against the caller's control is not yet recorded.                                                                                                                     |
| Review checks           | Reject a caller-supplied control rendered inside a component-owned `<button>`, link, or `tabindex` host; a caller's control wrapped in a box-less element the component then anchors or paints focus against; disclosure ARIA missing from the handed-back props; hover or pressed paint the component writes onto an element it does not render; both controls rendering, or a silently dropped one.                                                                                                 |
| Governing rules         | [`spec:AST-002`](../AST-002/spec.md) FR4, FR15, FR16 and `spec:AST-002/DEC-1` for admission and one responsibility per input; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5 for the shared naming and pass-through grammar; [`architecture:layer-runtime`](../../architecture/layer-runtime.md) for anchoring the overlay to the trigger; [`family:overlay-dismissal`](../../families/overlay-dismissal.md) for dismissal order, unchanged here. |

This table is a review projection; the body below is authoritative.

## Intent

A menu or a picker often has to open from a control the design system does not
own: an icon button in a toolbar, an avatar, a chip, a glyph inside a list row
that is itself a link. Astryx components paint their own control, so a builder
in that position hand-rolls the overlay — anchoring, dismissal, focus return,
keyboard opens, ARIA — and every copy drifts. The person using the product then
meets a different keyboard path and whatever accessibility the copy kept.

This record owns one answer for that case, across components: **the caller
renders the control, and the component hands back the props that make it work.**
It states the shape, what the handed-back props must carry, where the styling
boundary falls, and the accessibility the arrangement must keep.

Four draft component records are each writing their own decision for this same
fact in flight. One fact has one owner (`architecture:knowledge-contracts`
INV2), so the decision lives here and those records cite it.

## Ownership boundary

**Owns**

- The shape a component uses when a caller supplies the control that opens the
  component's overlay: a render prop, not a content slot and not a wrapper.
- What the handed-back props object must carry, and that spreading it is
  sufficient.
- Where the styling boundary falls between the caller's element and the
  component, and how the open state reaches the caller's CSS.
- The accessibility contract for the arrangement: disclosure state, one
  interactive element, focus return, and a complete keyboard path.
- The resolution when a component offers both a built-in control and this prop.

**Why no existing record can hold it**

- [`architecture:public-component-api`](../../architecture/public-component-api.md)
  is `current` and approved. A record carries one `authority` value, so adding
  these unapproved claims to it would present them as approved
  (`architecture:knowledge-contracts` INV1, INV5). That record also holds the
  shared API grammar and links its human rulings as `deciding_specs`
  (`spec:AST-002/DEC-1`, `spec:AST-005/DEC-1`, `spec:AST-012/DEC-1`); a new
  cross-component ruling belongs in the spec it links, not inside it. Its own
  INV18/DEC-7 boundary keeps observable caller-visible behavior in a product
  contract.
- [`spec:AST-002`](../AST-002/spec.md) is `current`, with the same authority
  problem, and owns a different fact: whether a public API is admitted at all,
  and the naming of operations and functions. This record sits downstream of
  AST-002 FR4 — it says which composition seam a caller-supplied control uses
  once admission has been passed.
- [`family:overlay-dismissal`](../../families/overlay-dismissal.md) owns how an
  overlay closes, not how it opens or who renders the control. The adopters here
  are not one sibling family either: they span menus, context menus, and
  pickers.
- The four component records in flight would each hold a private copy of this
  decision, which INV2 forbids.

## Non-goals

- A trigger **region** that wraps arbitrary caller content and responds to a
  contextual gesture, such as `ContextMenu`'s right-click area. That is a
  different fact: no control is supplied, no props are handed back, and the
  overlay is anchored to the pointer rather than to a rect. OQ3 asks the owner
  to confirm that boundary.
- Non-interactive trigger **content** rendered inside a control the component
  owns, such as `Collapsible`'s `trigger: ReactNode` inside its own `<button>`.
  That shape stays valid for content and is unchanged; FR3 only forbids using it
  for a caller-supplied control.
- The system-wide `render<X>` prefix rule itself (DEC-6): this record follows it; `spec:AST-002` and the API Conventions page own it.
- Hook-shaped trigger APIs (FR8).
- Anchoring, dismissal order, the press model, focus trapping, and overlay
  anatomy. Their owners are linked, not restated.
- Equivalent internal implementations remain valid when they satisfy this
  contract. Internal modules, files, function names, algorithms, data
  structures, storage layouts, manifests, journals, locks, transaction
  protocols, and CI job/workflow topology belong in architecture or
  implementation unless callers or interoperating systems intentionally depend
  on that exact mechanism as a public protocol.

## Public API and concepts

| Concept                 | Closed values or states                    | Meaning                                                                                                                    | Default          | Owner                  | Stability |
| ----------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------- | --------- |
| caller-rendered mode    | prop absent, prop present                  | Absent: the component renders its own control and chrome. Present: the caller's control replaces them.                     | absent           | `spec:AST-055`         | proposed  |
| the render prop         | `(props: <Name>TriggerProps) => ReactNode` | The caller's function; the component calls it and renders the result unwrapped                                             | —                | `spec:AST-055`         | proposed  |
| handed-back props       | one object, spread onto one element        | Ref/anchor, `id`, the opening and toggling handlers, and the disclosure ARIA of AR1; see FR2                               | —                | `spec:AST-055`         | proposed  |
| open state in CSS       | `aria-expanded="true"` / `"false"`         | The only system-supplied signal a caller styles the open state from; the component writes no paint on the caller's element | `false`          | `spec:AST-055`         | proposed  |
| hover and pressed paint | caller's own CSS                           | Not supplied by the component, because the component does not render the element                                           | none             | the caller             | proposed  |
| both controls given     | caller's control renders                   | The caller's control wins; a development warning fires; the component's own control and its chrome do not render           | caller's control | `spec:AST-055`         | proposed  |
| hook trigger props      | `triggerProps`, `getTriggerProps`          | A hook's return value, a separate convention, unchanged by this record                                                     | —                | the owning hook record | stable    |

## Requirements

### Behavioral contract

| ID  | Invariant                                                                                                                                                                                                                                                                                                                                                                                                                  | Basis                                                                                                | Verification state                       |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| FR1 | A component that lets a caller supply the control which opens its overlay MUST take that control as a render prop named `renderTrigger` (DEC-5): a function the component calls with one props object, whose returned element the component renders as given. The component MUST NOT wrap the returned element in an element of its own, and MUST NOT require a particular tag or a particular Astryx component.           | DEC-1; `spec:AST-002` FR4                                                                            | Accepted; jsdom evidence pending         |
| FR2 | Spreading the handed-back props onto one element MUST be the whole integration. The object MUST carry the ref or anchor the overlay positions against, the `id` AR1 needs, every handler that opens, toggles, and closes the overlay from that control on the component's supported input paths, and the disclosure attributes of AR1. The object's type MUST be exported from the component's public entry point.         | DEC-1; `architecture:public-component-api` INV1, INV5                                                | Accepted; jsdom evidence pending         |
| FR3 | A caller-supplied control MUST NOT be taken as a `ReactNode` slot that the component renders inside an interactive element of its own. Callers routinely pass an interactive component, and that shape nests one interactive element inside another (AR2). A content slot rendered inside a component-owned control remains valid for non-interactive content and is unchanged.                                            | DEC-1; AR2                                                                                           | Accepted; jsdom evidence pending         |
| FR4 | The caller's control MUST own a box. A component MUST NOT render it inside a wrapper with no box of its own (`display: contents`), and MUST NOT depend on such a wrapper where it paints or offsets a focus indicator on the trigger, or positions the overlay against the trigger's rect: there is no rect to anchor to and no box on which to paint. FR1 removes the need for any wrapper.                               | DEC-2; `architecture:layer-runtime` anchor contract                                                  | Accepted; real-browser evidence required |
| FR5 | Hover, pressed, and every other pointer paint on the caller's control MUST remain the caller's own CSS. The component MUST NOT write paint onto an element it does not render. The component MUST publish the overlay's open state on the handed-back props as `aria-expanded`, so a caller styles the open state from the rendered attribute and no additional public API is added for it.                                | DEC-3; `spec:AST-002` FR1, FR2                                                                       | Accepted; jsdom evidence pending         |
| FR6 | The prop's consumer documentation MUST show styling the open state from `aria-expanded` on the caller's control, and MUST state that a pressed look keyed to `:active` is not a substitute, because `:active` does not behave the same under a coarse pointer — `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer `:active` paint on menu rows for that reason.                                                  | DEC-3; no current core component styles off `aria-expanded`, so callers have no example to copy      | Accepted; documentation evidence pending |
| FR7 | Where a component offers both a built-in control and this prop, exactly one control MUST render. The caller's control MUST win, identically in every presentation the component supports, and a development warning MUST fire; the component's own chrome for the replaced control MUST NOT render. Silently ignoring a supplied prop, or rendering both, is rejected.                                                     | `spec:AST-002` FR15 (never silently render a broken state); `architecture:public-component-api` INV3 | Accepted; jsdom evidence pending         |
| FR8 | A hook that returns props for a trigger — `triggerProps` and `getTriggerProps` on `useLightbox`, `usePopover`, `useMenuHover`, and `useHoverCard` — follows hook conventions and is unchanged by this record. Those return-value names are NOT precedent for a component prop's name or shape. This record does not require a hook to grow a render prop, nor a component to republish its trigger props as a hook return. | DEC-4                                                                                                | Settled by DEC-4; no code change         |

### Accessibility contract

- **AR1 — The caller's control carries the disclosure.** The handed-back props
  MUST put `aria-haspopup` naming the overlay's kind, `aria-expanded` carrying
  its open state, and `aria-controls` naming its element on the caller's
  control. Where the overlay takes its accessible name from that control, the
  props MUST also carry the `id` the overlay's `aria-labelledby` points at; a
  component that names its overlay another way, such as its own `label`, states
  that in its own record instead.
- **AR2 — One interactive element, one focus ring.** The caller's control MUST
  be the only interactive element for opening the overlay. The component MUST
  NOT add a `<button>`, a link, or a `tabindex` host around it: a control inside
  a control is invalid HTML, and it breaks keyboard operation and screen-reader
  announcement. The control's own focus ring MUST be the visible focus
  indicator, which requires the box FR4 preserves.
- **AR3 — Focus returns to the control.** Closing the overlay by any supported
  route — a selection, Escape, a light dismiss, or an imperative handle — MUST
  return focus to the caller's control while that control is still in the
  document.
- **AR4 — Every supported configuration keeps a complete keyboard path.** A
  caller's control that can take focus MUST open and operate the overlay from
  the keyboard exactly as the built-in control does. Where the component
  supports a control that cannot take focus, it MUST supply and document another
  complete keyboard path to open and operate the overlay; it MUST NOT present a
  pointer-only configuration as supported.

### Platform support

- Supported feature/engine floor: every supported renderer and every supported
  browser. Nothing here is behind a capability check.
- Unsupported behavior: a caller's control that cannot take focus degrades under
  AR4's documented alternative path, never into a pointer-only overlay.
- Browser evidence: FR4 and AR2 are layout and paint claims — the focus ring on
  the caller's control, and the overlay's position against that control's rect.
  jsdom has neither layout nor paint, so real-Chromium evidence is required for
  them. The remaining requirements are provable in jsdom.

## Current-state impact

- `architecture:public-component-api` gains `spec:AST-055` in its
  `deciding_specs` and a line in its `Deciding specs` list when this record
  becomes `current`. No invariant of that record changes: FR1–FR8 sit inside its
  INV2, INV3, and INV5 grammar rather than amending it.
- `contributing:api-conventions` gains the render-prop composition rule for a
  caller-supplied control, spelled `renderTrigger` (DEC-5). The system-wide
  `render<X>` prefix rule (DEC-6) lands in `spec:AST-002` and the API
  Conventions page, not here.
- `component:DropdownMenu`, `component:ComplexSelector`, and
  `component:MultiSelector` cite this record instead of each recording a private
  copy of the same decision. Their consumer docs gain the `aria-expanded`
  styling guidance FR6 requires.
- `family:overlay-dismissal`, `architecture:layer-runtime`, and
  `architecture:interaction-modality` are read, not changed: dismissal order,
  anchoring, and modality keep their owners.
- No shipped public API changes. Every component keeps its built-in control and
  its current default.
- While this record is `draft` its `review-applicability:v1` block routes
  nothing: global routing loads `current` claims only
  (`architecture:knowledge-contracts` INV20). It becomes live on approval.

## Verification

| Contract      | Verification                                                                                                                  | Representative states                                                                                 | Mutation or failure expectation                                                                                                      |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| FR1, FR3, AR2 | Per-component caller-rendered-trigger suites in `DropdownMenu.test.tsx`, `ComplexSelector.test.tsx`, `MultiSelector.test.tsx` | The caller returns an interactive component; the caller returns a non-button element                  | A component-owned interactive ancestor, or a second element with a role for opening, fails: the rendered tree must hold exactly one. |
| FR2           | The same suites, spreading the object and adding nothing else                                                                 | Open by pointer, open by keyboard, toggle closed, anchor resolved                                     | A prop the caller must supply by hand, or a missing exported props type, fails.                                                      |
| FR4, AR2      | Real-Chromium evidence beside the owning component's suites                                                                   | Keyboard focus on the caller's control; the overlay positioned against it                             | A focus ring with no box to paint on, or an overlay that loses its anchor rect, fails. Not provable in jsdom.                        |
| FR5, FR6      | The same suites plus the component's consumer-doc checks                                                                      | Open and closed; a caller styling the open state from `aria-expanded`                                 | Component-written hover or pressed paint on the caller's element, or a prop documented without the `aria-expanded` route, fails.     |
| FR7           | The same suites, both controls supplied, in every presentation the component supports                                         | Pointer presentation and touch presentation                                                           | Two rendered controls, a silently dropped prop, or a presentation that resolves the other way, fails.                                |
| AR1, AR3      | The same suites                                                                                                               | Closed, open, named overlay; close by selection, Escape, light dismiss, and handle                    | A missing disclosure attribute, an unnamed overlay, or focus landing on `<body>` after close, fails.                                 |
| AR4           | The owning component's keyboard suite                                                                                         | A focusable caller control; a caller control that cannot take focus, where the component supports one | A configuration reachable only by pointer, or an undocumented alternative path, fails.                                               |
| FR8           | Source inspection of the named hooks                                                                                          | `useLightbox`, `usePopover`, `useMenuHover`, `useHoverCard`                                           | A hook return renamed or reshaped to match a component prop fails: this record changes none of them.                                 |

Known verification gap: the per-component suites and the real-Chromium evidence
named above do not exist on `main` yet. This record is `draft` and does not
govern review until they do and the owner approves it.

## Decision log

### DEC-1 — A caller-supplied overlay control is a render prop

**Reference:** `spec:AST-055/DEC-1`
**Decider:** `cixzhang`, `2026-10-02`

The caller renders the control; the component hands back the props that make it
work. Spreading those props on one element is the whole integration, so the
builder writes no `ref` plumbing and re-derives no ARIA, and the person using
the product gets the system's keyboard opens, focus return, and disclosure
semantics on an icon button, a chip, an avatar, or a list row.

Rejected: a content slot on the `Collapsible` pattern — `trigger: ReactNode`
rendered inside a component-owned `<button>`. Callers routinely pass an
interactive component; a button inside a button is invalid HTML and breaks both
keyboard operation and screen-reader announcement, so that shape cannot serve
this case at all. The slot shape remains correct for non-interactive trigger
content.

### DEC-2 — The trigger owns a box

**Reference:** `spec:AST-055/DEC-2`
**Decider:** `cixzhang`, `2026-10-02`

A caller's control keeps a box of its own, and the component renders no wrapper
around it.

Rejected: `display: contents` on a trigger wrapper. A box-less element gives the
focus indicator nothing to paint on and the overlay no rect to anchor against,
so keyboard users lose the visible focus the control is supposed to show and the
overlay loses its position. This rejection is about a wrapper the component
renders around a caller's control and about a component that anchors or paints
against it; a trigger region that anchors to the pointer instead is a different
case (OQ3).

### DEC-3 — Pointer paint stays with the caller; the open state is `aria-expanded`

**Reference:** `spec:AST-055/DEC-3`
**Decider:** `cixzhang`, `2026-10-02`

The caller owns the element, so hover and pressed states are the caller's own
CSS. The one thing the caller cannot know is whether the overlay is open, and
the handed-back props already carry it as `aria-expanded`, so the open state
reaches CSS through the rendered attribute instead of through new public API.

Because no current core component styles off `aria-expanded` — every current use
only emits the attribute — the route has no in-repo example to copy, so FR6
requires it to be documented on the prop. The same documentation states that
`:active` is not a substitute: it does not behave the same under a coarse
pointer, which is why `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer
`:active` paint on menu rows.

### DEC-4 — Hook trigger props are a different convention

**Reference:** `spec:AST-055/DEC-4`
**Decider:** `cixzhang`, `2026-10-02`

`useLightbox` returns `triggerProps` and `getTriggerProps`; `usePopover`,
`useMenuHover`, and `useHoverCard` return `triggerProps`. That matches hook
conventions, where a returned object of props to spread is the normal shape, and
it stays exactly as it is.

Rejected: reading those return-value names as precedent for a component prop's
name or shape. A hook hands back a value the caller destructures; a component
prop hands the caller a callback. They are different conventions, and neither
one settles the other.

### DEC-5 — The prop is named `renderTrigger`

**Reference:** `spec:AST-055/DEC-5`
**Decider:** `cixzhang`, `2026-10-02`

`render*` is the existing convention for function-valued public component
props, with no counterexample in core: `renderOption`, `renderValue`,
`renderItem`, `renderToken`, `renderContent`, `renderSelectionLabel`,
`renderLineContent`, `renderExpanded`, `renderGroupHeader`, `renderCell`,
`renderTooltip`, `renderHoverCard`, `renderMenu`, `renderOverlay`. A bare noun
holding a function appears only inside config objects a caller passes, never as
a public component prop.

Rejected: a bare `trigger`. It reads well at a callsite, but the name is
already public in the system with a different shape — `Collapsible`'s
`trigger: ReactNode` — so reusing it for a function would give one name two
shapes, which `spec:AST-002` FR16 and `architecture:public-component-api` INV2
both push against. The builder reading `renderTrigger` also knows from the name
alone that a function is expected.

### DEC-6 — `render<X>` is the prefix for every render prop

**Reference:** `spec:AST-055/DEC-6`
**Decider:** `cixzhang`, `2026-10-02`

A public component prop whose value is a function returning rendered output is
named `render<X>`. The convention was already near-universal; this writes it
down so a builder can predict the name and a reviewer can cite it instead of
re-arguing it.

This record follows the rule rather than owning it: a naming rule for public
API is `spec:AST-002`'s fact — it already owns operation and function naming in
FR11 and FR17 — together with the API Conventions page's `Prop Naming` section,
which today rules on booleans, callbacks, enums, direction, and HTML collisions
but says nothing about function-valued props. Both owe an amendment carrying
this rule; until they carry it, cite this decision.

## Open questions

- **OQ3 — Does a trigger region that wraps arbitrary content fall inside this record?** (`human-api`)

  `ContextMenu` does not take a control: it wraps caller content in an element of
  its own and opens on a contextual gesture, anchored to the pointer rather than
  to the wrapper's rect, and it paints no focus ring on that wrapper. Open pull
  request #6839 proposes making that wrapper inline or box-less so a reference
  inside prose can own a context menu.

  Non-goals reads that as a different fact, outside FR1 and outside DEC-2's
  rejection, because neither of DEC-2's two reasons — nothing to paint a focus
  ring on, no rect to anchor to — applies when the component paints no ring and
  anchors to the pointer. The owner is asked to confirm that boundary, or to pull
  trigger regions inside this record, in which case FR1 and FR4 need a stated
  exception for them.

## Content boundary

This record does not duplicate any component's anatomy, prop table, theming
targets, or consumer examples; the overlay positioning and anchor-name contract
in `architecture:layer-runtime`; the dismissal and Escape ordering in
`family:overlay-dismissal`; the press model in
`module:DropdownMenu/useMenuPress`; or the admission argument in
`spec:AST-002`. It links their canonical owners.

---

### Specs AST 056 Search Prop Shape Spec (`docs/specs/AST-056-search-prop-shape/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-056
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-02
phase: accepted
owners: [cixzhang]
affects_architecture: [architecture:public-component-api]
affects_families: [family:input-fields]
affects_contributing: [contributing:api-conventions]
affects_consumer_docs:
  [Selector, MultiSelector, Tokenizer, Typeahead, BaseTypeahead, CommandPalette]
review_triggers: [public-api, accessibility, compatibility]
---

# Search state vocabulary system spec



```json
{
  "scope": "global",
  "triggers": {
    "public-api": [
      "FR1",
      "FR2",
      "FR3",
      "FR4",
      "FR5",
      "FR6",
      "FR7",
      "DEC-1",
      "DEC-2",
      "DEC-3",
      "DEC-4"
    ],
    "accessibility": ["AR1", "AR2"],
    "compatibility": ["FR7"]
  }
}
```

## Contract at a glance

| Area                    | Contract                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public contract         | One vocabulary, not one structure. "The query matched nothing" is `emptySearchText` taking `ReactNode`, on every component that has the state; "there is nothing here at all" stays `emptyText` and the two never merge (FR1, FR2, DEC-1). A capability that needs a query names that in its own prop name, and its handler is its only switch (FR5, DEC-3). No configuration object (DEC-2).                                                                                                                   |
| Behavior                | Unchanged. Who resolves a query is settled by who owns the choices and is not restated per component: a component holding its own choice set filters them and exposes a boolean to show the input; a component whose presented choices ARE the query's result takes a required `searchSource` (FR4, DEC-2).                                                                                                                                                                                                     |
| End-user impact         | A person meets the same dead end everywhere, and can be offered the same way out of it: widening `emptySearchResultsText` to `ReactNode` lets a product put a link or a create row in a `Tokenizer`'s empty result as it already can in a `Selector`'s. A half-configured create row stops silently rendering nothing (FR5). Whatever a caller supplies is also what gets announced (FR3, AR1).                                                                                                                 |
| Builder impact          | One rename to learn: `emptySearchResultsText` becomes `emptySearchText`. No prop moves, no object literal, no restructuring. Every shipped prop keeps working through the cycle.                                                                                                                                                                                                                                                                                                                                |
| Compatibility/readiness | Not additive, but small: one prop renamed on three components, with its type widened from `string` to `ReactNode`. No required prop moves. The replacement-first cycle and its cost are in `Migration and compatibility`; FR7 is the only requirement that constrains it. Authority: `draft`. Three owner questions remain open; none of them changes FR1 or FR2.                                                                                                                                               |
| Review checks           | Reject a second name for a state these props already express; a boolean that gates a handler; a capability that renders inert when a sibling prop is missing; an empty-state value that cannot reach assistive technology (AR1); a query input named only by its placeholder (AR2).                                                                                                                                                                                                                             |
| Governing rules         | [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16 and `spec:AST-002/DEC-1`, `DEC-6` for admission and one responsibility per input; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV1, INV2, INV3, INV9 for the shared naming grammar and released-API lifecycle; [`spec:AST-017`](../AST-017/spec.md) FR28–FR31 for deprecation and cleanup; [`family:input-fields`](../../families/input-fields.md) for the field chrome around the query input, unchanged here. |

This table is a review projection; the body below is authoritative.

## Intent

Typing to narrow a list is one thing a person does, and Astryx spells it seven
ways. A builder who has used `hasSearch` on `Selector` finds no `hasSearch` on
`Tokenizer`; a builder who has set `emptySearchResultsText` on `Typeahead`
finds `emptySearchText` on `CommandPalette`, taking a different type. Nothing
is broken for the person using the product, but every new search-adjacent
capability lands as another flat prop on one component, with its own name, and
the next component copies whichever neighbour it happened to read.

This record owns one answer: **a shared vocabulary for the states a typed
query produces, with no restructuring.** It fixes the name and type of "the
query matched nothing", keeps that state distinct from "there is nothing here
at all", and says how a capability that cannot work without a query declares
that dependency.

An earlier draft proposed collecting these props into one object-valued
`search` prop. That is rejected in DEC-2: it would have cost nine
deprecations across seven components, four of them a required prop, to solve
a tidiness problem rather than one a person using an Astryx product meets.

The trigger is [#6829](https://github.com/facebook/astryx/pull/6829), which
proposes `hasCreate` + `onCreate` on `MultiSelector` — a sixth and seventh
search-adjacent prop on a component that already carries three, under a name
`Tokenizer` already ships with a different contract. That pull request is
blocked on this ruling, and the shape it adopts is the shape every later
component copies.

## Ownership boundary

**Owns**

- The name and type of each observable state a typed query produces, shared
  across every component that has that state.
- That "the query matched nothing" and "there is nothing here at all" are
  distinct states under distinct names, and neither absorbs the other.
- How a capability that cannot function without a query declares that
  dependency, and what gates it.
- Whether a newly proposed search-related prop joins this vocabulary or
  introduces a second name for something it already says.
- The accessibility floor the arrangement must keep for the empty-result
  message and the query input's name.

**Why no existing record can hold it**

- [`spec:AST-002`](../AST-002/spec.md) is `current` and approved, and a record
  carries one `authority` value, so adding unapproved claims to it would
  present them as approved (`architecture:knowledge-contracts` INV1, INV14).
  It also owns a different fact — whether a public API is admitted at all —
  and both its FR17 and its DEC-7 say in their own words that **component prop
  and event naming remains separate**. A rule about the shape and name of a
  component prop is structurally outside what AST-002 claims. This record sits
  downstream of AST-002 FR1 and FR4: admission still decides whether a search
  capability exists, and this record decides only where it is spelled.
- [`architecture:public-component-api`](../../architecture/public-component-api.md)
  is `current` and approved, with the same authority problem. It holds the
  shared naming grammar (INV2) and links its human rulings through
  `deciding_specs` — `spec:AST-002/DEC-1`, `spec:AST-005/DEC-1`,
  `spec:AST-012/DEC-1`. A new cross-component ruling belongs in a spec it
  links, not inside it; its own INV18 boundary keeps observable caller-visible
  behavior in a product contract.
- [`family:input-fields`](../../families/input-fields.md) is `current` and owns
  the field chrome — label, status, size, theming — shared by its members. Two
  of the components this record binds, `BaseTypeahead` and `CommandPalette`,
  are not members and are not input fields; `CommandPalette` is a dialog. They
  are not one sibling family, which is why the vocabulary is owned here rather
  than by that contract.
- `contributing:api-conventions` and the wiki's `Prop Naming` section document
  conventions for booleans, callbacks, enums, direction, and HTML collisions.
  They say nothing about one state keeping one name across components, and
  guidance is not an authority record. Both owe an amendment once this record
  is `current`.
- The six component records would each hold a private copy of the same
  decision, which `architecture:knowledge-contracts` INV2 forbids.
  `component:MultiSelector` is already drafting one as its FR10 / DEC-3.

## Non-goals

- **Matching quality.** Fuzzy matching, ranking, highlighting, tokenization,
  and diacritic folding are not settled here. FR4 fixes only the floor for the
  mode where the component does the filtering.
- **Whether any particular component should gain search, or a create row.**
  That is `spec:AST-002` admission, case by case.
- **`PowerSearch`.** Its query is a structured field/operator/value expression,
  not a typed string narrowing a list. Its `maxSearchResults` and its per-field
  `searchSource` are cited as evidence of spread, not governed (OQ4).
- **`emptyBootstrapText` on `CommandPalette`.** "No query yet, and the default
  set is empty" is a different observable state from "the query matched
  nothing"; it stays a top-level prop.
- **The trigger-menu configuration on `ChatComposerInput`.** Its per-trigger
  objects each carry an `emptySearchResultsText`, so FR1 reaches them, but
  whether that nested rename rides this record's cycle is OQ4.
- **Internal filtering implementation.** Equivalent internal implementations
  remain valid when they satisfy this contract. Internal modules, files,
  function names, algorithms, data structures, storage layouts, manifests,
  journals, locks, transaction protocols, and CI job/workflow topology belong in
  architecture or implementation unless callers or interoperating systems
  intentionally depend on that exact mechanism as a public protocol.

## Evidence: every search-shaped prop in core today

Read from `packages/core/src` at this record's base commit. This table is the
evidence for FR1–FR5; it is not a migration plan.

| Component           | Query input exists because                        | Who resolves the query                                 | Placeholder                                       | "Nothing matched"                                        | Query observable by the caller                       | Create affordance                                         | Other search-shaped props                                      |
| ------------------- | ------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| `Selector`          | `hasSearch?: boolean` — default `false`           | the component, over its own `options`                  | `searchPlaceholder?: string` — `'Search…'`        | `emptySearchText?: ReactNode` — `'No results found'`     | **no** — `searchQuery` is internal `useState`        | —                                                         | `emptyText?: ReactNode` (`'No options'`, a different state)    |
| `MultiSelector`     | `hasSearch?: boolean` — default `false`           | the component, over its own `options`                  | `searchPlaceholder?: string` — `'Search…'`        | `emptySearchText?: ReactNode` — `'No results found'`     | **no** — `searchQuery` is internal `useState`        | `hasCreate` + `onCreate` **proposed** in #6829            | `emptyText?: ReactNode` (`'No options'`)                       |
| `Tokenizer`         | `searchSource: SearchSource<T>` — **required**    | the caller's source                                    | `placeholder?: string` — the input is the control | `emptySearchResultsText?: string` — `'No results found'` | `onChangeQuery?: (query: string) => void`            | `hasCreate?: boolean` — **the component mints the token** | `minQueryLength?: number` (`1`), `debounceMs?: number` (`150`) |
| `Typeahead`         | `searchSource: SearchSource<T>` — **required**    | the caller's source                                    | `placeholder?: string`                            | `emptySearchResultsText?: string`                        | `onChangeQuery?: (query: string) => void`            | —                                                         | `minQueryLength?: number`, `debounceMs?: number`               |
| `BaseTypeahead`     | `searchSource: SearchSource<T>` — **required**    | the caller's source                                    | `placeholder?: string`                            | `emptySearchResultsText?: string`                        | `onChangeQuery?: (query: string) => void`            | `__queryEntries` — underscored, `@internal`               | `minQueryLength?: number`, `debounceMs?: number`               |
| `CommandPalette`    | `searchSource: SearchSource<T>` — **required**    | the caller's source                                    | `CommandPaletteInput.placeholder` — `'Search…'`   | `emptySearchText?: ReactNode` — `'No results'`           | through the `input` slot's `value` / `onValueChange` | —                                                         | `emptyBootstrapText?: ReactNode` (`'Type to search'`)          |
| `ChatComposerInput` | one `searchSource` per entry in `triggers[]`      | the caller's source, per trigger                       | — (the composer's own `placeholder`)              | `triggers[].emptySearchResultsText?: string`             | —                                                    | —                                                         | `triggers[].loadingText`, `triggers[].menuLabel`, `debounceMs` |
| `PowerSearch`       | a structured filter expression, not a typed query | the caller's per-field `searchSource?` (value pickers) | internal (`@astryx.powersearch.placeholder`)      | —                                                        | —                                                    | —                                                         | `maxSearchResults?: number`                                    |

Four drift axes are visible in that table, and each one is a separate decision
somebody made alone:

1. **Two names for "nothing matched":** `emptySearchText` on `Selector`,
   `MultiSelector`, and `CommandPalette`; `emptySearchResultsText` on
   `Typeahead`, `BaseTypeahead`, `Tokenizer`, and each `ChatComposerTrigger`.
2. **Two types for the same message:** `ReactNode` on the first group, `string`
   on the second. A builder who can pass an element to one component cannot to
   its neighbour.
3. **Three defaults for it:** `'No results found'`, `'No results'`, and —
   because `Selector` and `MultiSelector` also carry a top-level `emptyText` —
   `'No options'` for the adjacent state.
4. **The prefix only appears where search is optional.** `Selector` and
   `MultiSelector` write `searchPlaceholder` because they also have a trigger
   placeholder; `Typeahead` and `Tokenizer` write `placeholder` because the
   query input _is_ the control. Both are locally right, which is exactly why
   no component will fix this on its own.

Beyond core, `packages/lab` has already copied the drift and added a fifth
name: `MobileTokenizer` takes `searchSource` + `emptySearchResultsText`,
`TransferList` takes `hasSearch` + `searchPlaceholder` + `searchLabel`, and
`ChatEmojiPicker` takes `searchLabel`. Lab is not a stable public promise, so
this record does not govern it; it is evidence that the pattern spreads by
copying.

### Two prior claims about prior art, corrected

- **`searchConfig` is not a component prop and is not prior art for this
  shape.** It is a parameter and plugin-config field in
  `Table/plugins/filtering/useTableFiltering.tsx`, holding a
  `PowerSearchConfig` — the structured field/operator/value configuration of
  the filter builder. It configures a different concept.
- **The repository's real precedent for this shape is `ChatComposerTrigger`.**
  Each entry in `ChatComposerInput`'s `triggers[]` is an object carrying
  `searchSource`, `renderItem`, `onSelect`, `emptySearchResultsText`,
  `loadingText`, and `menuLabel` — the whole configuration of one trigger's
  query in one value, with keys that mostly drop the prefix the surrounding
  name already supplies. It works, it is shipped, and nobody has asked for it
  to be flattened.

## Public API and concepts

| Concept                 | Closed values or states                      | Meaning                                                                                                | Default                           | Owner          | Stability |
| ----------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------- | -------------- | --------- |
| `emptySearchText`       | `ReactNode`                                  | What the component shows when the query matched nothing. One name and one type everywhere (FR1)        | per component                     | `spec:AST-056` | proposed  |
| showing the input       | a boolean, or a required `searchSource`      | A component holding its own choices exposes a boolean; one whose choices ARE the result takes a source | per component                     | the component  | shipped   |
| a query-only capability | a handler whose name says it needs a query   | Presence of the handler is the switch; no boolean beside it (FR5)                                      | absent                            | `spec:AST-056` | proposed  |
| "no choices at all"     | the component's own top-level `emptyText`    | A different observable state from "the query matched nothing"; unchanged by this record                | the component's localized default | the component  | stable    |
| fetch tuning            | `minQueryLength`, `debounceMs`, loading text | Not placed by this record; they belong to whoever fetches, and OQ5 asks whether they move inside       | per component                     | the component  | stable    |

## Requirements

### Behavioral contract

| ID  | Invariant                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Basis                                                                        | Verification state                      |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------- |
| FR1 | One observable state has one prop name and one type across every component with a typed query. "The query matched nothing" is `emptySearchText`, accepting `ReactNode`. `emptySearchResultsText` resolves to it and its `string` typing is widened, so a caller may pass an element. No component may name or type this state differently.                                                                                                                                                                                                        | DEC-1; `architecture:public-component-api` INV2                              | Proposed; no evidence on `main`         |
| FR2 | "The query matched nothing" and "the component was given no choices at all" are different states and MUST stay separately expressible under distinct names: `emptySearchText` and `emptyText`. `Selector` and `MultiSelector` already declare both, so neither name may absorb the other, and a component offering only one MUST still use the name that matches the state it means.                                                                                                                                                              | DEC-1; `spec:AST-002` FR16                                                   | Proposed; shipped behavior, unspecified |
| FR3 | Whatever a caller supplies for an empty state MUST reach the person operating the component, visually and non-visually alike. A component that announces this state MUST announce what it rendered. Announcing a built-in default while rendering the caller's element is a defect, not a fallback.                                                                                                                                                                                                                                               | AR1; `architecture:interaction-modality`                                     | Proposed; defect on `main` (see AR1)    |
| FR4 | Who resolves the query is settled by who owns the choices, and is not restated per component. A component that holds its own choice set resolves the query itself, matching case-insensitively on each choice's displayed label at minimum, and exposes a boolean to turn the query input on. A component whose presented choices ARE the query's result takes a required `searchSource`. Both kinds reach the same observable states: a narrowed list, an empty result, and `emptyText`.                                                         | DEC-2; `spec:AST-002` FR16                                                   | Proposed; shipped behavior, unspecified |
| FR5 | A capability that cannot function without a typed query MUST have exactly one switch, and MUST report through the component's primary change callback with a descriptor naming the kind of change, rather than through a handler prop of its own. A caller MUST NOT be able to express a configuration that renders nothing and reports nothing, which `hasCreate && hasSearch && onCreate != null` permits whenever any one piece is missing. Enabling it on a component with no query input MUST warn in development rather than fail silently. | DEC-3; `spec:AST-002` FR15 (never silently render a broken state)            | Proposed; no evidence on `main`         |
| FR6 | A new search-related capability is admitted under `spec:AST-002` against this record, or not at all. A new top-level prop whose name carries the word `search` MUST name the state or capability it governs in the vocabulary this record fixes, and MUST NOT introduce a second name for a state one of these props already expresses.                                                                                                                                                                                                           | DEC-4; `spec:AST-002` FR1, FR5, DEC-1                                        | Proposed; no evidence on `main`         |
| FR7 | Every prop this record renames is a released public surface with a victim, so it MUST follow `spec:AST-017` FR28–FR31: the replacement ships first, old usage stays equivalent through the overlap, and removal happens in a declared minor with a `[breaking]` Changeset naming the replacement. While both exist, a component given both MUST resolve to the new prop and MUST fire a development warning.                                                                                                                                      | `architecture:public-component-api` INV9; `spec:AST-017` FR1, FR3, FR28–FR31 | Proposed; cycle not started             |

### Accessibility contract

- **AR1 — The empty-result message reaches assistive technology as written.**
  Whatever `emptySearchText` renders on screen MUST be what a screen reader
  receives when the panel becomes empty. Today it is not: `Selector` and
  `MultiSelector` announce `typeof emptySearchText === 'string' ? emptySearchText
: t('@astryx.selector.emptySearchResults')`, so a caller who passes an element
  — which the `ReactNode` type invites — is silently announced the default
  string instead of their own message. Accepting `ReactNode` (FR1) obliges the
  component to derive an announceable string from it, or to document and
  require a separate announceable form; it may not announce something the
  person on screen is not reading.
- **AR2 — The query input has an accessible name that is not its placeholder.**
  A placeholder is placeholder text and MUST NOT be the input's only
  accessible name. The component supplies the name: a localized "Search
  options" in `Selector` and `MultiSelector`, and a localized "Search commands"
  in `CommandPaletteInput`.

### Platform support

- Supported feature/engine floor: every supported renderer and every supported
  browser. Nothing here is behind a capability check.
- Unsupported behavior: none. A component that cannot satisfy FR3's
  required/optional rule does not partially adopt the prop; it keeps its
  current API until the rule is settled for it.
- Browser evidence: DOM live-region assertions prove the rendered content source,
  but they do not prove what assistive technology announces or when. Any adopter
  that changes announcement content or timing proves its routing, text, and
  occasion count at the DOM and accessibility-tree layers; real-AT observation is
  recommended follow-up under `spec:AST-009`.

## Migration and compatibility

This is not additive, and the cost is concentrated in one place.

**What is shipped and has a released victim.** Nine public props across seven
core components: `hasSearch`, `searchPlaceholder`, and `emptySearchText` on
`Selector` and `MultiSelector`; `searchSource` and `emptySearchResultsText` on
`Tokenizer`, `Typeahead`, and `BaseTypeahead`; `searchSource` and
`emptySearchText` on `CommandPalette`; `onChangeQuery` on the three typeaheads;
`hasCreate` on `Tokenizer`; `emptySearchResultsText` on every
`ChatComposerTrigger`. Under `spec:AST-017` FR1 and FR3, and
`architecture:public-component-api` INV9, removing or retyping any of them is a
breaking change.

**Nothing required moves.** An earlier draft would have folded the **required**
`searchSource` on `Tokenizer`, `Typeahead`, `BaseTypeahead` and
`CommandPalette` into an object, failing every existing callsite of those four
components at type-check. DEC-2 rejected that, so the whole migration is now
one optional prop renamed on four surfaces, with its type widened.

**This record's recommendation: deprecate with a replacement-first cycle.** Not
coexist indefinitely, not replace.

- A patch ships `emptySearchText` on each renaming component while the old prop keeps working,
  carries `@deprecated` naming its replacement, and behaves exactly as before
  (`spec:AST-017` FR28, FR29). During the overlap a component reads both and
  the new prop wins, with one development warning on conflict (FR7).
- Removal happens in a later declared minor with a `[breaking]` Changeset that
  names the replacement (`spec:AST-017` FR31). This record sets no clock; FR30
  forbids reading release cadence as one.
- A caller who passed a `string` needs no edit beyond the name: the type only
  widens, so every existing value stays valid.

**What each option actually costs.**

- **Deprecate with a cycle (recommended).** One prop doubles for the length of
  the overlap, on `Tokenizer`, `Typeahead`, `BaseTypeahead` and
  `ChatComposerTrigger`: `emptySearchResultsText` and `emptySearchText` both
  present in the types, both in `.doc.mjs`, both in the consumer docs. Four
  `DEP-*` records and four `CLN-*` ids. The rename is mechanically
  codemoddable, including where a caller spreads props, because the value's
  meaning does not change and the type only widens. Then one minor that breaks
  unmigrated callsites of those four surfaces.
- **Coexist indefinitely.** Nothing breaks, ever. The drift survives in the
  type signature: a builder still meets `emptySearchResultsText` on one
  component and `emptySearchText` on another, and the standard governs only
  components built after it. That answers "so when we add it later on other
  components we match the pattern" and leaves today's inconsistency in place.
- **Replace outright.** A minor that renames with no overlap. Rejected: the
  deprecation costs little here and no user problem justifies breaking
  callsites that a cycle would carry.

**`Tokenizer.hasCreate` needs no migration.** An earlier reading treated it as
a collision, because `Tokenizer`'s `hasCreate` is a boolean with no callback
while #6829 proposed a handler for `MultiSelector`. OQ5 settles it the other
way: `Tokenizer` mints the token itself and reports it through
`onChange(items, {item, type: 'create'})`, which is exactly the shape DEC-3
describes, so `Tokenizer` already conforms and changes nothing.
`MultiSelector` converges on it when #6829 returns.

## Current-state impact

- `architecture:public-component-api` gains `spec:AST-056` in its
  `deciding_specs` and a line in its `Deciding specs` list when this record
  becomes `current`. No invariant of that record changes: FR1–FR7 sit inside
  its INV2, INV3, and INV9 grammar rather than amending it.
- `contributing:api-conventions` and the wiki's `Prop Naming` section gain the
  state-naming rule: one observable state has one prop name and one type
  across every component that has it, and a new prop may not introduce a
  second name for a state an existing one already expresses. Neither surface
  rules on this today.
- `component:Selector`, `component:MultiSelector`, `component:Tokenizer`,
  `component:Typeahead`, `component:BaseTypeahead`, and
  `component:CommandPalette` cite this record instead of each recording a
  private copy. `component:MultiSelector` is `authority: draft` and is drafting
  exactly such a copy; that claim is withdrawn in favour of FR5 and FR6 here.
- [#6829](https://github.com/facebook/astryx/pull/6829) is blocked on this
  ruling. Its `hasCreate` + `onCreate` pair is rejected by FR5, which admits
  the same user need as `hasCreate` alone, reported through the component's
  existing `onChange` with a descriptor naming the creation — the shape
  `Tokenizer` already ships — so the capability cannot be half-configured and
  one word does not carry two contracts.
- `family:input-fields`, `spec:AST-002`, and `spec:AST-017` are read, not
  changed: field chrome, admission, and the deprecation lifecycle keep their
  owners.
- No shipped public API changes on this record's own merge. Every component
  keeps every prop and every default until an adoption change lands.
- While this record is `draft` its `review-applicability:v1` block routes
  nothing: global routing loads `current` claims only
  (`architecture:knowledge-contracts` INV20). It becomes live on approval.

## Verification

| Contract  | Verification                                                                                      | Representative states                                                                                     | Mutation or failure expectation                                                                                                                                     |
| --------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR2  | Per-adopter prop-surface suites plus the repository's exported-surface and `.doc.mjs` prop checks | Each adopter's declared props; the exported object type imported from the package entry point             | A top-level prop whose name carries `search`, a key repeating the prefix, or an unexported object type fails.                                                       |
| FR3       | Per-adopter rendering suites                                                                      | `search` absent; `search={{}}`; `search` omitted on a component that requires it                          | A query input rendering with the prop absent, residual query state, or a required/optional declaration that contradicts whether the component holds choices, fails. |
| FR4       | Per-adopter suites in both modes                                                                  | No `source`, with choices the component holds; a `source` supplied; a query matching nothing in each mode | Divergent observable states between the two modes, or a component re-filtering what a `source` returned, fails.                                                     |
| FR5, AR1  | Per-adopter suites plus their live-region assertions                                              | A string `emptyText`; an element `emptyText`; the adjacent "no choices at all" state                      | A differently named or typed key, or an announcement that does not carry what the element rendered, fails. This is the gap AR1 names on `main` today.               |
| FR6, FR7  | Type-level checks plus per-adopter suites                                                         | `onCreate` given with `search` present; `onCreate` given with `search` absent                             | A create affordance reachable without search, a boolean gating a handler, or a silently inert configuration, fails.                                                 |
| FR8       | Per-adopter suites                                                                                | Typing; clearing; programmatic change                                                                     | A callback that suppresses the component's own resolution, or a component requiring it, fails.                                                                      |
| FR9       | Overlap suites on each adopter plus the declared-tier Changeset check                             | Old prop alone; key alone; both given; neither                                                            | Old usage changing behavior, a silent win for the old prop, a missing `DEP-*`/`CLN-*` pair, or a removal absent from the frozen minor manifest, fails.              |
| FR10, AR2 | Public-API review against this record; per-adopter accessible-name assertions                     | A proposed new search capability; each adopter's query input, named and unnamed                           | A new top-level `search*` prop admitted, or a query input whose only name is its placeholder, fails.                                                                |

Known verification gap: none of the suites above exist on `main`, and no
component implements `search`. This record is `draft`, does not govern review,
and names no implementation. AR1 additionally records a defect in current
shipped behavior rather than a passing contract.

## Decision log

Every decision below was ruled by `cixzhang` on 2026-10-02, on this record's
pull request.

### DEC-1 — One name and one type for "the query matched nothing"

**Reference:** `spec:AST-056/DEC-1`
**Decider:** `cixzhang`, `2026-10-02`

`emptySearchText`, accepting `ReactNode`, everywhere.

This is the drift that reached people. A person who searches a `Selector` and
a `Tokenizer` in the same product meets the same dead end, and today the two
components do not even agree what to call it, let alone what a caller may put
there. `Selector`, `MultiSelector` and `CommandPalette` say `emptySearchText`
and accept a `ReactNode`; `Tokenizer`, `Typeahead`, `BaseTypeahead` and
`ChatComposerInput` say `emptySearchResultsText` and accept a `string` — so
the same product cannot offer the same "no results, try X" row in both, and a
builder who learns one component has to discover the other.

The name resolves toward `emptySearchText` rather than a shorter one, and the
reason is load-bearing: `Selector` and `MultiSelector` already declare BOTH
`emptyText` and `emptySearchText`, for two genuinely different sentences —
"there is nothing here at all" and "nothing matched what you typed". Shortening
the search state's name would collide with a shipped prop meaning the other
thing, on the two components with the most search surface. So three components
rename toward the majority spelling instead of seven renaming away from it.

The type resolves toward `ReactNode`, not toward `string`. A dead end is
exactly where a product wants a link, an illustration, or a create
affordance, and narrowing the four components that already allow it would
remove a shipped capability to win consistency. Widening the other three is
what lets a product offer the same dead end everywhere, which is the whole
point of agreeing on the name.

### DEC-2 — No `search` configuration object

**Reference:** `spec:AST-056/DEC-2`
**Decider:** `cixzhang`, `2026-10-02`

An earlier draft of this record proposed collecting every search-related prop
into one object-valued `search` prop, with `placeholder`, `emptyText`,
`onChange` and `onCreate` as keys. Rejected.

The object would have solved tidiness rather than anything a person using an
Astryx product meets. Three flat props are not hard to pass, and the survey
below shows the repository has no settled convention to appeal to: every
`*Config` object in core configures a hook, and the only object-valued
component props are `collapsible` and `button`.

Against that, the cost was concrete. Nine shipped props would have entered a
deprecation cycle, four of them a **required** `searchSource`, breaking every
callsite of four components in the cleanup minor. `ComplexSelector` would
have taken a `search` object with one meaningful key, since it owns its own
content. And autocomplete gets worse, not better: typing inside a component's
tag lists flat props immediately, while `search={{` has to be known about
first.

What the drift actually called for was a shared vocabulary, which DEC-1 and
FR6 give without restructuring anything.

### DEC-3 — A query-only capability is enabled by a boolean and reported through the existing change callback

**Reference:** `spec:AST-056/DEC-3`
**Decider:** `cixzhang`, `2026-10-02`

A capability that only exists while a query does — creating a value from text
that matched nothing — is enabled by one boolean and reported through the
component's primary change callback, carrying a descriptor that says what kind
of change it was.

`Tokenizer` already does this: `hasCreate` turns the row on, and
`onChange(items, change)` reports `{item, type: 'create'}` beside `'add'`,
`'remove'` and `'reorder'`. A caller learns about a creation on the channel it
already listens to for every other change, and nothing can be half-configured,
because there is only one switch.

The shape this rules out is a boolean and a handler gating each other, which
is what `hasCreate && hasSearch && onCreate != null` produces: three ways to
be half-set, every one of them rendering nothing, reporting nothing, and
warning about nothing. A builder sets the boolean, sees no create row, and has
no way to learn why.

A component whose value does not carry the created object — `MultiSelector`
holds `options` separately from `value: string[]` — reports the next value and
the creation together, so the caller adds the option and accepts the value in
one update. That obligation is the caller's and must be stated where the
capability lands; it does not justify a second shape for the same capability.

The one case a boolean cannot prevent is enabling creation on a component
whose query input is switched off. That warns in development (FR5) rather than
rendering nothing in silence.

### DEC-4 — The vocabulary is fixed; new capabilities join it

**Reference:** `spec:AST-056/DEC-4`
**Decider:** `cixzhang`, `2026-10-02`

Every prop in the survey below was a reasonable local decision. The drift came
from each one being made alone: two names for one state, two types for one
name, `searchLabel` in lab, `maxSearchResults` on one component.

So the standard is a vocabulary rather than a structure. A new search-related
prop names its state in the words this record fixes, and does not introduce a
second name for something an existing prop already says. That is checkable in
review without anybody restructuring a component.

## Open questions

- **OQ1 — ANSWERED. Two concepts, split by who owns the choices.** (`human-api`)

  `cixzhang`, 2026-10-02: a component that holds its own choices takes the
  `search` object; a component whose choices arrive from the caller keeps
  `searchSource`. The shared matching rule and `createStaticSource` show the
  two can be unified, but unifying them would make `source` a required member
  of an otherwise-optional object on four components, and would move a shipped
  required prop for no gain to its callers. FR1 and FR4 record the split, and
  DEC-1 carries the reasoning.

- **OQ2 — ANSWERED. Deprecate with a cycle.** (`human-api`)

  `cixzhang`, 2026-10-02: `emptySearchResultsText` is marked `@deprecated`
  pointing at `emptySearchText`, both work through the overlap, and removal
  happens in a later minor under `spec:AST-017` FR28–FR31. The cost is one
  doubled prop on three components, which is
  what FR7 already describes — far less than the nine pairs the rejected
  object form would have cost. The rename is codemoddable even where a caller
  spreads props, because the value's meaning does not change and the type only
  widens.

- **OQ3 — ANSWERED. No.** (`human-api`)

  `cixzhang`, 2026-10-02: observing the query does not become public on
  `Selector` or `MultiSelector` as part of this record. `searchQuery` stays
  internal state on both. A caller who needs to act on the query gets a
  capability that names what it is for — creation, below — rather than a raw
  query feed they must then re-implement the component's own behavior around.

- **OQ4 — ANSWERED. Three adopters; the rest are governed by FR1 only.** (`human-api`)

  `cixzhang`, 2026-10-02: `Selector`, `MultiSelector` and `ComplexSelector`
  are where a `search` object would have gone, so they are the components this
  record most directly addresses; with the object rejected (DEC-2) the
  distinction mostly dissolves. `Tokenizer`, `Typeahead`, `BaseTypeahead`,
  `CommandPalette` and `ChatComposerInput` keep `searchSource` and are bound by
  FR1 (one name, one type for "the query matched nothing") and FR6 (no second
  name for a state these props already express). `PowerSearch` stays cited and
  excluded: a structured field/operator/value expression is not a typed query
  over a list.

- **OQ5 — ANSWERED. One capability, a boolean plus a change descriptor.** (`human-api`)

  `cixzhang`, 2026-10-02: a create affordance is enabled by a boolean and
  reported through the component's existing change callback, carrying a
  descriptor that says the change was a creation. No separate handler prop,
  and no `create` configuration object.

  `Tokenizer` already works this way: `onChange(items, change)` where `change`
  is `{item, type: 'create'}`, alongside `'add'`, `'remove'` and `'reorder'`.
  `MultiSelector` can report the same way — the next value together with a
  descriptor naming the created query — so the caller adds the option and
  accepts the value in one update, learning about creation on the channel it
  already listens to for every other selection change.

  This supersedes an earlier answer here that treated the two as different
  capabilities needing different shapes, on the grounds that `MultiSelector`
  splits `options` from `value: string[]` and so cannot mint an option the way
  `Tokenizer` mints a token. That difference is real but does not force a
  separate handler: reporting the value and the creation together is exactly
  what the descriptor is for. `hasCreate` therefore means one thing in both
  components, which is FR1's rule applied to a verb rather than a state.

  Two things the contract owes, to be settled where the capability lands
  rather than here: the descriptor's shape as a public type, and the caller's
  obligation to add the created option — `Tokenizer` has no such obligation
  because its value carries the object itself, while a `MultiSelector` caller
  who accepts the value without adding the option holds an id that matches
  nothing.

  `minQueryLength`, `debounceMs` and the trigger-menu `loadingText` stay where
  they are. They describe fetching, which only exists where the caller owns
  the choices, and this record does not reorganize shipped props.

## Content boundary

This record does not duplicate any component's anatomy, prop table, theming
targets, or consumer examples; the admission argument in `spec:AST-002`; the
deprecation, cleanup, and release-manifest mechanics in `spec:AST-017`; the
shared naming grammar in `architecture:public-component-api`; the field chrome
in `family:input-fields`; or the matching, debouncing, and cancellation
behavior each component's own record owns. It links their canonical owners.

---

### Specs AST 057 Row Actions Spec (`docs/specs/AST-057-row-actions/spec.md`)

---
schema_version: 4
template_version: 1
kind: system-spec
id: spec:AST-057
authority: current
archive_reason: null
superseded_by: null
approved_by: cixzhang
approved_at: 2026-10-03
phase: accepted
owners: [cixzhang]
affects_architecture:
  [architecture:interaction-modality, architecture:public-component-api]
affects_families: []
affects_contributing: [contributing:api-conventions]
affects_consumer_docs: [Item, List, ListItem]
review_triggers: [public-api, accessibility, behavior]
---

# Swipe actions on `Item` system spec



```json
{
  "scope": "global",
  "triggers": {
    "public-api": [
      "FR1",
      "FR2",
      "FR3",
      "FR9",
      "DEC-1",
      "DEC-3",
      "DEC-4",
      "DEC-6"
    ],
    "accessibility": ["AR1", "AR2", "AR3"],
    "behavior": ["FR4", "FR5", "FR6", "FR7", "FR8", "DEC-2", "DEC-5"]
  }
}
```

## Contract at a glance

| Area                    | Contract                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public contract         | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal (DEC-4). No new DOM: the row's root translates and its panels counter-translate (DEC-5).        |
| Behavior                | On a coarse pointer a sideways drag moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. |
| End-user impact         | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. Nothing changes for anyone else: a mouse, a keyboard, or a screen reader meets the row exactly as today.                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| Builder impact          | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: <Icon icon={ArchiveIcon} />, onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard.                                                                                                                                                  |
| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Review checks           | Reject a hover-revealed panel; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query.                                                                                                                                                        |
| Governing rules         | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5.                                                                                                                                                                                                                                                                                                                                                                           |

This table is a review projection; the body below is authoritative.

## Intent

On a phone, a person flicks a row sideways to get at a verb — archive,
delete, mark unread, select. Astryx rows do not flick. This record adds the gesture to `Item`, the row primitive, so a `ListItem`
and a bare `Item` in a product's own markup swipe the same way. The rule for where it is available is the row's role: a swipe panel's
controls are interactive descendants of the row, so swipe actions exist on
any row whose role permits them — a `listitem`, a row with no role at all —
and not on one whose role forbids them. That sorts the list row and the
standalone row in, and the option, menu-item and radio rows out, without
naming a component; a row type nobody has written yet sorts itself.

The gesture is touch-only and is an accelerator: it adds a faster path to a
verb on a coarse pointer and changes nothing on any other input. Each side's
verbs are declared as data — a label that names the state the verb reaches,
an icon, a handler, how it looks, and whether the row holds out afterwards —
so the component knows everything it needs before the finger moves: what to
paint, what to fire, and where the row rests when it is done. The row's own root moves
and its panels stay put, so no element is added to any row.

## Ownership boundary

**Owns**

- The public shape of `Item`'s swipe actions: `ItemSwipeAction` and its
  fields, the per-side arrays, and `swipeBehavior`.
- The rule for where swipe actions are available: the row's role.
- The two models, `reveal` and `commit`, meaning the same thing wherever
  swipe actions are available.
- The coarse-pointer behavior: rest or fire, the accelerator, where the row
  rests after an action, one open row at a time, and how a row closes.
- The anatomy the gesture uses: two out-of-flow positions on `Item`'s
  existing root, the root's translation and the panels' counter-translation,
  and the group's inline clip.
- The accessibility contract of the gesture itself: what the panel exposes
  while resting and while closed, and that the gesture touches nothing on a
  pointer that can hover.

**Why no existing record can hold it**

- [`architecture:interaction-modality`](../../architecture/interaction-modality.md)
  is `current` and owns the rule the gesture must respect — INV7 "essential
  actions remain reachable" — but owns no public API. A record carries one
  `authority` value, so adding unapproved API claims to it would present
  them as approved (`architecture:knowledge-contracts` INV1, INV14).
- `Item` has no component record. This record is the first durable decision
  about `Item`'s public surface; `component:Item`, when written, inherits
  these claims rather than re-deciding them. Of `Item`'s seven in-core renderers, six give the row a role that forbids
  interactive descendants and are out of scope; `ListItem` and a bare `Item` in caller markup — which the system's own
  templates render inside a `BottomSheet` and on a page — are the rows this
  record serves: two real consumers, one home, and the prop stays on the
  primitive (DEC-3).
- [`component:List`](../../../packages/core/src/List/List.spec.md) is
  `current` and owns List's geometry; it gains the inline clip and cites
  this record.
- [`spec:AST-002`](../AST-002/spec.md) owns admission: the caller owns which
  verbs earn a gesture and which model fits them, and the component cannot
  derive either.

## Non-goals

- **What else the row offers.** A row's visible controls — a menu or buttons
  in `endContent`, its own primary — are `Item`'s existing composition and
  are not governed, paired, or checked here. The consumer documentation
  states the obligation; the component does not police it.
- **A hover-revealed action panel on a fine pointer** (DEC-4).
- **Animating a row's exit or enter.** Not settled here: a row the caller
  removes after an action unmounts as any row does today. When it is
  settled, the likely form is imperative (`handleRef`, as `Selector` and
  `MultiSelector` expose open/close) so enter can be animated too. `hasRemoval` is not it: it says only where the row
  rests after an action, and a row that holds out and is never removed sits
  off-screen — the caller's bug, which that eventual API is what makes
  graceful.
- **A render prop for a custom panel.** Deferred, not foreclosed: a
  `render*`-shaped addition can land when a caller needs something the
  vocabulary cannot say (DEC-1).
- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity, slide
  and spring durations, resistance past the panel: behavior constants in
  source, not public API and not design tokens (the same ruling the touch
  press model received for its clocks).
- **Vertical swipes**, two-finger trackpad swipes, and a swipe whose only
  meaning is dismissal (Toast already has that in `useToastGesture`).
- **Public out-of-flow slots on `Item`.** Deliberately not admitted: the
  panel positions are internal, rendered from `swipeActions`. A second
  consumer — an accent bar, a slide-in checkbox, a drag handle — is a
  `component:Item` anatomy record with its own admission argument.
- **A programmatic open or close.** Not admitted: a swipe panel's open state
  belongs to the gesture, and the one coordination case met in practice — a
  resting row closing when a neighbour's drag starts — is already a pointer
  outside the row (FR7). No product has asked, and the row this gesture was modelled on exposes no
  such handle. If one ever does, `handleRef` is the established pattern to add
  it with, as `Selector` and `MultiSelector` expose open/close/toggle.
- **Rows whose role forbids interactive descendants.** Swipe actions are
  available on a row whose role permits interactive descendants, because a
  swipe panel's controls are interactive descendants of the row; a
  `listitem` and a role-less `Item` qualify, and "it'll just work" there. A
  row whose role forbids them is out of scope as a consequence: `option`
  (inside a `listbox`), `menuitem`, `menuitemcheckbox`, `menuitemradio`, and
  a row that is the enlarged target of a native radio. This is a scope
  boundary, not a per-call or per-model restriction, so `swipeBehavior`
  means one thing everywhere swipe actions exist. In core today that
  excludes `SelectorOption`, the four `DropdownMenu` rows, and
  `RadioListItem`, and includes `ListItem` and every bare `Item`.
- Equivalent internal implementations remain valid when they satisfy this
  contract. Internal modules, files, function names, algorithms, data
  structures, storage layouts, manifests, journals, locks, transaction
  protocols, and CI job/workflow topology belong in architecture or
  implementation unless callers or interoperating systems intentionally depend
  on that exact mechanism as a public protocol.

## Evidence: in the repository

| Where                                                                                                                                                                                              | What it shows                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Item`'s root: `position: relative`, `borderRadius`, `paddingInline`; no `overflow`, `isolation`, or `z-index`; the focus-within outline is painted on this root; `themeProps('item')` lands on it | The root is already a containing block for absolutely positioned children, and transforming it moves its outline and its theme target with it — nothing on it fights a `transform`. Only the component that owns this element can translate it.                                                                                                                                                                                                                                                                                                                   |
| `Item`'s content: `marker`, a `startContent` span, the label element (a span, an anchor in `href` mode, a `<button>` in `onClick` mode), an `endContent` span — four siblings in every render mode | Nothing can move four siblings as one unit except a common parent. The root is that parent already.                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `ListItem` renders `<Item as="li">` directly under `List`'s `<ul role="list">`                                                                                                                     | Any wrapper around the row is a non-`li` child of `<ul>`: invalid HTML and a broken "list, N items" announcement. The gesture has to live on `Item`.                                                                                                                                                                                                                                                                                                                                                                                                              |
| `List`'s root sets no `overflow`                                                                                                                                                                   | A translated row paints past the list's inline edge unless the group clips. `overflow-inline: clip` clips without creating a scroll container; `hidden` would create one and fight the list's vertical scroller.                                                                                                                                                                                                                                                                                                                                                  |
| `useToastGesture` drives Toast's slide by writing `--_toast-swipe-y` onto the root with `style.setProperty`; Toast's styles consume it in `transform`                                              | The in-core way a gesture moves a component without re-rendering it: a custom property on the root, read by CSS.                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| The AppShell/Field ruling of 2026-09-04: a `z-index` adjustment stays local inside an `isolation: isolate` container                                                                               | The panel must paint above the root's background and below its content; `isolation: isolate` on the root with the panel at `z-index: -1` does that without reaching outside the row.                                                                                                                                                                                                                                                                                                                                                                              |
| `DropdownMenu.items`, `MoreMenu.items`, `DropdownMenuItemData`                                                                                                                                     | Core already declares a verb as data — a label, an icon, a handler, `isDisabled`, a `variant` — and data mode "renders through `DropdownMenuItem`, so the two APIs describe the same thing". `ItemSwipeAction` is that shape.                                                                                                                                                                                                                                                                                                                                     |
| `useMenuPress.onActivate(row, release)`, `useTreeFocus.onActivate`, `Selector` and `TreeList` wiring                                                                                               | Core's word for "this row's verb fired" when the path is a release, a key, or a pointer and not a click. A commit fires on release with no click at all.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `ProgressBar.variant` defaults to `'accent'`                                                                                                                                                       | In-core precedent for `accent` as a variant word and as a default on a filled surface.                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `hasSearch`, `hasCreate`, `hasPaging`, `hasHomeEnd` vs `isLoading`, `isOpen`, `isDisabled`                                                                                                         | Core's two boolean prefixes: `has*` declares that a thing has a property; `is*` names a state it is currently in. Whether the row holds out after an action is a property of the action, so `has*`.                                                                                                                                                                                                                                                                                                                                                               |
| `Item` consumers: `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubMenu`, `SelectorOption`, `RadioListItem`, `ListItem`                                    | Six of seven in-core renderers give the row a role that forbids interactive descendants (`option` from `Selector`'s listbox, the three `menuitem*` roles, and `RadioListItem`'s delegation to a native radio where the input is the option's sole focusable); they are out of scope. `ListItem` renders `role="listitem"` under `role="list"`. The system's own templates render a bare `Item` with no `role` at all (a `BottomSheet` snap-point demo, a table-filter page, a hook demo), so the standalone row is a real consumer and sorts in by the same rule. |
| `Toast/useToastGesture.ts`                                                                                                                                                                         | A shipped commit-only swipe whose one meaning is dismissal, with constants (`SWIPE_DISMISS_RATIO`) in source.                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| Real-device feedback on an earlier implementation of this gesture                                                                                                                                  | On an iOS device the swipe required a press-and-hold before it would start, and the row background did not restore until release. Chromium evidence alone did not predict device behavior.                                                                                                                                                                                                                                                                                                                                                                        |
| The product row this gesture was modelled on                                                                                                                                                       | Its panel names the **state** ("Unread") where a menu would name the sentence ("Mark as unread"). A toggle swipes back; a verb with no single instant (snooze) opens a chooser.                                                                                                                                                                                                                                                                                                                                                                                   |

## Cases the contract serves

| Case                                                                                     | Served | How                                                                                                                                                                                                         |
| ---------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mail: archive and delete                                                                 | Yes    | Two entries in `trailing`, both `hasRemoval`, Delete `variant: 'destructive'`. Under `reveal` the panel rests open with both; a long drag fires the outermost; the row holds out and the caller removes it. |
| One destructive action                                                                   | Yes    | One entry with `variant: 'destructive'` and, if it removes the row, `hasRemoval`. Under `commit` the row slides out and it fires; it holds out or springs back by the bit.                                  |
| Reversible toggle (read/unread, pin/flag)                                                | Yes    | `label` names the state the verb reaches ("Unread"); no `hasRemoval`. Under `reveal` the row springs back wearing it; under `commit` it slides out, fires, and springs back wearing it.                     |
| Archive — removes the row but is not destructive                                         | Yes    | `hasRemoval` without `variant: 'destructive'`. The axes are independent: all four combinations occur (DEC-1).                                                                                               |
| Soft delete with Undo — destructive but row stays                                        | Yes    | `variant: 'destructive'` without `hasRemoval`: the row springs back.                                                                                                                                        |
| More than two actions                                                                    | Yes    | N entries on a side, outermost last. Under `reveal` each is a button.                                                                                                                                       |
| An action that confirms or opens a chooser                                               | Yes    | No `hasRemoval`: the row springs back and `onActivate` opens the dialog; the caller removes the row on confirmation. A verb with no single instant (snooze) commits to opening its chooser.                 |
| Rows in a virtualized list                                                               | Yes    | Open state is the row's own; a row that unmounts takes it along. A mostly vertical drag stays the scroller's (FR6).                                                                                         |
| Rows that are links (`href`)                                                             | Yes    | The axis lock separates the drag from the tap, and the click the browser synthesizes after a drag is swallowed (FR6).                                                                                       |
| `role="list"` rows                                                                       | Yes    | `ListItem` inherits the prop; arbitrary interactive children are valid.                                                                                                                                     |
| A bare `Item` outside a list (a sheet row, a card row)                                   | Yes    | No role, so interactive descendants are permitted; the prop is `Item`'s and the host clips its own group (FR9).                                                                                             |
| A row whose role forbids interactive descendants (`option`, `menuitem*`, a radio target) | **No** | Out of scope by the role rule (Non-goals). Not a model restriction — swipe actions are simply not available there.                                                                                          |
| A panel the vocabulary cannot draw                                                       | **No** | Not in this record. A `render*` addition is the deferred route (DEC-1).                                                                                                                                     |
| Dismiss-only swipe (Toast)                                                               | **No** | Owned by `useToastGesture`; a different concept with one meaning per direction.                                                                                                                             |

## Public API and concepts

| Concept                  | Closed values or states                                                                                                                                                 | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Default  | Owner                       | Stability |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------- | --------- |
| `Item.swipeActions`      | `{leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`                                                                                                           | The verbs a sideways drag uncovers on each side, outermost last. `leading` is uncovered by a drag toward the inline end, `trailing` by one toward the inline start. Inherited by `ListItem` and every host that renders an `Item`.                                                                                                                                                                                                                                   | absent   | `spec:AST-057`              | proposed  |
| `ItemSwipeAction`        | `{id?: string; label: string; icon?: ReactNode; onActivate: () => void \| Promise<void>; isDisabled?: boolean; variant?: ItemSwipeActionVariant; hasRemoval?: boolean}` | One verb. `Item` renders it as a filled surface in its `variant`'s colour with the icon above the label; `label` is the button's accessible name under `reveal` and names the **state** the verb reaches ("Unread"). Every field is known before the gesture begins.                                                                                                                                                                                                 | —        | `spec:AST-057`              | proposed  |
| `ItemSwipeActionVariant` | `'neutral'` \| `'accent'` \| `'destructive'`                                                                                                                            | How the action looks: ordinary, the one you mean, the dangerous one. Not a status set — a verb is not a state. Minted here (DEC-6).                                                                                                                                                                                                                                                                                                                                  | `accent` | `spec:AST-057`              | proposed  |
| `hasRemoval`             | `false`, `true`                                                                                                                                                         | After this action activates, the row holds out rather than springing back. Only that: not a promise the caller removes the row, not an animation, not a timing rule. Independent of `swipeBehavior` and of `variant`; all combinations occur.                                                                                                                                                                                                                        | `false`  | `spec:AST-057`              | proposed  |
| `onActivate`             | called on tap of a resting panel, on the full-swipe accelerator, and on a `commit` release                                                                              | Core's word for a verb firing by any path. Not `onClick`: a commit fires with no click. Fires at activation.                                                                                                                                                                                                                                                                                                                                                         | —        | `spec:AST-057`              | proposed  |
| `Item.swipeBehavior`     | `'reveal'` \| `'commit'`                                                                                                                                                | `reveal`: past the panel's width the row rests open with every entry a real button; a long drag or fling fires the outermost. `commit`: a release past the commit point slides the row out and fires the outermost entry; nothing rests; the panel is presentational; valid for one entry per side, warns with more. A commit is not a removal: the row springs back unless the entry has `hasRemoval`. `commit` means the same on every row that has swipe actions. | `reveal` | `spec:AST-057`              | proposed  |
| the panels               | two out-of-flow positions on `Item`'s root                                                                                                                              | Absolutely positioned children of the existing root at the inline start and end, painting above the root's background and below its content; present only when the side has entries. Internal anatomy with a theme target; not public slots.                                                                                                                                                                                                                         | —        | `spec:AST-057`              | proposed  |
| the drag's travel        | a private custom property on the row root                                                                                                                               | Written by the gesture during a drag. The root's `transform` reads it; each panel's `transform` reads its negation, so the row moves and the panel appears fixed in the space the row vacates.                                                                                                                                                                                                                                                                       | `0px`    | `spec:AST-057`              | proposed  |
| the group's clip         | `overflow-inline: clip` on the element containing the rows                                                                                                              | A moving row paints past its group's inline edge unless the group clips. `List` sets it; a host that renders `Item`s outside `List` sets it once. `Item` cannot clip itself: it is the thing moving.                                                                                                                                                                                                                                                                 | —        | `component:List` / the host | proposed  |
| open state               | closed, resting open                                                                                                                                                    | The row's own. A pointer landing anywhere outside a resting row closes it, which is also how a drag on a neighbour closes it; no group state exists.                                                                                                                                                                                                                                                                                                                 | closed   | `spec:AST-057`              | proposed  |
| the modality split       | internal                                                                                                                                                                | A mouse never starts the drag; a coarse pointer does. Read from the pointer event, not a prop or a media query (`architecture:interaction-modality` INV6).                                                                                                                                                                                                                                                                                                           | —        | `spec:AST-057`              | proposed  |

Not public: axis-lock distance, commit ratio, fling velocity, durations,
resistance, panel widths, the custom property's name. They are behavior
constants.

## Requirements

### Behavioral contract

| ID  | Invariant                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Basis                                                                           | Verification state                        |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- |
| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged.                                                                                                                                                                                                                                                                                                                                                                                                  | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main`           |
| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | DEC-5; `useToastGesture`                                                        | Proposed; real-Chromium evidence required |
| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | DEC-2; `spec:AST-002` FR15                                                      | Proposed                                  |
| FR4 | A sideways drag on a coarse pointer MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Real-browser finding: a zero-width panel still paints its padding               | Proposed; real-Chromium evidence required |
| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe`                          | Proposed; real-device evidence required   |
| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | Press-model ORD1                                                                | Proposed; real-device evidence required   |
| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome                        | Proposed                                  |
| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `architecture:public-component-api` INV2 (logical direction)                    | Proposed                                  |
| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | DEC-5; `List` root styles                                                       | Proposed                                  |

### Accessibility contract

- **AR1 — The panel is real while resting and absent while closed.** Under
  `reveal`, while the row rests open each entry MUST be a real, focusable
  `<button>` named by `label`, so a touch screen reader that lands on it can
  activate it; while closed the panel MUST be out of the accessibility tree
  and the tab order. Under `commit` the panel MUST be presentational
  (`aria-hidden`) at all times, because nothing in it is ever tappable.
- **AR2 — The gesture touches nothing on a pointer that can hover.**
  `swipeActions` MUST have no effect on the tab order, the accessible tree,
  or the row's paint on such a pointer. Hover MUST NOT uncover the panel.
- **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT
  change the primary element's role, name, or activation, and MUST add no
  stop while closed.
- **Stated in documentation, not enforced:** a swipe is a touch accelerator,
  so a verb reachable only by swipe is unreachable by keyboard and by mouse.
  The consumer documentation of `swipeActions` MUST say so and MUST show the
  verb also reachable through the row's own content. The component does not
  inspect what else the row offers.

### Platform support

- Supported feature/engine floor: every supported renderer and browser.
  Pointer type is read from the pointer event.
- Unsupported behavior: a device that never produces a coarse pointer gets
  no swipe and nothing else changes.
- Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has
  neither; real-Chromium evidence with dispatched touch is required, and
  FR5/FR6 additionally need a physical touch device, because Chromium evidence
  for this gesture has not predicted device behavior.

## Current-state impact

- `Item` gains `swipeActions`, `swipeBehavior`, the exported types
  `ItemSwipeAction` and `ItemSwipeActionVariant`, two out-of-flow panel
  positions on its root with a theme target, and the root and panel
  transforms. No new element, no gesture on any row that does not ask for
  it. `component:Item`, when written, inherits these claims.
- `ListItem` inherits both props through its existing `Item` passthrough.
- `component:List` gains the inline clip on `List`'s root, citing this
  record.
- `architecture:interaction-modality` gains `spec:AST-057` in its deciding
  specs; no invariant changes.
- `contributing:api-conventions` gains the rule this record relies on: a
  gesture is an accelerator and never a component's only path to its own
  behavior.
- `spec:AST-058` takes nodes where this record takes objects, on the shared principle in
  DEC-1: the picker places its actions; `Item` renders its panel.
- No shipped public API changes on this record's own merge.
- While this record is `draft` its `review-applicability:v1` block routes
  nothing: global routing loads `current` claims only
  (`architecture:knowledge-contracts` INV20).

## Verification

| Contract      | Verification                                                                                       | Representative states                                                                                                                                                 | Mutation or failure expectation                                                                                                                                                                                                 |
| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR3      | `Item` and `ListItem` prop-surface suites plus exported-type checks                                | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side                                                  | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails.                                                                                                      |
| FR2           | `Item` DOM snapshot suite plus real-Chromium evidence                                              | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag                                                       | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails.                                                          |
| FR4–FR6, FR8  | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. |
| FR7           | `Item` suite with two rows under one parent                                                        | Open one, drag the other; outside tap; Escape; entry fired; unmount                                                                                                   | Two rows resting open, or a row that stays open after any closing event, fails.                                                                                                                                                 |
| FR9           | `List` style assertion plus real-Chromium evidence of a row dragged past the edge                  | A row at the list's inline edge mid-drag; the list scrolled vertically                                                                                                | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails.                                                                                                                            |
| AR1–AR3       | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence  | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer                                                      | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, or any effect on a fine pointer fails.                                                                    |
| Documentation | Consumer-doc check on `swipeActions`                                                               | The prop's documentation                                                                                                                                              | A doc that does not state the keyboard consequence or the group clip fails.                                                                                                                                                     |

Known verification gap: none of the suites above exist on `main`, and no
component implements the contract. This record is `draft`, does not govern
review, and names no implementation.

## Decision log

Every decision below records the owner's direction; the record is `draft`
until `approved_by` is set.

### DEC-1 — Swipe actions are declared as data

**Reference:** `spec:AST-057/DEC-1`
**Decider:** `cixzhang`, `2026-10-02`

`swipeActions.leading` and `.trailing` are `ItemSwipeAction[]`: `{id?, label,
icon?, onActivate, isDisabled?, variant?, hasRemoval?}`. `Item` renders each entry —
under `reveal` as a real button, under `commit` as a presentational block —
and paints the panel during the drag from the data.

An object is needed where the component renders the thing; a node works where it only places it (`spec:AST-058` DEC-1 draws the same line from the other side, and its picker-row actions are nodes because the picker only places them in a cell). `Item` paints and animates the panel, so every field is known before the gesture begins; a caller's own node could do neither without registering through a context, which splits the API into nodes that get the behavior and nodes that silently do not. Core already declares a verb as data
in `DropdownMenu.items` and `MoreMenu.items`, and two independent vibe-test recall probes — one for this record, one for
`spec:AST-058` — found every builder reaching for exactly this array with no
prop names given, and none reaching for child components or a render prop.

The vocabulary: `onActivate` is core's word for a row's verb firing by a
path that is not a click (`useMenuPress`, `useTreeFocus`, `Selector`,
`TreeList`), and a commit fires on release with no click at all. `variant` is
`'neutral' | 'accent' | 'destructive'`, with `accent` the default (DEC-6). `label` names the state the verb reaches ("Unread"), because the panel
shares one row width with the title it uncovers and the gesture already
supplies the verb. `hasRemoval` is one bit the component cannot infer: after
the action fires, does the row spring back or hold out? It is independent of
`swipeBehavior` — a commit is not a removal, and a tap on a resting "Archive"
is — and of `variant`, since Archive leaves without being destructive and a
soft Delete with Undo is destructive while the row stays. It takes `has*`
because it declares a property of the action, not a state the row is in. It
is nothing more than the resting position: not a promise the caller removes
the row, so no obligation and no warning; not an animation, so nothing
collapses or fades; not a timing rule, so `onActivate` still fires at
activation.

Composition is deferred, not lost: a `render*`-shaped addition for a panel
the vocabulary cannot draw can land when a caller needs it. How a held-out
row leaves, and how a kept row returns, is not this record's (Non-goals).

Rejected: nodes per side with a shipped `ItemSwipeAction` component — it
needed context registration for `commit`, and left any caller-supplied node
silently without it. Rejected: one action per side with `onAction` and a four-tone `tone` — a
second verb vocabulary. Rejected: a wider `hasRemoval` that promised the caller would remove the row, owned the exit animation, and fired `onActivate` after it — it made a confirm-first delete an exception and tied the resting bit to
the unsettled exit animation. Rejected: `isCommitExit` — the bit applies under `reveal` too, so naming it after commit is wrong.

### DEC-2 — Rest is the default; commit is a caller choice for one verb

**Reference:** `spec:AST-057/DEC-2`
**Decider:** `cixzhang`, `2026-10-02`

`swipeBehavior: 'reveal' | 'commit'`, default `reveal`. Under `reveal` the
row rests open and a long drag fires the outermost entry; under `commit` the row slides out and the outermost entry fires. The slide-out is the gesture's own confirmation; where the row rests afterwards is the entry's `hasRemoval`, not the model's — a commit is not a removal — and the component waits for nothing after firing. `commit` means the same on every row that has swipe actions; no host narrows it.

Where a platform offers both, the full swipe is defined over the resting
list — UIKit's flag is
[`performsFirstActionWithFullSwipe`](https://developer.apple.com/documentation/uikit/uiswipeactionsconfiguration),
indexing the resting actions — so resting is the model that holds several
verbs and commit is the one that holds exactly one; the count and the rest
state are one question, which is why `commit` with several entries on a side
warns. A resting panel also gives a touch user something to see before a
destructive verb fires, which is why it is the default. Commit is kept because both models are wanted, and it is what the row this
gesture was modelled on uses for its one-verb rows.

Rejected: commit-only as the sole model — it cannot grow into resting
without a default change. Rejected: a boolean `hasFullSwipe` — under `reveal` the full swipe is already on and under `commit` it is the whole behavior, so a boolean gates the wrong axis. Rejected: a `hasCommitAnimation` flag — the slide-out is how a commit confirms and `commit` already decides it; a second switch for one thing is the boolean-plus-handler duplication rejected on `hasCreate`. Rejected: requiring the row to stay out after every commit (#6821) — a committed "Mark read" row is still there.

### DEC-3 — The capability is `Item`'s; where it is available follows the row's role

**Reference:** `spec:AST-057/DEC-3`
**Decider:** `cixzhang`, `2026-10-03`

`Item` owns `swipeActions` and `swipeBehavior`. `ListItem` inherits them
through its existing passthrough and declares nothing of its own; a bare
`Item` in a product's own markup gets them the same way. Where swipe actions are available follows one rule: the row's role must
permit interactive descendants, because a swipe panel's controls are
interactive descendants of the row. A `listitem` and a role-less `Item`
qualify; `option`, the `menuitem*` roles, and a row that is the enlarged
target of a native radio do not, and are out of scope — not narrowed to one
model, not warned about per call, simply not served. A rule about the role
rather than a list of components means a row type nobody has written yet
sorts itself, and a scope boundary keeps `swipeBehavior` meaning one thing
everywhere it exists. Open state is the row's own: a pointer landing anywhere outside a
resting row closes it, and a drag on a neighbour is such a pointer, so one
row rests open at a time with no group state and no coordinating hook.

Only the component that owns the root can translate it, and a wrapper around
the row is ruled out by the HTML list content model — it would sit between
`<ul>` and `<li>` — so the gesture has to live where the root lives. Of `Item`'s seven in-core renderers, six give the row a role that forbids
interactive descendants; the seventh is `ListItem`, and the system's own templates render a bare `Item`
outside any list, so the capability has two consumers rather than one, which is why it stays
on the primitive: list rows and standalone rows share one home, and the
excluded rows are excluded by their role, not by where the prop lives.

Rejected: moving the prop to `ListItem` now that six of `Item`'s seven
in-core renderers are out of scope — it would put the prop only where it is
most used and take it from the standalone row, a real consumer the system's
own templates render. Rejected: deciding scope per model — permitting `commit` where `reveal` is
forbidden — because it couples a design choice (what the row does when
swiped) to a structural constraint (what the host may contain), so the same
prop value behaves differently by host. Rejected: a behavior hook that hosts
compose — one-open-at-a-time was its reason to exist as group state, and
outside-close gives the same outcome with nothing shared. Rejected: an
imperative or controlled open — nothing has asked, and the gesture already
owns the one coordination case.

### DEC-4 — No hover reveal

**Reference:** `spec:AST-057/DEC-4`
**Decider:** `cixzhang`, `2026-10-02`

On a pointer that can hover, `swipeActions` does nothing. The gesture is a
touch accelerator and changes nothing on any other input; what a row offers
a mouse or a keyboard is the row's own content, which this record does not
govern.

Rejected: a reveal axis that rendered the same actions as hover-revealed
buttons on a fine pointer — a second surface for verbs the row's content can
already carry. [React Aria's iOS List example](https://react-aria.adobe.com/examples/ios-list)
takes that route (a panel button in the tree, revealed on focus) and is the
alternative this decision sets aside.

### DEC-5 — The root translates and the panels counter-translate; no element is added

**Reference:** `spec:AST-057/DEC-5`
**Decider:** `cixzhang`, `2026-10-02`

`Item` renders each side's panel as an absolutely positioned child of its
existing root, at the inline start or end, above the root's background and
below the content. The drag writes its travel as a custom property on the
root — the way `useToastGesture` writes `--_toast-swipe-y` — the root's
transform reads it, and each panel's transform reads its negation, so the
row moves and the panel appears fixed in the space the row vacates. Both are
compositor transforms and the cancellation is exact. No element is added to
any row. The group clips with `overflow-inline: clip`. The positions are
internal: no public slot is admitted for them.

Layering and translation are different problems and neither needs a new
element. Layering is absolute positioning inside a containing block the root
already provides, with `isolation: isolate` on the root so a negative
`z-index` on the panel stays local (the 2026-09-04 isolation ruling).
Translation is the root's own transform: the content is four siblings in
every render mode, and the root is already their common parent. The root's
own styles survive the transform — no `overflow` or `contain` on it, the
focus-within outline and the theme target ride it. The clip cannot be the
row's, because the row is the thing moving, so it is the group's; `hidden`
would create a scroll container and fight the list's vertical scroller.

Rejected: a wrapper element inside `Item`, conditional or not — a second
row shape for CSS and tests to straddle, for a problem the root's transform
already solves. Rejected: a clipping container around a new inner row — an element on
every swiping row. Rejected: panels as siblings at
the list level, positioned at each row's offset — a position-sync loop
against resize, reflow and virtualized mounts. Rejected: animating
`padding-inline-start` on the root — layout on every frame under a finger
instead of a compositor transform. Rejected: public `beforeContent` /
`afterContent` slots — nothing in this record needs them, and general
anatomy takes its own admission.

### DEC-6 — The verb variant set is `neutral | accent | destructive`, minted knowingly

**Reference:** `spec:AST-057/DEC-6`
**Decider:** `cixzhang`, `2026-10-02`

`ItemSwipeActionVariant` is `'neutral' | 'accent' | 'destructive'`: three
flavours of verb — ordinary, the one you mean, the dangerous one — and
`accent` is the default on a swipe panel, because the panel is a filled
surface that has to be some colour and `ProgressBar` already defaults a
filled surface to `accent`.

This is minted vocabulary, not reuse, and it was taken knowing that:
`accent` appears on no other variant enum in core except `ProgressBar`'s
default, and `Step.status` carries it as a status. The shared status set —
`success | warning | error` across seven components — names states, and a
verb is not a state: "Archive" is not a success and "Delete" is not an
error. `spec:AST-058` takes the same set for picker-row actions with
`neutral` as its default, so the vocabulary is one across the two surfaces
where a verb gets a colour.

Rejected: the status set — it would make a verb claim a state. Rejected: a `tone: 'accent' | 'success' | 'warning' | 'error'` — a status
set with one decorative member. Rejected: `Badge`'s variant map — it carries
decorative colours (`blue`, `cyan`, …) that a verb slot must not admit.
Rejected: the menu row's two-value `'default' | 'destructive'` — it has no
word for the one action a panel exists to offer.

## Open questions

None.

## Content boundary

The research that produced this record — the cross-library survey, the
shapes weighed, and the vibe test's design and scores — is a report, not a contract, and lives outside it.

This record does not duplicate `Item`'s or `List`'s anatomy, prop tables,
theming targets, or geometry contracts; the modality invariants in
`architecture:interaction-modality`; the admission argument in
`spec:AST-002`; the press model in `module:DropdownMenu/useMenuPress`; or the
gesture constants an implementation will hold in source. It links their
canonical owners.

---

--- METRICS ---
- Files Extracted: 101
- Estimated Token Budget: ~513425 tokens
- Canonical Reference: https://codewiki.google/github.com/facebook/astryx
