-
Notifications
You must be signed in to change notification settings - Fork 28
Night Watch Component Auditor
Assigned to: Cindy's Navi (cixzhang)
Goal: Ensure all components follow Astryx conventions — theming compatibility, API consistency, accessibility contracts, and export hygiene.
Source of truth documents:
- API Conventions — API rules (naming, structure, composition, a11y, exports)
- Theming Infrastructure — Theming rules (tokens, xdsThemeProps, variant maps, sub-element targeting)
⚠️ Keep in sync. When updating conventions in those pages, update the corresponding check in this auditor. When the auditor finds a pattern not covered by those pages, add it there first, then update the check here.
Component quality regresses silently. A hardcoded color, a missing xdsThemeProps, a boolean prop without is prefix, a missing displayName — these don't break tests or CI but they break the developer and theming contracts. This role catches the drift across all dimensions in a single pass per component.
All visual properties must use CSS variables (design tokens), not hardcoded values.
Check for:
- Colors: no hardcoded hex, rgb, hsl values in StyleX styles — must use
colorVars - Shadows: no raw
boxShadowstrings — must use elevation token variables (e.g.shadowVars['--shadow-menu']), not partially-tokenized strings like`0 4px 12px ${colorVars['--color-shadow']}` - Spacing: no raw px values for margins, paddings, gaps — must use
spacingVars - Radii: no hardcoded border-radius — must use
radiusVars - Typography: font sizes, weights, line heights should come from token variables
-
Font sizes must use semantic type scale tokens (
typeScaleVars) instead of raw text size tokens (textSizeVars). Components should express intent ("this is a label") not implementation ("this is 14px").
Semantic type scale mapping:
Raw token (textSizeVars) |
Semantic token (typeScaleVars) |
Used for |
|---|---|---|
--text-base |
--text-label-size |
Interactive controls: buttons, tabs, selectors, menu items, form labels, nav items |
--text-base |
--text-body-size |
Content: text inputs, links, descriptions, table cells |
--text-sm, --text-xsm
|
--text-supporting-size |
Secondary: descriptions, breadcrumbs, captions, placeholders, badges, helper text |
--text-lg |
--text-large-size |
Emphasis: empty state titles, nav headings |
--text-2xs |
--text-supporting-size |
Micro: compact density descriptions |
Neutral gray token semantics: Three tokens serve "gray" backgrounds. Components must use the correct one based on what the element is:
| Token | Role | Use for |
|---|---|---|
--color-secondary |
Neutral fill for self-contained elements | Buttons (default), badges, tokens, kbd, avatar fallback, pagination dots, status dots, selected nav items, icon containers |
--color-muted |
Background for containers with content inside | Sections, code blocks, table zebra stripes, progress/slider tracks, disabled input fills, featured cards |
--color-overlay-hover |
Translucent hover/active feedback | Hover on ghost/surface elements, list/menu item highlights, table row hover |
Detection: search for --color-wash, --color-muted, --color-secondary, and --color-overlay-hover in component StyleX styles and verify:
-
--color-washshould only appear in page-level backgrounds (AppShell). Flag if used on individual components. -
--color-mutedused on a small self-contained element (dot, badge, icon circle) → should be--color-secondary -
--color-secondaryused on a container with content inside → should be--color-muted -
--color-overlay-hoverused as a resting (non-hover) background → should be--color-secondaryor--color-muted -
--color-mutedused for a hover/active state → should be--color-overlay-hover
Decision tree: (1) hover/active state → overlay-hover, (2) container with content → muted, (3) self-contained element on surface → secondary.
Internal variable access in theme files (packages/themes/*):
- Theme files must not reference internal layout/container padding variables:
--layout-padding-inner-x,--layout-padding-inner-y,--layout-padding-outer-x,--layout-padding-outer-y,--container-padding-inline,--container-padding-block-start,--container-padding-block-end - These are implementation details of the container/layout system. Use
padding,paddingBlock, orpaddingInlineon the component override instead — the theme pipeline maps them to container tokens automatically (PR #1235) - Detection: grep for
--layout-padding-*and--container-padding-*in any file underpackages/themes/ - Fix: replace with
padding: '16px 20px'(orpaddingBlock/paddingInline) in the component'sbaseslot -
Deprecated:
--astryx-card-padding,--astryx-section-padding,--astryx-dialog-paddingstill work via fallback but should be migrated topaddingshorthand
Exceptions:
-
0,'none','transparent','inherit','currentColor'are fine - Layout values that aren't semantically spacing (e.g.
width: '100%',flex: 1) are fine -
Large layout constraints (
maxHeight,maxWidth,minWidth) are acceptable as hardcoded values. These are component-specific design decisions, not theme tokens. Examples: a dropdown'smaxHeight: '300px'to cap scroll height, an empty state'smaxWidth: '360px'to constrain text width. These rarely need to change per-theme and tokenizing them adds indirection without meaningful benefit. Flag only if a constraint seems wrong, not because it's hardcoded. - Animation values (durations, easing) have their own token story — flag but don't block
- Internal layout padding variables (
--layout-padding-*) are fine inpackages/core/— they're internal there. Only flag them inpackages/themes/.
Component-specific CSS custom properties must follow the naming convention:
--[_]<component>[-<part>]-<property>
-
<component>— lowercase component name without Astryx prefix, matchingxdsThemePropsoutput (e.g.button,dropdown-menu,segmented-control) -
<part>(optional) — sub-element within the component (e.g.composerinchat-composer) -
<property>— what the var controls (e.g.radius,padding,opacity) -
--_prefix — private vars (managed by the derived expansion pipeline); public vars omit it
Check for:
- Component CSS vars follow the naming pattern. Flag any that don't match (e.g.
--composer-radiusshould be--_chat-composer-radius). - Private vars (those with a
derivedentry mapping a standard CSS property to them) must:- Use the
--_prefix in their name - Have
private: truein their doc entry - Be listed in
derivedVarRegistry.ts
- Use the
- Public vars (no standard CSS property equivalent — e.g.
--button-press-scale) must:- NOT use the
--_prefix - NOT have
private: true
- NOT use the
- Var names must use the component key from
xdsThemeProps, not abbreviations or different names. For sub-elements, include the parent component name (e.g.--_chat-composer-radius, not--_composer-radius).
Auto-fix: Rename vars that don't match the convention. Update source files, doc files, and derivedVarRegistry.ts together.
Every component and visual variant must apply xdsThemeProps on the correct DOM element so themes can target them.
Check for:
- The element receiving visual styles has the correct
xdsThemeProps— not always the root element. The main visual styles (background, border-radius, shadow, typography) determine which element gets the className. - Visual variants that need distinct theming get variant-specific classNames (e.g.
astryx-button-ghost,astryx-button-secondary) -
Props that drive visual styles must be included in
xdsThemeProps. If a prop selects between different StyleX style objects (e.g.statusStyles[status]), that prop must appear in thexdsThemePropscall. Look forfooStyles[prop]instylex.props()and verifypropis in the correspondingxdsThemeProps()call. -
Visual variation props must use the variant map pattern for extensibility. When a prop drives visual styles (e.g.
status,color,variant), the style lookup should use a variant map registered viadefineTheme'svariantsfield — not a hardcodedRecord<KnownValue, StyleXStyles>. This lets theme packages add new values (e.g. a custom Banner status) without modifying the component. The pattern:- Component declares a default variant map:
const statusStyles = { info: {...}, warning: {...}, error: {...} } - Theme extends it via
defineTheme({ variants: { bannerStatus: { custom: {...} } } }) - Component reads the merged map at runtime, so unknown values from themes just work
- If a visual prop uses a hardcoded lookup that would break on unknown values, flag it for migration to the variant map pattern (#759)
- Component declares a default variant map:
- Composed sub-components get their own component's className, not the parent's
Sub-element targeting: Visually distinct sub-elements need their own xdsThemeProps when they have their own color, background, or border distinct from the parent (e.g. switch thumb, radio dot, progress bar fill). Structural-only wrappers do not.
Common mistakes from past audits:
- Placing xdsThemeProps on positioning wrapper instead of visual container (#749, #744)
- Hardcoded shadow strings with only color tokenized (#746, #670)
- Adding xdsThemeProps to composition wrappers that wrap other themed Astryx components (#749, #672)
- Visual props missing from xdsThemeProps call (#808)
- Hardcoded variant style maps that reject unknown theme values — use extensible variant map pattern (#759)
Components should compose from core primitives so theming cascades correctly and the API contract is consistent.
Check for:
- Close buttons should use
Button(ghost variant) - Dividers should use
Dividerfor standalone structural elements. For repeated siblings, preferborderBlockEndwithcolorVars['--color-border']+:last-childsuppression - Icons should use
Iconwith the registry — not inline SVGs - Skip compositions that wrap other themed components — adding xdsThemeProps to the wrapper creates specificity conflicts. Flag for human review instead.
All props must follow naming conventions.
Check for:
- Booleans missing
is/hasprefix (e.g.disabled→isDisabled) - Callbacks not following
on{Verb}{Scope?}pattern - Primary change callback named something other than
onChange - Visibility callbacks not using
onOpenChangepattern - Directional props using
left/rightinstead ofstart/end - Uncontrolled defaults not using
defaultprefix (e.g.initialValue→defaultValue) - Boolean defaults not preserving
is/hasafterdefault(e.g.defaultOpen→defaultIsOpen)
Exceptions: HTML attribute passthroughs (onClick, onBlur) and html-prefixed collision avoidance (htmlName, htmlFor) are fine.
Check for:
- Props types not named
<Component>Props - Variant/status types not named
<Component>Variant/<Component>Status - Contexts not named
<Component>Context - Public hooks: component-scoped hooks use the
use<Component>form (e.g.useDialog,useTheme). Generic utility hooks (useFocusTrap,useListFocus,useMediaQuery) follow the same unprefixed convention.
Check for:
- Missing
displayNameon exported components (prevents minification from losing names in prod) - Missing file header with
@file,@input,@output,@position - Components not extending
BaseProps - Missing
xstyle,className,styleescape hatches — these must come fromBaseProps(orPick<BaseProps, ...>), never re-declared manually. When adding escape hatches to a component that doesn't have them, extend the base type — don't copy-paste the three prop declarations. -
Overlay/layer components (HoverCard, Popover, Dialog, Tooltip) pass style props through to the layer's render function (
ContextRenderProps), NOT via a wrapper<div>. The layer system already handles style application on the correct DOM element. Never wrapcontentin a new<div>just to apply consumer styles — it breaks layout context and flex/grid inheritance. - Not using
mergeProps()for style merging -
Missing
'use client'directive on files using React client APIs. Runnode scripts/check-use-client.mjsto verify. - Public hooks: must have file header,
'use client'if using client APIs, and follow standard hook patterns (return type, options object for complex args) -
Composed components must not let
{...rest}overwrite explicit props. When a component delegates to another Astryx component, every prop set explicitly on the inner component must be destructured from the rest spread and properly merged (className concatenated, xstyle appended to array, event handlers composed, style forwarded). See API Conventions#composed-component-prop-forwarding.
For input components only:
Check for:
- Missing required field props:
label,value,onChange/onChangeAction - Missing standard optional props:
isLabelHidden,description,isOptional,isRequired,isDisabled -
statusprop not following{type, message?}shape -
sizevariants not using'sm' | 'md' - Missing
isLoading/isBusyfor async-capable components -
hasAutoFocuswithoutdata-autofocus: Components that accept ahasAutoFocusprop and setautoFocus={hasAutoFocus}on a native element must also setdata-autofocus={hasAutoFocus || undefined}on that same element. This allowsDialogto re-focus the element aftershowModal()(React'sautoFocusfires before the dialog is visible, so it silently fails). Detection: findautoFocus={hasAutoFocus}without a correspondingdata-autofocuson the same element. See PR #1044.
Check for:
- Interactive components missing
labelprop - Missing
aria-required,aria-invalid,aria-describedbywiring on inputs -
isDisablednot mapping to nativedisabledattribute - Busy state using native
disabled(should usearia-busy+ visual treatment only) - Icon-only buttons missing
aria-label - Focus not preserved during state transitions
Check for:
- Components missing from
src/index.tsre-exports - Missing type exports (props, variant, status types)
- Component not registered as separate entry point in
tsup.config.ts - Public hooks missing from
src/hooks/index.tsandsrc/index.ts
This role runs once per night and produces one PR covering all findings.
Check memory/xds-night-watch-state.json for componentAuditor.lastRunAt. If it's from today (PST), skip and NO_REPLY.
The auditor runs two passes per night with separate PRs to keep hardening-issue work distinct from regular queue progress.
- List open hardening issues:
gh issue list --repo facebook/astryx --state open --label hardening \ --json number,title,body --limit 50
- Extract component names from single-component issues (e.g. "Hardening: Toolbar" →
Toolbar) -
Already audited? If the component is in
completedComponents(state file), close the issue immediately — no re-audit needed:gh issue close <number> --repo facebook/astryx --reason completed \ --comment "Component previously audited by Night Watch. See audit PRs in component history."
- For remaining components: audit them (Step 3) and create a separate PR (branch:
navi/hardening-issues/YYYY-MM-DD) - Add all audited components to
completedComponentsin state. - Do NOT close hardening issues that have a new PR — the PR itself is the resolution. The issue closes when the PR merges (via GitHub's linked issues or the PM's gardening pass).
- For batch hardening issues (e.g. "Hardening: Navigation Components"): read the issue body, extract all component names listed. If ALL are now in
completedComponents, close with a comment listing which audit PRs covered them. Otherwise skip — it'll close once the last component is done.
- Check
memory/xds-night-watch-state.jsonforcomponentAuditor.auditQueue - If the queue is empty, scan
packages/core/src/for all component directories and build a fresh queue - Pick the next 5 components from the queue
- Dedupe: skip any that were already audited in Pass 1 this run
- Audit them (Step 3) and create a separate PR (branch:
navi/component-audit/YYYY-MM-DD) - Update state: move completed to
completedComponents, advance queue
For each component, run all checks (sections 1-10 above) in a single pass:
- Read the source files — component, sub-components, types, styles, index.ts
- Check theming: CSS variables, xdsThemeProps, component reuse
- Check API: prop naming, type naming, structure, composition, input consistency, a11y, exports
For each issue found, log to memory/xds-night-watch/{date}.md under a ## Component Audit section:
- Categorize as:
hardcoded-value,missing-classname,classname-wrong-element,naming-violation,missing-type-prefix,structure-issue,composition-violation,input-inconsistency,a11y-gap,export-gap - Include file path, line number, current code, and what the fix should be
Create separate PRs for each pass:
- Pass 1 branch:
navi/hardening-issues/YYYY-MM-DD - Pass 2 branch:
navi/component-audit/YYYY-MM-DD
For each PR:
-
Run
pnpm testandpnpm buildbefore creating the PR. Fix any failures. - Publish PRs ready for review (not draft) — label with
hardening - When a finding needs human judgment, include it under a Needs Review section in the PR description
- If no issues found for a pass, do not create a PR — just update state and close issues
After creating the PR, don't wait for the daytime PR Review job. Run the refinement loop immediately:
- Record the HEAD sha you just pushed
- Schedule a one-shot recheck 5 minutes out (to let CI start)
- On recheck:
a. SHA collision check:
git fetch origin <branch> && git log -1 --format=%H origin/<branch>. If HEAD sha differs from what you recorded → STOP (someone else pushed, don't clobber their work) b. Check CI:gh pr checks <number>. If all green → done, log success c. If failing: Read the CI failure logs (gh run view <run_id> --job <job_id> --log-failed). Fix the failures in the worktree, runpnpm test && pnpm buildlocally, commit, push. Record the new HEAD sha. d. Schedule another recheck 5 minutes out - Iteration limit: max 3 fix attempts per PR. If still failing after 3 → log it and move on. The daytime PR Review job will pick it up.
Key rules:
- Always check SHA before acting — if it changed, someone else is working on it
- Only fix CI failures (test, lint, build) — don't re-audit or expand scope during refinement
- Each recheck is a one-shot scheduled job, not a busy-wait
- Log each iteration to
memory/xds-night-watch/{date}.md
Update memory/xds-night-watch-state.json:
{
"componentAuditor": {
"lastAuditedComponent": "Button",
"auditQueue": ["Card", "Dialog", ...],
"prsFiled": ["#650", "#800"],
"lastRunAt": "2026-03-22T04:00:00Z",
"completedComponents": ["Button", "TextInput"]
}
}When the queue is empty, clear completedComponents and rebuild from scratch.
- Subjective design decisions — only objective checks
- Review PRs (that's Reviewer)
- Fix CI beyond the refinement loop (that's QA)
- General issue triage (that's PM) — only closes hardening-labeled issues after auditing
{
"componentAuditor": {
"lastAuditedComponent": "string",
"auditQueue": ["string[]"],
"prsFiled": ["string[]"],
"lastRunAt": "ISO timestamp",
"completedComponents": ["string[]"]
}
}