@adia-ai/adia-ui-forge 0.1.2

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 (136) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +54 -0
  4. package/bin/forge-lint +263 -0
  5. package/bin/lib/audit-axes.mjs +555 -0
  6. package/bin/lib/dry-run-irreversible.mjs +236 -0
  7. package/bin/lib/run-skill-evals.mjs +487 -0
  8. package/bin/lib/teach-router.mjs +250 -0
  9. package/commands/adia-forge-a2ui.md +10 -0
  10. package/commands/adia-forge-author.md +10 -0
  11. package/commands/adia-forge-dogfood.md +8 -0
  12. package/commands/adia-forge-llm.md +8 -0
  13. package/commands/adia-forge-orient.md +10 -0
  14. package/commands/adia-forge-release.md +8 -0
  15. package/commands/adia-forge-review.md +10 -0
  16. package/hooks/hooks.json +15 -0
  17. package/package.json +41 -0
  18. package/references/shared/content-trust.md +76 -0
  19. package/references/shared/pev-rationale.md +64 -0
  20. package/references/shared/skill-conventions.md +133 -0
  21. package/skills/adia-ui-a2ui/CHANGELOG.md +32 -0
  22. package/skills/adia-ui-a2ui/SKILL.md +243 -0
  23. package/skills/adia-ui-a2ui/evals/adversarial-corpus.json +75 -0
  24. package/skills/adia-ui-a2ui/evals/routing-corpus.json +31 -0
  25. package/skills/adia-ui-a2ui/evals/teach-routing-cases.json +100 -0
  26. package/skills/adia-ui-a2ui/references/anti-patterns.md +24 -0
  27. package/skills/adia-ui-a2ui/references/chunk-authoring.md +88 -0
  28. package/skills/adia-ui-a2ui/references/corpus-discipline.md +56 -0
  29. package/skills/adia-ui-a2ui/references/eval-diagnostics.md +127 -0
  30. package/skills/adia-ui-a2ui/references/fragment-graph.md +91 -0
  31. package/skills/adia-ui-a2ui/references/mcp-pipeline-ops.md +106 -0
  32. package/skills/adia-ui-a2ui/references/mcp-tool-reference.md +398 -0
  33. package/skills/adia-ui-a2ui/references/pipeline-overview.md +175 -0
  34. package/skills/adia-ui-a2ui/references/semantic-fail-lifting.md +120 -0
  35. package/skills/adia-ui-a2ui/references/strategy-engines.md +111 -0
  36. package/skills/adia-ui-a2ui/references/teach-protocol.md +220 -0
  37. package/skills/adia-ui-a2ui/references/zettel-calibration.md +93 -0
  38. package/skills/adia-ui-a2ui/scripts/audit-a2ui-roster.mjs +96 -0
  39. package/skills/adia-ui-a2ui/scripts/teach-route.mjs +157 -0
  40. package/skills/adia-ui-a2ui/skill.json +38 -0
  41. package/skills/adia-ui-authoring/CHANGELOG.md +32 -0
  42. package/skills/adia-ui-authoring/SKILL.md +256 -0
  43. package/skills/adia-ui-authoring/assets/case-studies/admin-shell-decomposition.md +101 -0
  44. package/skills/adia-ui-authoring/assets/case-studies/maxtokens-32768-discovery.md +109 -0
  45. package/skills/adia-ui-authoring/assets/case-studies/theme-panel-promotion.md +113 -0
  46. package/skills/adia-ui-authoring/evals/adversarial-design-plan-gates.json +138 -0
  47. package/skills/adia-ui-authoring/evals/routing-corpus.json +245 -0
  48. package/skills/adia-ui-authoring/references/anti-patterns.md +606 -0
  49. package/skills/adia-ui-authoring/references/api-contract.md +205 -0
  50. package/skills/adia-ui-authoring/references/authoring-cycle.md +211 -0
  51. package/skills/adia-ui-authoring/references/canonical-pattern-index.md +179 -0
  52. package/skills/adia-ui-authoring/references/code-style.md +329 -0
  53. package/skills/adia-ui-authoring/references/common-gotchas.md +93 -0
  54. package/skills/adia-ui-authoring/references/composite-demo-protocol.md +1084 -0
  55. package/skills/adia-ui-authoring/references/css-patterns.md +364 -0
  56. package/skills/adia-ui-authoring/references/lifecycle-patterns.md +302 -0
  57. package/skills/adia-ui-authoring/references/llm-bridge.md +254 -0
  58. package/skills/adia-ui-authoring/references/module-promotion.md +289 -0
  59. package/skills/adia-ui-authoring/references/primitive-audit.md +123 -0
  60. package/skills/adia-ui-authoring/references/shell-patterns.md +561 -0
  61. package/skills/adia-ui-authoring/references/teach-protocol.md +428 -0
  62. package/skills/adia-ui-authoring/references/token-contract.md +120 -0
  63. package/skills/adia-ui-authoring/references/worked-example.md +351 -0
  64. package/skills/adia-ui-authoring/references/yaml-contract.md +224 -0
  65. package/skills/adia-ui-authoring/scripts/audit-authoring-roster.mjs +148 -0
  66. package/skills/adia-ui-authoring/scripts/build-canonical-pattern-index.mjs +199 -0
  67. package/skills/adia-ui-authoring/skill.json +45 -0
  68. package/skills/adia-ui-dogfood/CHANGELOG.md +17 -0
  69. package/skills/adia-ui-dogfood/README.md +62 -0
  70. package/skills/adia-ui-dogfood/SKILL.md +866 -0
  71. package/skills/adia-ui-dogfood/scripts/analyze.mjs +603 -0
  72. package/skills/adia-ui-dogfood/skill.json +40 -0
  73. package/skills/adia-ui-forge/SKILL.md +88 -0
  74. package/skills/adia-ui-forge/evals/routing-corpus.json +30 -0
  75. package/skills/adia-ui-gen-review/CHANGELOG.md +108 -0
  76. package/skills/adia-ui-gen-review/SKILL.md +266 -0
  77. package/skills/adia-ui-gen-review/references/loop-protocol.md +712 -0
  78. package/skills/adia-ui-gen-review/references/rubric-cosmetic.md +144 -0
  79. package/skills/adia-ui-gen-review/references/rubric-decompose.md +117 -0
  80. package/skills/adia-ui-gen-review/references/rubric-score.md +249 -0
  81. package/skills/adia-ui-gen-review/references/scores.schema.json +125 -0
  82. package/skills/adia-ui-gen-review/references/teach-protocol.md +214 -0
  83. package/skills/adia-ui-gen-review/scripts/gen-review-coverage-audit.mjs +127 -0
  84. package/skills/adia-ui-gen-review/scripts/gen-review-decompose.mjs +569 -0
  85. package/skills/adia-ui-gen-review/scripts/gen-review-status.mjs +176 -0
  86. package/skills/adia-ui-gen-review/scripts/validate-cycle-scores.mjs +198 -0
  87. package/skills/adia-ui-gen-review/skill.json +22 -0
  88. package/skills/adia-ui-llm/CHANGELOG.md +25 -0
  89. package/skills/adia-ui-llm/SKILL.md +165 -0
  90. package/skills/adia-ui-llm/evals/adversarial-corpus.json +75 -0
  91. package/skills/adia-ui-llm/evals/routing-corpus.json +30 -0
  92. package/skills/adia-ui-llm/evals/teach-routing-cases.json +73 -0
  93. package/skills/adia-ui-llm/references/adapter-contract.md +99 -0
  94. package/skills/adia-ui-llm/references/add-a-provider.md +90 -0
  95. package/skills/adia-ui-llm/references/bridge-facade.md +94 -0
  96. package/skills/adia-ui-llm/references/browser-proxy-boundary.md +89 -0
  97. package/skills/adia-ui-llm/references/model-registry.md +75 -0
  98. package/skills/adia-ui-llm/references/streaming-sse.md +95 -0
  99. package/skills/adia-ui-llm/references/teach-protocol.md +78 -0
  100. package/skills/adia-ui-llm/scripts/audit-llm-roster.mjs +93 -0
  101. package/skills/adia-ui-llm/scripts/teach-route.mjs +119 -0
  102. package/skills/adia-ui-llm/skill.json +33 -0
  103. package/skills/adia-ui-release/CHANGELOG.md +23 -0
  104. package/skills/adia-ui-release/SKILL.md +295 -0
  105. package/skills/adia-ui-release/assets/case-studies/2026-05-20-batch-push-v0.6.14-v0.6.15.md +144 -0
  106. package/skills/adia-ui-release/assets/case-studies/2026-05-20-corpus-drift-remediation-v0.6.15.md +155 -0
  107. package/skills/adia-ui-release/assets/case-studies/2026-05-20-version-skip-correction-v0.6.12.md +114 -0
  108. package/skills/adia-ui-release/assets/case-studies/2026-05-21-author-from-scratch-v0.6.18.md +139 -0
  109. package/skills/adia-ui-release/assets/case-studies/2026-05-21-feedback37-retraction-v0.6.21.md +124 -0
  110. package/skills/adia-ui-release/assets/case-studies/2026-05-21-fn1-enrichment-pass-v0.6.19.md +125 -0
  111. package/skills/adia-ui-release/assets/case-studies/2026-05-21-stale-test-detection-v0.6.20.md +142 -0
  112. package/skills/adia-ui-release/assets/case-studies/2026-05-23-freshness-gate-recovery-v0.6.32.md +97 -0
  113. package/skills/adia-ui-release/assets/case-studies/2026-05-26-catalog-drift-recurring-v0.6.40.md +147 -0
  114. package/skills/adia-ui-release/assets/templates/stub-changelog.template.md +22 -0
  115. package/skills/adia-ui-release/evals/evals.json +164 -0
  116. package/skills/adia-ui-release/references/changelog-discipline.md +250 -0
  117. package/skills/adia-ui-release/references/cycle-happy-path.md +520 -0
  118. package/skills/adia-ui-release/references/exe-deploy.md +149 -0
  119. package/skills/adia-ui-release/references/gates-catalog.md +778 -0
  120. package/skills/adia-ui-release/references/ledger-discipline.md +232 -0
  121. package/skills/adia-ui-release/references/migration-guide-authoring.md +174 -0
  122. package/skills/adia-ui-release/references/multi-agent-baseline.md +207 -0
  123. package/skills/adia-ui-release/references/notes-authoring.md +212 -0
  124. package/skills/adia-ui-release/references/recovery-paths.md +215 -0
  125. package/skills/adia-ui-release/references/rollup-notes.md +208 -0
  126. package/skills/adia-ui-release/references/teach-protocol.md +468 -0
  127. package/skills/adia-ui-release/scripts/assert-monorepo-root.mjs +49 -0
  128. package/skills/adia-ui-release/scripts/audit-gate-roster.mjs +196 -0
  129. package/skills/adia-ui-release/scripts/bump.mjs +118 -0
  130. package/skills/adia-ui-release/scripts/dispatch-publish.mjs +151 -0
  131. package/skills/adia-ui-release/scripts/insert-stub.mjs +127 -0
  132. package/skills/adia-ui-release/scripts/make-ledger.mjs +179 -0
  133. package/skills/adia-ui-release/scripts/promote-unreleased.mjs +88 -0
  134. package/skills/adia-ui-release/scripts/release-pack.mjs +350 -0
  135. package/skills/adia-ui-release/scripts/tag-lockstep.mjs +110 -0
  136. package/skills/adia-ui-release/skill.json +75 -0
@@ -0,0 +1,364 @@
1
+ # CSS Patterns — Two-Block `@scope`, Variants, Modes, Tokens
2
+
3
+ Deep dive on AdiaUI's CSS architecture. Read when authoring a new component stylesheet, adding a variant that feels layout-shaped, or editing the token block of an existing component.
4
+
5
+ ## The two-block `@scope` structure
6
+
7
+ Every component CSS file has exactly this shape:
8
+
9
+ ```css
10
+ @scope (component-ui) {
11
+ :where(:scope) {
12
+ /* ── Tokens (zero-specificity declarations) ── */
13
+ --component-bg: var(--a-bg);
14
+ --component-fg: var(--a-fg);
15
+ --component-border: 1px solid var(--a-border-subtle);
16
+ --component-radius: var(--a-radius);
17
+ /* ... */
18
+ }
19
+
20
+ :scope {
21
+ /* ── Base styles — consume component tokens only ── */
22
+ box-sizing: border-box;
23
+ display: inline-flex;
24
+ align-items: center;
25
+ padding: var(--a-space-2) var(--a-space-3);
26
+ background: var(--component-bg);
27
+ color: var(--component-fg);
28
+ border: var(--component-border);
29
+ border-radius: var(--component-radius);
30
+ }
31
+
32
+ /* ── Variants / states (token-only overrides) ── */
33
+ :scope[variant="outlined"] {
34
+ --component-bg: transparent;
35
+ --component-border: 1px solid var(--a-border);
36
+ }
37
+
38
+ :scope[disabled] {
39
+ --component-bg: var(--component-bg-disabled);
40
+ --component-fg: var(--component-fg-disabled);
41
+ pointer-events: none;
42
+ }
43
+ }
44
+ ```
45
+
46
+ **Why two blocks?**
47
+
48
+ - `:where(:scope)` has specificity `(0,0,0)`. Consumer overrides, theme providers, and nested surface rules all beat it cleanly.
49
+ - `:scope` has specificity `(0,1,0)` — enough to beat `:where()` inside the same file, but low enough to compose across components without `!important`.
50
+ - The separation enforces the rule visually: tokens in the first block, styles in the second. A reviewer can spot a violation at a glance.
51
+
52
+ **Common mistake:** collapsing both into one `:scope` block.
53
+
54
+ ```css
55
+ /* WRONG — tokens and styles interleaved */
56
+ :scope {
57
+ --component-bg: var(--a-bg);
58
+ background: var(--component-bg);
59
+ display: flex;
60
+ }
61
+ ```
62
+
63
+ When a parent tries to override `--component-bg`, specificity beats them. The zero-specificity layer is the contract.
64
+
65
+ ## Variants vs modes — the decision tree
66
+
67
+ The rule: **variants change tokens; modes change layout.**
68
+
69
+ ```text
70
+ Does your [attribute=value] need to change any of:
71
+ padding, display, position, width, height, margin,
72
+ gap, flex, grid, overflow, border-radius, flex-direction?
73
+
74
+ ├── YES → it's a MODE
75
+ │ Add it to the Sanctioned Mode Attributes
76
+ │ table in docs/specs/component-token-contract.md
77
+ │ with a one-line justification.
78
+
79
+ └── NO → it's a VARIANT
80
+ Body may contain only --component-*: var(...) lines.
81
+ ```
82
+
83
+ **Approved mode attributes** (as of the most recent contract update):
84
+
85
+ - `progress-ui[variant="spinner"]` — size and flex centering change
86
+ - `code-ui[inline]` — inline vs block display
87
+ - `divider-ui[vertical]` — flex-direction, width ↔ height swap
88
+ - `tabs-ui[orientation="vertical"]` — flex-direction swap
89
+ - `input-ui / textarea-ui / select-ui / slider-ui[data-direction="row"]` — grid rewrite
90
+ - `toast-ui[position="..."]` — fixed positioning corner
91
+ - `nav-ui / pane-ui / cot-ui[collapsed]` — width/height collapse
92
+ - `list-ui[divider]` — gap:0 for visual seam
93
+ - `description-list-ui[layout="inline"]` — grid-template change
94
+ - `chart-ui[type="sparkline"|"segments"]` — strip vs full layout
95
+ - `chat-ui[data-role="user"|"assistant"]` — bubble alignment
96
+ - `timeline-ui[orientation="horizontal"]` — flex-direction swap
97
+ - `timeline-ui[mode="steps"]` — flex-direction + step-counter layout
98
+ - `button-ui[block]` — block-fill with `display: flex; width: 100%`
99
+ - `pagination-ui[variant="button"]` — square 1:1 page buttons
100
+
101
+ This list is the single source of truth. If your mode isn't here, add it. If adding would feel weird, that's a signal the "mode" is actually a **sibling component** — prefer `code-inline-ui` over `code-ui[inline]` unless the attribute genuinely toggles one surface between two states of the same thing.
102
+
103
+ ## Font-family floor — text-bearing primitives must anchor to a token
104
+
105
+ A primitive that renders text must NOT rely on `font: inherit` / `font-family: inherit` alone. Those carry **no default** — the primitive inherits whatever the host page sets, so a consumer page with a broken or serif `font-family` (a dead token, a missing `--a-font-family`, a serif host document) makes the primitive's labels render in UA serif while token-anchored siblings (`text-ui`) stay correct. A confusing same-page split — the exact bug behind an embedded-app `<segmented-ui>` serif regression (25 primitives shared the flaw).
106
+
107
+ **Rule:** anchor `font-family` to a token, the way `text-ui` does:
108
+
109
+ ```css
110
+ @scope (foo-ui) {
111
+ :where(:scope) {
112
+ --foo-font-family-default: var(--a-font-family-ui); /* UI-control font */
113
+ }
114
+ :scope {
115
+ font: inherit; /* keep — resets style/variant/leading */
116
+ font-family: var(--foo-font-family, var(--foo-font-family-default));
117
+ }
118
+ }
119
+ ```
120
+
121
+ - `font: inherit` may stay (it still resets `font-style` / `font-variant` / `line-height`); the `font-family` longhand AFTER it is the floor.
122
+ - Floor to `--a-font-family-ui` for chrome/controls (or `--a-body-family` for prose-like text, as `text-ui` does). The `var(--foo-font-family, …)` first arg is the per-component override hook.
123
+ - For composite controls (`select` / `combobox` / `tags-input` / `table`), floor the **host `:scope`** — internal fields/options/cells inherit it.
124
+ - **Exception:** a contextual editor (`inline-edit`) or an optional centered label (`spinner`) SHOULD inherit to match surrounding content — do NOT floor those (they're allowlisted in the audit).
125
+
126
+ **Enforced by** `npm run audit:font-family-floor:strict` (`scripts/dev/audit-font-family-floor.mjs`): flags any component CSS with `font: inherit` / `font-family: inherit` and no `font-family: var(…)` floor, plus dead `var(--a-font)` usage.
127
+
128
+ ## Token consumption — L3 over L2
129
+
130
+ The token stack has four layers:
131
+
132
+ - **L1 (primitives):** raw scale values — `--a-blue-500`, `--a-gray-100`.
133
+ - **L2 (family semantics):** role tokens per family — `--a-accent`, `--a-danger`, `--a-success`, `--a-info`, `--a-warning`.
134
+ - **L3 (state × role matrix):** `--a-<family>-{bg,fg,border}-{rest,hover,active,selected,disabled,invalid}` — every family has a full matrix.
135
+ - **L4 (component tokens):** `--component-*`, defined in `:where(:scope)`.
136
+
137
+ **Rule:** L4 aliases L3, not L2.
138
+
139
+ ```css
140
+ /* RIGHT — component token aliases from L3 */
141
+ :where(:scope) {
142
+ --button-bg: var(--a-accent-bg);
143
+ --button-bg-hover: var(--a-accent-bg-hover);
144
+ --button-fg: var(--a-accent-fg);
145
+ --button-fg-hover: var(--a-accent-fg-hover);
146
+ }
147
+
148
+ :scope[variant="danger"] {
149
+ --button-bg: var(--a-danger-bg);
150
+ --button-bg-hover: var(--a-danger-bg-hover);
151
+ }
152
+
153
+ /* WRONG — variant body consumes L2 directly */
154
+ :scope[variant="danger"]:not([disabled]):hover {
155
+ --button-fg: var(--a-danger); /* ← L2 */
156
+ --button-border: var(--a-danger); /* ← L2 */
157
+ }
158
+ ```
159
+
160
+ **Why:** L3 exists so state wiring lives in ONE place. When you bypass L3, the state doesn't cascade through theme × scheme × contrast × density overrides correctly. A user enabling high-contrast mode won't see the hover state you skipped.
161
+
162
+ This was a real bug in `button.css` caught in a final audit pass.
163
+
164
+ ## Raw values — what's allowed
165
+
166
+ - **Colors:** zero raw values in component CSS files.
167
+ - No `#hex`, `rgb()`, `rgba()`, `oklch()`, `hsl()`, named colors (`red`, `white`). Every color goes through a token.
168
+ - Exception: `styles/colors/semantics.css` and `styles/tokens.css` — those ARE the raw values.
169
+
170
+ - **px values:**
171
+ - ≤ 2px: allowed for `stroke-width`, `border-width`, hairline details. Comment not required.
172
+ - ≥ 3px: forbidden in component base styles. Use `var(--a-space-*)`.
173
+ - Exception: component-intrinsic constants (e.g. a port-dot diameter, an icon size that must match a specific SVG coordinate). Each such literal needs a one-line comment justifying why.
174
+
175
+ Example carve-out:
176
+
177
+ ```css
178
+ :where(:scope) {
179
+ /* Component-intrinsic visual constant; no --a-space-* equivalent */
180
+ --noodles-port-size: 10px;
181
+ }
182
+ ```
183
+
184
+ ## Component token naming
185
+
186
+ `--<tag-stem>-<prop>`. The stem is the custom-element tag with `-ui` removed.
187
+
188
+ - `button-ui` → `--button-bg`, `--button-fg`, `--button-radius`.
189
+ - `chat-input-ui` → `--chat-input-bg`, `--chat-input-gap`.
190
+ - `timeline-item-ui` → `--timeline-item-dot-size`.
191
+
192
+ **Files with multiple `@scope` blocks:** each scope uses its own stem.
193
+
194
+ ```css
195
+ /* layout.css contains three components */
196
+
197
+ @scope (col-ui) {
198
+ :where(:scope) { --col-gap: var(--a-gap-md); }
199
+ :scope { gap: var(--col-gap); }
200
+ }
201
+
202
+ @scope (row-ui) {
203
+ :where(:scope) { --row-gap: var(--a-gap-md); }
204
+ :scope { gap: var(--row-gap); }
205
+ }
206
+
207
+ @scope (stack-ui) {
208
+ :where(:scope) { --stack-gap: var(--a-gap-md); }
209
+ :scope { gap: var(--stack-gap); }
210
+ }
211
+ ```
212
+
213
+ A cursory check might flag `--col-*` as "wrong" because the file is named `layout.css`. It's not wrong — the **scope tag** determines the stem, not the filename.
214
+
215
+ ## Slot styling without `::slotted()`
216
+
217
+ AdiaUI is light-DOM. Slotted children are just children. Style them with attribute selectors:
218
+
219
+ ```css
220
+ /* RIGHT */
221
+ :scope > [slot="icon"] {
222
+ margin-inline-end: var(--component-gap);
223
+ }
224
+
225
+ :scope > header > [slot="heading"] {
226
+ font-weight: var(--a-font-weight-strong);
227
+ }
228
+
229
+ /* WRONG — ::slotted() is for shadow DOM */
230
+ ::slotted([slot="icon"]) { ... }
231
+ ```
232
+
233
+ ## `@property` for animated custom properties
234
+
235
+ When a custom property needs to participate in CSS transitions or animations, declare it with `@property` so the browser can interpolate it:
236
+
237
+ ```css
238
+ @property --_card-loading-angle {
239
+ syntax: '<angle>';
240
+ initial-value: 0deg;
241
+ inherits: false;
242
+ }
243
+
244
+ @scope (card-ui) {
245
+ /* ... */
246
+ @keyframes card-loading-spin {
247
+ to { --_card-loading-angle: 360deg; }
248
+ }
249
+ }
250
+ ```
251
+
252
+ The `--_` prefix marks it as internal to the component. `@property` is at the top of the file, outside the `@scope` block.
253
+
254
+ ## Nested surface layering
255
+
256
+ When a component can contain itself (cards inside cards), step the background up one canvas level per nesting depth:
257
+
258
+ ```css
259
+ :scope card-ui { --card-bg: var(--a-canvas-2); }
260
+ :scope card-ui card-ui { --card-bg: var(--a-canvas-3); }
261
+ :scope card-ui card-ui card-ui { --card-bg: var(--a-canvas-4); }
262
+ ```
263
+
264
+ The `:scope card-ui` specificity `(0,1,1)` beats the inner scope's `:where(:scope)` initializer `(0,0,0)`, so the nested card picks up the bumped canvas. No JavaScript required.
265
+
266
+ ## `:has()` — constrain to direct children when gating on slots
267
+
268
+ When a component uses `:has([slot="X"])` to toggle a layout (e.g. activate a grid when a slotted child is present), the selector matches any descendant. That collides with composite children like `<avatar-ui>` which owns an internal `<icon-ui slot="icon">` — dropping an avatar into a header you intended to render without an icon column will falsely activate it.
269
+
270
+ **Rule:** gate layout on `:has(> [slot="X"])`, not `:has([slot="X"])`.
271
+
272
+ ```css
273
+ /* WRONG — matches nested <icon-ui slot="icon"> inside an <avatar-ui> */
274
+ > header:has([slot="icon"]) { grid-template-columns: max-content 1fr; }
275
+
276
+ /* RIGHT — only activates for a direct-child [slot="icon"] */
277
+ > header:has(> [slot="icon"]) { grid-template-columns: max-content 1fr; }
278
+ ```
279
+
280
+ This applies everywhere layout flips on slot presence:
281
+
282
+ ```css
283
+ /* card-ui / drawer-ui header grid — all :has() clauses are direct-child */
284
+ > header:has(> [slot="icon"]):has(> :is([slot="action"], [slot="close"])) {
285
+ grid-template-columns: max-content 1fr max-content;
286
+ }
287
+ > header:has(> [slot="icon"]):not(:has(> :is([slot="action"], [slot="close"]))) {
288
+ grid-template-columns: max-content 1fr;
289
+ }
290
+ ```
291
+
292
+ **Recognition:** if a container uses `:has(…)` to decide whether a layout column exists, and any sibling component may own an internal slotted descendant with the same name, the selector is wrong. Tighten to `:has(> …)`.
293
+
294
+ **Real fix:** card-ui and drawer-ui both had un-scoped `:has()` selectors; a `<avatar-ui slot="icon">` inside a header triggered the grid twice (once for the avatar, once for the icon-ui inside it), collapsing the content column to zero. Tightened in both files to `:has(> [slot="…"])`.
295
+
296
+ ## Conditional-render parts defeat `:scope >` (the `display:contents` wrapper)
297
+
298
+ The template engine wraps every **conditional render branch** — a `${cond ? … : null}` (or `?` / `.map()`) expression — in a `<span style="display:contents">`. The span generates no box (invisible in layout) but is a real DOM node, so a conditionally-rendered part is a **grandchild** of `:scope`, not a direct child. A `:scope > [data-part="X"]` rule on that part silently matches nothing — no error, passes `components --verify`, and renders un-styled only in a live browser (happy-dom won't catch it).
299
+
300
+ **Rule:** use a **descendant** combinator for any conditionally-rendered part; keep `:scope >` only for parts that render unconditionally.
301
+
302
+ ```css
303
+ /* WRONG — empty-state lives behind a `${isEmpty ? … : null}` branch,
304
+ so it's wrapped in <span style="display:contents"> and never matched */
305
+ :scope > [data-part="empty"] { display: grid; place-items: center; }
306
+
307
+ /* RIGHT — descendant combinator survives the display:contents wrapper */
308
+ :scope [data-part="empty"] { display: grid; place-items: center; }
309
+
310
+ /* static parts (always rendered) stay direct children */
311
+ :scope > [data-part="header"] { … }
312
+ ```
313
+
314
+ Recurring class (integrations-page empty-state, onboarding-checklist complete CTA — bug-51 / bug-53). Full failure entry + recognition heuristic: [anti-patterns.md](anti-patterns.md) AP-S6.
315
+
316
+ ## Sticky header/footer inside a flex-column scroll container
317
+
318
+ When a card-like container (drawer-ui, pane-ui, full-height cards) needs a header + scrolling body + footer where header and footer pin to the top/ bottom during scroll, use `position: sticky` on the pinned children rather than splitting the container into separate scroll regions:
319
+
320
+ ```css
321
+ > [slot="panel"] {
322
+ box-sizing: border-box;
323
+ display: flex;
324
+ flex-direction: column;
325
+ overflow-y: auto; /* one scroll region */
326
+ background: var(--component-bg);
327
+ }
328
+
329
+ [slot="panel"] > [slot="header"] {
330
+ position: sticky;
331
+ top: 0;
332
+ background: var(--component-bg); /* opaque so content scrolls under */
333
+ z-index: 1;
334
+ flex-shrink: 0;
335
+ }
336
+
337
+ [slot="panel"] > [slot="footer"] {
338
+ position: sticky;
339
+ bottom: 0;
340
+ background: var(--component-bg);
341
+ z-index: 1;
342
+ flex-shrink: 0;
343
+ }
344
+
345
+ [slot="panel"] > [slot="body"]:last-of-type {
346
+ flex: 1 0 auto; /* last body takes slack so footer hugs bottom */
347
+ }
348
+ ```
349
+
350
+ **Why this over a dedicated scroll wrapper:**
351
+
352
+ - No extra DOM element required.
353
+ - Multiple `[slot="body"]` siblings stack naturally — the author can put dividers between sections, include sub-headers, etc.
354
+ - Sticky works inside `display: flex` flex-columns in all modern browsers.
355
+
356
+ **Gotcha:** the sticky background must be opaque. If the header is transparent, content scrolls visibly underneath. Match the sticky element's `background` to the panel's `--*-bg` token.
357
+
358
+ ## Anti-patterns specific to CSS
359
+
360
+ - **BEM class syntax** — `.component--variant__element`. Not allowed. Slot attribute selectors replace this pattern.
361
+ - **`::part()` / `::slotted()`** — shadow DOM syntax. AdiaUI is light DOM.
362
+ - **Global selectors inside `@scope`** — `body { ... }`, `html { ... }`, `* { ... }`. The scope is the component; don't reach outside.
363
+ - **`!important`** — ever. If you need it, the specificity layering is wrong. Fix the layering.
364
+ - **Setting tokens at `:root`** — tokens scoped to a component belong in `:where(:scope)`. Only cross-component semantic tokens live in `:root` / `styles/colors/semantics.css`.
@@ -0,0 +1,302 @@
1
+ # Lifecycle Patterns — Listeners, Timers, Observers, Popovers
2
+
3
+ Deep dive on symmetric `connected()` / `disconnected()` in AdiaUI components. Read when adding any side effect (listener, timer, observer, popover) to a component, or when inheriting from `AdiaFormElement`.
4
+
5
+ ## The symmetry rule
6
+
7
+ Every side effect set up in `connected()` has a matching teardown in `disconnected()`. The teardown is authored at the same time as the setup, not later — if you leave `disconnected()` for tomorrow, tomorrow won't happen.
8
+
9
+ ```javascript
10
+ class AdiaWidget extends AdiaElement {
11
+ static properties = { /* ... */ };
12
+ static template = () => null;
13
+
14
+ #buttonEl = null;
15
+
16
+ #onButtonClick = (e) => {
17
+ this.dispatchEvent(new CustomEvent('widget-click', { bubbles: true }));
18
+ };
19
+
20
+ connected() {
21
+ this.#buttonEl = this.querySelector('button');
22
+ this.#buttonEl?.addEventListener('click', this.#onButtonClick);
23
+ }
24
+
25
+ disconnected() {
26
+ this.#buttonEl?.removeEventListener('click', this.#onButtonClick);
27
+ this.#buttonEl = null;
28
+ }
29
+ }
30
+ ```
31
+
32
+ Four things happen in `disconnected()`:
33
+
34
+ 1. Remove the listener (reference equality via the `#onButtonClick` field).
35
+ 2. Null the DOM reference (`this.#buttonEl = null`).
36
+ 3. [Not shown] Clear any timers.
37
+ 4. [Not shown] Disconnect any observers.
38
+
39
+ ## Stable handlers — `#field` arrows, never inline arrows
40
+
41
+ `removeEventListener(type, handler)` requires reference equality on `handler`. Inline arrows create a fresh function every time:
42
+
43
+ ```javascript
44
+ // WRONG — the inline arrow is a new function every render
45
+ this.#buttonEl.addEventListener('click', (e) => this.doThing(e));
46
+ // Later: removeEventListener can't match — no reference equality.
47
+
48
+ // RIGHT — stable reference via private field
49
+ #onButtonClick = (e) => this.doThing(e);
50
+ // connected:
51
+ this.#buttonEl.addEventListener('click', this.#onButtonClick);
52
+ // disconnected:
53
+ this.#buttonEl.removeEventListener('click', this.#onButtonClick);
54
+ ```
55
+
56
+ The `#` prefix keeps the handler private to the class (can't be accidentally used as a public API). The arrow function form auto-binds `this`.
57
+
58
+ **Historical bugs this pattern prevents:**
59
+
60
+ - `cot.js:116` — summary click handler as inline arrow. Couldn't be removed; fixed by promoting to `#onSummaryClick`.
61
+ - Generic pattern across ~35 components in the audit cycle — all promoted to stable fields.
62
+
63
+ ## Ephemeral DOM with inline arrows — the tolerance rule
64
+
65
+ There's ONE exception: inline arrows on DOM elements that are guaranteed to be fully detached + GC'd before the next re-render.
66
+
67
+ ```javascript
68
+ // Tolerated: row is built fresh per render, parent's innerHTML
69
+ // replacement detaches the old subtree, GC collects both the
70
+ // node AND its listener.
71
+ #renderRows() {
72
+ this.#container.innerHTML = '';
73
+ for (const item of this.items) {
74
+ const row = document.createElement('div');
75
+ row.addEventListener('click', (e) => this.#onRowClick(item, e));
76
+ this.#container.appendChild(row);
77
+ }
78
+ }
79
+ ```
80
+
81
+ **This is tolerated but not preferred.** If anything about the lifetime changes — if someone adds a `row.remove()` that leaves a cached reference, or converts to incremental DOM updates — the listeners stop getting GC'd and become leaks.
82
+
83
+ The safer pattern is event delegation on the stable parent:
84
+
85
+ ```javascript
86
+ // Preferred — one listener on the parent, reads data-attributes
87
+ connected() {
88
+ this.#container.addEventListener('click', this.#onContainerClick);
89
+ }
90
+
91
+ #onContainerClick = (e) => {
92
+ const row = e.target.closest('[data-row-id]');
93
+ if (!row) return;
94
+ const item = this.items.find(i => i.id === row.dataset.rowId);
95
+ this.#onRowClick(item, e);
96
+ };
97
+
98
+ disconnected() {
99
+ this.#container.removeEventListener('click', this.#onContainerClick);
100
+ }
101
+ ```
102
+
103
+ One listener, always removable, survives DOM churn.
104
+
105
+ ## Timers — `setInterval`, `setTimeout`
106
+
107
+ Store the handle, clear it in `disconnected()`:
108
+
109
+ ```javascript
110
+ #timerInterval = null;
111
+ #finishTimer = null;
112
+
113
+ connected() {
114
+ this.#timerInterval = setInterval(() => this.#tick(), 1000);
115
+ }
116
+
117
+ disconnected() {
118
+ if (this.#timerInterval != null) {
119
+ clearInterval(this.#timerInterval);
120
+ this.#timerInterval = null;
121
+ }
122
+ if (this.#finishTimer != null) {
123
+ clearTimeout(this.#finishTimer);
124
+ this.#finishTimer = null;
125
+ }
126
+ }
127
+ ```
128
+
129
+ The null-guard is defensive; `clearInterval(null)` is actually a no-op but the guard makes the "was it created?" intent explicit.
130
+
131
+ ## Observers — ResizeObserver, MutationObserver, IntersectionObserver
132
+
133
+ Every observer has `.disconnect()`:
134
+
135
+ ```javascript
136
+ #resizeObserver = null;
137
+
138
+ connected() {
139
+ this.#resizeObserver = new ResizeObserver(() => this.#handleResize());
140
+ this.#resizeObserver.observe(this);
141
+ }
142
+
143
+ disconnected() {
144
+ this.#resizeObserver?.disconnect();
145
+ this.#resizeObserver = null;
146
+ }
147
+ ```
148
+
149
+ **Historical bug:** `chart.js` had TWO `disconnected()` methods — the second one silently overrode the first. The first had the `ResizeObserver.disconnect()` call; the second didn't. Chart listeners leaked until a production repro surfaced it.
150
+
151
+ **Lesson:** a class must declare `disconnected()` exactly once. If you need to add teardown, edit the existing method. Duplicate method names silently shadow — no error, no warning.
152
+
153
+ ## `AdiaFormElement` — `super` discipline
154
+
155
+ Form-participating components inherit `ElementInternals` wiring from `AdiaFormElement`. That wiring only works if `super.connected()` and `super.disconnected()` are called:
156
+
157
+ ```javascript
158
+ import { AdiaFormElement } from '../../core/form.js';
159
+
160
+ class AdiaInput extends AdiaFormElement {
161
+ static properties = {
162
+ ...AdiaFormElement.properties,
163
+ placeholder: { type: String, default: '', reflect: true },
164
+ };
165
+
166
+ #inputEl = null;
167
+
168
+ #onInput = () => this.syncValue(this.#inputEl?.value ?? '');
169
+
170
+ connected() {
171
+ super.connected(); // MUST — registers ElementInternals, form association
172
+ this.#inputEl = this.querySelector('input');
173
+ this.#inputEl?.addEventListener('input', this.#onInput);
174
+ }
175
+
176
+ disconnected() {
177
+ super.disconnected(); // MUST — unregisters form participation
178
+ this.#inputEl?.removeEventListener('input', this.#onInput);
179
+ this.#inputEl = null;
180
+ }
181
+ }
182
+ ```
183
+
184
+ Omit `super.connected()` and the component won't participate in form submission — the field value is never collected, no validation fires, no form-reset handler runs. It's a silent failure; you only find out when form submission misses the field.
185
+
186
+ ## Popovers, tooltips, document-level overlays
187
+
188
+ When a component creates elements OUTSIDE its own subtree (attached to `document.body`, or triggered via the Popover API), those elements don't GC with the component. Explicit cleanup is required:
189
+
190
+ ```javascript
191
+ #tooltipEl = null;
192
+
193
+ #showTooltip(anchor) {
194
+ this.#tooltipEl = document.createElement('div');
195
+ this.#tooltipEl.popover = 'auto';
196
+ document.body.appendChild(this.#tooltipEl);
197
+ this.#tooltipEl.showPopover();
198
+ }
199
+
200
+ disconnected() {
201
+ if (this.#tooltipEl) {
202
+ this.#tooltipEl.remove(); // detaches from body
203
+ this.#tooltipEl = null;
204
+ }
205
+ }
206
+ ```
207
+
208
+ Same rule for popovers anchored via CSS anchor-positioning, `<dialog>` elements appended to body, and any DOM node escaping the component's subtree.
209
+
210
+ ## Global document listeners
211
+
212
+ Some components need to listen on `document` or `window` (e.g., a dropdown that closes on click-outside, a keyboard shortcut handler).
213
+
214
+ ```javascript
215
+ #onDocumentClick = (e) => {
216
+ if (!this.contains(e.target)) this.close();
217
+ };
218
+
219
+ #onDocumentKeydown = (e) => {
220
+ if (e.key === 'Escape') this.close();
221
+ };
222
+
223
+ open() {
224
+ document.addEventListener('click', this.#onDocumentClick);
225
+ document.addEventListener('keydown', this.#onDocumentKeydown);
226
+ }
227
+
228
+ close() {
229
+ document.removeEventListener('click', this.#onDocumentClick);
230
+ document.removeEventListener('keydown', this.#onDocumentKeydown);
231
+ }
232
+
233
+ disconnected() {
234
+ // Defensive: if component is ripped out while open, ensure global
235
+ // listeners don't outlive it.
236
+ document.removeEventListener('click', this.#onDocumentClick);
237
+ document.removeEventListener('keydown', this.#onDocumentKeydown);
238
+ }
239
+ ```
240
+
241
+ The `close()` method handles the happy path; `disconnected()` handles the rude-removal case.
242
+
243
+ ## Pointer capture
244
+
245
+ For drag interactions, `setPointerCapture` + `releasePointerCapture`. Release in both the up handler AND as a defensive clear in `disconnected()`:
246
+
247
+ ```javascript
248
+ #dragging = false;
249
+ #activePointerId = null;
250
+
251
+ #onPointerDown = (e) => {
252
+ this.#dragging = true;
253
+ this.#activePointerId = e.pointerId;
254
+ this.setPointerCapture(e.pointerId);
255
+ this.addEventListener('pointermove', this.#onPointerMove);
256
+ this.addEventListener('pointerup', this.#onPointerUp);
257
+ };
258
+
259
+ #onPointerUp = (e) => {
260
+ this.#dragging = false;
261
+ this.releasePointerCapture(e.pointerId);
262
+ this.removeEventListener('pointermove', this.#onPointerMove);
263
+ this.removeEventListener('pointerup', this.#onPointerUp);
264
+ };
265
+
266
+ disconnected() {
267
+ if (this.#activePointerId != null) {
268
+ this.releasePointerCapture(this.#activePointerId);
269
+ }
270
+ this.removeEventListener('pointerdown', this.#onPointerDown);
271
+ this.removeEventListener('pointermove', this.#onPointerMove);
272
+ this.removeEventListener('pointerup', this.#onPointerUp);
273
+ }
274
+ ```
275
+
276
+ ## DOM-reference nulling — why it matters
277
+
278
+ A cached DOM reference (`this.#fooEl`) held by the component class keeps the referenced element alive for GC purposes, along with its own subtree. When the component is removed from the DOM, the old tree hangs around in memory until the component itself is garbage-collected.
279
+
280
+ Nulling the refs in `disconnected()` releases them eagerly:
281
+
282
+ ```javascript
283
+ disconnected() {
284
+ // ... remove listeners ...
285
+ this.#inputEl = null;
286
+ this.#buttonEl = null;
287
+ this.#previewEl = null;
288
+ this.#fileInput = null;
289
+ }
290
+ ```
291
+
292
+ Especially important for components that are attached/detached frequently (modals, toasts, drawers) — each incarnation leaks its old subtree into memory if refs aren't released.
293
+
294
+ ## Anti-patterns to watch for
295
+
296
+ - **Inline arrow + `removeEventListener`** — the listener isn't really being removed. Silent leak.
297
+ - **Forgot to create `disconnected()`** — if `connected()` adds side effects, `disconnected()` must exist. No exceptions.
298
+ - **Two `disconnected()` methods in the same class** — the second silently wins; the first's cleanup is lost.
299
+ - **`super.connected()` missing in `AdiaFormElement` subclass** — form participation silently fails.
300
+ - **Popover / tooltip never cleaned up** — stays in `document.body` forever.
301
+ - **Observer recreated on every render** — re-observing without disconnecting first leaks observer refs.
302
+ - **Timer fired after component removed** — not cleared in `disconnected()`, fires on a dead component. Can cause null-deref crashes or phantom re-renders.