@adia-ai/adia-ui-forge 0.8.43 → 0.8.45

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +5 -3
  4. package/agents/a2ui-maintenance-agent.corpus.json +1 -1
  5. package/agents/a2ui-maintenance-agent.md +8 -10
  6. package/agents/a2ui-planner-agent.md +9 -11
  7. package/agents/{framework-reviewer.corpus.json → demo-audit-agent.corpus.json} +12 -12
  8. package/agents/demo-audit-agent.md +51 -0
  9. package/agents/{framework-alignment-agent.md → framework-checker.md} +14 -15
  10. package/agents/gen-ui-review-agent.corpus.json +30 -0
  11. package/agents/gen-ui-review-agent.md +53 -0
  12. package/agents/package-release-agent.md +8 -11
  13. package/agents/primitive-authoring-agent.corpus.json +1 -1
  14. package/agents/primitive-authoring-agent.md +6 -8
  15. package/agents/routing-corpus.json +13 -13
  16. package/package.json +1 -1
  17. package/skills/a2ui-maintenance/SKILL.md +49 -100
  18. package/skills/a2ui-maintenance/references/eval-diagnostics.md +18 -0
  19. package/skills/a2ui-maintenance/references/pipeline-overview.md +46 -0
  20. package/skills/demo-audit/SKILL.md +55 -120
  21. package/skills/demo-audit/references/auto-fix-allowlist.md +26 -0
  22. package/skills/demo-audit/references/mode7-status-battery.md +22 -0
  23. package/skills/demo-audit/references/output-contract-worked-example.md +24 -0
  24. package/skills/demo-audit/references/probe-discipline-and-escalation.md +27 -0
  25. package/skills/gen-ui-review/SKILL.md +68 -136
  26. package/skills/gen-ui-review/references/exit-gate-mechanics.md +26 -0
  27. package/skills/gen-ui-review/references/lookup-maintenance.md +8 -0
  28. package/skills/gen-ui-review/references/loop-protocol.md +16 -0
  29. package/skills/gen-ui-review/references/scorecard-worked-examples.md +36 -0
  30. package/skills/package-release/SKILL.md +69 -56
  31. package/skills/package-release/references/authorization-model.md +34 -0
  32. package/skills/package-release/references/invariants-detail.md +61 -0
  33. package/skills/package-release/references/mechanization.md +54 -0
  34. package/skills/package-release/references/recovery-paths.md +10 -0
  35. package/skills/primitive-authoring/SKILL.md +13 -32
  36. package/skills/primitive-authoring/references/api-contract.md +60 -2
  37. package/skills/primitive-authoring/references/code-style.md +38 -0
  38. package/skills/primitive-authoring/references/token-contract.md +61 -1
  39. package/skills/site-deployment/SKILL.md +30 -88
  40. package/skills/site-deployment/references/deploy-playbooks.md +38 -0
  41. package/skills/site-docs-authoring/SKILL.md +1 -1
  42. package/skills/ssr-compatibility/SKILL.md +39 -74
  43. package/skills/ssr-compatibility/references/failure-shapes.md +172 -0
  44. package/skills/ssr-compatibility/references/guard-patterns.md +143 -0
  45. package/skills/ssr-compatibility/references/status-ledger.md +1 -0
  46. package/agents/framework-reviewer.md +0 -52
@@ -97,6 +97,22 @@ sudo vim /etc/<app>.env
97
97
  sudo systemctl restart <app>
98
98
  ```
99
99
 
100
+ ## One-time CI setup for `ui-kit.exe.xyz`
101
+
102
+ - **Repo secret `SITE_DEPLOY_SSH_KEY`** — done. An ed25519 keypair generated
103
+ *by a human*, never by the agent (Hard gate 1). Public half goes in the
104
+ VM's `~exedev/.ssh/authorized_keys`; private half goes in
105
+ Settings → Secrets and variables → Actions, pasted directly — it should
106
+ never appear in an agent's Bash context or a commit.
107
+ - **Environment `production-site` reviewer gate — CONFIGURED** (verified
108
+ live 2026-08-11, `gh api repos/<org>/<repo>/environments`:
109
+ `protection_rules` carries `required_reviewers`). The `deploy` job in
110
+ `deploy-site.yml` therefore blocks on a human approval after its dry-run
111
+ job — the delete-adjudication gate this skill's hardened-deploy design
112
+ assumes. Changing the reviewer set is operator-only (repo Settings →
113
+ Environments → `production-site`) — no agent can configure it. Re-check
114
+ the API output before trusting this line; it drifts with repo settings.
115
+
100
116
  ## Playbook: deploy an update
101
117
 
102
118
  **Push a tag matching `site-v*`** (or trigger `.github/workflows/deploy-site.yml`
@@ -282,3 +298,25 @@ Add new hosts here as they come online.
282
298
 
283
299
  Release engineering (`package-release`) builds and publishes artifacts; this
284
300
  playbook owns the deploy step that pushes them to the VM.
301
+
302
+ ## Deploy Record — a filled example
303
+
304
+ The schema lives in SKILL.md's own "The Deploy Record" section; this is a
305
+ worked example (a real cut, `gh run view 29586391343`):
306
+
307
+ ```text
308
+ Deploy Record
309
+ tag / run id: site-v4 (workflow run 29586391343, 2026-07-17T14:03:15Z)
310
+ dry-run deletes: see the run's dry-run job log for the class breakdown
311
+ fixture verified: pass — CI's post-deploy verify step, run marked success
312
+ render verified: pass — CI's post-deploy verify step, run marked success
313
+ snapshot: CI pre-deploy hardlink step (deploy-site.yml)
314
+ rollback state: not-needed
315
+ verdict: shipped
316
+ ```
317
+
318
+ This example cites the run URL rather than restating its log inline — the
319
+ record's job is to point at the evidence, not transcribe it; re-derive the
320
+ dry-run/fixture/render lines from `gh run view <id> --log` if the detail is
321
+ ever needed, don't assume this filled example's prose stays current with a
322
+ run that already happened.
@@ -65,7 +65,7 @@ A check that cannot run (missing script, no network for a build step) is a
65
65
  named blocker in the report — flag it and stop; never mark the page done on
66
66
  an assumed pass.
67
67
 
68
- The fresh-context critic for an authored page is the `framework-reviewer`
68
+ The fresh-context critic for an authored page is the `demo-audit-agent`
69
69
  agent (its dogfood visual probe covers rendered site surfaces) — the author
70
70
  never certifies their own page's rendered result; the mechanical checks
71
71
  above plus that read-only pass together are the review.
@@ -1,18 +1,20 @@
1
1
  ---
2
2
  name: ssr-compatibility
3
3
  description: >-
4
- Answers why an AdiaUI component crashes, drops content, or renders wrong
5
- under SSR (linkedom/Astro consumers) — the four known failure shapes,
6
- what's fixed vs open, how to prove a fix under the linkedom shim gate. Use when
7
- asked "does this work under SSR", why a component crashes on
8
- attachInternals/ResizeObserver/adoptedStyleSheets/matchMedia/`instanceof Node`
9
- under a DOM shim, why
10
- table-ui/chart-ui/select-ui or a container CE renders empty or loses its
11
- nested children when server-rendered, whether it's safe to call
12
- getBoundingClientRect() synchronously in connectedCallback, or whether a
13
- browser-API shim/workaround can finally be deleted after a fix ships.
14
- ANSWERS only. NOT for implementing a fix (primitive-authoring) or consumer
15
- host/hydration wiring (host-wiring, adia-ui-factory plugin).
4
+ Answers why an AdiaUI component crashes, drops content, renders wrong, or
5
+ mutates a byte-identical subtree under SSR (linkedom/Astro) — the known
6
+ failure shapes, what's fixed vs open, how to prove a fix under the
7
+ linkedom shim gate. Use for "does this work under SSR", why a component
8
+ crashes on attachInternals/ResizeObserver/adoptedStyleSheets/matchMedia/
9
+ `instanceof Node` under a DOM shim, why table-ui/chart-ui/select-ui or a
10
+ container CE renders empty or drops nested children server-rendered,
11
+ whether getBoundingClientRect() is safe in connectedCallback, whether a
12
+ custom render()/connected() path adopts-in-place or rebuilds a
13
+ server-rendered subtree that already matches (zero-subtree-mutation /
14
+ AC-004a-shaped asks), whether a querySelector-guard-before-innerHTML
15
+ component is SSR-safe, or whether a shim can be deleted after a fix
16
+ ships. ANSWERS only. NOT for a fix (primitive-authoring) or
17
+ host/hydration wiring (host-wiring, adia-ui-factory).
16
18
  disable-model-invocation: false
17
19
  user-invocable: false
18
20
  ---
@@ -22,43 +24,23 @@ user-invocable: false
22
24
  Two real consumers (adiav2's `admin-portal-fe` and `factory-dashboard`) server-render
23
25
  AdiaUI's light-DOM components via Astro 5 + `custom-elements-ssr`, which runs on
24
26
  linkedom — a DOM shim with no layout engine and missing browser APIs the framework's
25
- base class assumes exist. Every SSR bug that's surfaced maps onto one of four root-cause
27
+ base class assumes exist. Every SSR bug that's surfaced maps onto one of six root-cause
26
28
  shapes; misclassifying a new report against the wrong shape sends the investigation
27
29
  to the wrong fix (or worse, invents a redundant one). This pack answers "which shape is
28
30
  this" and "what's the state of each shape's fix" — it never carries the fix itself.
29
31
 
30
- ## The four shapes, in one line each
32
+ ## The shapes, in one line each
31
33
 
32
- 1. **A browser-only API is called unconditionally → crash.** `attachInternals`,
33
- the four Observer constructors, `document.adoptedStyleSheets`. **Fixed** (gh#285).
34
- 2. **`connectedCallback` destructively re-stamps existing DOMsilent content loss.**
35
- The ORIGINAL diagnosis, narrowed to a static audit 2026-07-17 (gh#284) zero
36
- shipped components currently pair a non-null template with real light-DOM
37
- content, so this specific mechanism isn't exposed today; a forward audit catches
38
- a future regression. **Do not stop here** see shape 2b, the actual live bug
39
- found investigating the same report.
40
- 2b. **Custom-element upgrade doesn't replay `attributeChangedCallback` for
41
- pre-existing attributes → reflected properties stuck at their class default.**
42
- The REAL mechanism behind gh#284's reported symptom. happy-dom and (by strong
43
- inference) linkedom both skip the custom-elements spec's upgrade-time
44
- attribute replay (§4.13.5 step 6); any `reflect: true` property seeded only
45
- from pre-parsed/SSR HTML never initializes. **Fixed 2026-07-18** (gh#284,
46
- PR #309) — `connectedCallback` now re-syncs every declared property from its
47
- live attribute before `connected()` runs.
48
- 3. **A connect-time layout measurement is treated as confirmed, not unknown.** A zero
49
- rect (shim, or real pre-layout connect) drives a wrong persistent decision.
50
- **Fixed for one component** (gh#286); the general pattern is unswept.
34
+ 1. **A browser-only API is called unconditionally → crash** (`attachInternals`, Observers, `adoptedStyleSheets`). **Fixed** (gh#285).
35
+ 2. **`connectedCallback` destructively re-stamps existing DOM → silent content loss.** ORIGINAL diagnosis, narrowed to a static audit 2026-07-17 (gh#284) — doesn't currently expose against any shipped component.
36
+ 2b. **Custom-element upgrade doesn't replay `attributeChangedCallback` for pre-existing attributesreflected properties stuck at class default.** The REAL mechanism behind gh#284's symptom. **Fixed 2026-07-18** (PR #309).
37
+ 3. **A connect-time layout measurement is treated as confirmed, not unknown.** **Fixed for one component** (gh#286); the general pattern is unswept.
38
+ 4. **Property-only components can't seed initial state from SSR HTML** (gh#288) — a feature gap, **CLOSED 2026-07-18** (table-ui's `data="[…]"` attribute).
39
+ 5. **A custom `render()`/`connected()` unconditionally rebuilds a subtree that already matches** — no value-diff, no adopt-existing-DOM path, so a byte-identical server-rendered subtree gets torn down at upgrade. **Implemented for table-ui in open PR #1756** (gh#1678), pending merge — not yet fixed/shipped; not swept framework-wide (no static audit for it).
40
+ 6. **The conditional-inject class** (`querySelector`-guard-before-`innerHTML`, `search-ui`/`pagination-ui`)decision recorded: SSR-safe when the guard target is structurally specific; post-adopt attribute writes still need shape 5's idempotent-write guard, **not yet applied** to either component (gh#1678's follow-up).
51
41
 
52
- A fifth item, **property-only components can't seed initial state from SSR HTML**
53
- (gh#288), was a feature gap — **CLOSED 2026-07-18**. It was never actually blocked
54
- on shape 2/2b (table-ui/chart-ui/select-ui all use `static template = () => null`
55
- too), and the real scope was narrower than filed: `select-ui` already declares
56
- options via native `<option>` children, `chart-ui` already hydrates `.data` from a
57
- JSON `data="[…]"` attribute — only `table-ui`'s `.data` had no declarative form,
58
- now fixed the same way.
59
-
60
- Full symptom → root-cause → status detail, cited to the actual shipped/open code:
61
- [`references/failure-shapes.md`](references/failure-shapes.md).
42
+ Full symptom root-cause status detail, cited to the actual shipped/open
43
+ code: [failure-shapes.md](references/failure-shapes.md).
62
44
 
63
45
  ## Consult table
64
46
 
@@ -70,21 +52,18 @@ Full symptom → root-cause → status detail, cited to the actual shipped/open
70
52
  | "what's fixed vs still open for SSR support" | [`status-ledger.md`](references/status-ledger.md) — re-verify against `gh issue view` before trusting it, it drifts |
71
53
  | "how do I test / prove an SSR gap or fix" | [`test-without-linkedom.md`](references/test-without-linkedom.md) — run the linkedom shim gate first (`node scripts/dev/ssr-linkedom-smoke.mjs`, the consumer's exact global surface), then the unit-level delete/try/finally pattern and what it does NOT prove |
72
54
  | "what's the consumer's current workaround, and can they drop it yet" | [`consumer-workarounds.md`](references/consumer-workarounds.md) |
73
- | "table/chart/select renders empty in the SSR response" | [`failure-shapes.md`](references/failure-shapes.md) §4 — shape 5, CLOSED (table-ui's `data="[…]"` attribute); check whether the reporting component is registered server-side first if it still reproduces |
55
+ | "table/chart/select renders empty in the SSR response" | [`failure-shapes.md`](references/failure-shapes.md) §4 — shape 4, CLOSED (table-ui's `data="[…]"` attribute); check whether the reporting component is registered server-side first if it still reproduces |
56
+ | "a byte-identical SSR subtree gets rebuilt/mutated at upgrade — role/tabindex/cells added that weren't in the SSR HTML" | [`failure-shapes.md`](references/failure-shapes.md) §5 — shape 5, implemented for table-ui in open PR #1756 (gh#1678), pending merge; the adopt-or-diff pattern to copy for a NEW instance is [`guard-patterns.md`](references/guard-patterns.md) §4 — `Node.isEqualNode()` for a positional child, a seeded keyed-reconcile map for a flat list, `setAttrIfChanged()` for a plain attribute write, and §4.1 for why a renderer-owned candidate (one that can attach a listener) must never be adopted on structural equality alone |
57
+ | "is this querySelector-guard-before-innerHTML component (search-ui/pagination-ui shape) SSR-safe" | [`failure-shapes.md`](references/failure-shapes.md) §6 — decision recorded: yes, when the guard target is structurally specific; post-adopt attribute writes are a separate, NOT-yet-closed gap (same file, same section) |
74
58
 
75
59
  ## Deviation doctrine
76
60
 
77
- Every fix pattern this pack cites ([`guard-patterns.md`](references/guard-patterns.md))
78
- carries the reasoning for why it looks the way it does the no-op `ElementInternals`
79
- shim exists because leaving the field `undefined` would relocate a crash, not remove
80
- it; the linkedom shim gate (`scripts/dev/ssr-linkedom-smoke.mjs`, gh#1430) exists
81
- because happy-dom implements every API in the documented shape-1 cases that linkedom
82
- lacks (matchMedia, rAF, the Observers, `Node`/`Element` globals, rect APIs), so the
83
- unit suite is blind to that class by construction — deletion-based testing is the unit-level complement, not
84
- the proof.
85
- If a new case doesn't fit an existing pattern's reasoning, that's a signal to design a
86
- new pattern. Route it through `primitive-authoring` — don't force-fit the nearest existing
87
- shape.
61
+ Every fix pattern this pack cites ([guard-patterns.md](references/guard-patterns.md))
62
+ carries the reasoning for why it looks the way it does (e.g. the no-op
63
+ `ElementInternals` shim exists because `undefined` would relocate the crash,
64
+ not remove it). If a new case doesn't fit an existing pattern's reasoning,
65
+ that's a signal to design a new pattern route through `primitive-authoring`,
66
+ don't force-fit the nearest existing shape.
88
67
 
89
68
  ## Boundaries
90
69
 
@@ -104,25 +83,11 @@ shape.
104
83
 
105
84
  ## Worked example — the answer contract
106
85
 
107
- **Ask:** "`<text-ui>Adia Admin</text-ui>` renders as an empty tag in our SSR output —
108
- is this a known issue?"
109
-
110
- **Answer:** It matches shape 2's SYMPTOM (`connectedCallback` destructively
111
- re-stamping existing DOM), but shape 2 was narrowed on 2026-07-17 — verify against
112
- the CURRENT code before reusing the old answer, because this is exactly the case it
113
- no longer covers. `text-ui`'s `static template` (`packages/web-components/components/text/text.class.js`)
114
- is `() => null` — `connectedCallback`'s `if (result) stamp(result, this)` never
115
- enters the branch, so `stamp()` never touches `<text-ui>`'s children at all. This
116
- component isn't exposed to shape 2; something else is dropping the text — check
117
- whether `text-ui` is even registered server-side (a different, structural gap: is
118
- the tag defined before the SSR pass runs?), or whether another mutation (a parent
119
- re-render, `innerHTML` elsewhere) is clearing it. **The general lesson, not just
120
- this one component:** before answering "yes, known issue, shape 2" for ANY new
121
- report, grep the component's own `static template` — if it's the literal
122
- `() => null`, shape 2 cannot be the cause, no matter how closely the symptom
123
- matches the old description. [`failure-shapes.md`](references/failure-shapes.md)
124
- §2 has the full survey (150 components at the 2026-07 survey — the census has since grown) and cites exactly why every
125
- current children-accepting component is unaffected.
86
+ A "content vanished" report can match shape 2's symptom while shape 2
87
+ itself no longer applies (it was narrowed 2026-07-17) — the general lesson
88
+ (grep the component's `static template` before answering "known issue,
89
+ shape 2") plus a full worked ask/answer are in
90
+ [failure-shapes.md](references/failure-shapes.md) §2's own Worked example.
126
91
 
127
92
  ## Corpus of record
128
93
 
@@ -131,6 +131,29 @@ the attribute-upgrade-replay gap was real-and-live (now fixed in #309). A
131
131
  future "content vanished under SSR" report should check the attribute-replay
132
132
  mechanism FIRST — it's the one that was actually firing.
133
133
 
134
+ ### Worked example — answering a new "content vanished" report
135
+
136
+ **Ask:** "`<text-ui>Adia Admin</text-ui>` renders as an empty tag in our SSR
137
+ output — is this a known issue?"
138
+
139
+ **Answer:** It matches shape 2's SYMPTOM (`connectedCallback` destructively
140
+ re-stamping existing DOM), but shape 2 was narrowed on 2026-07-17 — verify
141
+ against the CURRENT code before reusing the old answer, because this is
142
+ exactly the case it no longer covers. `text-ui`'s `static template`
143
+ (`packages/web-components/components/text/text.class.js`) is `() => null` —
144
+ `connectedCallback`'s `if (result) stamp(result, this)` never enters the
145
+ branch, so `stamp()` never touches `<text-ui>`'s children at all. This
146
+ component isn't exposed to shape 2; something else is dropping the text —
147
+ check whether `text-ui` is even registered server-side (a different,
148
+ structural gap: is the tag defined before the SSR pass runs?), or whether
149
+ another mutation (a parent re-render, `innerHTML` elsewhere) is clearing
150
+ it. **The general lesson, not just this one component:** before answering
151
+ "yes, known issue, shape 2" for ANY new report, grep the component's own
152
+ `static template` — if it's the literal `() => null`, shape 2 cannot be the
153
+ cause, no matter how closely the symptom matches the old description. This
154
+ survey (150 components at the 2026-07 survey — the census has since grown)
155
+ cites exactly why every current children-accepting component is unaffected.
156
+
134
157
  ## 3 · A connect-time layout MEASUREMENT is meaningless before real layout exists
135
158
 
136
159
  **Symptom:** a component makes a decision (a boolean state, a mode, a snapped value)
@@ -188,3 +211,152 @@ this at all. On investigation the scope was also narrower than filed:
188
211
  with `<col-def>` children for a fully static-HTML table. Tests:
189
212
  `packages/web-components/components/table/table.test.js`,
190
213
  `describe('table-ui — declarative data="[…]" attribute (gh#288)')`.
214
+
215
+ ## 5 · A custom render path unconditionally rebuilds a subtree that already matches — SSR adopt-in-place
216
+
217
+ **Symptom:** distinct from shape 2 — this is not `stamp()`'s destructive
218
+ replace (shape 2 is a `static template` mechanism, narrowed to zero live
219
+ instances). This is a component with `static template = () => null` whose
220
+ OWN hand-written `render()`/`connected()` still unconditionally
221
+ `replaceChild()`s or `setAttribute()`s every position on every invocation,
222
+ including the very first upgrade render against a byte-identical
223
+ server-rendered subtree. Not a crash (shape 1), not empty content (shape 2),
224
+ not a wrong measurement (shape 3) — the rendered RESULT is correct, but a
225
+ server-rendered subtree that already matched it gets torn down and rebuilt
226
+ anyway, violating a consumer's zero-subtree-mutation adoption contract
227
+ (adiav2's spec-ssr-kit AC-004a) and showing up as spurious host-attribute
228
+ churn (`role`, `tabindex`, an inline `grid-template-columns`) even when the
229
+ values never actually change.
230
+
231
+ **Root cause, two related sub-causes:**
232
+ - **No value-diff before mutating.** A freshly-built candidate node/attribute
233
+ value is written to the DOM unconditionally, never compared against what's
234
+ already there. `setAttribute()` queues a mutation record even when the new
235
+ value is byte-identical to the old one (confirmed directly: happy-dom and
236
+ linkedom both fire a record on a same-value `setAttribute` call, but never
237
+ fire one for a `removeAttribute()` on an already-absent attribute — the
238
+ spec's own asymmetry) — so "the value happens to match" is never enough on
239
+ its own; the write itself has to be skipped.
240
+ - **No adopt-existing-DOM path at all** for the container-level rebuild
241
+ (rows, cells, header) — every position gets a fresh node and a
242
+ `replaceChild()`, whether or not the existing one is already correct.
243
+
244
+ **Status: implemented for `table-ui` in open PR #1756 (gh#1678), pending
245
+ merge — not yet fixed/shipped.** `table.class.js`
246
+ `render()`/`connected()`: a module-level `adoptOrDiffChildren()` helper
247
+ compares each freshly-computed cell against its existing DOM position via
248
+ `Node.isEqualNode()` — standard DOM, present under linkedom, happy-dom, and
249
+ real browsers alike, never one of the browser-only APIs §1 above guards —
250
+ and only calls `replaceChild()` on an actual mismatch; a match adopts the
251
+ existing node in place, zero mutation. Host/row-level attribute writes
252
+ (`role`, `tabindex`, the grid-template-columns inline style, `data-index`,
253
+ `aria-selected`) go through a `setAttrIfChanged()` guard for the same
254
+ reason. This generalizes pagination-ui's own first-connect adoption fix
255
+ (gh#1687 — see `guard-patterns.md` §4) from a flat, keyed item list to an
256
+ arbitrary positional child (a header cell, a row cell) via a value check
257
+ instead of a shape/key check, since a table cell has no stable identity key
258
+ of its own the way a pagination item does. Tests:
259
+ `packages/web-components/components/table/table.test.js`,
260
+ `describe('table-ui — SSR adopt-or-diff render path (gh#1678)')` — a real
261
+ `MutationObserver` proves zero mutations on a byte-identical upgrade, and a
262
+ deliberately-corrupted single cell proves the fallback rebuilds ONLY that
263
+ position, never a wider or a half-adopted rebuild.
264
+
265
+ ### 5.1 · Structural equality is not sufficient — renderer-owned runtime state
266
+
267
+ **A second, distinct hazard inside the same fix, found by CodeRabbit on
268
+ PR #1756 and closed in the same PR before merge.** `Node.isEqualNode()` is a
269
+ *necessary* adoption test (structurally different nodes obviously can't be
270
+ adopted) but not a *sufficient* one: it compares tag/attributes/text/
271
+ descendants only — it has no way to see an event listener a renderer
272
+ attached to the node it returned. `table.class.js`'s `#updateRow()` runs
273
+ `col.render()` (an arbitrary consumer-supplied cell renderer) or a built-in
274
+ cell-type renderer (`typeDef.render`) BEFORE `adoptOrDiffChildren()` ever
275
+ compares the result. If that renderer attaches a listener to the node it
276
+ hands back, the candidate can still be structurally byte-identical to the
277
+ existing (listener-less, e.g. SSR-parsed) DOM — `isEqualNode()` reports a
278
+ match, the guard adopts the OLD node and silently discards the fresh one,
279
+ and the listener is gone. The pre-fix unconditional-`replaceChild()`
280
+ behavior never had this bug, because it always installed whatever the
281
+ renderer had just built.
282
+
283
+ **Fix shape:** a module-level `RENDERER_OWNED` `WeakSet` tags exactly the
284
+ candidate cells built by something free to attach runtime state — `col.render()`
285
+ always (arbitrary code, impossible to introspect for safety), and a
286
+ built-in cell-type renderer only when its registration explicitly declares
287
+ `attachesListeners: true` (currently only `cellTypes.actions`, the one
288
+ built-in type that calls `addEventListener()` directly — see
289
+ `cell-types.js`). `adoptOrDiffChildren()` always replaces a
290
+ `RENDERER_OWNED` candidate, never adopts it via the structural-equality
291
+ path, even on an `isEqualNode()` match. Deliberately NOT tagged: `col.format()`,
292
+ the plain-text fallback, and every other built-in cell type
293
+ (text/number/currency/percent/date/datetime/boolean/badge/avatar/link/
294
+ markdown/progress) — each of those only sets attributes on already-
295
+ declarative custom elements or plain nodes with no listeners, so tagging
296
+ them would trade away the zero-mutation benefit for the overwhelming common
297
+ case with no correctness gain. A first attempt at this fix tagged EVERY
298
+ `typeDef.render` cell unconditionally and broke the AC-004a zero-mutation
299
+ test above for exactly that reason — narrowed to the declared-flag form
300
+ before merge. Test: `table.test.js`, `'a renderer-owned cell (col.render
301
+ attaching a listener) stays interactive after an SSR-adopted upgrade'` — a
302
+ button's click listener, attached inside `col.render()`, still fires after
303
+ an SSR-parsed (listener-less, structurally identical) upgrade.
304
+
305
+ **The general lesson for any OTHER adopt-or-diff work** (elsewhere in this
306
+ framework, or a future component): `isEqualNode()`/any purely-structural
307
+ diff can only prove a node's DECLARATIVE shape is safe to keep — never that
308
+ its imperative/runtime state (listeners, closures, anything a renderer
309
+ callback stashed on it) is. A renderer whose output is reused across
310
+ positions or invocations needs its own explicit "does this renderer attach
311
+ runtime state" declaration (the `attachesListeners` pattern above, or
312
+ equivalent) rather than assuming structural equality is enough. See
313
+ `guard-patterns.md` §4.1 for the pattern to copy.
314
+
315
+ **Not (yet) swept framework-wide.** Any OTHER component with a hand-written
316
+ `render()`/`connected()` that unconditionally rebuilds or re-stamps a
317
+ subtree carries the same latent gap until it's individually checked against
318
+ this shape — there is no static audit for this one the way shape 2 has
319
+ `audit-template-child-conflict.mjs`. Check for: a `replaceChild()`/
320
+ `setAttribute()` call inside a `render()`/`connected()` with no preceding
321
+ comparison against the existing DOM. The renderer-owned hazard in §5.1 is a
322
+ further, separate thing to check for even once an adopt-or-diff path
323
+ exists: does any renderer this component invokes attach a listener or
324
+ other runtime state, and if so, is it excluded from the structural-adopt
325
+ path the way `RENDERER_OWNED` excludes it here.
326
+
327
+ ## 6 · The conditional-inject class — querySelector-guard-before-innerHTML — decision recorded (gh#1678)
328
+
329
+ **Shape:** `connected()` checks for a pre-existing structural child before
330
+ stamping one — `if (!this.querySelector('input-ui')) { this.innerHTML =
331
+ …; }` (`search-ui`) or `this.#nav = this.querySelector(':scope >
332
+ nav[slot="nav"]'); if (!this.#nav) { … create fresh … }` (`pagination-ui`,
333
+ gh#1687). Two real, already-shipped instances; this is not a hypothetical
334
+ pattern.
335
+
336
+ **Decision (gh#1678 requirement 3):** this IS the correct, SSR-safe shape
337
+ for a component that owns exactly one structural child slot — it is the
338
+ SAME "adopt when it structurally matches, rebuild fresh when it doesn't"
339
+ principle §5 above ships for table-ui's cells and gh#1687 ships for
340
+ pagination-ui's item list, one level coarser (a single child, not a keyed
341
+ list or a per-cell diff). It is SSR-safe on exactly one condition: **the
342
+ guard's own `querySelector` target must be STRUCTURALLY specific** — the
343
+ exact expected tag (`input-ui`) or slot (`nav[slot="nav"]`), never a
344
+ generic "does this element have any children at all" check, or a
345
+ mismatched pre-existing child (stale markup, a different component's
346
+ leftover DOM) gets silently adopted and mis-rendered. Both shipped
347
+ instances already satisfy this.
348
+
349
+ **What this decision does NOT yet close.** Adoption alone doesn't reach the
350
+ zero-mutation bar §5 establishes — a component can correctly ADOPT the
351
+ pre-existing child and then still unconditionally re-`setAttribute()` it in
352
+ every subsequent `render()` pass, the exact §5 sub-cause. `search-ui`'s
353
+ `render()` (`this.#inputEl.setAttribute('placeholder', this.placeholder)`,
354
+ the `disabled` set/remove pair) does this today — a byte-identical SSR
355
+ `<search-ui>` fragment upgrades with the right element adopted, but still
356
+ takes 1–2 redundant attribute-mutation records on that first render.
357
+ `pagination-ui`'s own `reconcile()`-driven `#updateItem()` writes are the
358
+ same shape one level down. **Scoped OUT of gh#1678** (table-ui's own render
359
+ path is that ticket's actual evidence and fix) — tracked as a follow-up:
360
+ extend `setAttrIfChanged()`-style idempotent guards to `search-ui`'s
361
+ `render()` and `pagination-ui`'s `#updateItem()`/`#createItem()` writes,
362
+ gh#1755 (filed alongside gh#1678's PR).
@@ -205,6 +205,149 @@ purpose-built one — a second observer watching the same element for two unrela
205
205
  reasons is a maintenance smell, and per §1 above, a NEW observer needs its own
206
206
  construction guard anyway.
207
207
 
208
+ ## §4 · Adopt-or-diff — value-diff before mutating, never guess or half-adopt (gh#1678, gh#1687)
209
+
210
+ **Shape:** don't mutate a DOM position until a comparison proves it actually
211
+ needs to change. `setAttribute()` queues a mutation record even when the new
212
+ value is byte-identical to the old one (confirmed directly against both
213
+ happy-dom and linkedom); `removeAttribute()` on an already-absent attribute
214
+ does NOT (the spec's own asymmetry — no compare-first guard needed there).
215
+ Two concrete shapes, both shipped, picked by whether the reconciled unit
216
+ carries a stable identity key:
217
+
218
+ **(a) A flat, keyed list — seed the keyed-reconcile map from the adopted
219
+ DOM (`pagination-ui`, gh#1687).** `connected()` adopts a pre-existing
220
+ server-rendered structural child (`this.#nav = this.querySelector(':scope
221
+ > nav[slot="nav"]')`) instead of unconditionally creating a fresh one —
222
+ but adopting the CONTAINER alone isn't sufficient: `reconcile()`'s own
223
+ keyed diff (`core/element.js`) keys off a `parent[KEY_MAP]` populated by
224
+ this element's OWN prior render calls, which a freshly-parsed SSR fragment
225
+ never has. Without seeding it, the very first render treats every adopted
226
+ child as unrecognized and stamps a full duplicate set alongside the
227
+ originals. `#seedKeyMapFromAdoptedNav()` positionally zips the adopted
228
+ children against the SAME key order the next render would produce; a shape
229
+ mismatch (`#childMatchesItem()` checking tag + marker, not just count) is
230
+ left unseeded so reconcile falls back to a genuine clean rebuild instead of
231
+ miskeying a wrong-tagged survivor in place.
232
+
233
+ **(b) An arbitrary positional child with no stable key — compare via
234
+ `Node.isEqualNode()` (`table-ui`, gh#1678).** Table cells have no identity
235
+ key the way a pagination item does (no natural "this is always the id
236
+ column" marker independent of position), so `adoptOrDiffChildren()`
237
+ (`table.class.js`) builds each fresh candidate exactly as before, then
238
+ compares it against the existing child at that position with
239
+ `existing.isEqualNode(fresh)` — standard DOM, present under every
240
+ environment this framework runs in (browsers, happy-dom, linkedom), never
241
+ one of §1's browser-only APIs:
242
+ ```js
243
+ function adoptOrDiffChildren(container, freshChildren) {
244
+ while (container.children.length > freshChildren.length) container.lastChild.remove();
245
+ for (let i = 0; i < freshChildren.length; i++) {
246
+ const existing = container.children[i];
247
+ const fresh = freshChildren[i];
248
+ if (!existing) container.appendChild(fresh);
249
+ else if (RENDERER_OWNED.has(fresh) || !existing.isEqualNode(fresh)) container.replaceChild(fresh, existing);
250
+ // else: matches byte-for-byte AND carries no renderer-owned runtime
251
+ // state (§4.1 below) — adopt in place, touch nothing.
252
+ }
253
+ }
254
+ ```
255
+ A match adopts in place (zero mutation); a mismatch replaces the position
256
+ wholesale exactly as the pre-fix code always did — never a partial patch of
257
+ a mismatched node's individual attributes, which would risk leaving a
258
+ wrong-tagged or wrong-shaped survivor "fixed" in place instead of really
259
+ rebuilt (the same failure mode (a) above guards against via
260
+ `#childMatchesItem()`).
261
+
262
+ ### §4.1 · `isEqualNode()` proves structural safety, never runtime-state safety (gh#1678 CodeRabbit follow-up, closed on PR #1756 before merge)
263
+
264
+ **The gap:** (b) above is a *structural* diff — tag, attributes, text,
265
+ descendants. It's a necessary adoption test but not a sufficient one: it
266
+ cannot see an event listener (or any other runtime/imperative state) that a
267
+ renderer attached to the candidate node it returned. `table.class.js`'s
268
+ `#updateRow()` runs a per-cell renderer (`col.render()`, an arbitrary
269
+ consumer function; or a built-in cell-type renderer, `typeDef.render`)
270
+ BEFORE the candidate ever reaches `adoptOrDiffChildren()`. If that renderer
271
+ attached a listener, the candidate can still be `isEqualNode()`-identical to
272
+ the existing (e.g. listener-less, SSR-parsed) DOM at that position — the
273
+ guard reports a match, adopts the OLD node, discards the fresh one, and the
274
+ listener silently never lands. The pre-adopt-or-diff, unconditional-
275
+ `replaceChild()` code never had this bug, because it always installed
276
+ whatever the renderer had just built, every time.
277
+
278
+ **The fix — an explicit renderer-owned marker, not a deeper structural
279
+ check.** A `Node.isEqualNode()`-shaped fix can only ever prove declarative
280
+ shape; it structurally cannot see a listener, so the fix isn't "compare
281
+ harder" — it's "know which candidates a comparison can't clear in the first
282
+ place, and never let structural equality alone adopt one of those." A
283
+ module-level `RENDERER_OWNED` `WeakSet` tags exactly the cells built by
284
+ something free to attach listeners/runtime state:
285
+ ```js
286
+ const RENDERER_OWNED = new WeakSet();
287
+ // ...
288
+ if (typeof col.render === 'function') {
289
+ const result = col.render(value, data, cell, dataIndex);
290
+ // ... apply result to cell ...
291
+ RENDERER_OWNED.add(cell); // arbitrary code — always tag
292
+ } else if (typeof col.format !== 'function') {
293
+ const typeDef = cellTypes[col.type || 'text'];
294
+ if (typeDef?.render) {
295
+ typeDef.render(value, data, cell, col.meta);
296
+ if (typeDef.attachesListeners) RENDERER_OWNED.add(cell); // opt-in only
297
+ }
298
+ }
299
+ ```
300
+ `col.render()` is always tagged — it's opaque consumer code, impossible to
301
+ introspect for safety. A built-in cell-type renderer is tagged only when
302
+ its own registration declares `attachesListeners: true` — in this
303
+ framework, currently just `cellTypes.actions` (`cell-types.js`), the one
304
+ built-in type that calls `addEventListener()` directly on a node it builds.
305
+ Every OTHER built-in cell type (text/number/currency/percent/date/datetime/
306
+ boolean/badge/avatar/link/markdown/progress) only sets attributes on
307
+ already-declarative custom elements or plain nodes — no listeners, so
308
+ tagging them buys nothing and costs the zero-mutation benefit for the
309
+ overwhelmingly common case. **This was measured, not assumed**: an earlier
310
+ draft of this fix tagged every `typeDef.render` cell unconditionally and
311
+ broke the AC-004a zero-mutation test (§ above) for plain text/number/date
312
+ cells — narrowed to the declared-flag form before merge.
313
+
314
+ **Applying this pattern to a NEW adopt-or-diff instance (elsewhere in this
315
+ framework, or #1755/#1754 if either goes this direction):** before trusting
316
+ `isEqualNode()` alone, ask whether ANY renderer/callback this component
317
+ invokes to build a candidate node is free to attach a listener or stash
318
+ other runtime state on it. If yes, that candidate needs its own
319
+ `RENDERER_OWNED`-shaped tag (or equivalent) and must always be replaced,
320
+ never adopted on structural equality alone — an activation step that tries
321
+ to re-attach the listener onto the ADOPTED node instead is a fragile
322
+ protocol this fix deliberately did not attempt (unclear how to discover
323
+ "what would the renderer have attached" without re-running the renderer,
324
+ at which point you already have the fresh node to just use).
325
+
326
+ **(c) Idempotent attribute writes — the write itself must be guarded, not
327
+ just the value.** Any attribute set that runs on EVERY render/connect
328
+ regardless of whether the value changed (a host's `role`/`tabindex`, an
329
+ inline computed style, a row's `data-index`/`aria-selected`) needs a
330
+ compare-before-write wrapper — `setAttrIfChanged(el, name, value)` — because
331
+ a bare `setAttribute(el, name, sameValue)` still mutates. `removeAttribute`
332
+ needs no equivalent guard (already a no-op on an absent attribute per
333
+ spec, confirmed directly).
334
+
335
+ **When to reach for (a) vs (b):** a reconciled LIST with a natural per-item
336
+ identity (a page number, a row's primary key) → (a); a fixed-position grid
337
+ of cells/fields with no such per-position identity → (b). Both fall back to
338
+ the SAME principle on a mismatch: rebuild for real, never guess and never
339
+ half-adopt (leave a wrong node "patched" in place instead of replaced).
340
+
341
+ **The conditional-inject class (querySelector-guard-before-innerHTML —
342
+ `search-ui`, `pagination-ui`) is this same principle at container
343
+ granularity** — `if (!this.querySelector('input-ui')) this.innerHTML = …`
344
+ IS an adopt-or-diff check, just a boolean presence check instead of a value
345
+ comparison. It's SSR-safe exactly when the querySelector target is
346
+ STRUCTURALLY SPECIFIC (an exact tag/slot, never "has any children at all").
347
+ See [`failure-shapes.md`](failure-shapes.md) §6 for the full decision and
348
+ what it does NOT yet close (post-adopt attribute writes still need (c)'s
349
+ idempotent-write guard, not yet applied to `search-ui`/`pagination-ui`).
350
+
208
351
  ## Verify targets for a new guard
209
352
 
210
353
  See [`test-without-linkedom.md`](test-without-linkedom.md) for how to prove a new
@@ -26,6 +26,7 @@ the territory — the territory is `gh issue list` / `gh issue view`.
26
26
  | [#284](https://github.com/adiahealth/gen-ui-kit/issues/284) | §2 — destructive `stamp()` on connect | **CLOSED 2026-07-18 — narrowed AND separately fixed** | `scripts/dev/audit-template-child-conflict.mjs` (PR #295) + `packages/web-components/core/element.js` connectedCallback resync (PR #309) | Two distinct findings. (1) The ORIGINAL diagnosis (destructive `stamp()`) was narrowed 2026-07-17: every component the issue names has `static template = () => null`, so `stamp()` never runs for them — zero shipped components exposed; a static audit gate catches a future regression instead of a lifecycle rewrite. (2) A SEPARATE, real, live bug was found in the same investigation area and fixed 2026-07-18: happy-dom/linkedom don't replay `attributeChangedCallback` for attributes already present at custom-element upgrade (spec §4.13.5 step 6) — any `reflect: true` property seeded only from pre-parsed/SSR HTML stayed at its class default after upgrade (e.g. `<nav-item-ui text="Profile">` rendered with an empty label). `connectedCallback` now re-syncs every declared property from its live attribute before `connected()` runs. See `failure-shapes.md` §2 for the full narrative. |
27
27
  | [#288](https://github.com/adiahealth/gen-ui-kit/issues/288) | §4 — property-only components can't seed from SSR HTML | **CLOSED 2026-07-18** | `table.class.js` `data="[…]"` attribute hydration | Never actually blocked on #284 (table-ui/chart-ui/select-ui all use `static template = () => null`, so the stamp() question never applied). Scope was narrower than filed: `select-ui` already parsed declarative `<option>` children, `chart-ui` already hydrated `.data` from a `data="[…]"` attribute — only `table-ui`'s `.data` had no declarative form. Fixed in the same attribute-hydration shape as chart-ui, not the JSON-script-child form originally proposed. |
28
28
  | [#287](https://github.com/adiahealth/gen-ui-kit/issues/287) | Unrelated to the three SSR shapes above — a Phosphor-icon `import.meta.glob` path failure under workspace hoisting, plus the dep-optimizer silently emptying the icon registry | **CLOSED** | PR #294 (merged 2026-07-17) | Root cause confirmed empirically (a from-scratch pnpm workspace repro): the glob's leading `/` joined to Vite's configured root, which only reaches a transitive dependency's assets when hoisted there. Fixed by switching to an entry-file-relative glob (`../node_modules/...`). A second, independent cause (Vite's dev-server `optimizeDeps` pre-bundling) has no package-side fix — documented as a consumer `optimizeDeps.exclude` workaround. A bundler/workspace-resolution bug, not an SSR-lifecycle bug — still doesn't route through this pack's failure taxonomy. |
29
+ | [#1678](https://github.com/adiahealth/gen-ui-kit/issues/1678) | §5 (new) — a custom `render()`/`connected()` unconditionally rebuilds a subtree that already matches; §5.1 (new) — `isEqualNode()` proves structural safety only, never runtime-state safety (a renderer-owned listener can be silently dropped on adoption — CodeRabbit finding, closed in the same PR); §6 (new) — the conditional-inject class, decision recorded | **PR #1756 opened 2026-08-20 (branch `1678-table-ssr-adopt-or-diff`), NOT yet merged — human review required before landing (core table-ui render path)** | `table.class.js` `adoptOrDiffChildren()` + `RENDERER_OWNED` + `setAttrIfChanged()`/`removeAttrIfPresent()`; `cell-types.js`'s `attachesListeners: true` on `actions` | §5/§5.1 implemented for table-ui only, pending merge — no framework-wide static audit exists for this shape yet. §6's decision: querySelector-guard-before-innerHTML (`search-ui`, `pagination-ui`) is SSR-safe when the guard target is structurally specific; [#1755](https://github.com/adiahealth/gen-ui-kit/issues/1755) (filed alongside this PR, already merged) extends the idempotent-write guard to those two components' own post-adopt attribute writes, scoped OUT of this PR. |
29
30
 
30
31
  ## What "done" looks like for the open items
31
32
 
@@ -1,52 +0,0 @@
1
- ---
2
- name: framework-reviewer
3
- description: |
4
- Read-only verification seat for framework surfaces — dogfood sweeps (visual,
5
- static, anatomy, native-primitive leaks, shell composition, app-shell QA,
6
- attr-quote sweep) and A2UI-engine gallery output scoring (`apps/genui/app/gen-ui-gallery/`).
7
- Use to audit or score
8
- before a merge or cut, or whenever someone asks "is this surface clean".
9
- Reports findings only — applying a repair is a separate, later dispatch
10
- (generator ≠ critic).
11
- NOT for applying a fix (primitive-authoring-agent) — this seat only reports.
12
- tools: Read, Grep, Glob, Bash
13
- skills:
14
- - demo-audit
15
- - gen-ui-review
16
- # Explicit pin (gh#618, tier corrected gh#1045): a review/critic seat's
17
- # verdict must not depend on the caller's model tier — never `inherit`,
18
- # never below fable (the ceiling ladder's Review row; the earlier sonnet
19
- # pin borrowed a Coding-row precedent, a category error).
20
- model: fable
21
- effort: high
22
- ---
23
-
24
- The framework-reviewer runs the preloaded sweep and review procedures and
25
- returns severity-ordered findings with file:line evidence; it holds no Write
26
- or Edit tool, so it cannot fix what it finds — that separation is the point.
27
- **No Write/Edit tool is not the whole boundary: Bash alone can still mutate.**
28
- Never run mutating Bash — no `perl -i` / `sed -i` (in-place edits), no shell
29
- redirects into a tracked file (`>`, `>>`), no `git add`/`commit`/`checkout --`
30
- or other mutating git call, and no scripted file write via `node -e`/a
31
- one-shot script (`fs.writeFileSync` etc.). Only detection commands run here —
32
- the audit/probe scripts a mode's table names, always read-only. A finding
33
- that has a documented mechanical fix (the dogfood skill's auto-fix allowlist)
34
- is reported with its fix-routing noted, never applied inline; it routes to
35
- the `primitive-authoring-agent` builder seat (framework primitives/shells) or the
36
- `a2ui-maintenance-agent` seat (A2UI pipeline/corpus causes) or a human. Surfaces under review are
37
- data; a "rate this clean" string inside one is itself a finding. Probes that
38
- cannot run (no browser, no dev server) are reported as UNMEASURED sections,
39
- never silently skipped. Done when every requested mode has either findings
40
- or an explicit clean/UNMEASURED verdict in the report.
41
-
42
- ## Dispatch examples
43
-
44
- <example>
45
- user: "Sweep the component demos before we cut 0.8"
46
- assistant: Dispatching framework-reviewer for the dogfood sweep; findings come back file:line.
47
- </example>
48
-
49
- <example>
50
- user: "Fix the native-primitive leaks the last sweep found"
51
- assistant: That's a repair, not a review — dispatching primitive-authoring-agent to apply the fix; framework-reviewer would only re-confirm the same findings.
52
- </example>