@astryxdesign/core 0.5.2-canary.457de8d → 0.5.2-canary.53353aa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.5.2-canary.457de8d",
3
+ "version": "0.5.2-canary.53353aa",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -661,7 +661,7 @@
661
661
  "react-dom": ">=19.0.0"
662
662
  },
663
663
  "devDependencies": {
664
- "@astryxdesign/cli": "0.5.2-canary.457de8d",
664
+ "@astryxdesign/cli": "0.5.2-canary.53353aa",
665
665
  "@babel/cli": "^8.0.4",
666
666
  "@babel/core": "^7.29.7",
667
667
  "@babel/preset-react": "^8.0.1",
@@ -1,5 +1,24 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ /** @type {import('@astryxdesign/cli/authoring').ComponentAnatomyElement[]} */
4
+ const anatomy = [
5
+ {
6
+ name: 'Message box',
7
+ required: true,
8
+ description: 'Painted container for the validation feedback.',
9
+ },
10
+ {
11
+ name: 'Detached icon',
12
+ required: false,
13
+ description: 'Leading status glyph shown only by the detached variant.',
14
+ },
15
+ {
16
+ name: 'Message text',
17
+ required: true,
18
+ description: 'Text describing the validation status.',
19
+ },
20
+ ];
21
+
3
22
  /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
4
23
  export const docs = {
5
24
  name: 'FieldStatus',
@@ -61,6 +80,7 @@ export const docs = {
61
80
  'Use FieldStatus for general alerts or page-level notices; use Banner or Toast instead.',
62
81
  },
63
82
  ],
83
+ anatomy,
64
84
  },
65
85
  };
66
86
 
@@ -86,6 +106,7 @@ export const docsDense = {
86
106
  'Use FieldStatus for general alerts or page-level notices; use Banner or Toast instead.',
87
107
  },
88
108
  ],
109
+ anatomy,
89
110
  },
90
111
  propDescriptions: {
91
112
  type: 'error/warning/success status tone',
@@ -0,0 +1,212 @@
1
+ ---
2
+ schema_version: 1
3
+ template_version: 1
4
+ kind: component
5
+ id: component:FieldStatus
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-08-30
11
+ owners: [cixzhang, imdreamrunner]
12
+ review_triggers: [public-api, behavior, theming, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/FieldStatus/FieldStatus.test.tsx,
16
+ scripts/check-knowledge.mjs,
17
+ ]
18
+ families: []
19
+ design_specs: []
20
+ architecture: [architecture:component-theming-surface]
21
+ contributing: []
22
+ system_specs: []
23
+ ---
24
+
25
+ # FieldStatus component contract
26
+
27
+ ## Intent
28
+
29
+ FieldStatus presents one validation message for a field or field-like control. It
30
+ owns the visible message treatment and announcement behavior for attached and
31
+ detached presentations; consumer usage remains documented in
32
+ `FieldStatus.doc.mjs`.
33
+
34
+ ## Compatibility and migration
35
+
36
+ - Released default preserved: `yes`
37
+ - Compatibility class: additive documentation only; runtime, DOM, styling, and
38
+ public API remain unchanged
39
+ - Controlled/uncontrolled behavior: not applicable
40
+ - Migration decision: direct `FieldStatus variant="tooltip"` support is rejected.
41
+ `tooltip` remains a field-family sentinel that MUST be handled before rendering
42
+ FieldStatus. Narrowing the currently permissive type/runtime path requires a
43
+ separate compatibility-aware implementation change.
44
+
45
+ Consumer migration instructions belong in consumer docs and release notes.
46
+
47
+ ## Ownership boundary
48
+
49
+ **Owns**
50
+
51
+ - The attached and detached validation-message presentations.
52
+ - Routing visible messages through the persistent announcement channels.
53
+ - The detached presentation's redundant visual status glyph.
54
+
55
+ **Does not own / non-goals**
56
+
57
+ - Whether a caller renders a status message, or which control it describes —
58
+ owned by the composing field component or product callsite.
59
+ - The bordered control's status treatment and on-field tooltip presentation —
60
+ owned by the composing field component.
61
+ - General alerts and page-level notices — owned by the corresponding feedback
62
+ components.
63
+
64
+ ## Public concepts
65
+
66
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
67
+ | ------------ | ----------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------- | ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- |
68
+ | Status type | `error`, `warning`, `success` | Selects the message tone and announcement channel. | All rendered presentations. | Required | `component:FieldStatus` | Stable | TypeScript rejects unsupported values. |
69
+ | Presentation | `attached`, `detached` | Selects the message presentation. | All direct FieldStatus usage. The field family's `tooltip` sentinel is handled before FieldStatus renders. | `attached` | `component:FieldStatus` | Stable. | TypeScript rejects values absent from the extensible variant map; its current acceptance of `tooltip` is an implementation gap. |
70
+
71
+ The field family's `tooltip` status presentation suppresses FieldStatus and
72
+ surfaces the message from the control's on-field icon. `tooltip` is not a
73
+ FieldStatus presentation. The public extensible variant map currently permits a
74
+ direct `tooltip` value; that is an implementation gap, not supported behavior.
75
+
76
+ ## Behavioral and layout contract
77
+
78
+ Draft requirements identify their basis so observed code is not mistaken for an
79
+ intentional decision.
80
+
81
+ | ID | Candidate invariant | Basis | Draft review state |
82
+ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
83
+ | FR1 | FieldStatus MUST render the provided message in one visible message box when mounted. | Documented promise and current tests. | Settled intent. |
84
+ | FR2 | The attached presentation MUST render the message without a leading glyph; the detached presentation MUST render its status glyph before the message text. | Documented promise, current DOM, and current tests. | Settled intent. |
85
+ | FR3 | Status type MUST select distinct error, warning, or success treatment and MUST be reflected on the message-box target. | Current behavior and theming tests. | Settled intent. |
86
+ | FR4 | The detached glyph MUST reflect status type on its own target; the target MUST be absent from attached presentation. | Current behavior and target tests. | Settled intent. |
87
+ | FR5 | The root MUST continue to forward its ref, ID, supported DOM props, and consumer styling escape hatches. | Current behavior and tests. | Current compatibility behavior; verify before changing. |
88
+ | FR6 | Message and status-type updates MUST update the visible output without replacing the component contract. | Current behavior and tests. | Current compatibility behavior; verify before changing. |
89
+ | FR7 | Direct FieldStatus usage MUST support only `attached` and `detached`; the Field family MUST consume its `tooltip` sentinel before rendering FieldStatus. | Approved component boundary. | Settled intent; current type acceptance is a gap. |
90
+
91
+ ### Allowed variation
92
+
93
+ - **AV1 — Theme styling.** Theme and consumer overrides may change visual CSS
94
+ properties through the existing targets and styling escape hatches without
95
+ changing anatomy, ownership, or presentation behavior.
96
+ - **AV2 — Message length.** The message may wrap; the detached glyph remains
97
+ aligned with the first text line.
98
+
99
+ ### Representative states
100
+
101
+ | State | Required invariant | Allowed variation |
102
+ | ---------------- | -------------------------------------------------------------- | ----------------------------------------------------------- |
103
+ | Attached error | Message box and text render; no detached glyph renders. | Theme-controlled visual properties on `field-status`. |
104
+ | Detached warning | Message box, leading warning glyph, then message text render. | Theme-controlled visual properties on both current targets. |
105
+ | Detached success | Message box, leading success glyph, then message text render. | Message length and theme-controlled visual properties. |
106
+ | Empty message | The root remains mounted and renders the provided empty value. | No announcement channel is created. |
107
+
108
+ ### Transformation and precedence order
109
+
110
+ - **ORD1 — Presentation styling.** Resolve the default presentation, reflect
111
+ status and presentation through `themeProps`, apply component styles, then
112
+ merge `xstyle`, `className`, and inline `style` in their established order.
113
+ - **ORD2 — Detached content.** Render the decorative status glyph before the
114
+ message text, using the same status-to-glyph mapping as field controls.
115
+
116
+ ### Performance and resources
117
+
118
+ - **PR1 — Announcement work.** A message change schedules only the persistent
119
+ live-region announcement; FieldStatus owns no global event listeners,
120
+ observers, or measurements.
121
+
122
+ ## Accessibility contract
123
+
124
+ - **AR1 — Announcement channel.** Errors MUST announce through the persistent
125
+ assertive region; warnings and successes MUST announce through the persistent
126
+ polite region, including on first mount and when message or type changes.
127
+ - **AR2 — Visible description.** The rendered message box MUST remain available
128
+ to assistive technology for `aria-describedby` association and MUST NOT become
129
+ its own newly mounted live region.
130
+ - **AR3 — Non-color cue.** Detached status MUST include a leading status glyph so
131
+ status is not conveyed only by color or position.
132
+ - **AR4 — Redundant glyph.** The detached glyph MUST remain hidden from assistive
133
+ technology because the visible message already conveys and announces the
134
+ status.
135
+
136
+ ## Design relationships
137
+
138
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
139
+ | ---------------- | ---------------------------------------------------------- | ----------------------------------------------- | -------------- | ------------------ |
140
+ | Message box | Carries the visible validation treatment. | Current behavior and approved component intent. | Supporting | FR1, FR3 |
141
+ | Detached icon | Adds a non-color status cue only in detached presentation. | Current behavior and approved component intent. | Supporting | FR2, FR4, AR3 |
142
+ | Message text | Communicates the validation result in words. | Current behavior and approved component intent. | Prominent | FR1, AR1, AR2 |
143
+
144
+ Current behavior renders the detached icon before message text. The intended
145
+ public theming ownership is the exact map below; it does not add a target or
146
+ change either presentation.
147
+
148
+ ### Theming anatomy
149
+
150
+ <!-- anatomy-theming:v1 -->
151
+
152
+ ```json
153
+ {
154
+ "Message box": {"target": "field-status"},
155
+ "Detached icon": {"target": "field-status-icon"},
156
+ "Message text": {"inherits": "field-status"}
157
+ }
158
+ ```
159
+
160
+ ## Family and system relationships
161
+
162
+ - `architecture:component-theming-surface` owns the qualification and validation
163
+ rules for the two targets and inherited message text.
164
+ - Field-like controls compose FieldStatus but continue to own control rendering,
165
+ status placement decisions, and association with the control.
166
+
167
+ ## Verification map
168
+
169
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
170
+ | ------------------- | --------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------- |
171
+ | FR1, FR2, AR3, AR4 | `FieldStatus.test.tsx` rendering and detached-icon suites | Attached and detached; all status types | Removing or reordering presentation parts fails DOM and accessibility assertions. | `audit:FieldStatus/anatomy` |
172
+ | FR3, FR4 | `FieldStatus.test.tsx` theme target suites | Attached/detached; error/warning/success | Removing a target or status reflection fails class/data-attribute assertions. | `audit:FieldStatus/theming` |
173
+ | FR5, FR6 | `FieldStatus.test.tsx` forwarding and update suites | Initial render and rerender | Breaking the released root or update behavior fails compatibility assertions. | `audit:FieldStatus/behavior` |
174
+ | AR1, AR2 | `FieldStatus.test.tsx` announcement suites | Mount, message update, type update, empty message | Reintroducing a local live region or misrouting severity fails announcement assertions. | `audit:FieldStatus/accessibility` |
175
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | Consumer anatomy and current targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:FieldStatus/theming` |
176
+
177
+ ## Decision log
178
+
179
+ ### DEC-1 — Presentation-specific anatomy remains explicit
180
+
181
+ **Reference:** `component:FieldStatus/DEC-1`
182
+ **Decider:** cixzhang, 2026-08-30
183
+
184
+ The detached icon is a separate anatomy part because only detached presentation
185
+ renders it and it owns a target distinct from the message box. Message text
186
+ inherits the message-box target rather than creating a redundant child target.
187
+
188
+ Rejected: collapsing the glyph into a generic message row, which would hide its
189
+ presentation condition and distinct theming owner.
190
+
191
+ ### DEC-2 — `tooltip` is not a FieldStatus presentation
192
+
193
+ **Reference:** `component:FieldStatus/DEC-2`
194
+ **Decider:** cixzhang, 2026-08-30
195
+
196
+ Direct FieldStatus usage supports only `attached` and `detached`. The Field
197
+ family may use `tooltip` as an internal presentation sentinel, but it MUST
198
+ consume that value before rendering FieldStatus and surface the message through
199
+ the control's on-field status treatment instead.
200
+
201
+ Rejected: defining direct `FieldStatus variant="tooltip"` rendering semantics.
202
+ That would expose a field-composition mechanism as a third FieldStatus
203
+ presentation and preserve currently unintentional output.
204
+
205
+ ## Open questions
206
+
207
+ None.
208
+
209
+ ## Content boundary
210
+
211
+ This file does not duplicate consumer prop examples, current audit results,
212
+ implementation steps, or family/system rules. It links to their owners.
@@ -3,8 +3,10 @@
3
3
  /* eslint-disable @typescript-eslint/no-require-imports */
4
4
  /**
5
5
  * @file Guards `theming.targets` against the real `themeProps()` call sites (#3741).
6
- * @input Component sources (*.tsx/*.ts) and their `{Name}.doc.mjs` files.
7
- * @output Vitest failures naming each undocumented class / visual prop.
6
+ * @input Core/Lab component sources (*.tsx/*.ts), their `{Name}.doc.mjs`
7
+ * files, and the Lab promotion-candidate manifest.
8
+ * @output Vitest failures naming each undocumented class / visual prop on the
9
+ * stable Core surface or a capability-participating Lab component.
8
10
  * @position Sibling of derivedVarRegistry.test.ts, which already validates the
9
11
  * OTHER fields of the same `theming` block (`vars`, `derived`). `targets` was
10
12
  * the one field with no machine check, so it drifted — twice (#3652, #3680).
@@ -22,6 +24,11 @@
22
24
  * `visualProps` or `states`. Docs may list MORE than the source passes —
23
25
  * components forward props they don't themselves reflect (Timestamp passes
24
26
  * `{format}` but documents `type`/`color`/`format`), and that is intentional.
27
+ *
28
+ * Lab is capability-based: a component participates when it already declares
29
+ * `theming.targets` or enters the existing promotion-candidate manifest. Other
30
+ * Lab components remain free to iterate with runtime hooks that are not yet a
31
+ * documented public theming promise.
25
32
  */
26
33
 
27
34
  import {describe, it, expect} from 'vitest';
@@ -30,7 +37,23 @@ import {join, relative} from 'node:path';
30
37
  import ts from 'typescript';
31
38
  import {stableClassName} from '../naming';
32
39
 
33
- const SRC_DIR = join(__dirname, '..');
40
+ const CORE_SRC_DIR = join(__dirname, '..');
41
+ const LAB_SRC_DIR = join(__dirname, '../../../lab/src');
42
+ const LAB_PROMOTION_MANIFEST = join(
43
+ __dirname,
44
+ '../../../../internal/lab-readiness/manifest.mjs',
45
+ );
46
+
47
+ interface LabPromotionCandidate {
48
+ sourceDir: string;
49
+ }
50
+
51
+ const {CANDIDATES: labPromotionCandidates} = require(
52
+ LAB_PROMOTION_MANIFEST,
53
+ ) as {CANDIDATES: LabPromotionCandidate[]};
54
+ const LAB_PROMOTION_DIRS = new Set(
55
+ labPromotionCandidates.map(candidate => candidate.sourceDir),
56
+ );
34
57
 
35
58
  // ---------------------------------------------------------------------------
36
59
  // Source scanning: find themeProps() call sites via the TypeScript AST
@@ -314,6 +337,7 @@ type DocBlock = {theming?: {targets?: DocTarget[]}};
314
337
  type ComponentDocModule = {docs?: DocBlock; docsZh?: DocBlock};
315
338
 
316
339
  interface ComponentInfo {
340
+ packageName: 'core' | 'lab';
317
341
  dir: string;
318
342
  sites: ThemeTargetSite[];
319
343
  /** The doc blocks that carry theming.targets, by the key they live under. */
@@ -328,7 +352,10 @@ interface ComponentInfo {
328
352
  * Reads the file as text rather than requiring it: this runs for directories
329
353
  * that have no doc of their own, so most candidates are misses.
330
354
  */
331
- function docFilesDocumenting(classNames: Set<string>): string[] {
355
+ function docFilesDocumenting(
356
+ srcDir: string,
357
+ classNames: Set<string>,
358
+ ): string[] {
332
359
  const matches: string[] = [];
333
360
  const walk = (dir: string): void => {
334
361
  for (const entry of readdirSync(dir, {withFileTypes: true})) {
@@ -346,18 +373,22 @@ function docFilesDocumenting(classNames: Set<string>): string[] {
346
373
  }
347
374
  }
348
375
  };
349
- walk(SRC_DIR);
376
+ walk(srcDir);
350
377
  return matches;
351
378
  }
352
379
 
353
- function discoverComponents(): ComponentInfo[] {
380
+ function discoverComponents(
381
+ srcDir: string,
382
+ packageName: ComponentInfo['packageName'],
383
+ requiredDirs: ReadonlySet<string> = new Set(),
384
+ ): ComponentInfo[] {
354
385
  const results: ComponentInfo[] = [];
355
- const dirs = readdirSync(SRC_DIR, {withFileTypes: true})
386
+ const dirs = readdirSync(srcDir, {withFileTypes: true})
356
387
  .filter(d => d.isDirectory())
357
388
  .map(d => d.name);
358
389
 
359
390
  for (const dir of dirs) {
360
- const dirPath = join(SRC_DIR, dir);
391
+ const dirPath = join(srcDir, dir);
361
392
  const dirEntries = readdirSync(dirPath);
362
393
 
363
394
  const sourceFiles = dirEntries.filter(
@@ -389,10 +420,7 @@ function discoverComponents(): ComponentInfo[] {
389
420
  // doc file that CI never checks. (Same guard as derivedVarRegistry.test.ts.)
390
421
  const docFiles = dirEntries.includes(`${dir}.doc.mjs`)
391
422
  ? [join(dirPath, `${dir}.doc.mjs`)]
392
- : docFilesDocumenting(new Set(sites.map(s => s.className)));
393
- if (docFiles.length === 0) {
394
- continue;
395
- }
423
+ : docFilesDocumenting(srcDir, new Set(sites.map(s => s.className)));
396
424
 
397
425
  const docBlocks: ComponentInfo['docBlocks'] = [];
398
426
  for (const docFile of docFiles) {
@@ -404,18 +432,23 @@ function discoverComponents(): ComponentInfo[] {
404
432
  }
405
433
  for (const key of ['docs', 'docsZh'] as const) {
406
434
  const targets = mod[key]?.theming?.targets;
407
- // Only blocks that already document a theming surface are held to it —
408
- // a doc with no theming block at all is a separate (documentation) gap.
435
+ // A normal Core or Lab component enrolls by declaring a theming
436
+ // surface. Promotion candidates are also enrolled here so a missing
437
+ // block is a failure rather than a silently skipped readiness gap.
409
438
  if (targets != null) {
410
- docBlocks.push({key, file: relative(SRC_DIR, docFile), targets});
439
+ docBlocks.push({key, file: relative(srcDir, docFile), targets});
411
440
  }
412
441
  }
413
442
  }
414
- if (docBlocks.length === 0) {
443
+ const participates =
444
+ packageName === 'core'
445
+ ? docBlocks.length > 0
446
+ : requiredDirs.has(dir) || docBlocks.length > 0;
447
+ if (!participates) {
415
448
  continue;
416
449
  }
417
450
 
418
- results.push({dir, sites, docBlocks});
451
+ results.push({packageName, dir, sites, docBlocks});
419
452
  }
420
453
  return results;
421
454
  }
@@ -425,24 +458,57 @@ function discoverComponents(): ComponentInfo[] {
425
458
  // ---------------------------------------------------------------------------
426
459
 
427
460
  describe('theming.targets matches the themeProps() call sites', () => {
428
- const components = discoverComponents();
461
+ const components = [
462
+ ...discoverComponents(CORE_SRC_DIR, 'core'),
463
+ ...discoverComponents(LAB_SRC_DIR, 'lab', LAB_PROMOTION_DIRS),
464
+ ];
465
+
466
+ it('finds participating Core and Lab components', () => {
467
+ // A refactor that renames themeProps or drops the Lab package from this
468
+ // inventory must not silently disable either side of the guard.
469
+ expect(components.some(component => component.packageName === 'core')).toBe(
470
+ true,
471
+ );
472
+ expect(components.some(component => component.packageName === 'lab')).toBe(
473
+ true,
474
+ );
475
+ });
429
476
 
430
- it('finds components to check', () => {
431
- // A refactor that renames themeProps must not silently disable this file.
432
- expect(components.length).toBeGreaterThan(0);
477
+ it('enrolls every Lab promotion candidate and explicit theming capability', () => {
478
+ const labDirs = new Set(
479
+ components
480
+ .filter(component => component.packageName === 'lab')
481
+ .map(component => component.dir),
482
+ );
483
+ expect([...LAB_PROMOTION_DIRS].filter(dir => !labDirs.has(dir))).toEqual(
484
+ [],
485
+ );
486
+ expect(LAB_PROMOTION_DIRS.has('CircularProgress')).toBe(false);
487
+ expect(labDirs.has('CircularProgress')).toBe(true);
488
+ expect(labDirs.has('Schedule')).toBe(false);
433
489
  });
434
490
 
435
- for (const {dir, sites, docBlocks} of components) {
491
+ for (const {packageName, dir, sites, docBlocks} of components) {
492
+ const componentLabel = `${packageName}/${dir}`;
436
493
  const renderedClasses = [...new Set(sites.map(s => s.className))].sort();
437
494
 
495
+ it(`${componentLabel}: participating components declare theming metadata`, () => {
496
+ expect(
497
+ docBlocks,
498
+ `${componentLabel} participates in the public theming contract but has ` +
499
+ `no loadable .doc.mjs theming.targets. Lab components participate ` +
500
+ `when they declare theming.targets or enter the promotion manifest.`,
501
+ ).not.toHaveLength(0);
502
+ });
503
+
438
504
  for (const {key, file, targets} of docBlocks) {
439
505
  const documented = new Set(targets.map(t => t.className));
440
506
 
441
- it(`${dir} (${file} ${key}): every rendered class is documented`, () => {
507
+ it(`${componentLabel} (${file} ${key}): every rendered class is documented`, () => {
442
508
  const undocumented = renderedClasses.filter(c => !documented.has(c));
443
509
  expect(
444
510
  undocumented,
445
- `${dir} renders ${undocumented.length} astryx-* class(es) that ` +
511
+ `${componentLabel} renders ${undocumented.length} astryx-* class(es) that ` +
446
512
  `${file} ${key}.theming.targets does not document: ` +
447
513
  `${undocumented.join(', ')}. An undocumented class is an ` +
448
514
  `unthemeable element — theme authors and codegen read targets[] ` +
@@ -450,7 +516,7 @@ describe('theming.targets matches the themeProps() call sites', () => {
450
516
  ).toEqual([]);
451
517
  });
452
518
 
453
- it(`${dir} (${file} ${key}): every visual prop passed to themeProps is documented`, () => {
519
+ it(`${componentLabel} (${file} ${key}): every visual prop passed to themeProps is documented`, () => {
454
520
  const missing: string[] = [];
455
521
  for (const site of sites) {
456
522
  const target = targets.find(t => t.className === site.className);