-
Notifications
You must be signed in to change notification settings - Fork 27
Night Watch Theme Auditor
⚠️ This role has been merged into Night Watch Component Auditor. The Component Auditor combines theming, API, accessibility, and export checks into a single nightly pass per component. This page is kept for historical reference only.
Assigned to: Cindy's Navi (cixzhang)
Goal: Ensure all components are fully compatible with theming — using CSS variables, reusing primitives correctly for swizzle, and applying xdsThemeProps on the right elements.
Theming compatibility regresses silently. A hardcoded color, a missing xdsThemeProps, a component that renders its own close button instead of using Button — these don't break tests or CI but they break the theming contract. When a consumer applies a theme, they expect it to cascade consistently. This role catches the gaps.
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 - 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", "this is supporting text") not implementation ("this is 14px"). See the mapping table below.
Semantic type scale migration (PR #661):
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 |
When auditing, any textSizeVars['--text-*'] usage for font sizes should be flagged and replaced with the appropriate typeScaleVars semantic token. Use the semantic meaning of the text (is it a label? body content? supporting info?) to pick the right token — don't just mechanically map based on the raw size.
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 (e.g. dropdownmaxHeight: '300px', empty statemaxWidth: '360px'). - Animation values (durations, easing) have their own token story — flag but don't block
Components should compose from core primitives so theming cascades correctly.
Check for:
- Close buttons in dialogs, popovers, sheets should use
Button(ghost variant) — so theming ghost buttons affects close buttons too - Dividers should use
Divider— not raw<hr>or styled divs - Icons should use
Iconwith the registry — not inline SVGs - List items in menus, selectors, command palettes should share the same base primitive so theming is consistent
Nuance:
- Not every button-like thing should be the same. Nav items, tab items, and toolbar buttons have distinct theming needs — they shouldn't be ghost buttons just because they look similar. The test is: would a theme author expect changing ghost button styles to affect this element?
-
Prefer borders over extra DOM elements for dividers. When a component needs visual separators between items (e.g. list dividers), use
borderBlockEndwithcolorVars['--color-border']on the existing elements rather than injecting<hr>,<Divider>, or any additional DOM nodes. Use:last-childto suppress the final border. This keeps the DOM minimal while still using theme tokens. Only use<Divider>when the divider is a standalone structural element (e.g. separating sections in a dropdown menu), not when separating repeated siblings. - Skip compositions that wrap other themed components. If a component is primarily a composition of other Astryx components (e.g. PowerSearch wraps Tokenizer), adding xdsThemeProps to the wrapper creates specificity conflicts with the inner component's own theming. The rational target for styling is the inner component, not the wrapper. When in doubt, skip and flag for human review.
- When in doubt, flag it for human review rather than filing a fix
Every component and visual variant must apply xdsThemeProps on the correct DOM element so themes can target them with @scope. xdsThemeProps is the primary mechanism for per-component theming — it is how theme authors reach into components to override visual properties. Component CSS vars (--card-radius, etc.) are only used when values participate in calc() or are referenced by multiple elements.
Check for:
- The element receiving visual styles has the correct
xdsThemeProps— this is not always the root element. The main visual styles (background, border-radius, shadow, typography) determine which element gets the className. For example, a dialog'sxdsThemePropsgoes on the panel element where the visual styling lives, not the backdrop wrapper. A tooltip'sxdsThemePropsgoes on the container div with the background and border-radius, not the positioning wrapper. - 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],colorStyles[color]), that prop must appear in thexdsThemePropscall on the same element. Otherwise themes can't target those visual states. Look for patterns likefooStyles[prop]instylex.props()and verifypropis in the correspondingxdsThemeProps()call. - Composed sub-components (e.g. dialog close button) get their own component's className, not the parent's
Common mistakes from past audits:
- Placing xdsThemeProps on the wrong element. Layer-based components (Tooltip, HoverCard, Popover) have a positioning wrapper and a visual container — the className goes on the visual container where background/shadow/radius live, not the positioning wrapper (#749, #744).
-
Using hardcoded shadow strings with only the color tokenized. A shadow like
`0 4px 12px ${colorVars['--color-shadow']}`is only half-themed — the offsets and blur are hardcoded. Use the full shadow token (e.g.shadowVars['--shadow-menu']) so the entire shadow is themeable (#746, #670). - Adding xdsThemeProps to composition wrappers. If a component wraps other themed Astryx components (e.g. Tokenizer wraps Token, MoreMenuToggle wraps Button), adding xdsThemeProps to the wrapper creates specificity conflicts with the inner component's theming. Skip it and flag for review (#749, #672).
-
Visual props missing from xdsThemeProps. A prop that selects between different StyleX style objects must be in the
xdsThemePropscall, or themes can't target those states. Example: Banner'sstatusprop drovestatusStyles[status](per-status background colors) but wasn't included inxdsThemeProps('banner', {variant})— themes couldn't override per-status backgrounds without a CSS custom property escape hatch. Fixed in #808 by addingstatusto the call:xdsThemeProps('banner', {variant, status}). The detection pattern: findfooStyles[prop]in anystylex.props()call and check ifpropappears in the nearestxdsThemeProps()call on the same element or a parent wrapper.
Visually distinct sub-elements within a component need their own xdsThemeProps so theme authors can target them directly. This is the equivalent of CSS ::part() (which requires Shadow DOM and is not available to us).
A sub-element needs an xdsThemeProps when:
- It has its own color, background, or border distinct from the parent (e.g. a switch thumb, a radio dot, a progress bar fill)
- It cannot be styled via a CSS descendant selector from the root class due to specificity or structural reasons
A sub-element does NOT need an xdsThemeProps when:
- It is structural only (wrapper divs for layout, flex containers)
- It is text content that inherits from the parent (labels, descriptions)
- Its appearance is fully controlled by global tokens with no per-component override need
Standard: prefer xdsThemeProps targeting over component CSS vars. Theme authors write plain CSS property overrides on class targets — no per-component token vocabulary to learn, no API surface to maintain, and components can refactor internals without breaking a token contract. State overrides use CSS pseudo-selectors on the targets (:hover, :checked, [aria-selected]), not state-specific vars.
Reference — current sub-element targets:
| Component | Root | Sub-elements | Notes |
|---|---|---|---|
| Avatar | avatar |
avatar-status-dot |
|
| Banner | banner |
banner-icon |
Icon color varies per status |
| Calendar | calendar |
calendar-day |
Day buttons have selected/today states |
| CheckboxInput | checkbox-input |
checkbox |
Visual checkbox box |
| DropdownMenu | — | dropdown-menu-item |
|
| Field | field |
field-label, field-status
|
|
| Layout | layout |
layout-content, layout-header, layout-footer, layout-panel
|
|
| ProgressBar | progressbar |
progressbar-fill |
Fill bar has distinct color |
| RadioList |
radio-list, radio-list-item
|
radio, radio-dot
|
Circle and inner dot |
| Selector | selector |
selector-option |
|
| SideNav | side-nav |
side-nav-heading, side-nav-item, side-nav-section
|
|
| Slider | slider |
slider-track, slider-thumb
|
Track bg, thumb color |
| Switch | switch |
switch-thumb, switch-field
|
Track targetable via root |
| Table | base-table |
table-row, table-cell, table-header-cell
|
|
| TopNav | top-nav |
top-nav-heading, top-nav-item, top-nav-mega-menu
|
Scale guideline: Most components need 0-1 sub-element targets. Compound components (Layout, Table, Navigation) may need 3-5. If a component seems to need more than 5 targets, flag for human review — it may indicate the component should be decomposed.
When to flag vs fix: If unsure whether a sub-element is "visually distinct" enough to warrant a target, flag it in the audit notes for human review. The bar is: would a theme author plausibly want to override this element's appearance independently of its parent?
This role runs once per night and produces one PR covering all findings. Only one PR per calendar day.
Check memory/xds-night-watch-state.json for themeAuditor.lastRunAt. If it's from today (PST), skip and NO_REPLY.
Check memory/xds-night-watch-state.json for themeAuditor.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 (or fewer if less than 5 remain)
For each of the selected components:
- Read the source files — main component file, any sub-components, style definitions
-
Check CSS variable usage — scan StyleX
stylex.create()calls for hardcoded visual values - Check component reuse — look for raw HTML elements that should be Astryx primitives
- Check xdsThemeProps — verify it's applied on the right element for all variants
For each issue found:
- Log it to
memory/xds-night-watch/{date}.mdunder a## Theme Auditsection - Categorize as:
hardcoded-value,missing-primitive,missing-classname,classname-wrong-element - Include the file path, line number, and what the fix should be
Batch all findings from this run into a single PR:
- Branch naming:
navi/theme-audit/YYYY-MM-DD - Fix hardcoded values → token variables, missing primitives → Astryx components, missing/misplaced classNames
-
Run
pnpm testandpnpm buildbefore creating the PR. Fix any failures. Do not send a PR with broken tests or build. - Publish PRs ready for review (not draft) — label with
hardeningandtheming - When a finding needs human judgment (e.g. whether a button-like element should reuse Button or stay independent), include it in the PR description under a Needs Review section rather than skipping it or deciding unilaterally
- If no issues are found across all audited components, do not create a PR — just update state
Update memory/xds-night-watch-state.json:
{
"themeAuditor": {
"lastAuditedComponent": "Button",
"auditQueue": ["Card", "Dialog", ...],
"prsFiled": ["#650"],
"lastRunAt": "2026-03-17T04:00:00Z"
}
}When the queue is empty (all components audited), clear completedComponents and rebuild the queue from scratch. This ensures components are rechecked continuously — fixes from previous cycles may have regressed, and new code may have introduced new issues.
- Subjective design decisions — only objective checks
- Subjective design feedback — only objective checks (is this a CSS variable? is this using Button?)
- Review PRs (that's Reviewer)
- Fix CI (that's QA)
- Triage issues (that's PM)
{
"themeAuditor": {
"lastAuditedComponent": "string — last component fully audited",
"auditQueue": ["string[] — remaining components to audit"],
"prsFiled": ["string[] — PR numbers filed this cycle"],
"lastRunAt": "ISO timestamp",
"completedComponents": ["string[] — all components audited this cycle"]
}
}const styles = stylex.create({
error: { color: '#E53935' }, // ❌ hardcoded
});const styles = stylex.create({
error: { color: colorVars['--color-negative'] }, // ✅ token
});// Dialog close button as raw HTML
<button onClick={onClose} className="close-btn">
<svg>...</svg> {/* ❌ inline SVG, raw button */}
</button>// Dialog close button using Button
<Button variant="ghost" size="sm" onPress={onClose}>
<Icon name="close" /> {/* ✅ themed, swizzlable */}
</Button>// Injecting <hr> or <Divider> between siblings
{items.map((item, i) => (
<>
<li>{item}</li>
{i < items.length - 1 && <hr style={{...}} />} {/* ❌ extra DOM nodes */}
</>
))}const styles = stylex.create({
label: { fontSize: textSizeVars['--text-base'] }, // ❌ raw size token
});const styles = stylex.create({
label: { fontSize: typeScaleVars['--text-label-size'] }, // ✅ semantic token — expresses intent
});// Border on the existing element — zero extra DOM
const styles = stylex.create({
withDivider: {
borderBlockEnd: `1px solid ${colorVars['--color-border']}`, // ✅ token, no extra DOM
':last-child': {
borderBlockEnd: 'none',
},
},
});