@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.
- package/.claude-plugin/plugin.json +23 -0
- package/CHANGELOG.md +26 -0
- package/README.md +54 -0
- package/bin/forge-lint +263 -0
- package/bin/lib/audit-axes.mjs +555 -0
- package/bin/lib/dry-run-irreversible.mjs +236 -0
- package/bin/lib/run-skill-evals.mjs +487 -0
- package/bin/lib/teach-router.mjs +250 -0
- package/commands/adia-forge-a2ui.md +10 -0
- package/commands/adia-forge-author.md +10 -0
- package/commands/adia-forge-dogfood.md +8 -0
- package/commands/adia-forge-llm.md +8 -0
- package/commands/adia-forge-orient.md +10 -0
- package/commands/adia-forge-release.md +8 -0
- package/commands/adia-forge-review.md +10 -0
- package/hooks/hooks.json +15 -0
- package/package.json +41 -0
- package/references/shared/content-trust.md +76 -0
- package/references/shared/pev-rationale.md +64 -0
- package/references/shared/skill-conventions.md +133 -0
- package/skills/adia-ui-a2ui/CHANGELOG.md +32 -0
- package/skills/adia-ui-a2ui/SKILL.md +243 -0
- package/skills/adia-ui-a2ui/evals/adversarial-corpus.json +75 -0
- package/skills/adia-ui-a2ui/evals/routing-corpus.json +31 -0
- package/skills/adia-ui-a2ui/evals/teach-routing-cases.json +100 -0
- package/skills/adia-ui-a2ui/references/anti-patterns.md +24 -0
- package/skills/adia-ui-a2ui/references/chunk-authoring.md +88 -0
- package/skills/adia-ui-a2ui/references/corpus-discipline.md +56 -0
- package/skills/adia-ui-a2ui/references/eval-diagnostics.md +127 -0
- package/skills/adia-ui-a2ui/references/fragment-graph.md +91 -0
- package/skills/adia-ui-a2ui/references/mcp-pipeline-ops.md +106 -0
- package/skills/adia-ui-a2ui/references/mcp-tool-reference.md +398 -0
- package/skills/adia-ui-a2ui/references/pipeline-overview.md +175 -0
- package/skills/adia-ui-a2ui/references/semantic-fail-lifting.md +120 -0
- package/skills/adia-ui-a2ui/references/strategy-engines.md +111 -0
- package/skills/adia-ui-a2ui/references/teach-protocol.md +220 -0
- package/skills/adia-ui-a2ui/references/zettel-calibration.md +93 -0
- package/skills/adia-ui-a2ui/scripts/audit-a2ui-roster.mjs +96 -0
- package/skills/adia-ui-a2ui/scripts/teach-route.mjs +157 -0
- package/skills/adia-ui-a2ui/skill.json +38 -0
- package/skills/adia-ui-authoring/CHANGELOG.md +32 -0
- package/skills/adia-ui-authoring/SKILL.md +256 -0
- package/skills/adia-ui-authoring/assets/case-studies/admin-shell-decomposition.md +101 -0
- package/skills/adia-ui-authoring/assets/case-studies/maxtokens-32768-discovery.md +109 -0
- package/skills/adia-ui-authoring/assets/case-studies/theme-panel-promotion.md +113 -0
- package/skills/adia-ui-authoring/evals/adversarial-design-plan-gates.json +138 -0
- package/skills/adia-ui-authoring/evals/routing-corpus.json +245 -0
- package/skills/adia-ui-authoring/references/anti-patterns.md +606 -0
- package/skills/adia-ui-authoring/references/api-contract.md +205 -0
- package/skills/adia-ui-authoring/references/authoring-cycle.md +211 -0
- package/skills/adia-ui-authoring/references/canonical-pattern-index.md +179 -0
- package/skills/adia-ui-authoring/references/code-style.md +329 -0
- package/skills/adia-ui-authoring/references/common-gotchas.md +93 -0
- package/skills/adia-ui-authoring/references/composite-demo-protocol.md +1084 -0
- package/skills/adia-ui-authoring/references/css-patterns.md +364 -0
- package/skills/adia-ui-authoring/references/lifecycle-patterns.md +302 -0
- package/skills/adia-ui-authoring/references/llm-bridge.md +254 -0
- package/skills/adia-ui-authoring/references/module-promotion.md +289 -0
- package/skills/adia-ui-authoring/references/primitive-audit.md +123 -0
- package/skills/adia-ui-authoring/references/shell-patterns.md +561 -0
- package/skills/adia-ui-authoring/references/teach-protocol.md +428 -0
- package/skills/adia-ui-authoring/references/token-contract.md +120 -0
- package/skills/adia-ui-authoring/references/worked-example.md +351 -0
- package/skills/adia-ui-authoring/references/yaml-contract.md +224 -0
- package/skills/adia-ui-authoring/scripts/audit-authoring-roster.mjs +148 -0
- package/skills/adia-ui-authoring/scripts/build-canonical-pattern-index.mjs +199 -0
- package/skills/adia-ui-authoring/skill.json +45 -0
- package/skills/adia-ui-dogfood/CHANGELOG.md +17 -0
- package/skills/adia-ui-dogfood/README.md +62 -0
- package/skills/adia-ui-dogfood/SKILL.md +866 -0
- package/skills/adia-ui-dogfood/scripts/analyze.mjs +603 -0
- package/skills/adia-ui-dogfood/skill.json +40 -0
- package/skills/adia-ui-forge/SKILL.md +88 -0
- package/skills/adia-ui-forge/evals/routing-corpus.json +30 -0
- package/skills/adia-ui-gen-review/CHANGELOG.md +108 -0
- package/skills/adia-ui-gen-review/SKILL.md +266 -0
- package/skills/adia-ui-gen-review/references/loop-protocol.md +712 -0
- package/skills/adia-ui-gen-review/references/rubric-cosmetic.md +144 -0
- package/skills/adia-ui-gen-review/references/rubric-decompose.md +117 -0
- package/skills/adia-ui-gen-review/references/rubric-score.md +249 -0
- package/skills/adia-ui-gen-review/references/scores.schema.json +125 -0
- package/skills/adia-ui-gen-review/references/teach-protocol.md +214 -0
- package/skills/adia-ui-gen-review/scripts/gen-review-coverage-audit.mjs +127 -0
- package/skills/adia-ui-gen-review/scripts/gen-review-decompose.mjs +569 -0
- package/skills/adia-ui-gen-review/scripts/gen-review-status.mjs +176 -0
- package/skills/adia-ui-gen-review/scripts/validate-cycle-scores.mjs +198 -0
- package/skills/adia-ui-gen-review/skill.json +22 -0
- package/skills/adia-ui-llm/CHANGELOG.md +25 -0
- package/skills/adia-ui-llm/SKILL.md +165 -0
- package/skills/adia-ui-llm/evals/adversarial-corpus.json +75 -0
- package/skills/adia-ui-llm/evals/routing-corpus.json +30 -0
- package/skills/adia-ui-llm/evals/teach-routing-cases.json +73 -0
- package/skills/adia-ui-llm/references/adapter-contract.md +99 -0
- package/skills/adia-ui-llm/references/add-a-provider.md +90 -0
- package/skills/adia-ui-llm/references/bridge-facade.md +94 -0
- package/skills/adia-ui-llm/references/browser-proxy-boundary.md +89 -0
- package/skills/adia-ui-llm/references/model-registry.md +75 -0
- package/skills/adia-ui-llm/references/streaming-sse.md +95 -0
- package/skills/adia-ui-llm/references/teach-protocol.md +78 -0
- package/skills/adia-ui-llm/scripts/audit-llm-roster.mjs +93 -0
- package/skills/adia-ui-llm/scripts/teach-route.mjs +119 -0
- package/skills/adia-ui-llm/skill.json +33 -0
- package/skills/adia-ui-release/CHANGELOG.md +23 -0
- package/skills/adia-ui-release/SKILL.md +295 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-20-batch-push-v0.6.14-v0.6.15.md +144 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-20-corpus-drift-remediation-v0.6.15.md +155 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-20-version-skip-correction-v0.6.12.md +114 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-21-author-from-scratch-v0.6.18.md +139 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-21-feedback37-retraction-v0.6.21.md +124 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-21-fn1-enrichment-pass-v0.6.19.md +125 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-21-stale-test-detection-v0.6.20.md +142 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-23-freshness-gate-recovery-v0.6.32.md +97 -0
- package/skills/adia-ui-release/assets/case-studies/2026-05-26-catalog-drift-recurring-v0.6.40.md +147 -0
- package/skills/adia-ui-release/assets/templates/stub-changelog.template.md +22 -0
- package/skills/adia-ui-release/evals/evals.json +164 -0
- package/skills/adia-ui-release/references/changelog-discipline.md +250 -0
- package/skills/adia-ui-release/references/cycle-happy-path.md +520 -0
- package/skills/adia-ui-release/references/exe-deploy.md +149 -0
- package/skills/adia-ui-release/references/gates-catalog.md +778 -0
- package/skills/adia-ui-release/references/ledger-discipline.md +232 -0
- package/skills/adia-ui-release/references/migration-guide-authoring.md +174 -0
- package/skills/adia-ui-release/references/multi-agent-baseline.md +207 -0
- package/skills/adia-ui-release/references/notes-authoring.md +212 -0
- package/skills/adia-ui-release/references/recovery-paths.md +215 -0
- package/skills/adia-ui-release/references/rollup-notes.md +208 -0
- package/skills/adia-ui-release/references/teach-protocol.md +468 -0
- package/skills/adia-ui-release/scripts/assert-monorepo-root.mjs +49 -0
- package/skills/adia-ui-release/scripts/audit-gate-roster.mjs +196 -0
- package/skills/adia-ui-release/scripts/bump.mjs +118 -0
- package/skills/adia-ui-release/scripts/dispatch-publish.mjs +151 -0
- package/skills/adia-ui-release/scripts/insert-stub.mjs +127 -0
- package/skills/adia-ui-release/scripts/make-ledger.mjs +179 -0
- package/skills/adia-ui-release/scripts/promote-unreleased.mjs +88 -0
- package/skills/adia-ui-release/scripts/release-pack.mjs +350 -0
- package/skills/adia-ui-release/scripts/tag-lockstep.mjs +110 -0
- 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.
|