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
classNameusing 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:
# 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/cliYou 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:
npm i @astryxdesign/cliProject 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:
"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@canarydist-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:
corepack enable
pnpm installIf 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 andpackages/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) usepackages//src/<ComponentRoot>/.spec.md; - flat packages (
charts,richtext,vega) usepackages//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 usekind: module records named .spec.md at least one directory below
the same component root. Their canonical id ismodule:/; 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, andnode_modules directories. Those files are not knowledge records or spec-only
changes. Theme contracts live beside the package aspackages/themes//.spec.md.
Authority
Every knowledge record declares authority: draft | current | archived.
draftdocuments are not policy; unresolved evidence and owner decisions are
recorded as blockers inside the document.- Initial promotion to
currentrequires explicit owner approval recorded in
the document metadata. Any current.github/ENGOWNERSmember may approve every
record kind; current design and theme records and normative design assets also
accept current.github/DESIGNOWNERSmembers. Legacy v1ownersmetadata is
descriptive and does not grant approval rights. - Pull requests that create, change, or archive a
currentrecord wait on thespec-owner-approvalstatus 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
currentdocuments 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.
modulesandparent_componentare 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. archiveddocuments declarearchive_reason(superseded,withdrawn, orhistorical) and linksuperseded_bywhen a replacement exists.
Templates and schemas
Templates create documents; schemas keep existing documents structurally
aligned. They are intentionally separate:
template_versionrecords the authoring form used to create a document.schema_versionrecords 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 (
draftorcurrent) 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 onlyauthority: 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 legacyaffects_* fields with one typed affects list containing ids such asarchitecture:*, 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 asreview-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 thepackages/clicommand 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 atpackages/themes//.spec.md, analogous to component specs. This
page is guidance and an index, not a canonical record. Start a record fromdocs/templates/knowledge/theme-spec.md and use an id of the formtheme: (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 anowners field, and record metadata never grants approval rights.
Packages Charts Docs README (`packages/charts/docs/README.md`)
Charts planning and verification
These package-local records track the canary-only Charts package while its API,
visuals, and verification mature:
CHARTV2_PHASE1_PLAN.md: implementation sequence and evidence.CHARTV2_STAGE1_DESIGN.md: API and correctness research.CHARTV2_READINESS.md: launch-readiness snapshot.CHARTV2_VERIFICATION_CHECKLIST.md: verification matrix.
The historical ChartV2 name remains in these filenames because the records
predate extraction into @astryxdesign/charts. Current consumer guidance lives
in the package README 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
- Run the canonical full-scope
release-checkon an exact commit from currentmain. Cut only from that checked SHA while it remains in currentmain
history. A minor cut additionally requires Cindy, the release owner, to
explicitly sign off on the exactvX.Y.0immediately before the branch is
created; scheduled or standing authority never substitutes for that sign-off. - Create the single marked
release/vX.Y.Zbranch. 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. - 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. - 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 theupdateprofile, 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 frommainis refused. - Squash the published branch outputs back into current
mainthroughchore/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 onmain. - 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 inpackages/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 intotransforms/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.Ztag with the exact
release branch, version, head, tag, and plan digest. It publisheslatest. - Canary: automatic main push. It publishes
<declared-version>-canary.<short-sha>tocanaryonly 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:
pnpm publish ./packages/<package> \
--tag latest --provenance --access public --no-git-checksTrusted-publisher setup
Trust is configured per package. It must name release.yml and allow directnpm 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):
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.
--bootstrapclaims a missing package name with a deprecated0.0.0-bootstrap.0placeholder under thebootstrapdist-tag. npm makes a
package's first version itslatest, so a plain install of the package
resolves to the empty placeholder until the package's first stable release.--setup-trustregistersrelease.ymlforfacebook/astryxwith 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
--replaceonly 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 thefacebook/astryx release.yml entry allows npm publish, not onlynpm stage publish. The same page is the manual path when the script cannot
finish: add a GitHub Actions publisher with owner facebook, repositoryastryx, 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.
- Preflight.
clients/cli/bin/astryx.mjsgates the Node version using
only built-ins, before importing anything that could fail on an old
runtime. The gate honours--jsonwith a hand-rolled envelope carryingERR_NODE_VERSION. - Release choice.
foundation/release/handoff.mjschooses the release
that answers the folder (spec:AST-081FR2) 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-081FR9); a release that cannot run fails
the run withERR_VERSIONS_UNAVAILABLEbefore any write. - Dispatch.
clients/cli/index.mjsparses with Commander, sets JSON mode
inpreActionbefore any command body runs, and rejects a command that
cannot support--jsonbefore any side effect. - 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 thetypediscriminator on its own envelope.spec:AST-042FR1 makes this the
contract; INV20–INV22 fix where each part lives. - Render. Exactly one of two paths. In
--jsonmode,jsonOutwrites 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
--jsonemission is one valid envelope. Success is{apiVersion, type, data}plus optionalpackage(INV28) andmeta, which
carriesansweredByon every success (spec:AST-081FR5). Failure is{apiVersion, error, code}plus optionalsuggestions. 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.
codeis stable and
append-only. Once shipped, a code's meaning never changes and the code is
never removed. Theerrorstring may be reworded at any time. A consumer branches oncode.ERR_PROJECT_REQUIREDandERR_VERSIONS_UNAVAILABLEcarry the refusals and
failuresspec:AST-081FR2 and FR4 define.INV4 — Human output is a projection of the JSON, produced only by the
formatters.emitaccepts a renderer-producedBlockand 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, orclients/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'sCommandDocdeclaresoutsideProject, which the manifest
publishes (spec:AST-081FR4).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 undertheme 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-039FR14) is the one that does (DEC-4).INV33 — Documented component props agree with the source.
astryx doctor integration componentsMUST 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,
arequiredflag 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 omitsref, the React-reservedkey, the internal and
styling props the publishedComponentPropDoctype tells authors to skip
(xstyle,className,style, anddata-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 validateMUST check each descriptor an
integration's roots hold, and each translation a descriptor carries, against
the type@astryxdesign/cli/authoringpublishes 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 carrynameanddisplayName, 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 templatecategory,@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 validateMUST warn on a PascalCase.tsxat the top of the components root
with no same-stem component doc (source_without_component_doc), and on a.tsxat the top of the templates root with no same-stem template doc
(source_without_template_doc). Test, spec and story files (.test,.specand.storiesstems) 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 checkcommand, which takes no package argument, MUST run
thevalidate,components,templates, anddocschecks 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--strictit 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.--strictalso
fails on three doc faults thedocscheck 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
exactlyastryx 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
shipsintegration 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. Eachdoctor 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/clipeer range,astryx doctor integration validateandintegration checkMUST list acli_peer_versionnote, which fails nothing,--strictincluded, andintegration verifydoes not report it: below the range it names the
project's commands to install a newer Core and CLI together and runupgrade --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-reportMUST send a report to the configured report handlers, or
with none to the owning package'sissuesUrl(spec:AST-031FR8), unless
the caller names a destination with--to. A destination is a
loaded integration whose manifest setsissuesUrl. With--to, the report
goes only to thatissuesUrl, the way the zero-handler fallback sends it: a
GitHub issues URL is filed only with--confirm-publicand reportsconsent_requiredwithout it, and any other URL comes back asrouted_only. A--tothat names no destination MUST fail withERR_UNKNOWN_PACKAGEbefore anything is sent, listing the destinations it
accepts. While at least one destination exists, the categories listing and
the receipt of a run without--toMUST 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--towhen the person
does not choose (DEC-9).INV9 — A command that cannot support
--jsonis 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 isERR_PATH_TRAVERSAL. Bounded inputs are capped rather than trusted. The only
writes outside a project or output folder are the per-user onesspec:AST-081FR7 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 thespec:AST-017release 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 upgradeoperates on the projectspec:AST-040FR9 finds: the nearest folder at or above where it runs whosepackage.jsondeclares an Astryx package, or that an Astryx workspace lists.
A folder that installs@astryxdesign/corebut 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-017FR54). 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. TheProjectseam 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 doctorfails on a configured integration that cannot
load. An integration named inastryx.configthat is not installed, has
no manifest, or whose manifest throws or fails its schema is a failedconfigured-integrationscheck, sodoctorexits 1; anastryx.config
that imports but from which the CLI cannot load the project is a failedconfigcheck with the same exit. What counts as configured is every nameastryx.configlists, 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; whatdoctorreports 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.
humanLogandhumanWarnare the only chatter primitives, and both are no-ops under--json. The run-level linesspec:AST-081defines, 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 integrationagentDocsthrough the existingProject
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 thatastryx init --userwrites renders from
the same source (spec:AST-081FR8).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).--agentand--agent-docs-path
name the files instead; an--agentpreset still never leaves a copy in both
Claude Code files, except as the ignored-root rule below says.AGENTS.mdat 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 importsAGENTS.md,
because Claude Code does not follow an import of a file above the folder it
starts in. While rootCLAUDE.mdholds a copy,.claude/CLAUDE.mdholds
none, except while rootCLAUDE.mdis untracked and ignored by the
repository's ignore rules, including its local exclude file and configured
excludes: then a.claude/CLAUDE.mdthe project commits (tracked, or not
ignored) holds a copy too, as it does when the repository cannot say, in
the default layout and with theclaudeandallpresets, so a fresh
clone still gives Claude Code one.
Init creates no Claude Code file, and a Claude Code file that isAGENTS.mdunder another name holds no second copy. GEMINI.mdholds a one-line managed import ofAGENTS.md. Init creates
it when it is missing, and adds no import to aGEMINI.mdthat already
importsAGENTS.mdthe way Gemini CLI reads imports. Settings that point
Gemini CLI atAGENTS.mddo 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 thanAGENTS.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.mdholds the block.
Doctor fails when it does not. - Init writes at the project root: the nearest folder at or above the
working folder whosepackage.jsondeclares an@astryxdesignpackage,
else the nearest folder with apackage.json, else the working folder.
The search stops at a repository root and never counts the home folder. A
path given with--agent-docs-pathstays 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 apackage.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 filespec:AST-081FR8 writes
(~/.agents/skills/astryx/SKILL.md, with its folder links or copy in other
agents' skill folders) is not a project's agent docs: onlyastryx init --userand 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-agentsremoves 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 rootastryx.integration.*manifest, is
loaded;node_modulesis 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-081FR12) contribute
only outside a project, from the packages installed beside the global copy.
Inside a project they contribute nothing:discoveranddoctorload 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 createfilesorexports; 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.tsor.tsxsource. 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 verifyruns the package lifecycle throughnpm 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.tsor.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'sthemesroot contains one directory per slug.
Every theme source has a mandatory same-stem, strongly typedThemeDoc; 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>Themesource, 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 localsrc/themesroot:
a source copy made by the releasedtheme addwithout 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 verifyrebuilds
source, compares the local built module and stylesheet, and proves every
declared theme export resolves from the packed package.theme listretains
package ownership, andtheme add --importrecords that owner and imports only built
outputs. The deprecated plaintheme addkeeps copying source until its scheduled
cleanup.theme ejectis 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
inapi/<subject>/.<subject>.mjsis the subject's entry, andapi/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 colocatedFunctionDoc—<subject>.doc.mjsfor the
entry function,<function>.doc.mjsfor any other — and response typedefs in a*.type.mjsfile. Tests sit beside the module they cover as<module>.test.mjs. A private helper is an underscore-prefixed file, such as_site.mjs.api/blogis the reference layout.INV21 — An API subject reaches the environment only through its adapter.
Withinapi/<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.mjsand any underscore-prefixed
helper that only it imports. The adapter may call foundation seams such as theProjectseam andassertWithin. The entry and the leaves shape inputs and
results and call the adapter; they do not import filesystem, network, or
subprocess modules, theProjectseam, or discovery and loader functions. A
subject that needs no environment access has no adapter.api/blog/_adapter.mjsis the reference adapter.INV22 — A command handler parses, calls, and renders. A handler in
clients/cli/commands/registers throughdefineCommandwith itsCommandDoc, and an executable command'sCommandDocnames infnthe API
function it calls. The handler parses its arguments, calls that function, and
renders the result throughjsonOutor 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 namespec:AST-081FR5's picker
chooses. Only command handlers, theirCommandDocfiles, and
their tests live in that directory.clients/cli/commands/blog.mjsis the
reference handler.INV23 — The formatter kit is closed. Text output is built only from the
block constructors inclients/cli/formatters/index.mjs—section,text,list,record,records, andcode— and printed throughemit. 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 asspec:AST-042FR3 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'snameis 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 incomponents
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 anamespace. The docs
tree adoptscli/commandsandcli/apidocs, one route each undercli/commandsandcli/api/<kind>s;authoringdocs are sections of the
field reference,astryx docs authoring/field-reference, and appear in its
list;astryx docs authoring <name>reads one.astryx doctorfails 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 infoundation/doc-compiler/tree.mjsfrom namespace docs, the
guides they place, and the typed docs they adopt; the CLI's own tree files
live inassets/docs/tree/. A namespace never lists or scans its children.astryx docs <route>reads it one level at a time, andastryx doctorwarns
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 underauthoring.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 aslinks; a search hit
carriesparent. Nothing a read shows can go stale: moves are derived, and a
test proves every command a doc shows is real.astryx docsandastryx searchmeet 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
--jsonresult about one artifact (a component, doc, template, or hook)
carriespackagein its envelope, directly aftertype. A result
that lists artifacts gives each listed artifact its ownpackage, 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 --listandswizzle --list, hold
only Core's artifacts and namepackageonce, in the envelope;build'sframeandfoundationare 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 whosenode_modulesholds 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-081FR1); which release answers a hosted run, and every line
a hosted run prints, arespec:AST-081FR2, FR3, FR5, and FR9's. Only the
host entry activates a package set: no CLI flag, environment variable, config
key, or command does, and nofoundation/runtimemodule 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-081FR12). 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-081FR9) 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 releasespec:AST-081FR2
names, or as FR2's fallback, the root that holds that copy serves the run. - When
spec:AST-081FR2 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-081FR2 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 whatspec:AST-081FR12 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 (
doctorand its integration checks) judge only the
project's own install: a package set never satisfies or fails a project
check, andspec:AST-081FR12 decides what they list as available.
- A project's installed packages always resolve from the project. Inside a
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
followsspec:AST-017/DEC-4; - adding a formatter or block kind (INV23,
spec:AST-042FR3), or writing to
stdout from anywhere other thanemitandjsonOut; - 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 verifyexecutes, 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 inverified_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— theCommandDocthat 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— theProjectdiscovery seam and integration precedence.host/index.mjsandhost/index.d.ts— the supported programmatic host
entry,@astryxdesign/cli/host(spec:AST-081FR1).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.mjsandapi/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 thetypeon 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, andmeta.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 thatdoctor integration runs and the integration check command that builds run.
They are command contracts beside INV32, so this record governs them, andspec: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 runastryx 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 exactlyastryx 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 straynode_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
metacarry 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
{
"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:
- A participating consumer accepts the standard
sm | md | lgelement-size
axis and resolves it through the shared size context. - A provider owner establishes a default size for documented eligible
descendants. - A component default supplies the fallback when no explicit or inherited
value applies.
A consumer resolves one effective size in this order:
- explicit component prop;
- nearest active provider value; and
- 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 vocabularysm | 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
namedsizedoes 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
nullsuppresses
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, andSizeProviderare public exports and the current transport.
Their public compatibility belongs toarchitecture: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
useSizeorSizeProviderfrom 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
ElementSizetype; 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.tsowns shared resolution and
transport.packages/core/src/ButtonGroup/,packages/core/src/InputGroup/,packages/core/src/Toolbar/, andpackages/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-apiowns export reachability and compatibility
forSizeContext,useSize, andSizeProvider.architecture:component-theming-surfaceand 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
nullprovider 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
{
"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:
- CSS-resolvable presentation. The browser can derive the visual state,
relationship, responsive condition, fallback, or transition from CSS. - Behavioral state. JavaScript owns information or interaction that CSS
cannot derive. The component exposes only the resulting style inputs to StyleX. - 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.mdbefore 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-tokensowns 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-apiownsBaseProps,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/andpackages/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
{
"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:
- Assemble the component's current public behavior from every applicable current
component, module, family, architecture, system, design, and theme record, plus
applicable objective standards. - Identify the promises whose failure would harm a user, consumer, accessibility
path, compatibility boundary, or regression-prone integration seam. - Partition inputs, states, and boundaries by observable behavior and risk.
- Check whether existing evidence already fails for the distinct promise and
partition. If it does, cite that evidence and add no duplicate test. - 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. - Add a new automated test only when no current mutation-sensitive evidence covers
the promise at that layer. - 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. Wherespec:AST-029permits 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 anAST-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, thespec:AST-029audit 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 followsspec: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
{
"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:
target— this component promises a stable public target for the visible
part, such asselector-popup.inherits— the part has no separate target. It uses styles from this
component's parent target, such as a Button label inheriting frombutton.delegatesTo— another Astryx component owns the part and its target, such
as a Selector label usingField/field-label.nonewith 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
factualnoneare valid outcomes. Anonereason 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 throughthemeProps; 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'sguaranteedPropertieslists 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 inarchitecture: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 inarchitecture: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 underspec: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.typequalifies because requiredHeading.levelprovides that
baseline.Icon.sizedoes 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 rootcomponents. 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 canonicalastryx-* target plus its explicit reflected data-* attribute. SemanticdefineTheme({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.mjspublic 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 factualnone. It does not automatically
create CSS API. Anoneentry classifies the current state asintentional,reachability-gap, orunsettled; 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
guaranteedPropertiesrecords 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.mjsusage.anatomy[]owns the consumer-facing semantic part
inventory and contains no theming disposition or maintainer rationale. - Component
.doc.mjstheming.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.visualPropsdoes not
declare CSS property support. - The approved property catalog is owned by this record. Target
guaranteedPropertiesdeclarations will live in component.doc.mjstheming.targets[]once the authoring schema supports them; there is not yet a
machine-readable field. - Component
.doc.mjstheming.vars[]owns documented custom properties and
marks private implementation variables;theming.derived[]records checked
public-property expansion metadata. - A colocated component
.spec.md### Theming anatomyblock 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 reflecteddata-*
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, andderivedVarRegistry.test.tsenforce the
source/metadata/generated relationship.extensibleAxes.test.tsproves 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-2andspec:AST-012/DEC-3constrain adaptation component
writes to the closed ordered rule model.spec:AST-012/DEC-5makes component
validation shared with root themes and reserves effective-root presence for
values whose validity depends on theme enrollment.spec:AST-017/DEC-1owns 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.tstherefore 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.variantandBanner.containerare 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
guaranteedPropertiesfield. 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 byarchitecture: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
{
"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:
- Public component inputs and theme properties. Component props use the
public spacing scale. Theme component overrides may set supported padding
properties.architecture:theme-authoring-contractandarchitecture:component-theming-surfaceown those public surfaces. - 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 inpadding.stylex.ts. - 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). - Boundary reset. An overlay leaves its ancestor's visual box while
remaining a DOM descendant.overlayPaddingResetzeroes 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 withedge="bleed", List consumes edge-compensation geometry withedge="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 thepadding 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:
- 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. - 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'sisFullBleed behaves as bleed, so component:TabList spells that modeedge="bleed" and keeps isFullBleed as its deprecated alias.edgeCompensation is the released name of outset: List'sedgeCompensation="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: theirinline, 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-propagatedare 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.
edgenames a component's
edge treatment with the valuespadded,flush,outset, andbleed
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 meanspadded, except that an anatomy
container keeps its documented omission behavior.isFullBleedremains for a
surface whose public promise is to consume the full host inset without a
content line of its own.edgeCompensationsurvives as List's deprecated
alias ofedge="outset"and in the releasedendContentEdgeCompensationof
DialogHeader and DrawerHeader until theirendContentEdgemapping 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 andpadding={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>Edgefor 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
edgepreserves the old
name as a deprecated alias through its approved compatibility window, moves
maintained examples toedge, 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:Listowns List'sedgeprojection,
including its full-bleedbleedvalue, andcomponent:TabListowns 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
edgeprojection.component:TabListandcomponent:Listdefine their local
padding, row/strip geometry, and private propagation. - Overlay roots — apply the reset at the visual boundary.
Deciding specs
spec:AST-002/DEC-1andspec:AST-002/DEC-2require the caller-owned
opt-in and its name to describe a dependable result rather than the margin or
token mechanism.spec:AST-025/DEC-2andcomponent:ScrollableArea/FR5establish the
contrasting full-bleed contract: ScrollableArea consumes the full inherited
inset only through explicitisFullBleed, with no content line of its own to
keep. List'sedge="outset"cancels at most its own Item inset and may stop
inside the container padding. List'sedge="bleed"and TabList'sisFullBleedconsume the full host inset with their paint and pad back in bymax(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 themeitem.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
{
"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:
- which shared icon name a component role uses; and
- 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:
- the active theme's
icons[name]entry; - a process-wide
registerIcons()entry; and - 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:
// 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}):
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:
- a consumer-provided instance prop, when the component exposes one;
- the nearest active theme's
componentIcons[slot]entry; and - 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 sharedIconName | 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, andextends. Theme compilation owns preserving the normalized map in built output.
Known deviations
- NumberInput stepper icon. Current
NumberInputresolves its stepper through
a general extension key instead ofcomponentIcons. This shipped behavior
remains supported and is not precedent for new component slots. - Owner:
component:NumberInputonce 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.
IconNameowns
shared semantic meanings.ComponentIconSlotNameowns 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
componentIconsvalue is anIconNameornull, never concrete artwork. - INV4 — Null suppresses a slot.
componentIcons[slot] = nullintentionally
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 widenIconName. - 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
componentIconsentry 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
useComponentIconSlotMapandcomponentIcons.
This record does not own:
DefineThemeInput, theme normalization, orextends; 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
nullmay 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 ascomponentIcons slots unless the component also promises a separate stable
theme-level role.
Change coupling
- Adding a Core slot updates
ComponentIconSlotMapin the public@astryxdesign/core/Iconmodule, 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
componentIconsauthoring 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/Iconsubpath ownsComponentIconSlotMapand
the public slot types that consumers augment. packages/core/src/Icon/globalIconRegistry.tsxownsgetIcon,getExtendedIcon,getComponentIconName, andgetComponentIcon.packages/core/src/Icon/useIcon.tsowns 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-contractownsDefineThemeInput, normalized
theme data, andextends, including integration of the separatecomponentIconsmap.architecture:theme-applicationowns active-theme selection and lookup.architecture:theme-compilationowns 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
{
"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
keyboardso 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 toarchitecture:public-component-api.
Owning code
utils/interactionModality.ts— records and exposes the shared last-input
modality.utils/focusOutline.stylex.tsand 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-runtimeowns 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 bycixzhang 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
{
"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
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 andaria-*/title
values, MUST be anen.jsonkey 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.
InternationalizationProviderplus the hooks under@astryxdesign/core/i18nremain 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 localeen, directionltr, 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 suppliedencatalog, so
non-Astryx keys have no cross-locale fallback today; seecomponent:InternationalizationProviderOQ1. - 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. Translatordescriptiontext lives in
the authored JSON only; runtime catalogs never carry it. - INV7 — Messages produce strings. Astryx catalogs use ICU MessageFormat 1
andintl-messageformat. The supported translation result is a string suitable
for visible text and attributes such asaria-labelandtitle. 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 rawIntlformatters or calling locale-sensitive prototype methods at arbitrary
call sites. Supplying an argument namedlocalealone 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-sensitiveIntl
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 readnavigator.languageor 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 platformIntlto 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 chartscurrency()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.PlainDateparsing, arithmetic, constraints, grids,
and navigation remain Gregorian. The existing low-levelplainDateFormat
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 DOMdir. Applications own the page or regiondir, 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 asgetLocaleDirectionremain 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
isRuntimeCatalog, a key-to-message record. The generated sources live underpackages/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 stringdefaultMessagefails generation; runtime maps carry nodescriptionor
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>.jsonto the rich
authored file and@astryxdesign/core/locales/<tag>.generated.jsto 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.tsgetTimeZoneParts— 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-runtimeincheck: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.jsonexports— publishes both./locales/*.jsonand./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, andannounce()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 tocomponent: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
{
"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:
- What behavior has a human already decided?
- 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 decisionstable 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 wikicomponent-scores.json, which supplies the current post-fix score
and unresolved findings to the sandbox. The bootstrap reserved colocated.audit.jsonfiles, 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:
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 evidenceA 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, andpath-applicability:v1patterns; - names: family
members, designcomponents, andaffects_consumer_docsids
naming the path's component or consumer-doc topic; - citations: a system-spec number such as
AST-030written 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
currentrecords 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.jsonis the operational source for current scores and
unresolved findings. The unactivated.audit.jsonconvention 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: currentapproves 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 itsreview-applicability:v1block declaresscope: "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 decisionstable under## Design relationshipsbelongs only to
component- or module-local appearance intent that a future redesign must
reconsider. Authors cite, reuse, or amend stableDD1,DD2, … rows before
adding one. Exact pixel tuning normally remains in code and visual evidence;
shared or cross-component rules remain indocs/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 tomainas disposable
assets underdocs/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 retainedmainhistory needs a durable
retention anchor at that exact commit; a commit hash or a temporary branch
alone is insufficient. An immutable non-releaseevidence/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 onepath-applicability:v1block. 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.
{
"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.
{
"scope": "global",
"triggers": {
"public-api": ["FR1", "DEC-2"],
"accessibility": ["AR1"]
}
}In an opted-in record, replace the example marker withreview-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:
- Search current records for the behavior, public symbols, affected paths, and
proposed canonical owner/id. - Search open pull requests for the same owner path, symbols, and semantic terms.
- 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.mdturns 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 recordcurrent. - 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 ofdocs/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 asonly, every, consistently, current, records, owns, delegates, andinherits 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
itemscontains exactly one visible item, the component MUST announce that
item exactly once; whenitemscontains zero or more than one visible item, it
MUST NOT announce an item, and consumers that omititemsMUST 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
- A draft is context only and cannot conflict with current policy.
- 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. - If two current records make different claims, review stops. Do not choose by
recency, path proximity, or specificity; record anovel-humangap. - 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. - 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 becomescurrent, the repository must support this flow:
- The record names the code surface that can affect it and the checks that
verify it. - A pull request touching that surface triggers a focused contract review.
- 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; orout-of-scope: another component, module, family, system, or product owns it.
preservesandsettledenter normal correctness review. They are eligible
for approval only when the implementation and evidence also pass.violatesreceives 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.- A separable
novel-humantagalong 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. Anovel-humandelta 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. - A bug fix is
preservesonly 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. - A visual correction may be
preservesorsettledwhen 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 remainsnovel-human; calling it
an improvement does not settle it. - 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:
- State the pull request's primary intent and partition its observable deltas.
- 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. - For a contradiction, conform or remove it. Do not propose changing the
specification unless changing policy is itself an intentional, separately
reviewable goal. - For an unsettled but separable delta, remove or split it. Continue the primary
change without asking the contributor to solve adjacent system design. - Escalate only a surviving, intentional
novel-humandelta. 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
- 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. - A reviewer or agent applies the contraction path above. Only a surviving,
intentionalnovel-humanquestion proceeds; the contributor does not invent
the answer or specify unrelated adjacent behavior. - An authorized owner answers in the pull-request review.
- 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.
- Prefer a commit in the same pull request when maintainers can update the
- 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 tomin/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 thatDECand 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.mdpoints reviewers to the narrowest relevant record.docs/templates/knowledge/contains authoring forms.- Component records are direct
.spec.mdchildren 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'smoduleslist and the module'sparent_componentfield must agree. Hidden,
fixture, test, generated, build-output, coverage, dependency, and*.generated.spec.mdpaths are ignored consistently by discovery and PR
routing. - Wiki
component-scores.jsonis the current operational audit datastore. The
historical.audit.jsonreservation never received a schema or active
records and is retired by INV12. packages/themes//.spec.mdcontains that package theme's
canonical record;docs/themes/README.mdis guidance and an index only.docs/schemas/knowledge/defines required structure.scripts/check-knowledge.mjsvalidates 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.--unroutedlists tracked paths with no linked current record;--unreachedlists current records no path reaches..github/scripts/component-design-decisions.cjsclassifies 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.cjsidentifies pure spec-record changes..github/workflows/spec-owner-gate.ymlbinds 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.ymllets 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 realstylex.createcall, 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 theircommit_idequals the
current pull-request head. The direct reconciler clears only a current-head
gate already owned byreview-signal; an effective ENGOWNERCHANGES_REQUESTEDorDISMISSEDstate 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 theREVIEW_APPrepository variable also satisfies the
engineering gate under DEC-11. Only that app's latestAPPROVED,CHANGES_REQUESTED, orDISMISSEDreview whosecommit_idequals 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 localdocs/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
{
"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 receivesposition-anchor, logicalposition-area, and ordered
fallback positions; - context / custom: the trigger and
position-anchorrelationship remain,
while the caller owns geometry, fallback, offset, and direction handling; and - fixed: the caller supplies viewport
xandycoordinates 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 asinset 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 queuedtoggle 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 andwasJustDismissed() 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 orblock 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 askshouldDismissOnCloseRequest() before acting on native platform close
requests. Other family members still use local Escape handling as listed infamily: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
mousedownlistener; - 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 toinitial. 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 privateLayer/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 withisTopLayer={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 oronHideupdates. - 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 presentcloseandblockentries 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
toggleand 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
useLayerhosting 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 theCore/Layerviewport-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-dismissalwhen 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
useLayerand Popover docs and tests
for both the native path and actual fallback.
Owning code
Layer/useLayer.tsxowns Popover API lifecycle, state reconciliation,
anchor/fixed/custom rendering, trigger source, and current same-gesture memory.Layer/layerHost.tsowns safe inline versus nearest corrective portal placement.Layer/anchorName.tsowns composition of anchor names on one trigger.Layer/layerTextReset.stylex.tsowns the text baseline andLayer/layerStructureReset.stylex.tsowns the named structural custom-property
reset applied on layer content roots.Layer/layerViewportInset.stylex.tsowns the gutter and the viewport caps;Layer/layerSlideRules.tsowns the slide options'@position-tryrules;LayerProviderpublishes the declared inset throughLayerContext, andLayer/layerInset.tsowns 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.tsowns physical pointer/key gesture identity.Layer/layerStack.ts,Layer/useLayerDismissal.ts, andLayer/LayerDepthContext.tsxown current registration, presence, ordering, and
Escape/platform routing.Popover/usePopover.tsxowns focus-trap composition and default native light
dismiss for Popover-family surfaces.hooks/useFocusTrap.tsadapts dismissible traps into current shared
registration; it does not own component focus policy.hooks/useMenuHover.tsowns hover-open confirmation, native invoker wiring,
menu focus, and exposure-induced re-hover suppression.Layer/useTouchTrigger.tsowns touch trigger classification and the current
touch-only outside listener for Tooltip/HoverCard.DropdownMenuContextandDropdownMenuSubMenuown 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-modalshowPopover()hosting while the
shared dismissal stack owns Escape and platform close routing. LayerProvider,ToastContext,useToast, andToastViewportown current
notification state, dispatch, and viewport rendering; the viewport sits at
the provider's inset on each edge unless its owntoast.insetoverrides that
edge.- CommandPalette owns command search and selection; Dialog owns its native modal
host. family:overlay-dismissalowns Escape/platform-close membership.architecture:interaction-modalityowns last-input modality.architecture:public-component-apiowns public component and hook contracts.architecture:theme-applicationowns 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
{
"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 listsarchitecture: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, andstylecompose 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-fieldsDEC-8). - INV8 — Refs follow React 19. A public DOM component accepts
refas 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 byspec: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 byarchitecture: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 usesm/md/lg/xl/2xlfrom the active Theme. Abelowboundary 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 asXstyle,ClassName, andStyletogether, 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, followingspec: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 underarchitecture: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 fromspec: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.
BasePropsdefines the shared DOM/styling surface for DOM-owning components..doc.mjsfiles 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-1andspec:AST-012/DEC-2— components reuse the fixed
theme width names and inclusive-from/exclusive-belowedge 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
{
"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:
- Render derives output. Props, state, and context determine current rendered
semantics. - Handlers process discrete intent. Pointer, keyboard, handle, and other
commands perform the work caused by one action. - Refs retain non-rendering mutable data. Refs may hold DOM nodes, resources,
latest values, or other coordination data that does not itself drive output. - 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 asonChange,onOpenChange, oronSave: 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,nulldetachment, 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 byarchitecture:public-component-apiand
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:
reffor the documented primary DOM element and its native capabilities; andhandleReffor a component-defined semantic imperative API implemented withuseImperativeHandle.
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 toarchitecture: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
{
"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:
- a component's direction-sensitive behavior was actually measured;
- RTL genuinely does not apply to the component; or
- 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
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 stateLint 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 indesign: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 isrtlAudit.D1withapplicable: falseand a nonemptyreason
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 resolvedrtlAuditobject
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, andrtl-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
knownNotRtllabel 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;
programmaticinituses 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 intemplateComponents.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.
initselects every named built-in page.- The docsite overview selects ready pages not marked
scaffoldorisHiddenFromOverview. - 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.nameanddisplayNamemay 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.tsxthe 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 explicitShell -category may
ownAppShelland 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 underspec:AST-017/DEC-2. - INV10 — The operation surrounding the catalog remains contractual. The
templatecommand, 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-templatesguides 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 byspec:AST-017/DEC-2. design:template-compositionowns shared human visual, hierarchy, responsive, and
interaction intent. It remains a candidate record while its authority isdraft.- 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-2owns the compatibility split between mutable catalog values
and the stable surrounding CLI operation.TemplateDoctypes andparseTemplate()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.mjsand 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 unfinishedmessaging-shell;blankis an
intentional minimal scaffold rather than a gallery page. - 644 block source/metadata pairs with no orphaned source or metadata files. Every
block declarestype,name,displayName,isReady,exampleFor, positiveaspectRatio, and non-emptycomponentsUsed. 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, whileparseTemplate()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
namevalues;
that is allowed by INV2 and creates no CLI ambiguity. - The TypeScript type, runtime parser, and contributor field tables disagree about
whetherdisplayName,description,isReady,exampleFor,aspectRatio, andcomponentsUsedare required. The parser also acceptspreview, which the
TypeScript type does not declare. They must be aligned before this record becomescurrent.
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 becomecurrent? - 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
{
"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:
- The root
Themewrites its theme name and explicit light/dark mode to<html>. useThemereads those attributes and shares one observer per document.- It looks up the theme object by name in the shared registry.
- 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.
useThemeunder 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.
systemresolves to the active
light/dark preference without writing a forced mode to<html>. - INV8 — Media surfaces do not replace the theme.
MediaThemechanges 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/2xlfrom 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
useThemefallback 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.tsxowns provider scope, runtime style lifetime, and root document sync.themeRegistry.tsowns server-safe lookup by theme name.useTheme.tsowns 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.tsxowns local light/dark surface context.AppShell.tsxconsumes the nearest effective width map for mobile navigation.architecture:theme-compilationowns the CSS that Theme mounts.
Deciding specs
spec:AST-012/DEC-1owns 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
{
"scope": "global",
"triggers": {
"theming": ["INV1", "INV3", "INV8", "I