-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Night Watch Doc Reviewer
Assigned to: Joey's Navi (josephfarina)
Goal: Keep component documentation compatible with Storybook autodocs, validate block/page template metadata, keep translation overlays (docsZh/docsDense) complete and faithful for both components and hooks, keep the CLI's own colocated docs (FunctionDoc/CommandDoc/SchemaDoc/EnumDoc) present and in sync with the live CLI, and catch content-level issues the type checker can't see.
Grading vs enforcement. The graded doc criteria — what must exist, at what severity, and how it scores — live in Component Audit Rubric §8. This page is the enforcement layer the rubric cites by name: the per-check detail and the scripts that produce the findings. Keep both in step; when they disagree, the rubric states the bar and this page states how it is measured.
Frequency: Once per night.
Astryx docs serve three audiences simultaneously:
-
Humans browsing Storybook or reading
.doc.mjsfiles for API reference -
LLMs consuming CLI output (
astryx component --brief,--compact,--lang zh,--lang dense, andastryx hook) for code generation -
Storybook autodocs parsing JSDoc
@exampleblocks for live previews
The .doc.mjs files are type-checked by tsc --checkJs in CI — structural issues (wrong fields, missing required properties, bad types) are caught automatically. This role focuses on what the type checker can't catch: Storybook docblock formatting, prop documentation drift against source code, template metadata accuracy, and showcase/block completeness.
Component docs live in typed .doc.mjs files. Each component directory has a {Name}.doc.mjs that exports a docs constant typed as ComponentDoc (exported from @astryxdesign/cli/authoring).
The CLI imports these directly with import() — no markdown parsing. Type checking runs via tsc --checkJs in CI (pnpm --filter @astryxdesign/core typecheck:docs), so structural validity is already enforced before merge.
Templates are split into two categories:
-
Page templates (
templates/pages/) — Full-page scaffolding (dashboard, login, settings, etc.). Each has atemplate.doc.mjswithtype: 'page'. -
Block templates (
templates/blocks/) — Smaller UI patterns and component examples. Each has a.doc.mjswithtype: 'block',aspectRatio, andcomponentsUsed.
Component examples that used to live in .doc.mjs examples arrays have been migrated to standalone block template files under templates/blocks/components/<Component>/. The examples field on ComponentDoc and ComponentEntry is now optional.
The gallery preview ("hero") is no longer a showcase field on the component doc — that field was removed. The canonical hero is now a block template flagged isShowcase: true in its .doc.mjs (BlockTemplateDoc.isShowcase). See check 6.
This role runs once per night. Check state to see if it has already run today — if so, skip.
Run all checks against origin/main. Collect findings into categories.
Scan every component and hook .tsx source file under packages/core/src and packages/lab/src (excluding *Context*, *.test.*, *.story*/*.stories.*) for JSDoc @example blocks. NOTE: components were unprefixed in the Astryx rebrand — they are NO LONGER named Astryx*.tsx. A glob like packages/core/src/*/Astryx*.tsx matches ZERO files now and makes this check silently pass on nothing. Scan bare-named .tsx recursively instead.
Rules:
-
No
```tsxlanguage tag. Storybook's autodocs parser chokes on thetsxtag — use bare```instead. -
No blank lines inside code blocks. Storybook's markdown renderer can interpret a blank line as ending the code fence, causing the rest of the example to render as raw text/HTML. If you need to separate examples, use separate
@exampleblocks or JS comments without blank line gaps. Important: "inside code blocks" means between the opening```and closing```. Do NOT add extra blank lines before@example— if a blank*line already separates the description from@example, leave it as-is. -
No JS comments (
//) inside code blocks. Comments inside@examplecode blocks can confuse Storybook's markdown parser. If context is needed, put it in the JSDoc description above the@exampletag, or use the example's surrounding description. -
No bare
>on its own line inside code blocks. If the code fence breaks (due to blank lines or parser issues), a bare>becomes a markdown blockquote. Restructure multi-line JSX to avoid a lone>— e.g., put>on the same line as the last prop or use a self-closing pattern. -
Preserve indentation inside code blocks. JSX props and children must keep their indentation relative to the parent element. The
*JSDoc prefix is followed by the code content with its natural indentation. Never flatten* prop="value"to* prop="value"— the spaces after*are the code's indentation and must be preserved. Only the content type (comments, blank lines) should be changed, never the whitespace structure. -
Single concise example. Each
@exampleshould have exactly one code block showing the most common usage. Multiple code blocks confuse Storybook and bloat LLM context. -
Every exported component needs an
@example. If a component's JSDoc has no@example, Storybook autodocs shows nothing.
Good example format:
/**
* Avatar component for displaying user profile pictures.
*
* @example
* ```
* <Avatar src="/user.jpg" name="John Doe" />
* <Avatar name="Jane Smith" size="large" />
* ```
*/
Bad example format (will break Storybook):
/**
* @example
* ```
* // Basic usage ← JS comment breaks parser
* <Component foo="bar">
* ← blank line ends code fence
* <Child />
* </Component>
* ← blank line
* // Advanced usage ← now renders as raw text
* <Component baz="qux"
* > ← bare > becomes blockquote
* <Child />
* </Component>
* ```
*/
Audit commands:
# Find ```tsx in docblocks (bare-named components/hooks, both packages; exclude tests) grep -rn '```tsx' packages/core/src packages/lab/src --include='*.tsx' | grep -viE '\.(test|story|stories)\.|Context' # Find components missing @example # (check each component/hook .tsx for @example in its main JSDoc)
Use the audit script to find blank lines, comments, and bare > inside code blocks:
# Scan all components for @example issues python3 -c " import re, glob, os tsx_files = glob.glob('packages/core/src/**/*.tsx', recursive=True) + \ glob.glob('packages/lab/src/**/*.tsx', recursive=True) for f in sorted(tsx_files): if '.test.' in f or '.story' in f or 'Context' in f: continue comp = os.path.basename(f).replace('.tsx', '') with open(f) as fh: content = fh.read() for m in re.finditer(r'/\*\*(.*?)\*/', content, re.DOTALL): block = m.group(1) if '@example' not in block: continue ex = block[block.index('@example'):] in_code = False issues = [] for i, line in enumerate(ex.split('\n')): s = line.lstrip(' *') if s.startswith('\`\`\`'): in_code = not in_code; continue if in_code: if s.strip() == '': issues.append(f' blank line at {i}') if '//' in s: issues.append(f' JS comment at {i}: {s.strip()}') if s.strip() == '>': issues.append(f' bare > at {i}') if issues: print(f'{comp}:') for issue in issues: print(issue) "
The type checker validates .doc.mjs structure but can't verify that the documented props match what the component actually accepts. For each component, compare:
- Props in the TypeScript interface (
Astryx*Props) - Props in the
.doc.mjspropsarray
Flag any user-facing props that exist in code but not in docs. Skip props inherited from HTML attributes (e.g., onClick, onFocus) unless they have Astryx-specific behavior.
Note: The examples field is now optional on ComponentDoc and ComponentEntry. Do NOT flag missing examples as a prop drift issue — component examples now live as block templates.
Priority: Focus on props that affect behavior. Props inherited from HTML attributes (e.g., onClick, onFocus) don't need explicit documentation unless they have Astryx-specific behavior.
Component examples have moved from .doc.mjs examples arrays to standalone block template files in templates/blocks/components/<Component>/. For each component, verify:
-
At least one block template exists — check for a directory under
templates/blocks/components/<Component>/with at least one.tsxfile -
Block templates have matching
.doc.mjsfiles — every.tsxintemplates/blocks/should have a matching.doc.mjs, and vice versa. Flag orphaned files. -
Block
.doc.mjsfiles havetype: 'block'withaspectRatio(number > 0) andcomponentsUsed(string array) -
Page
.doc.mjsfiles havetype: 'page'— everytemplate.doc.mjsintemplates/pages/must havetype: 'page'
Audit script:
# Check block template file pairing python3 -c " import glob, os blocks_dir = 'packages/cli/assets/templates/blocks' pages_dir = 'packages/cli/assets/templates/pages' # Check block file pairing tsx_files = set(os.path.splitext(f)[0] for f in glob.glob(f'{blocks_dir}/**/*.tsx', recursive=True)) doc_files = set(os.path.splitext(f)[0].replace('.doc', '') for f in glob.glob(f'{blocks_dir}/**/*.doc.mjs', recursive=True)) orphan_tsx = tsx_files - doc_files orphan_doc = doc_files - tsx_files if orphan_tsx: print(f'ORPHANED .tsx files (no matching .doc.mjs): {len(orphan_tsx)}') for f in sorted(orphan_tsx): print(f' {f}.tsx') if orphan_doc: print(f'ORPHANED .doc.mjs files (no matching .tsx): {len(orphan_doc)}') for f in sorted(orphan_doc): print(f' {f}.doc.mjs') if not orphan_tsx and not orphan_doc: print('All block template files are properly paired.') " # Check components with no block templates python3 -c " import glob, os components = set() for f in glob.glob('packages/core/src/*/Astryx*.tsx'): if 'test' in f or 'story' in f or 'Context' in f: continue comp = os.path.basename(os.path.dirname(f)) components.add(comp) blocks_dir = 'packages/cli/assets/templates/blocks/components' covered = set(os.path.basename(d) for d in glob.glob(f'{blocks_dir}/*/') if glob.glob(f'{d}/*.tsx')) missing = components - covered if missing: print(f'Components with NO block templates ({len(missing)}):') for c in sorted(missing): print(f' {c}') else: print('All components have at least one block template.') "
Several props appear across many components and must have consistent, accurate JSDoc comments in the TypeScript source (.tsx files) and entries in the .doc.mjs props arrays. These are NOT internal props — they're part of the public API and LLMs rely on them heavily.
For each component that accepts these props, verify:
xstyle — Must have this exact JSDoc pattern:
/** * StyleX styles for layout customization (margins, positioning, sizing). * Must be a `stylex.create()` value — not an inline style object. * * @example * ``` * const styles = stylex.create({ wrapper: { marginTop: 8 }}); * <Component xstyle={styles.wrapper} /> * ``` */ xstyle?: StyleXStyles;
And this .doc.mjs entry:
{ name: 'xstyle', type: 'StyleXStyles', description: 'StyleX styles for layout customization (margins, positioning, sizing). Must be a stylex.create() value — not an inline style object like style={{}}.', }
Why this matters: LLMs frequently pass inline style objects to
xstyle(e.g.,xstyle={{ marginTop: 8 }}). This compiles but breaks StyleX's static extraction. The JSDoc and doc entry must make clear that onlystylex.create()values are valid.
id — If the component accepts an id prop (via HTML attributes or explicit declaration):
{ name: 'id', type: 'string', description: 'HTML id attribute. Useful for anchor links, label associations, and test selectors.', }
data-testid — If the component accepts data-testid:
{ name: 'data-testid', type: 'string', description: 'Test selector for automated testing frameworks.', }
className — If the component accepts className:
{ name: 'className', type: 'string', description: 'CSS class name for the root element. Prefer xstyle for styling — className is provided for integration with non-StyleX systems.', }
style — If the component accepts style:
{ name: 'style', type: 'CSSProperties', description: 'Inline styles for the root element. Prefer xstyle for styling — inline styles bypass StyleX optimization.', }
Audit steps:
- For each component with
xstylein its props interface, check the JSDoc comment. If it's a generic description like "StyleX styles to apply to the container" — flag it for thestylex.create()guidance. - For
.doc.mjsfiles, check if common props are present when the component accepts them. Add missing entries. - Don't add common prop entries for components that don't accept them (e.g., don't add
xstyleto a component that doesn't have it in its interface).
Every component that calls themeProps() in its source should have a matching theming section in its .doc.mjs. The theming section documents the CSS class names and visual props that defineTheme can target via @scope selectors.
Format:
theming: { targets: [ {className: 'astryx-button', visualProps: ['size', 'variant']}, ], },
-
className— the stable CSS class name fromthemeProps('button', ...)→astryx-button -
visualProps— the prop names passed as the second argument tothemeProps()(the variant classes) -
states— an ALTERNATIVE tovisualPropson a target: state-based selector names (e.g.selected,disabled,today,in-range) rather than variant props. A target may carryvisualPropsORstates. Astates-only target is a valid, intentional theme surface and must NOT be flagged as avisualPropsdrift; the audit tracks the two separately.
Audit steps:
- Extract all
themeProps()calls from source (excluding test files) - Extract all
theming.targetsfrom.doc.mjsfiles - Flag any mismatches:
- Component has
themeProps()but nothemingsection in docs → missing -
theming.targetslists a className that doesn't exist in source → stale -
visualPropsin docs don't match the props passed tothemeProps()→ drift
- Component has
Audit script:
python3 -c " import re, glob, os # Collect themeProps calls from source source_map = {} # className -> set of visualProps for f in sorted(glob.glob('packages/core/src/**/*.tsx', recursive=True)): if '.test.' in f: continue with open(f) as fh: for m in re.finditer(r\"themeProps\('([^']+)'(?:,\s*\{([^}]*)\})?\)\", fh.read()): cn = m.group(1) if cn not in source_map: source_map[cn] = set() if m.group(2): # Capture only the prop NAME (object key), not the value. Handles # shorthand ({variant}), key:value ({size: resolvedSize}), and spreads. for part in m.group(2).split(','): part = part.strip() if not part: continue key = part.split(':')[0].strip().lstrip('.').strip() if re.match(r'^\w+$', key): source_map[cn].add(key) # Collect theming targets from docs. Targets are objects that may span multiple # lines and expose EITHER visualProps (variant props) OR states (state selectors # like selected/disabled/today) — both are valid theme surfaces. A states-only # target is intentional and must NOT be flagged as a visualProps drift. doc_map = {} # className -> set of visualProps doc_states = {} # className -> set of state names (tracked separately) for f in sorted(glob.glob('packages/core/src/*/*.doc.mjs')): with open(f) as fh: content = fh.read() # Match each target OBJECT containing a className (non-greedy, DOTALL for multiline) for tm in re.finditer(r\"\{[^{}]*?className:\s*'astryx-([^']+)'[^{}]*?\}\", content, re.S): cn = tm.group(1); block = tm.group(0) doc_map.setdefault(cn, set()) vp = re.search(r\"visualProps:\s*\[([^\]]*)\]\", block, re.S) if vp: doc_map[cn] |= set(re.findall(r\"'(\w+)'\", vp.group(1))) st = re.search(r\"states:\s*\[([^\]]*)\]\", block, re.S) if st: doc_states[cn] = doc_states.get(cn, set()) | set(re.findall(r\"'[\w-]+'\", st.group(1))) # Compare (visualProps only; states-only targets are a separate, intentional surface) for cn in sorted(set(source_map) & set(doc_map)): # target documents states but no visualProps -> state selector surface, not a drift if cn in doc_states and not doc_map[cn]: continue if source_map[cn] != doc_map[cn]: print(f'DRIFT astryx-{cn}: source={sorted(source_map[cn])} docs={sorted(doc_map[cn])}') for cn in sorted(set(source_map) - set(doc_map)): print(f'MISSING astryx-{cn}: has themeProps() but no theming doc') for cn in sorted(set(doc_map) - set(source_map)): print(f'STALE astryx-{cn}: in docs but no themeProps() in source') if all(source_map.get(cn, set()) == doc_map.get(cn, set()) for cn in source_map) \ and not (set(source_map) ^ set(doc_map)): print('All theming docs are in sync with source!') "
Fix: Add or update the theming section. Derive targets directly from themeProps() calls — don't guess.
Components that expose public CSS custom properties for theme overrides must document them in their .doc.mjs theming.cssProperties array. This field was added in PR #850 — see CSSPropertyDoc in @astryxdesign/cli/authoring.
Format:
theming: { targets: [{className: 'astryx-card'}], cssProperties: [{ name: '--astryx-card-padding', description: 'Controls Card container padding. Set in theme component overrides.', default: 'var(--spacing-4)', }], },
Audit steps:
- Search for
--astryx-CSS custom properties set or read in component source files (excluding test files):grep -rn '\-\-astryx-' packages/core/src/ --include="*.tsx" --include="*.ts" | grep -v test | grep -v node_modules
- For each
--astryx-*property found, check if the component's.doc.mjshas a matchingcssPropertiesentry - Flag any mismatches:
- Component uses
--astryx-*property but nocssPropertiesin docs → missing -
cssPropertieslists a property not found in source → stale - Description or default is inaccurate → drift
- Component uses
Updated (block-template model): The old
showcasefield on component.doc.mjswas removed — it no longer exists inComponentDoc. The canonical preview is now a block template flaggedisShowcase: truein its.doc.mjs(BlockTemplateDoc.isShowcase, see@astryxdesign/cli/authoring). Do NOT check for ashowcasefield on component docs — that produces ~107 false positives. Check that each component's block-template directory has exactly one hero block instead.
Every component should have exactly one canonical hero block: a .doc.mjs under packages/cli/assets/templates/blocks/components/<Component>/ with isShowcase: true.
Audit script:
python3 -c " import glob, os, re base = 'packages/cli/assets/templates/blocks/components' for d in sorted(glob.glob(f'{base}/*/')): comp = os.path.basename(d.rstrip('/')) hero = 0 for doc in glob.glob(f'{d}*.doc.mjs'): with open(doc) as fh: c = fh.read() if re.search(r'isShowcase:\s*true', c): hero += 1 if hero == 0: print(f'MISSING hero block (isShowcase: true): {comp}') elif hero > 1: print(f'MULTIPLE hero blocks ({hero}): {comp} — only one block should be isShowcase: true') "
A clean run prints nothing. A component with no isShowcase: true block has no gallery hero; more than one is ambiguous. Fix by setting isShowcase: true on the single most representative block .doc.mjs for that component (and removing it from any others).
For each block .tsx file, parse the imports for @astryxdesign/core/* and compare against the componentsUsed array in the matching .doc.mjs.
KNOWN-NOISY CHECK — do NOT auto-PR from its raw output. The
componentsUsedconvention is not uniform in the source, so a naive comparison produces hundreds of false positives (400+ on recent main). Two independent causes:
- Module vs named import. The old script captured the module path segment (
@astryxdesign/core/Chat→Chat), butcomponentsUsedusually lists the named import (ChatComposer,ChatMessageList). Comparing module names against named entries flags nearly everything.- Layout-primitive aliasing is inconsistent in the data itself.
HStack/VStack/Center/Spacerare imported from@astryxdesign/core/Stack(or/Layout), butcomponentsUsedrecords them sometimes as the alias ('VStack'), sometimes as the module ('Stack'/'Layout') — different blocks do it differently. There is no single normalization rule that reconciles them, because the source is not internally consistent.As a result this check cannot be made reliably green by a regex tweak. Treat it as advisory only: use it to eyeball obviously-wrong entries (a listed component that is genuinely not used at all), never as a batch fix source. A real fix requires a maintainer decision on the canonical
componentsUsedcontract (named exports vs module names, and whether sub-components must be listed), followed by a one-time normalization. Until then, do NOT openblock-metadataPRs off this check's output. The detection helper below compares against the UNION of named imports and module names, which removes cause (1) but not (2); its remaining hits are still dominated by the aliasing inconsistency.
Audit script (advisory — union of named imports + module names; still noisy, see above):
python3 -c " import glob, re, os blocks_dir = 'packages/cli/assets/templates/blocks' issues = [] for doc_file in sorted(glob.glob(f'{blocks_dir}/**/*.doc.mjs', recursive=True)): tsx_file = doc_file.replace('.doc.mjs', '.tsx') if not os.path.exists(tsx_file): continue with open(doc_file) as f: doc_content = f.read() with open(tsx_file) as f: tsx_content = f.read() m = re.search(r'componentsUsed:\s*\[([^\]]*)\]', doc_content) if not m: continue documented = set(re.findall(r\"'(\w+)'\", m.group(1))) # Collect BOTH the named imports and the module names, since componentsUsed # mixes the two. A documented entry is accepted if it matches either. named, mods = set(), set() for im in re.finditer(r\"import\s*\{([^}]+)\}\s*from\s*['\\\"]@astryxdesign/core/(\w+)['\\\"]\", tsx_content): mods.add(im.group(2)) for n in im.group(1).split(','): n = n.strip() if n and not n.startswith('use') and n[:1].isupper(): named.add(n) valid = named | mods stale = documented - valid # listed but neither a named import nor a used module missing = named - documented - mods # imported component absent under its own name AND module name = os.path.basename(tsx_file).replace('.tsx', '') if stale: issues.append(f'{name}: componentsUsed lists {sorted(stale)} but not imported') if missing: issues.append(f'{name}: imports {sorted(missing)} but not in componentsUsed (may be layout-alias noise)') if issues: print(f'componentsUsed advisory ({len(issues)} blocks — EXPECT layout-alias false positives, do not batch-fix):') for i in issues: print(f' {i}') else: print('All block componentsUsed arrays match imports.') "
Run the block template typecheck and flag any new errors:
npx tsc --project packages/cli/tsconfig.template-docs.json --noEmit 2>&1 | head -50
Also scan for @ts-expect-error or @ts-ignore comments creeping back into block .doc.mjs files:
grep -rn '@ts-expect-error\|@ts-ignore' packages/cli/assets/templates/blocks/ --include="*.doc.mjs"
PR #2005 added two fields to the component doc system for the interactive playground:
playground.defaults (on BaseDoc) — initial prop values for the playground preview. Values can be primitives or ElementDescriptor objects (serializable React element specs).
slotElements (on PropDoc) — declares what Astryx components a ReactNode prop typically accepts. The playground renders a toggle/selector control based on this.
When to add slotElements:
| Prop pattern | Action | Example |
|---|---|---|
icon, startIcon, endIcon, etc. |
Add [{__element: 'Icon', props: {icon: 'check', size: 'sm'}}]
|
Button.icon |
endContent with ReactElement<IconProps> | ReactElement<BadgeProps>
|
Add both options | Button.endContent |
actions |
Add [{__element: 'Button', props: {label: 'Action', variant: 'secondary'}}]
|
EmptyState.actions |
status on Avatar |
Add [{__element: 'StatusDot', props: {variant: 'online'}}]
|
Avatar.status |
Named composition slots (topNav, sideNav, banner) |
Add the expected component | AppShell.topNav |
label, title, description, name (text-only) |
Skip — text input is correct | Badge.label |
Render functions (item: T) => ReactNode
|
Skip — not an element slot | Selector.children |
children on generic containers |
Skip — too many possible values | Card.children |
Compound component sub-entries (TableRow, usePopover) |
Skip — documentation, not interactive | Table.TableRow |
When to add playground.defaults:
- Component needs
childrento render visibly (Card, Dialog, Section) - Component has required props that benefit from better defaults (
label: 'Click me'vslabel: 'label') - Component renders broken without specific prop combinations
Format:
playground: { defaults: { label: 'Click me', variant: 'primary', children: { __element: 'VStack', props: {gap: 2}, children: [ {__element: 'Heading', props: {level: 3}, children: 'Title'}, {__element: 'Text', props: {type: 'body'}, children: 'Content'}, ], }, }, },
Audit script:
python3 -c " import re, glob, os src = 'packages/core/src' for f in sorted(glob.glob(f'{src}/*/*.doc.mjs')): with open(f) as fh: content = fh.read() comp = os.path.basename(f).replace('.doc.mjs', '') # Find ReactNode props without slotElements missing = [] for m in re.finditer(r\"\\{\\s*\\n?\\s*name:\\s*['\"](\w+)['\"][^}]*?type:\\s*['\"]([^'\"]+)['\"]\", content): pname, ptype = m.group(1), m.group(2) if 'ReactNode' not in ptype and 'ReactElement' not in ptype: continue # Check if slotElements exists nearby block_end = content.find('}', m.end()) block = content[m.start():block_end+1] if block_end > 0 else '' if 'slotElements' in block: continue # Skip text props, render functions, compound entries, children if pname in ('label','title','description','name'): continue if '=>' in ptype: continue if pname.startswith('Astryx') or pname.startswith('use'): continue if pname[0:1].isupper(): continue if pname == 'children': continue if pname in ('element','layerNode'): continue missing.append(pname) if missing: print(f'{comp}: missing slotElements on {missing}') "
Reference:
- Type definitions:
@astryxdesign/cli/authoring(ElementDescriptor, PlaygroundConfig, PropDoc.slotElements) - Issue tracking remaining work: #2008
- Existing examples: Button.doc.mjs, Card.doc.mjs, Badge.doc.mjs, EmptyState.doc.mjs
Every template.doc.mjs in templates/pages/ must have type: 'page'. Every .doc.mjs in templates/blocks/ must have type: 'block'.
python3 -c " import glob, re # Pages must have type: 'page' for f in sorted(glob.glob('packages/cli/assets/templates/pages/*/template.doc.mjs')): with open(f) as fh: content = fh.read() if \"type: 'page'\" not in content and 'type: \"page\"' not in content: print(f'MISSING type: page in {f}') # Blocks must have type: 'block' for f in sorted(glob.glob('packages/cli/assets/templates/blocks/**/*.doc.mjs', recursive=True)): with open(f) as fh: content = fh.read() if \"type: 'block'\" not in content and 'type: \"block\"' not in content: print(f'MISSING type: block in {f}') "
Flag blocks with aspectRatio: 0, negative values, or obviously wrong values:
python3 -c " import glob, re for f in sorted(glob.glob('packages/cli/assets/templates/blocks/**/*.doc.mjs', recursive=True)): with open(f) as fh: content = fh.read() # Match a bare number OR a ratio like `16 / 9` (with optional spaces). The old # pattern [\d./-]+ stopped at the space in `16 / 9` and matched just `16`, which # then tripped the >10 SUSPICIOUS branch on every widescreen block (271 false hits). m = re.search(r'aspectRatio:\s*(\d+(?:\.\d+)?\s*/\s*\d+(?:\.\d+)?|-?\d+(?:\.\d+)?)', content) if not m: name = f.split('/')[-1].replace('.doc.mjs', '') print(f'MISSING aspectRatio: {name}') continue try: val = eval(m.group(1)) name = f.split('/')[-1].replace('.doc.mjs', '') if val <= 0: print(f'BAD aspectRatio ({val}): {name}') elif val > 10: print(f'SUSPICIOUS aspectRatio ({val}): {name} — very wide, possibly miscategorized') except: pass "
The examples array was removed from all component .doc.mjs files in PR #1393. If someone adds one back, flag it and suggest creating a block template instead.
python3 -c " import glob, re for f in sorted(glob.glob('packages/core/src/*/*.doc.mjs')): with open(f) as fh: content = fh.read() # Look for examples: [ with actual content (not just examples: []) if re.search(r'examples:\s*\[(?!\s*\])', content): comp = f.split('/')[-1].replace('.doc.mjs', '') print(f'FOUND examples array in {comp} — should be a block template instead') "
Every .doc.mjs types itself from the package, not from a relative path: import('@astryxdesign/cli/authoring'). The old core/src/docs-types path no longer exists, and depth-specific ../../../.. chains are gone with it — a relative import is now the defect, whatever its depth.
Flag any doc file that types itself some other way:
python3 -c " import glob for f in sorted(glob.glob('packages/**/*.doc.mjs', recursive=True) + glob.glob('apps/**/*.doc.mjs', recursive=True)): with open(f) as fh: content = fh.read() if 'docs-types' in content or \"import('../\" in content: print(f'STALE doc type import: {f}') "
Astryx ships translated/compressed doc overlays alongside the English docs export in the same .doc.mjs file:
-
docsZh— Chinese Simplified translation. Served byastryx component <Name> --lang zh. -
docsDense— token-compressed English. Served byastryx component <Name> --lang dense.
Both are typed as TranslationDoc (components) or HookTranslationDoc (hooks) from @astryxdesign/cli/authoring. The CLI loader (packages/cli/foundation/discovery/component-loader.mjs, mergeTranslation) merges the overlay onto the English docs at render time. When an overlay is missing or incomplete, the CLI silently falls back to English — the user sees uncompressed/untranslated output with no error. This check exists to catch that silent fidelity loss the type checker can't see.
Scope note: This applies to both components and hooks, and to theme docs (
packages/core/src/theme/*.doc.mjs, reached viaastryx component <ThemeName>). Hooks live atpackages/core/src/hooks/*.doc.mjsand co-locatedpackages/core/src/<Comp>/use*.doc.mjs.
What the loader actually merges (anything else in an overlay is dead — flag it):
| Doc kind | English fields | Overlay (TranslationDoc / HookTranslationDoc) fields merged |
|---|---|---|
| Component |
usage.description, usage.bestPractices, props[], components[]
|
description, usage.description, usage.bestPractices, propDescriptions (keyed by prop name), components[] (by name) |
| Hook |
usage.description, usage.bestPractices, params[], returns[]
|
description, usage.description, usage.bestPractices, paramDescriptions (keyed by param name, incl. dotted options.foo), returnDescriptions (keyed by return field name) |
Hard rules the overlay must satisfy (these mirror the dense compression protocol in .claude/skills/dense-compression-protocol.md — read it before fixing):
-
Coverage: every component, hook, and theme doc with a
docsexport MUST have adocsDenseexport. (docsZhcoverage is tracked but lower priority — backfill where present and drifted, flag where wholly missing.) -
Count parity 1:1: overlay
usage.bestPracticesmust match English length, order, ANDguidance: true/falseflag per position. Same forfeatures,notes,accessibilityif present. Never pack two English bullets into one. -
Prop/param/return coverage:
propDescriptions(component) /paramDescriptions+returnDescriptions(hook) must have an entry for every English prop/param/return that has a description, except universal props (children,ref,key,style,className,xstyle). -
Sub-components: component overlay
components[]must include everydocs.components[].name(including hook sub-entries likeuseImperativeDialog). -
No invalid schema: the only valid overlay keys are those in the table above. Short-key schemas (
n,d,kw,p,ex) are dead — the loader never reads them, so the data silently doesn't render. Convert any short-key overlay to the canonicalTranslationDocshape (d→description,p→propDescriptions, dropn/kw/ex). -
No duplicate
(required)markers: the renderer auto-appends**(required)**from the Englishrequired: truefield. Overlay text must NOT also contain a literal(required)— that double-renders. -
Capitalization convention (match merged precedent): prop/param/return description fragments are lowercase-leading (e.g.
"card width","whether trap active");bestPracticesare capital-leading imperative sentences. -
Signal words + identifiers preserved: keep
if/when/unless/only/must/never/always, fullAstryx*cross-reference names + the relationship word (e.g. "use Foo instead"), and technical identifiers (event names, ARIA terms, types).
Audit script (a portable harness lives in the workspace at xds-worktrees/.tools/dense-audit.mjs — if present, prefer node <that> packages/core/src --hooks; otherwise inline):
node -e ' import {readdirSync, statSync} from "node:fs"; import {join, relative} from "node:path"; import {pathToFileURL} from "node:url"; const ROOT="packages/core/src", UNIVERSAL=new Set(["children","ref","key","style","className","xstyle"]); const len=x=>Array.isArray(x)?x.length:0; const walk=(d,o=[])=>{for(const e of readdirSync(d)){const p=join(d,e),s=statSync(p);s.isDirectory()?walk(p,o):e.endsWith(".doc.mjs")&&o.push(p);}return o;}; const I={missing:[],bullet:[],comp:[],prop:[],param:[],ret:[],shortkey:[]}; for(const f of walk(ROOT).sort()){ let m;try{m=await import(pathToFileURL(f).href);}catch{continue;} const d=m.docs,t=m.docsDense,rel=relative(ROOT,f);if(!d)continue; const isHook=Array.isArray(d.params)||Array.isArray(d.returns); if(!t){I.missing.push(rel+(isHook?" [hook]":""));continue;} for(const k of ["n","d","kw","p","ex"])if(k in t){I.shortkey.push(rel+" key:"+k);break;} if(len(d.usage?.bestPractices)!==len(t.usage?.bestPractices))I.bullet.push(`${rel}: ${len(d.usage?.bestPractices)} vs ${len(t.usage?.bestPractices)}`); const dc=(d.components||[]).map(c=>c.name),tc=new Set((t.components||[]).map(c=>c.name)); const dropped=dc.filter(n=>!tc.has(n));if(dropped.length)I.comp.push(`${rel}: dropped ${dropped.join(", ")}`); const pg=(Array.isArray(d.props)?d.props:[]).filter(p=>p?.name&&!UNIVERSAL.has(p.name)&&p.description&&!(p.name in (t.propDescriptions||{}))).map(p=>p.name); if(pg.length)I.prop.push(`${rel}: ${pg.join(",")}`); if(isHook){ const pm=(d.params||[]).filter(p=>p?.name&&p.description&&!(p.name in (t.paramDescriptions||{}))).map(p=>p.name); if(pm.length)I.param.push(`${rel}: ${pm.join(",")}`); const rm=(d.returns||[]).filter(r=>r?.name&&r.description&&!(r.name in (t.returnDescriptions||{}))).map(r=>r.name); if(rm.length)I.ret.push(`${rel}: ${rm.join(",")}`); } } for(const[k,v]of Object.entries(I)){console.log(`## ${k}: ${v.length}`);v.forEach(x=>console.log(" "+x));} '
Also spot-check rendering after any fix — confirm the dense/zh output actually changed and the duplicate-(required) bug is absent:
node packages/cli/clients/cli/bin/astryx.mjs component Card --lang dense | head -20 node packages/cli/clients/cli/bin/astryx.mjs hook useFocusTrap --lang dense | sed -n '/Parameters/,/Returns/p' # expect single **(required)**
Reference docs are a separate overlay system with a different shape. Everything above covers component and hook docs (TranslationDoc / HookTranslationDoc, merged by component-loader.mjs). Reference docs are the topic docs served by astryx docs <topic> --lang zh|dense (theme, tokens, principles, layout, and friends), and they use their own contract:
- Base files:
packages/cli/assets/docs/<topic>.doc.mjs. Overlays live in sibling files<topic>.doc.dense.mjs/<topic>.doc.zh.mjs, exportingdocsDense/docsZhtyped asReferenceTranslationDoc. The loader ispackages/cli/api/docs/docs.mjs.
Reference overlays are anchored, prose-only, and fail-safe. Generate them in this shape only:
/** @type {import('@astryxdesign/cli/authoring').ReferenceTranslationDoc} */ export const docsDense = { description: 'compressed doc description', sections: [ { section: 'Quick Start', // base section title, verbatim English (the anchor) title: 'Quick Start', // optional translated or compressed heading blocks: [ { id: 'built-setup', text: 'compressed prose' }, // base prose block id + prose { id: 'core-ideas', items: ['compressed', 'list'] }, // base list block id + items ], }, ], };
Hard rules:
- Anchor by name, never by position. Sections key on
section(the base title). Blocks key onid(the base block'sid). Do NOT use the old positionalcontent: [null, ...]array. The gate rejects it. - Prose only. A block override may carry
idplustext(for a baseproseblock) oritems(for a baselistblock), and nothing else. Structure stays canonical: types, code, and tables are never in an overlay. - Every
idmust resolve to a real block id in the anchored base section.textrequires aprosebase block;itemsrequires alistbase block. - Whatever the overlay does not name falls back to canonical English, so cover the blocks worth compressing or translating and leave the rest.
Canonical block ids: packages/cli/assets/docs/tokens.doc.mjs is generated, so its ids come from scripts/generate-token-docs.mjs (rerun after edits, then --check in CI). The hand-written reference docs (theme, layout, principles) carry an id on each block; a block with no id cannot be overlaid.
Gate: packages/cli/api/docs/docOverlays.test.mjs enforces all of the above (id resolves, prose only, unique base ids, no legacy content). Run it after any reference-overlay change:
node_modules/.bin/vitest run packages/cli/api/docs/docOverlays.test.mjs
The checks above (prop drift, common props, showcase) were originally written for components and glob Astryx*.tsx / component .doc.mjs. Extend them to hooks (packages/core/src/hooks/*.doc.mjs and co-located use*.doc.mjs):
- Every hook
.doc.mjsparams[]andreturns[]should match the hook's TypeScript signature (param/return drift — the hook analog of prop drift). - Hook docs use
HookDoc(withparams/returns), notComponentDoc— do NOT flag missingprops/showcase/themingon hooks (those fields don't apply). - Every hook should have a
docsDense(see check 13).
Doc prose should read like a careful engineer wrote it, not like an LLM generated it. Scan every prose string (description, text, title, guidance descriptions, best-practices entries) and README body for the AI-slop tells below, and fix them. This is the full rubric; em dashes are only one row of it.
This is a prose check. It applies to natural-language strings only, never to code (inside backticks, code fences, // or /* */ comments), name:/displayName: Component — Variant convention labels, prop/token identifiers, or CJK punctuation.
| Sub | Tell | Fix |
|---|---|---|
| A1 | Em dash — in prose |
Recast with the right punctuation: colon (list/definition intro), semicolon (two independent clauses), comma (appositive/contrast aside), parentheses (nested aside with internal commas), or period (full sentence break). |
| A2 | En dash – in prose (non-numeric) |
Same as A1. Leave numeric ranges (20–31px, h1–h6, 2–7). |
| A3 | Curly double quotes " "
|
Straight ". Escape correctly for the surrounding JS string. |
| A4 | Curly single quotes / apostrophes ‘ ’
|
Straight '. In a single-quoted JS string, escape the apostrophe (audience\'s) or the file won't parse. |
| A5 | Ellipsis char ...
|
ASCII ... (or the word "to" for a range). |
| B | Vocabulary filler / buzzwords | Cut or replace: seamless(ly), leverage→use, utilize→use, robust, delve, elevate, unlock, empower, streamline, harness, boast, cutting-edge / state-of-the-art / best-in-class / world-class, game-changer, supercharge / turbocharge, powerful, intuitive, effortless(ly), plethora / myriad, tapestry / realm of / landscape of. |
| C | Significance padding | Delete the throat-clearing: "it's worth noting", "it's important to note/remember", leading "Importantly,/Notably,/Crucially,/Interestingly,", "plays a crucial/key/vital/pivotal role", "is a testament to", "in today's fast-paced/digital/modern world", "when it comes to", "at the end of the day", "needless to say". |
| D | Structural / rhetorical tells | Rewrite plainly: "not only ... but also", "isn't just X — it's Y" / "more than just", sweeping "From X to Y, ..." openers, "whether you're X or Y", "it's not about X, it's about Y" antithesis. |
| E | Hollow intensifiers / hedging | Cut the empty word: very, really, truly, incredibly, extremely, highly, quite, simply, easily, effortlessly; hedging clusters "can help to", "may potentially", "might possibly"; over-enthusiasm ("Great!", "Amazing!", stray exclamation marks). |
| F | Redundancy / wordiness | Tighten: "in order to"→to, "due to the fact that"→because, "a variety of / a number of" (be specific), "various different", pleonasms ("absolutely essential", "end result", "final outcome", "advance planning"). |
Meaning is sacred. Only remove filler and normalize typography; never delete information, never reword a technical claim, never fabricate. If cutting a word changes the meaning, leave it. Regex is for detection only; every fix is a judgment call made by reading the sentence.
Escape sequences count. A tell authored as a JS escape (\u2014, \u2013, \u2018/\u2019, \u201c/\u201d, \u2026) is the same tell as the raw character: it decodes to an em/en dash, curly quote, or ellipsis and reaches rendered output. Fix these too. When you do, prefer replacing the escape with the correct plain character directly (\u2014 for an aside becomes ; or , or : ; \u2026 becomes ...; \u2019 becomes a straight ', escaped as \' inside a single-quoted string). The same exemptions below apply (\u2014\u2014 is CJK, numeric-range en dashes are fine).
Triage before fixing. These are NOT slop:
- API/domain identifiers that happen to match a buzzword:
isShowcase,showcaseblocks/registry, the'elevated'prop value/token, "test harness", "flexible" when it describes flexbox behavior, "first-class" when it means genuine first-class support, "scroll lock". Match the whole word in prose, then confirm it isn't a real term. - Numeric/identifier ranges with dashes (
h1–h6,6–8,0–2). - Chinese double em-dash
——(correct CJK punctuation). For a single—inside CJK prose, use,or:.
# Detection helper (DETECTION ONLY; then read each hit and judge): # Typographic tells outside code (raw Unicode chars): grep -rnP '[\x{2014}\x{2013}\x{2018}\x{2019}\x{201c}\x{201d}\x{2026}]' packages apps internal \ --include='*.doc.mjs' --include='*.md' | grep -v node_modules # ALSO catch the SAME tells authored as JS escape sequences. A byte/char scan # misses these, but they decode to em/en dash, curly quotes, and ellipsis at # runtime and reach rendered CLI/docs output just the same. Scan for the escapes # too (\u2014 em, \u2013 en, \u2018/\u2019 curly single, \u201c/\u201d curly # double, \u2026 ellipsis); \u2014\u2014 is CJK double, exempt. grep -rnP '\\u201[89cd]|\\u2013|\\u2014|\\u2026' packages apps internal \ --include='*.doc.mjs' --include='*.md' | grep -v node_modules # Vocab/filler (case-insensitive, word-boundary), e.g.: grep -rniE '\b(seamless(ly)?|leverage|utilize|robust|delve|effortless(ly)?|plethora|myriad|cutting[- ]edge|game[- ]chang(er|ing))\b' \ packages apps internal --include='*.doc.mjs' --include='*.md' | grep -v node_modules # Significance padding / hedging: grep -rniE "it'?s worth noting|it'?s important to (note|remember)|plays? a (crucial|key|vital|pivotal) role|in today'?s .* world|can help to|may potentially" \ packages apps internal --include='*.doc.mjs' --include='*.md' | grep -v node_modules
After fixing a .doc.mjs, always parse-check it (node --input-type=module -e "import('file://$PWD/<file>').then(()=>{}).catch(e=>{console.error(e.message);process.exit(1)})"). The most common breakage is an unescaped apostrophe created when straightening a curly quote inside a single-quoted string.
The CLI README (packages/cli/README.md) documents two typed APIs in field tables: the consumer astryx.config surface (under ## Configuration) and the author astryx.integration manifest (under ## Integrations). Both are validated at runtime by Zod schemas that are the source of truth. After the CLI restructure the schemas are module-private (not exported) and live in two files: the config schema is const configSchema in packages/cli/authoring/config/parse.mjs, and the integration schema is const integrationSchema in packages/cli/authoring/integration/parse.mjs. When a field is added, removed, or renamed in a schema, the README table drifts and consumers get docs that no longer match what the CLI accepts.
For each schema, compare the top-level field names against the README's field table (the table whose header is Field | Type | Purpose):
- Field in schema but not in the README table: a new or renamed field is undocumented. Add a row.
- Field in the README table but not in schema: the field was removed or renamed and the doc is stale. Remove or fix the row.
Nested config fields are surfaced in the README with dotted names: hooks → hooks.postCodemod, experimental → experimental.xle.components. Treat those dotted names as the schema keys when comparing the ## Configuration table. The manifest fields (components, templates, codemods, issuesUrl) map one-to-one.
This is a names-in-sync check, not a prose check. Flag added/removed/renamed fields. Do not rewrite descriptions to taste (that is a subjective style change and out of scope per "Does NOT Do"); only add a row for a genuinely new field, or remove one for a genuinely deleted field. When the config source of truth in packages/cli/authoring/config/type.ts (the AstryxConfig TypeScript interface) and the Zod schema disagree, trust the Zod schema. It is what the CLI enforces at load time. Note the interface drift in the report for human follow-up.
Audit script (detection only; exits 1 on drift, 0 in sync; run from the repo root):
python3 - <<'PY' import re, sys, pathlib ROOT = pathlib.Path('.') # Schemas are module-private after the CLI restructure: `const configSchema` in # authoring/config/parse.mjs and `const integrationSchema` in # authoring/integration/parse.mjs (NOT the old exported AstryxConfigSchema / # AstryxIntegrationSchema in src/lib/config-schema.mjs, which no longer exists). CONFIG_SCHEMA = (ROOT/'packages/cli/authoring/config/parse.mjs').read_text() INTEG_SCHEMA = (ROOT/'packages/cli/authoring/integration/parse.mjs').read_text() README = (ROOT/'packages/cli/README.md').read_text() def zod_block(src, name): # match a private `const <name> = z` (also tolerates an `export const` form) m = re.search(rf'(?:export\s+)?const {name}\s*=\s*z', src) if not m: return '' tail = src[m.start():] end = re.search(r'\n\s*\.strict\(\);', tail) return tail[:end.end()] if end else tail def top_keys(block): body = re.search(r'\.object\(\{(.*)\}\)\s*\.strict\(\);', block, re.S) if not body: return set() return set(re.findall(r'^ ([A-Za-z_][\w-]*)\s*:', body.group(1), re.M)) cfg = top_keys(zod_block(CONFIG_SCHEMA, 'configSchema')) integ = top_keys(zod_block(INTEG_SCHEMA, 'integrationSchema')) if 'hooks' in cfg: cfg.discard('hooks'); cfg.add('hooks.postCodemod') if 'experimental' in cfg: cfg.discard('experimental'); cfg.add('experimental.xle.components') def field_tables(section_heading): idx = README.find(section_heading) if idx < 0: return set() nxt = README.find('\n## ', idx+1) section = README[idx: nxt if nxt>0 else len(README)] fields=set(); in_ft=False for line in section.splitlines(): if line.strip().startswith('|'): header = re.sub(r'[`*]','',line).lower() if 'field' in header and 'purpose' in header: in_ft=True; continue if set(line.replace('|','').strip()) <= set('-: '): continue if in_ft: m = re.match(r'\|\s*`([^`]+)`', line) if m: fields.add(m.group(1).strip()) else: in_ft=False return fields def report(label, truth, doc): missing, extra = truth - doc, doc - truth ok = not missing and not extra print(f"[{'OK' if ok else 'DRIFT'}] {label}") if missing: print(f" MISSING from README: {sorted(missing)}") if extra: print(f" STALE in README (not in schema): {sorted(extra)}") return not ok drift = report('astryx.config (## Configuration)', cfg, field_tables('## Configuration')) drift |= report('astryx.integration (## Integrations)', integ, field_tables('## Integrations')) sys.exit(1 if drift else 0) PY
When it reports DRIFT, update the README field table to match the schema (add the missing row with a short type + purpose, or remove the stale one), then re-run until it prints OK for both. PR category: docs (branch navi/docs/config-readme-sync).
The CLI documents its own surface with typed .doc.mjs files colocated next to what they describe, the same pattern as component docs. They are the source that feeds --help and the capability manifest today (via the defineCommand converter), and the source astryx docs and the doc site will read from as those surfaces land. Their type structure is checked in CI by tsc --checkJs through pnpm -F @astryxdesign/cli typecheck:strict, which runs checkJs over api/, clients/, foundation/, and authoring/. So, as with component docs, this role does not re-check structure; it checks coverage and the content-level drift the type checker cannot see.
The doc kinds and where they live:
| Surface | Doc-type | Location |
|---|---|---|
An @astryxdesign/cli/api function (or a hook) |
FunctionDoc |
packages/cli/api/<name>/<fn>.doc.mjs |
| A CLI command |
CommandDoc (references its function via fn) |
packages/cli/clients/cli/commands/<name>.doc.mjs |
| An authored object (config, integration, codemod, the doc-types, the response envelope) | SchemaDoc |
beside the schema (packages/cli/authoring/**, packages/cli/foundation/response/) |
| A closed vocabulary (error codes, response types) | EnumDoc |
packages/cli/foundation/response/ |
Coverage. Every command in astryx manifest --json (including subcommands like theme build), every function exported from @astryxdesign/cli/api, and the config, integration, and codemod schemas should each have a colocated doc. A command or API function that ships without a .doc.mjs is a finding.
Drift and structure. Two committed checks enforce accuracy. Run both and flag any failure:
# Docs mirror the live CLI: every CommandDoc's fn/args/options resolve to a real # function and match the manifest, and the error-codes / response-types EnumDocs # equal ERROR_CODES / the manifest response-type set exactly. node packages/cli/test/drift/docs-drift.mjs # Structure: every doc-type ships type.ts + parse.mjs + <kind>.doc.mjs, re-exports # its parser from the authoring barrel, and appears in parseDoc's @returns union; # every api/<name>/ ships its typedefs, a FunctionDoc, and a test. pnpm check:cli-structure
Because --help is built from each CommandDoc via defineCommand, editing a command doc changes user-visible help. After any edit, run astryx <command> --help and confirm the rendered output still reads correctly.
Documented examples must actually run. Every CommandDoc carries examples, and nothing executes them — the drift harness compares flags and args, not whether the invocation works. A stale example is the most user-visible kind of doc rot, because it is the line someone copies.
Run them all in a throwaway cwd, since several commands write files. That cwd must live inside the repo, not in /tmp: the CLI resolves @astryxdesign/core by walking up from the cwd, so a /tmp sandbox fails every example with Could not find @astryxdesign/core package and reports the whole set as broken.
node - <<'EOF' const {spawnSync} = require('node:child_process'); const fs = require('node:fs'), path = require('node:path'); const BIN = 'packages/cli/clients/cli/bin/astryx.mjs'; // Inside the repo so `@astryxdesign/core` still resolves by walking up. const box = fs.mkdtempSync(path.join(process.cwd(), '.nw-examples-')); fs.writeFileSync(path.join(box, 'package.json'), '{"name":"nw","version":"0.0.0","private":true}'); const manifest = JSON.parse(spawnSync('node', [BIN, 'manifest', '--json'], {encoding: 'utf8'}).stdout).data; const examples = []; const walk = c => { (c.examples ?? []).forEach(e => examples.push(e)); (c.subcommands ?? []).forEach(walk); }; manifest.commands.forEach(walk); for (const ex of examples) { const argv = (ex.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).slice(1).map(s => s.replace(/^['"]|['"]$/g, '')); if (argv.includes('--watch')) continue; // long-running const r = spawnSync('node', [path.resolve(BIN), ...argv], {cwd: box, encoding: 'utf8', input: '', timeout: 90000}); const out = `${r.stdout ?? ''}${r.stderr ?? ''}`; if (/\n\s+at .+:\d+:\d+|TypeError|Cannot read propert/.test(out)) console.log(`CRASH ${ex}`); else if (r.status !== 0) console.log(`exit ${r.status} ${ex}`); } fs.rmSync(box, {recursive: true, force: true}); EOF
A crash is always a finding. A non-zero exit is only a finding if the example was meant to succeed — some legitimately demonstrate an error path. Fix by correcting the example in the doc (the command is the source of truth), not by changing the command to match a stale doc.
Detect fake subjects by running them, never by matching names. The failure useToggle represents is broader than examples: a doc names a subject that does not exist — a hook, component, topic, or template that was renamed or never existed — while the surrounding command is perfectly valid. It is tempting to catch these by extracting astryx <cmd> <subject> with a regex and checking the subject against --list. Do not. That approach was tried and was wrong 8 times out of 11:
-
Aliases still resolve.
astryx component XDSButtonis absent fromcomponent --list(components were unprefixed in the rebrand) yet exits 0 and prints the Button doc. Flagging it as fake is a false positive. - Prose reads like invocations. A sentence containing "...only contains astryx component strings." matches the same regex and is not an invocation at all.
Running the invocation settles both: the alias exits 0, the prose is never executed, and a genuinely fake subject exits non-zero with ERR_UNKNOWN_HOOK/ERR_UNKNOWN_COMPONENT. Extend the runner above to the invocations inside reference-doc code blocks (packages/cli/assets/docs/*.doc.mjs) and the README, since those are copy-paste targets too — but keep execution, not pattern matching, as the test.
A subject that resolves only through a legacy alias is worth a separate, softer finding: it works, so it is not broken, but it teaches the old name. Prefer the current one.
Never hand-write a .d.mts. The declarations under api/, authoring/, and foundation/ are generated from the .mjs JSDoc by packages/cli/scripts/sync-api-types.mjs (the emitted declarations are gitignored, regenerated at prepack, stamped @generated). If a type looks wrong, fix the JSDoc and run pnpm -F @astryxdesign/cli sync:api-types — never edit the emitted file, and never add one by hand to satisfy a missing-declaration error. The published surface is verified end to end by .github/scripts/cli-api-types-verify.mjs, which is the check to run when a type-level finding involves what consumers actually install.
Layering is linted, not reviewed. ESLint pins the import directions (authoring/ imports no other layer, foundation/ never imports api/ or clients/, api/ never imports clients/) and requires commands to register through defineCommand. A finding here is an ESLint error, not something to eyeball — if a fix seems to need an upward import, the code being imported probably belongs one layer down.
Fix. Author the missing .doc.mjs, copying the search docs as a template (packages/cli/api/search/search.doc.mjs for the function, packages/cli/clients/cli/commands/search.doc.mjs for the command), or update the doc until the drift harness passes. Do not edit a .doc.mjs to paper over a real behavior change in the command itself; that is a QA or feature concern, not a doc fix. PR category: cli-docs (branch navi/docs/cli-<scope>).
Group findings and create PRs. Each PR should be focused on one category of fix.
| Category | Branch Name | Scope |
|---|---|---|
| Storybook compat | navi/docs/storybook-compat |
Fix ```tsx → ```, remove blank lines/comments/bare > from code blocks, consolidate multiple examples, add missing @example
|
| Common props | navi/docs/common-props |
Add/standardize xstyle, id, className, data-testid, style prop docs |
| Prop drift | navi/docs/prop-docs-{component} |
Add undocumented props to .doc.mjs props arrays |
| Theming sync | navi/docs/theming-sync |
Add/fix theming sections to match themeProps() calls in source |
| Block template metadata | navi/docs/block-metadata |
Fix componentsUsed accuracy, missing type fields, aspect ratio issues, file pairing |
| Hero showcase | navi/docs/showcase |
Set isShowcase: true on the canonical hero block template for components missing one |
| Import paths | navi/docs/import-paths |
Fix incorrect relative import paths in template .doc.mjs files |
| Block coverage | navi/docs/block-coverage-{component} |
Add missing block templates for components with no examples |
| Examples removal | navi/docs/remove-examples |
Remove examples arrays that crept back into component .doc.mjs files |
| Translation coverage | navi/docs/dense-coverage |
Add missing docsDense (and backfill drifted docsZh), fix count/prop/param/return parity, convert invalid short-key overlays, drop duplicate (required) markers |
| Hooks docs | navi/docs/hooks-{name} |
Fix hook param/return drift and add missing hook docsDense
|
| AI-slop prose | navi/docs/slop-{nn} |
Remove AI-slop tells (em/en dashes, curly quotes, ellipsis chars, vocab filler, significance padding, structural tells, hollow intensifiers, redundancy) per check 17. One file per commit; prose strings only; meaning preserved. |
| CLI colocated docs | navi/docs/cli-{scope} |
Add a missing FunctionDoc/CommandDoc/SchemaDoc/EnumDoc, or update one so the drift harness passes (check 19). Never edit a doc to mask a real behavior change in the command itself. |
-
Create a worktree:
cd /vercel/sandbox/repos/xds git worktree add /vercel/sandbox/repos/xds-worktrees/night-watch-docs origin/main --detach cd /vercel/sandbox/repos/xds-worktrees/night-watch-docs git checkout -b navi/docs/{category}
-
Make fixes. For each fix:
- Read the source file to understand the actual props/behavior
- Update the
.doc.mjs(or JSDoc@examplefor Storybook fixes) - Keep changes minimal — fix what's wrong, don't rewrite what's fine
-
Validate fixes:
# Verify type checking passes pnpm --filter @astryxdesign/core typecheck:docs # Verify block template types pass npx tsc --project packages/cli/tsconfig.template-docs.json --noEmit # Verify CLI renders correctly node packages/cli/clients/cli/bin/astryx.mjs component {Name} --brief node packages/cli/clients/cli/bin/astryx.mjs component {Name} --props # Build to ensure no breakage pnpm build
-
Commit and PR:
git add -A git commit -m "docs({scope}): {description} Found during Night Watch doc review." git push -u origin navi/docs/{category} gh pr create --title "docs({scope}): {description}" \ --body "## What {summary of findings and fixes} ## Checklist - [ ] \`pnpm --filter @astryxdesign/core typecheck:docs\` passes - [ ] \`npx tsc --project packages/cli/tsconfig.template-docs.json --noEmit\` passes - [ ] CLI \`--brief\` output verified - [ ] CLI \`--props\` output verified - [ ] No Storybook \`\`\`tsx\` in docblocks - [ ] No blank lines, JS comments, or bare \`>\` inside \`@example\` code blocks - [ ] Single concise \`@example\` per component - [ ] Common props (\`xstyle\`, \`id\`, \`className\`, etc.) documented consistently - [ ] Block templates paired with \`.doc.mjs\` files - [ ] \`componentsUsed\` matches actual imports - [ ] No \`examples\` arrays in component \`.doc.mjs\` files --- *Night Watch — Doc Reviewer*"
-
If there are no findings in a category, skip that PR. Don't create empty PRs.
-
.doc.mjsprops: Match the exact prop name and type from the TypeScript definition. Userequired: truefor required props. Omitdefaultfor required props or props with no default. -
Block templates: When creating new block templates, include both a
.tsxfile with realistic JSX and a.doc.mjswithtype: 'block',aspectRatio, andcomponentsUsedmatching the actual imports. -
Don't rewrite good docs. If a
.doc.mjsis well-structured and just missing one prop, add the prop. Don't reorganize the whole file. -
Don't add props that are intentionally undocumented. If a prop exists in the TypeScript interface but seems purely internal, skip it and note it in the PR description. However, common props like
xstyle,id,className, anddata-*attributes should always be documented. -
Don't re-add
examplesarrays. If a component needs usage examples, create block templates instead.
Write a summary to memory/xds-night-watch/{date}.md (append to existing if other roles have already logged):
## Doc Reviewer — {timestamp} ### Findings - Storybook compat: {n} files with ```tsx, {n} with blank lines/comments in code blocks, {n} missing @example - Common props: {n} components missing xstyle/id/className docs - Prop drift: {n} components with undocumented props ({total} props total) - Theming sync: {n} missing, {n} stale, {n} drifted theming sections - Block coverage: {n} components with no block templates - Block metadata: {n} blocks with componentsUsed drift, {n} orphaned files, {n} missing type fields - Block type safety: {n} tsc errors, {n} @ts-expect-error comments - Hero showcase: {n} components missing an isShowcase block, {n} with multiple - Aspect ratio: {n} blocks with bad/suspicious ratios - Examples watchdog: {n} components with stale examples arrays - Import paths: {n} files with wrong relative paths - Translation coverage: {n} missing docsDense, {n} bullet-count drifts, {n} prop/param/return coverage gaps, {n} invalid short-key overlays, {n} duplicate (required) markers - Hooks docs: {n} hooks with param/return drift, {n} hooks missing docsDense - AI-slop prose: {n} files with tells fixed (A typographic: {n}, B vocab: {n}, C padding: {n}, D structural: {n}, E intensifiers: {n}, F redundancy: {n}) - Config/README drift: {n} config fields, {n} manifest fields out of sync (added/removed/renamed) ### PRs Created - #{number}: {title} - #{number}: {title} ### Skipped (already clean) - {n} components passed all checks
Send a summary card to your human with:
- Count of findings per category
- Links to PRs created
- Any components that need human judgment (e.g., "should this prop be documented or is it internal?")
- ❌ Fix CI or push code fixes (that's QA)
- ❌ Review PR code quality (that's Reviewer)
- ❌ Triage issues (that's PM)
- ❌ Rewrite component implementations
- ❌ Add new components or features
- ❌ Make subjective documentation style changes beyond the AI-slop rubric (check 17). Removing slop tells is in scope; re-voicing prose to taste is not.
- ❌ Produce AI-slop in your own writing. PR bodies, commit messages, code comments, and any doc prose you author must not contain the tells in check 17: no em dashes, no "seamless/leverage/robust", no "it's worth noting", no hollow intensifiers. Fixing slop while writing slop is self-defeating.
- ❌ Approve or merge PRs
- ❌ Validate
.doc.mjstype structure (CI handles this viatsc --checkJs)
Track state in memory/xds-night-watch-state.json:
{
"role": "doc-reviewer",
"lastRun": null,
"lastRunDate": null,
"prsCreated": [],
"findings": {
"storybookCompat": 0,
"propDrift": 0,
"themingSync": 0,
"commonProps": 0,
"blockCoverage": 0,
"blockMetadata": 0,
"blockTypeSafety": 0,
"showcaseCompleteness": 0,
"aspectRatioSanity": 0,
"examplesWatchdog": 0,
"importPaths": 0,
"translationCoverage": 0,
"hooksDocs": 0,
"configDocDrift": 0
},
"runsToday": 0
}Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist AST-009 assistive-technology verification
Operations Release Process Night Watch Overview