@adia-ai/adia-ui-forge 0.8.59 → 0.8.61

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.
Files changed (26) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/package.json +1 -1
  5. package/plugin.yaml +1 -1
  6. package/references/contracts/a2ui-mcp-surface.md +1 -1
  7. package/scripts/lint-rules.generated.mjs +2418 -359
  8. package/skills/a2ui-maintenance/references/eval-diagnostics.md +2 -2
  9. package/skills/a2ui-maintenance/references/pipeline-overview.md +26 -0
  10. package/skills/a2ui-maintenance/references/strategy-engines.md +12 -0
  11. package/skills/demo-audit/references/admin-shell-anatomy.md +18 -12
  12. package/skills/gen-ui-review/scripts/gen-review-decompose.mjs +27 -34
  13. package/skills/gen-ui-review/scripts/overflow-detect.generated.mjs +101 -0
  14. package/skills/package-release/references/cut-procedure.md +49 -5
  15. package/skills/package-release/references/recovery-paths.md +20 -0
  16. package/skills/package-release/scripts/bump.mjs +36 -6
  17. package/skills/package-release/scripts/dispatch-publish.mjs +8 -0
  18. package/skills/package-release/scripts/gate-roster.mjs +4 -4
  19. package/skills/package-release/scripts/release-pack.mjs +485 -68
  20. package/skills/package-release/scripts/stale-copy-warning.mjs +215 -0
  21. package/skills/package-release/scripts/tag-lockstep.mjs +8 -0
  22. package/skills/primitive-authoring/references/INDEX.md +1 -0
  23. package/skills/primitive-authoring/references/examples-structure.md +52 -0
  24. package/skills/primitive-authoring/references/shell-patterns.md +12 -11
  25. package/skills/primitive-authoring/references/token-contract.md +2 -2
  26. package/skills/primitive-authoring/references/yaml-contract.md +106 -9
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ // stale-copy-warning.mjs: generalizes release-pack.mjs's own STALE-COPY
3
+ // WARNING (ticket #3943 item 2, gh#3764 line 55) into a small helper this
4
+ // skill's own scripts can share.
5
+ //
6
+ // WHY
7
+ //
8
+ // A script reached through an installed plugin cache
9
+ // (`~/.claude/plugins/cache/...`) only refreshes on a version bump, so
10
+ // running it mid-cycle can execute a version that predates the repo's own
11
+ // copy at the same relative path. This is invisible to a repo grep: the
12
+ // grep reads the repo's own, already-corrected file, while the running
13
+ // process reads the cache's stale one: the filer of ticket #3943 hit this
14
+ // exact shape twice in one night (gh#3886, and the filing session itself).
15
+ //
16
+ // This module names ONE detector and ONE formatter, both pure, plus a thin
17
+ // convenience wrapper a script calls once near its own entrypoint. It never
18
+ // errors and never exits the caller's process: per the ticket's own
19
+ // framing, this is a documented check, not a gate: the repo copy is
20
+ // "almost certainly newer," never provably so from bytes alone, so the
21
+ // right response is a loud, named warning, not a failure.
22
+ //
23
+ // PACKAGE-LOCAL BY DESIGN (ticket #3943's own check:pr-ready finding): this
24
+ // file lives here, inside package-release/scripts/, rather than the repo's
25
+ // top-level scripts/lib/, because this skill publishes as its own npm
26
+ // package (`@adia-ai/adia-ui-forge`, PACKAGE_ROSTER). A script inside that
27
+ // package importing a file OUTSIDE its own package root resolves fine in a
28
+ // pnpm worktree but is an unresolvable specifier once packed
29
+ // (`npm run check:packed-imports`, which caught exactly this on the first
30
+ // draft of this change: an escaped-root import out of the tarball). Same
31
+ // reasoning `bump.mjs`'s own inlined `isEntryPoint()` already states for
32
+ // not importing `scripts/lib/is-entry-point.mjs`; this file follows that
33
+ // same precedent (its own inlined entry-point check below, no cross-
34
+ // package import) rather than introducing a second copy of the hazard.
35
+ // A future repo-TOOLING script under the top-level `scripts/` tree (never
36
+ // packaged into any tarball) that wants this same pattern is free to copy
37
+ // this file's own two pure functions there; nothing here assumes only one
38
+ // copy may ever exist, the packaging boundary is exactly why one shared
39
+ // copy cannot serve both sides.
40
+ //
41
+ // USAGE (a script inside this same package, e.g. a sibling in
42
+ // package-release/scripts/):
43
+ //
44
+ // import { warnIfStaleCopy } from './stale-copy-warning.mjs';
45
+ // warnIfStaleCopy(import.meta.url, REPO_ROOT, 'path/relative/to/REPO_ROOT/this-script.mjs');
46
+ //
47
+ // SELFTEST:
48
+ //
49
+ // node packages/plugins/adia-ui-forge/skills/package-release/scripts/stale-copy-warning.mjs selftest
50
+
51
+ import fs from 'node:fs';
52
+ import os from 'node:os';
53
+ import path from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+
56
+ /**
57
+ * Pure: compares `selfPath`'s own bytes against the repo's copy at
58
+ * `repoRoot/relativePath`. Returns `null` when there is nothing to warn
59
+ * about (no repo copy exists to compare against, `selfPath` already IS the
60
+ * repo copy, or the bytes agree); otherwise `{ repoCopyPath, selfPath }`,
61
+ * both resolved absolute paths.
62
+ * @param {{selfPath: string, repoRoot: string, relativePath: string, readFile?: Function, fileExists?: Function}} args
63
+ * @returns {{repoCopyPath: string, selfPath: string} | null}
64
+ */
65
+ export function detectStaleCopy({ selfPath, repoRoot, relativePath, readFile = fs.readFileSync, fileExists = fs.existsSync }) {
66
+ const resolvedSelf = path.resolve(selfPath);
67
+ const repoCopyPath = path.resolve(repoRoot, relativePath);
68
+ if (!fileExists(repoCopyPath)) return null; // nothing at the repo's own path to compare against
69
+ if (resolvedSelf === repoCopyPath) return null; // already running the repo's own copy
70
+ let selfText;
71
+ let repoText;
72
+ try {
73
+ selfText = readFile(selfPath, 'utf8');
74
+ repoText = readFile(repoCopyPath, 'utf8');
75
+ } catch {
76
+ return null; // unreadable on either side; nothing this check can prove
77
+ }
78
+ if (selfText === repoText) return null;
79
+ return { repoCopyPath, selfPath: resolvedSelf };
80
+ }
81
+
82
+ /**
83
+ * Pure: renders `detectStaleCopy`'s own non-null result as the printable
84
+ * warning block, the same shape release-pack.mjs's own inline check used.
85
+ * @param {{repoCopyPath: string, selfPath: string}} hit
86
+ * @returns {string}
87
+ */
88
+ export function formatStaleCopyWarning({ repoCopyPath, selfPath }) {
89
+ return [
90
+ '',
91
+ `⚠ STALE-COPY WARNING: this ${path.basename(selfPath)} differs from the repo's own copy at`,
92
+ ` ${repoCopyPath}`,
93
+ " The installed plugin cache lags the repo between cuts (ticket #3943, gh#3764 line 55);",
94
+ ' the repo copy is almost certainly newer, prefer running it:',
95
+ ` node ${repoCopyPath} <same args>`,
96
+ '',
97
+ ].join('\n');
98
+ }
99
+
100
+ /**
101
+ * Convenience wrapper for a script's own top-level self-check: resolves its
102
+ * own path from `import.meta.url`, compares it against
103
+ * `repoRoot/relativePath`, and logs (never throws, never exits the caller)
104
+ * when the two diverge. Call this once, near the top of a script's own
105
+ * entrypoint, before any real work.
106
+ * @param {string} selfUrl the caller's own `import.meta.url`
107
+ * @param {string} repoRoot the target repo's own root, resolved the same way the caller resolves it for everything else
108
+ * @param {string} relativePath this script's own path, relative to `repoRoot`
109
+ * @param {{log?: Function}} [opts]
110
+ * @returns {boolean} whether a warning was logged
111
+ */
112
+ export function warnIfStaleCopy(selfUrl, repoRoot, relativePath, { log = console.error } = {}) {
113
+ const selfPath = fileURLToPath(selfUrl);
114
+ const hit = detectStaleCopy({ selfPath, repoRoot, relativePath });
115
+ if (hit) log(formatStaleCopyWarning(hit));
116
+ return hit !== null;
117
+ }
118
+
119
+ // ---- selftest -------------------------------------------------------------
120
+
121
+ function selftest() {
122
+ let ran = 0;
123
+ const assert = (cond, msg) => {
124
+ ran += 1;
125
+ if (!cond) throw new Error(`selftest failed: ${msg}`);
126
+ };
127
+
128
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'stale-copy-warning-'));
129
+ try {
130
+ const repoRoot = path.join(sandbox, 'repo');
131
+ const relativePath = path.join('scripts', 'thing.mjs');
132
+ const repoCopyPath = path.join(repoRoot, relativePath);
133
+ const cachePath = path.join(sandbox, 'cache-copy.mjs');
134
+ fs.mkdirSync(path.dirname(repoCopyPath), { recursive: true });
135
+
136
+ // Positive control: cache bytes differ from the repo's own copy.
137
+ fs.writeFileSync(repoCopyPath, 'v2 (repo, newer)\n');
138
+ fs.writeFileSync(cachePath, 'v1 (cache, stale)\n');
139
+ const diverged = detectStaleCopy({ selfPath: cachePath, repoRoot, relativePath });
140
+ assert(diverged !== null, 'differing bytes must be detected');
141
+ assert(diverged.repoCopyPath === path.resolve(repoCopyPath), 'the hit names the resolved repo copy path');
142
+ const warning = formatStaleCopyWarning(diverged);
143
+ assert(warning.includes('STALE-COPY WARNING'), 'the formatted warning names itself');
144
+ assert(warning.includes(repoCopyPath), 'the formatted warning cites the repo copy path');
145
+
146
+ // Negative control: bytes agree.
147
+ fs.writeFileSync(cachePath, 'v2 (repo, newer)\n');
148
+ assert(
149
+ detectStaleCopy({ selfPath: cachePath, repoRoot, relativePath }) === null,
150
+ 'identical bytes must never warn',
151
+ );
152
+
153
+ // Negative control: the "self" path already IS the repo's own copy
154
+ // (release-pack.mjs's own first guard: never warn about itself).
155
+ assert(
156
+ detectStaleCopy({ selfPath: repoCopyPath, repoRoot, relativePath }) === null,
157
+ 'running the repo copy directly must never warn',
158
+ );
159
+
160
+ // Negative control: no repo copy exists at all (nothing to compare
161
+ // against, e.g. a script the repo has since deleted).
162
+ assert(
163
+ detectStaleCopy({ selfPath: cachePath, repoRoot, relativePath: path.join('scripts', 'gone.mjs') }) === null,
164
+ 'a missing repo copy must never warn (nothing provable)',
165
+ );
166
+
167
+ // warnIfStaleCopy: the convenience wrapper logs on divergence and
168
+ // reports it, using a real file:// URL the way a real caller's
169
+ // import.meta.url would.
170
+ fs.writeFileSync(cachePath, 'v1 (cache, stale)\n');
171
+ let logged = '';
172
+ const warned = warnIfStaleCopy(
173
+ new URL(`file://${cachePath}`).href,
174
+ repoRoot,
175
+ relativePath,
176
+ { log: (msg) => { logged += msg; } },
177
+ );
178
+ assert(warned === true, 'warnIfStaleCopy reports true when it warns');
179
+ assert(logged.includes('STALE-COPY WARNING'), 'warnIfStaleCopy actually logs the warning text');
180
+
181
+ // And stays silent (never throws, never exits) when bytes agree.
182
+ fs.writeFileSync(cachePath, 'v2 (repo, newer)\n');
183
+ let loggedAgain = '';
184
+ const clean = warnIfStaleCopy(
185
+ new URL(`file://${cachePath}`).href,
186
+ repoRoot,
187
+ relativePath,
188
+ { log: (msg) => { loggedAgain += msg; } },
189
+ );
190
+ assert(clean === false, 'warnIfStaleCopy reports false when bytes agree');
191
+ assert(loggedAgain === '', 'warnIfStaleCopy logs nothing when bytes agree');
192
+ } finally {
193
+ fs.rmSync(sandbox, { recursive: true, force: true });
194
+ }
195
+
196
+ console.log(`stale-copy-warning selftest, all assertions passed, ${ran} controls ran`);
197
+ return 0;
198
+ }
199
+
200
+ // Realpath-safe entry-point check, inlined rather than imported from the
201
+ // top-level scripts/lib/is-entry-point.mjs (see the package-local-by-design
202
+ // note above); mirrors bump.mjs's own identical inlining in this same dir.
203
+ function isEntryPoint() {
204
+ if (!process.argv[1]) return false;
205
+ try {
206
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ if (isEntryPoint()) {
213
+ const topArgv = process.argv.slice(2);
214
+ process.exit(topArgv[0] === 'selftest' ? selftest() : 0);
215
+ }
@@ -15,6 +15,7 @@
15
15
  import { execSync } from 'node:child_process';
16
16
  import process from 'node:process';
17
17
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
18
+ import { warnIfStaleCopy } from './stale-copy-warning.mjs';
18
19
  import { PACKAGE_ROSTER } from './package-paths.mjs';
19
20
 
20
21
  // The roster is single-sourced in package-paths.mjs (H3) — the `adia-ui-*-v*`
@@ -60,6 +61,13 @@ function main() {
60
61
  const args = parseArgs(process.argv.slice(2));
61
62
  // Fail-fast guard: refuse to run git against a non-monorepo directory.
62
63
  assertMonorepoRoot(args.repo);
64
+ // Stale-plugin-cache self-check (ticket #3943 item 2), same primitive
65
+ // release-pack.mjs's own top-level check now calls.
66
+ warnIfStaleCopy(
67
+ import.meta.url,
68
+ args.repo,
69
+ 'packages/plugins/adia-ui-forge/skills/package-release/scripts/tag-lockstep.mjs',
70
+ );
63
71
  const tags = buildTagList(args.version);
64
72
 
65
73
  if (args.deleteMode) {
@@ -30,6 +30,7 @@ file, including the depth references those entries cross-link.
30
30
  ## Demos & doc surfaces
31
31
 
32
32
  - [composite-demo-protocol.md](composite-demo-protocol.md) — what discipline governs any `packages/web-modules/**` demo edit, beyond what the `Pattern source:` gate can see?
33
+ - [examples-structure.md](examples-structure.md): what order do a primitive's own `<name>.examples.html` `<h2 variant="section">` blocks follow, and where does `Typography registers` sit relative to `Edge Cases` and the generated API tail?
33
34
  - [canonical-pattern-index.md](canonical-pattern-index.md) — which canonical `.contents.html` files should I survey for a given UI type? Auto-generated; regenerate via `scripts/build-canonical-pattern-index.mjs`.
34
35
  - [trait-pages.md](trait-pages.md) — which sections, in which order, must a `site/pages/traits/<name>/` detail page carry (ADR-0019 template)?
35
36
 
@@ -0,0 +1,52 @@
1
+ # `<name>.examples.html` section order convention
2
+
3
+ Every primitive's own `packages/web-components/components/<name>/<name>.examples.html`
4
+ follows one binding `<h2 variant="section">` order. gh#4159: a merged-registers sweep
5
+ (gh#3843) found the order inconsistent across five files, `alert` had "Typography
6
+ registers" near the top, `code`/`command` had it after the generated API tail, and
7
+ `accordion`/`divider` had it before Edge Cases, with no reference doc anywhere stating
8
+ which shape was correct. `site-docs-authoring`'s own `intent.md` explicitly declines
9
+ `packages/web-components/components/*/*.examples.html` and hands it here; this file is
10
+ that hand-off's landing spot.
11
+
12
+ ## Required order
13
+
14
+ 1. **Variant/usage sections**, one `<h2 variant="section">` per prop value, state, or
15
+ usage recipe the component demonstrates (`inline`, `block with language`, `grouped`,
16
+ …). Author-ordered; no fixed sequence among these.
17
+ 2. **`Combinations`**, sections composed together in context.
18
+ 3. **`Typography registers`**, the three-register (`scale="ui-sm"` / regular /
19
+ `scale="content-md"`) showcase, when the component carries one. Hand-authored, so it
20
+ sits with the other hand-authored sections above, immediately after `Combinations`
21
+ and before `Edge Cases`, never after the generated API tail.
22
+ 4. **`Edge Cases`**, boundary conditions and unusual content.
23
+ 5. **Generated API sections**, in order: `Properties`, `Events`, `Methods` (when
24
+ applicable), `CSS Tokens`, `Slots`, `A2UI`, `Related`.
25
+
26
+ Rationale for `Typography registers` landing before `Edge Cases` rather than after the
27
+ API tail: it is hand-authored prose+markup like `Combinations`/`Edge Cases`, not a
28
+ generated table like `Properties`/`Events`/`CSS Tokens`; grouping it with the other
29
+ hand-authored sections keeps the generated-vs-authored boundary at one place in the file
30
+ instead of two.
31
+
32
+ ## Why this also matters for `.examples.md`
33
+
34
+ `scripts/build/generate-examples-md.mjs` derives each `<name>.examples.md` from the
35
+ `.examples.html` source. Its main loop picks only the first `MAX_FRAGMENTS` (3)
36
+ `data-section` blocks in document order, so a section sitting far down the file (several
37
+ variant sections deep, as most shipped components run) would normally never be selected.
38
+ `Typography registers` is the one named exception: `processComponent()` guarantees its
39
+ inclusion regardless of ordinal position, so this convention is about authoring
40
+ coherence and readability, not about whether the section survives derivation at all.
41
+
42
+ ## Enforcement
43
+
44
+ `generate-examples-md.mjs`'s own `checkSectionOrder()`, run as part of `npm run
45
+ check:examples-md-fresh` (`--verify` mode) and the plain generate run alike, parses
46
+ every `packages/web-components/components/*/*.examples.html`'s ordered `<h2
47
+ variant="section">` list and reds, naming the file and both indices, when `Typography
48
+ registers` sits after `Edge Cases`. Pre-existing violations outside a given PR's own
49
+ lane are grandfathered via `scripts/build/examples-registers-order-baseline.json` (a
50
+ ratchet: printed as an advisory finding, never silently dropped, and never allowed to
51
+ grow) so the gate never regresses unrelated work; shrink that list to zero as each
52
+ file's own section order gets fixed.
@@ -448,17 +448,18 @@ Consumers (CodeMirror layout, canvas redraw, dependent UI) listen on the shell o
448
448
 
449
449
  ### admin cluster (canonical reference)
450
450
 
451
- **[deprecated 2026-09-01, ADR-0098]** `admin-page`/`admin-page-header`/
452
- `admin-page-body` are retired deprecate-then-delete; `admin-scroll` is
453
- renamed wholesale to `page-scroll`. `page-ui[band]` + `page-scroll` are
454
- now the canonical page-chrome pair this cluster still describes
455
- `admin-shell`'s pre-migration internal composition, valid during the
456
- deprecation window.
457
-
458
- - **3 JS-bearing children** — `<admin-shell>` (host coordinator), `<admin-sidebar>` (resize+collapse+persist), `<admin-command>` (Cmd+K palette)
459
- - **7 CSS-only structural children** `<admin-content>`, `<admin-topbar>`, `<admin-statusbar>`, `<admin-scroll>`, `<admin-page>`, `<admin-page-header>`, `<admin-page-body>`
460
- - **CSS bridge** — `packages/web-modules/shell/admin-shell/css/admin-shell.bespoke.css` (~240 LOC)
461
- - **Tests** — `admin-sidebar.test.js` 10/10, `admin-command.test.js` 9/9
451
+ **[deprecated 2026-09-01, ADR-0098]** The inline child enumeration this section
452
+ used to carry is retired. `admin-page`/`admin-page-header`/`admin-page-body`
453
+ were deleted outright (no compat alias); `admin-scroll` was renamed wholesale
454
+ to `page-scroll`, and its own one-release deprecation window has since closed,
455
+ the module deleted (gh#3745). `page-ui[band]` + `page-scroll` are the canonical
456
+ page-chrome pair.
457
+
458
+ For `admin-shell`'s own live composition, the current bespoke children (the
459
+ `@scope`-per-tag CSS convention landed in gh#4103), and the full 13-part
460
+ canonical anatomy, see
461
+ [admin-shell-anatomy.md](../../demo-audit/references/admin-shell-anatomy.md),
462
+ kept in sync with `site/index.html`'s live example rather than restated here.
462
463
 
463
464
  ### chat cluster (replicated pattern)
464
465
 
@@ -102,7 +102,7 @@ tag other than its own name needs the same tag-independent scope check.
102
102
 
103
103
  - `chart.css` data slots reference `--a-data-0..9` (tokens, not raw)
104
104
  - `card.css` mask uses `--a-chrome-light` (mask is opacity-only; value is semantic)
105
- - Drawer/modal scrims use `--a-chrome-backdrop`
105
+ - Drawer/modal scrims use `--a-scrim-dialog` (aliases.css, gh#373: routes to `--a-chrome-scrim-dialog`, 80% black, not `--a-chrome-backdrop`)
106
106
 
107
107
  If you find a raw value elsewhere, either:
108
108
 
@@ -118,7 +118,7 @@ Added in v0.5.0 — use these for UI chrome:
118
118
  - `--a-chrome-border` — subtle hairline borders
119
119
  - `--a-chrome-ring-subtle` — focus rings, outlines
120
120
  - `--a-chrome-shadow-soft` — elevation shadows
121
- - `--a-chrome-backdrop` modal/drawer backdrops
121
+ - `--a-chrome-backdrop` - generic 50% chrome overlay, reserved for non-dialog UI chrome; no current consumers. Modal/drawer backdrops use `--a-scrim-dialog` instead (80% black, gh#373, see `--a-chrome-scrim-dialog` in features.css' CHROME block)
122
122
 
123
123
  ## Data palette
124
124
 
@@ -89,13 +89,26 @@ check) is unaffected either way — it only checks for a slot literally named
89
89
  ## `a2ui.allowedParents:` / `a2ui.allowedChildren:` — composition constraints (SPEC REQ-011, gh#1353)
90
90
 
91
91
  Optional keys inside the `a2ui:` block, alongside `rules:`. Each is a
92
- non-empty list of catalog `component:` names (NOT tags) naming the direct
93
- parents this component may sit under / the direct children it may contain.
94
- The reserved name `Surface` (the A2UI v1.0 implicit root container) is legal
92
+ non-empty list of catalog `component:` names (NOT tags) naming the parents
93
+ this component may sit under / the direct children it may contain. The
94
+ reserved name `Surface` (the A2UI v1.0 implicit root container) is legal
95
95
  only in `allowedParents` and means "may sit at the surface root". **Omitted
96
96
  means unconstrained** — never write an empty list (that would mean "allowed
97
97
  nowhere"; the build refuses it).
98
98
 
99
+ `allowedParents` matches the nearest custom-element ancestor, not the
100
+ immediate DOM parent (gh#3310): the generated lint rule's
101
+ `compositionFindings()` walks up past native (non-hyphenated) wrapper
102
+ elements — `section`, `div`, `td`, `tr`, `tbody`, and any other plain HTML
103
+ tag — until it finds a real catalog component tag or reaches the surface
104
+ root. This mirrors Light DOM's own composition reality (AGENTS.md: CSS
105
+ positions by tag + ancestor + DOM order) — a wrapper interposed for layout
106
+ or semantics (a `<section>` inside a card, a `<td>` in a table body) doesn't
107
+ change a component's logical host. `allowedChildren`, by contrast, still
108
+ matches DIRECT children only — a named-slot child that should be exempt
109
+ from the default-slot list is a separate, open gap (gh#3308), not addressed
110
+ by this semantics change.
111
+
99
112
  ```yaml
100
113
  a2ui:
101
114
  allowedParents:
@@ -112,6 +125,35 @@ children). A parent that adopts items through wrappers (menu.class.js's
112
125
  deliberate descendant query) must NOT constrain — a declared constraint
113
126
  stricter than the source is a defect, not documentation.
114
127
 
128
+ **`allowedChildren` is a default-slot-only check (gh#3308).** A direct
129
+ child carrying ANY `slot=` attribute is a sibling-level named slot, not a
130
+ default-slot member — the lint-side matcher (`compositionFindings`)
131
+ exempts it from `allowedChildren` entirely, regardless of the slot's name.
132
+ This is why menu-ui can declare `allowedChildren: [MenuItem, MenuDivider,
133
+ MenuLabel]` for its default slot while still accepting an arbitrary
134
+ focusable element on `slot="trigger"` ("typically button-ui, but any
135
+ focusable element works") without a false positive. When a named slot
136
+ *should* be constrained too (rare — most named slots exist precisely
137
+ because their content varies), add an `allowedChildrenBySlot:` map
138
+ alongside `allowedChildren:`, keyed by slot name, same catalog-name-list
139
+ shape:
140
+
141
+ ```yaml
142
+ a2ui:
143
+ allowedChildren:
144
+ - MenuItem
145
+ - MenuDivider
146
+ - MenuLabel
147
+ allowedChildrenBySlot:
148
+ trigger: # only if the trigger slot ITSELF needs constraining
149
+ - ButtonUI
150
+ ```
151
+
152
+ A slot with no entry in `allowedChildrenBySlot` (or the key omitted
153
+ entirely) stays unconstrained by design — this is the common case, and
154
+ matches "omitted means unconstrained" for `allowedChildren`/`allowedParents`
155
+ above.
156
+
115
157
  Pipeline: `components.mjs` validates the shape per-yaml, cross-checks every
116
158
  referenced name against the full catalog on a full build, and forwards the
117
159
  lists onto `x-adiaui` → `catalog-a2ui_1_0.json`.
@@ -122,16 +164,71 @@ per gh#2116) across the five opt-out-scoped catalogs (`adia.core.json`,
122
164
  `adia.shells.json`, gh#2211/ADR-0093), where the vendored `@genui/core`
123
165
  validator enforces them (`UNALLOWED_PARENT`/`UNALLOWED_CHILD`). Module-tier
124
166
  yamls (web-modules) now carry a v1.0 sidecar too and land in `adia.shells`.
125
-
126
- Once a component earns a `component.md` (both `screenReader:` and
127
- `behavioral:` authored, gh#2615), a non-empty `allowedParents`/
128
- `allowedChildren` also generates an enforced lint rule (LLD-0016 §C4,
129
- gh#2647): `scripts/build/gen-composition-rules.mjs` emits `scripts/lint/
167
+ `allowedChildrenBySlot` (gh#3308) is NOT part of this catalog/sidecar
168
+ pipeline `components.mjs` never reads it, it never lands in
169
+ `x-adiaui`/`catalog-a2ui_1_0.json`, and the A2UI v1.0 protocol validator
170
+ never enforces it. It exists purely for the lint-side check below.
171
+
172
+ A non-empty `allowedParents`/`allowedChildren`/`allowedChildrenBySlot`
173
+ generates an enforced lint rule (LLD-0016 §C4, gh#2647):
174
+ `scripts/build/gen-composition-rules.mjs` emits `scripts/lint/
130
175
  rules/generated/composition/<name>.mjs`, which flags a markup file where
131
- the tag nests under (or contains) a non-declared tag. Advisory (`warn`)
176
+ the tag nests under (or contains) a non-declared tag `allowedChildren`
177
+ checked against default-slot children only, `allowedChildrenBySlot`
178
+ against the matching named-slot children (see above). Advisory (`warn`)
132
179
  until a corpus-wide rollout promotes it (`npm run build:composition-rules`
133
180
  / `check:composition-rules-fresh`).
134
181
 
182
+ ### `a2ui.noCompositionConstraint:`: the audited "no contract needed" verdict (lld-0029 C2a, PR #3456)
183
+
184
+ The third disposition a yaml can carry. Where `allowedParents` /
185
+ `allowedChildren` declare a containment edge, `noCompositionConstraint`
186
+ records that the authoring-rule audit above was actually run (read
187
+ `<name>.class.js` for `querySelector` / `closest` / `this.children`
188
+ child-tag expectations, then test any parent/child hypothesis against real
189
+ markup in `apps/`, `site/`, `catalog/`, `packages/web-components/patterns/`)
190
+ and found no constraint worth declaring. Its whole purpose is to let a
191
+ reader, or a gate, tell "audited, categorically needs none" apart from
192
+ "never audited". Do not write it as a shortcut: if the audit turns up a
193
+ real constraint, the fix is a genuine `allowedParents` / `allowedChildren`
194
+ addition, not this marker.
195
+
196
+ Shape (`scripts/schemas/component.yaml.schema.json`, `a2ui.noCompositionConstraint`):
197
+ an object with two required keys and nothing else. `reason` is the verdict
198
+ quoted from the disposition table (schema `minLength: 8`); `ticket` is the
199
+ issue whose table recorded it (`^gh#[0-9]+$`). As with every other schema
200
+ constraint (ADR-0057), the shape is IDE-visible only: `compileComponent()`
201
+ never reads this key, so a malformed block does not stop the build.
202
+
203
+ Real example, `packages/web-components/components/badge/badge.yaml`:
204
+
205
+ ```yaml
206
+ a2ui:
207
+ noCompositionConstraint:
208
+ reason: 'Referenced generically from a dozen+ unrelated components, not scoped to one parent; own rule says positioning, not ancestry.'
209
+ ticket: 'gh#3270'
210
+ rules:
211
+ - 'Use for small status/count labels attached to another element (notification counts, status pills, version tags).'
212
+ ```
213
+
214
+ Relationship to the two lists. A yaml carries either the marker or a
215
+ containment list, never both. This is now enforced (gh#3497), not just
216
+ convention: `component.yaml.schema.json` declares the two mutually
217
+ exclusive via the `a2ui` object's own `not` clause, and
218
+ `compileCompositionConstraints()` in `scripts/build/components.mjs` throws
219
+ at build time, naming the file and the offending field, when a yaml
220
+ declares both. `npm run check:components-valid` (part of `npm run
221
+ build:components`) is where that throw surfaces.
222
+
223
+ Pipeline: none. The marker is a yaml-only annotation with no sidecar,
224
+ `x-adiaui`, catalog, or generated-lint-rule forwarding (lld-0029 C2a, by
225
+ design), so a PR that only adds it changes no derived artifact and commits
226
+ no regen. The planned `check:composition-coverage` gate (lld-0029 C3,
227
+ `scripts/verify/check-composition-coverage.mjs`, not landed as of
228
+ 2026-09-04) fails any yaml whose `a2ui` block has none of `allowedParents`,
229
+ `allowedChildren`, or `noCompositionConstraint`, so the marker counts as
230
+ coverage exactly as either list does.
231
+
135
232
  ---
136
233
 
137
234
  ## `examples:` field — a2ui example ids (semantic-id grammar, gh#2492)