astryx Documentation

An open source design system that's fully customizable and agent ready

13,727 stars TypeScript TypeScript 101 files · ~513,504 tokens
RAW Doc

Technical Documentation: facebook/astryx

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

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 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 Components, theme system, and utilities README
@astryxdesign/cli CLI tooling: component docs, templates, scaffolding, themes, and codemods README
@astryxdesign/build Build plugins for StyleX source builds README
@astryxdesign/theme-* Seven ready-made, fully customizable themes (neutral, butter, chocolate, matcha, stone, gothic, y2k) README

@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 for the full guide.

Questions, or want a hand with a PR? Come find us in
Discord.

Our conventions and review rubrics live in the
Contributing wiki —
including API Conventions,
Design Conventions,
the Component Lifecycle,
the Contributing Templates,
and the Blog Review Rubric. 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 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.

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//src/<ComponentRoot>/.spec.md;
  • flat packages (charts, richtext, vega) use
    packages//src/.spec.md.

In a directory-layout package, `` 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 .spec.md at least one directory below
the same component root. Their canonical id is
module:/; 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//.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 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: choose one primary intent, use the
    matching template, and supply the right evidence without absorbing tagalongs.
  • API conventions: shape, propose, implement, and review a
    public component API.
  • CLI conventions: propose, implement, and review a change
    to the packages/cli command surface.
  • Writing specification records: 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 Person-driven rest, hover, press, focus, selection, and manipulation states
System states System-driven disabled, loading, processing, status, and transient feedback states
Agentic states Agent-driven thinking, streaming, tool execution, waiting, synchronization, inspection, and rendering states

Other records

Record Owns
Spatial hierarchy Perceptual grouping through proximity, spacing tiers, and alignment
Control rhythm Alignment and density relationships among mixed controls
Shape relationships Role-based and concentric geometry for nested surfaces
Elevation hierarchy Agreement between perceived depth and actual layer order
Typography hierarchy Legible, distinguishable text roles across themes and layouts
Iconography Icon meaning, size, artwork, color, composition, and accessibility
Color emphasis Semantic color roles, contrast intent, and local emphasis
Motion Meaningful movement, timing hierarchy, easing intent, and reduced motion
Ordered collection reordering Drag preview, candidate insertion, commit, and settle intent for ordered collections
Template composition 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/-<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//.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: (for example, theme:neutral).

Current records:

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:

The historical ChartV2 name remains in these filenames because the records
predate extraction into @astryxdesign/charts. Current consumer guidance lives
in the package README 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 — 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
lab → core promotion, what a component must satisfy Component-Lifecycle
how to build a new component Component-Authoring-Guide, Creating-New-Components
what a component must be hardened against Component-Hardening-Protocol, Hardening-Audit-Guide
a11y requirements as checks we run Accessibility-Checklist
how the system is put together System-Architecture, Theming-Infrastructure
API naming and shape decisions API-Conventions, API-Arbitration
contributor workflow, PR process Contributing, Contributing-with-AI-Assistants
release mechanics Release-Process
what a nightly agent role does the Night-Watch pages, from Night-Watch-Overview

Fits no row? It is still not caller-facing. Default it to 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
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

    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
    -<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 . 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 .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
    .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
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 .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 owns real-AT receipts;
    INV12 owns the operational audit datastore. AST-029
    FR5 and FR10 own the audit pull request's evidence receipt and exact-head
    eligibility report. AST-030 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 .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 .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 .audit.json reservation never received a schema or active
    records and is retired by INV12.
  • packages/themes//.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 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.
  • 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 Xstyle, ClassName, and
    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 .tsx and .template.* or legacy .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
    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", "I