@adia-ai/web-components 0.8.3 → 0.8.5

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 (95) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/USAGE.md +60 -25
  3. package/components/accordion/accordion.css +5 -0
  4. package/components/adia-mark/adia-mark.a2ui.json +123 -0
  5. package/components/adia-mark/adia-mark.class.js +64 -0
  6. package/components/adia-mark/adia-mark.css +48 -0
  7. package/components/adia-mark/adia-mark.d.ts +20 -0
  8. package/components/adia-mark/adia-mark.examples.md +26 -0
  9. package/components/adia-mark/adia-mark.js +17 -0
  10. package/components/adia-mark/adia-mark.test.js +69 -0
  11. package/components/adia-mark/adia-mark.yaml +121 -0
  12. package/components/agent-artifact/agent-artifact.css +1 -0
  13. package/components/agent-questions/agent-questions.a2ui.json +2 -1
  14. package/components/agent-questions/agent-questions.class.js +96 -53
  15. package/components/agent-questions/agent-questions.css +27 -106
  16. package/components/agent-questions/agent-questions.yaml +1 -0
  17. package/components/agent-reasoning/agent-reasoning.css +9 -5
  18. package/components/button/button.class.js +9 -2
  19. package/components/button/button.css +1 -1
  20. package/components/calendar-grid/calendar-grid.a2ui.json +3 -1
  21. package/components/calendar-grid/calendar-grid.class.js +11 -6
  22. package/components/calendar-grid/calendar-grid.css +42 -20
  23. package/components/calendar-grid/calendar-grid.yaml +5 -0
  24. package/components/calendar-picker/calendar-picker.a2ui.json +3 -1
  25. package/components/calendar-picker/calendar-picker.class.js +11 -6
  26. package/components/calendar-picker/calendar-picker.css +41 -19
  27. package/components/calendar-picker/calendar-picker.yaml +5 -0
  28. package/components/chart/chart.class.js +17 -14
  29. package/components/chat-thread/chat-input.a2ui.json +3 -1
  30. package/components/chat-thread/chat-input.css +12 -13
  31. package/components/chat-thread/chat-input.js +6 -2
  32. package/components/chat-thread/chat-input.yaml +3 -0
  33. package/components/check/check.css +1 -1
  34. package/components/color-input/color-input.class.js +8 -5
  35. package/components/color-picker/color-picker.class.js +6 -3
  36. package/components/combobox/combobox.a2ui.json +3 -1
  37. package/components/combobox/combobox.class.js +1 -3
  38. package/components/combobox/combobox.css +9 -12
  39. package/components/combobox/combobox.yaml +2 -0
  40. package/components/date-range-picker/date-range-picker.css +1 -0
  41. package/components/field/field.class.js +10 -7
  42. package/components/fields/fields.class.js +15 -12
  43. package/components/icon/icon.class.js +9 -5
  44. package/components/index.js +1 -0
  45. package/components/input/input.css +6 -2
  46. package/components/nav/nav.class.js +2 -1
  47. package/components/nav-group/nav-group.css +5 -6
  48. package/components/noodles/noodles.class.js +14 -10
  49. package/components/option-card/option-card.css +1 -0
  50. package/components/page/page.class.js +3 -2
  51. package/components/radio/radio.css +1 -1
  52. package/components/range/range.css +2 -0
  53. package/components/select/select.a2ui.json +2 -1
  54. package/components/select/select.class.js +12 -11
  55. package/components/select/select.css +29 -40
  56. package/components/select/select.test.js +6 -2
  57. package/components/select/select.yaml +1 -0
  58. package/components/swatch/swatch.a2ui.json +3 -1
  59. package/components/swatch/swatch.class.js +41 -32
  60. package/components/swatch/swatch.css +13 -18
  61. package/components/swatch/swatch.yaml +2 -0
  62. package/components/swiper/swiper.class.js +4 -1
  63. package/components/swiper/swiper.css +13 -4
  64. package/components/switch/switch.css +1 -1
  65. package/components/tag/tag.a2ui.json +4 -1
  66. package/components/tag/tag.class.js +3 -1
  67. package/components/tag/tag.css +13 -15
  68. package/components/tag/tag.yaml +3 -0
  69. package/components/tags-input/tags-input.css +1 -0
  70. package/components/textarea/textarea.css +2 -0
  71. package/components/time-picker/time-picker.css +2 -0
  72. package/components/timeline/timeline.a2ui.json +2 -1
  73. package/components/timeline/timeline.class.js +7 -8
  74. package/components/timeline/timeline.css +14 -26
  75. package/components/timeline/timeline.yaml +1 -0
  76. package/components/toggle-group/toggle-group.css +1 -0
  77. package/components/toolbar/toolbar.class.js +11 -6
  78. package/components/tree/tree.class.js +5 -2
  79. package/components/tree/tree.css +5 -2
  80. package/components/upload/upload.css +1 -0
  81. package/core/data-stream.js +21 -15
  82. package/core/element.js +36 -1
  83. package/core/element.test.js +67 -0
  84. package/core/icons-phosphor.js +86 -22
  85. package/core/icons.js +22 -8
  86. package/dist/theme-provider.min.js +2 -2
  87. package/dist/web-components.min.css +1 -1
  88. package/dist/web-components.min.js +109 -108
  89. package/dist/web-components.sheet.js +1 -1
  90. package/package.json +1 -5
  91. package/styles/colors/semantics/features.css +6 -1
  92. package/styles/components.css +1 -0
  93. package/traits/error-shake/error-shake.js +23 -17
  94. package/traits/fade-presence/fade-presence.js +19 -15
  95. package/traits/success-checkmark/success-checkmark.js +21 -16
@@ -191,7 +191,16 @@
191
191
  `--swiper-dot-size` for idle dots and `--swiper-dot-size-active`
192
192
  for the active one (one space token larger). Padding is computed
193
193
  from the size so the dot stays centered in the hitbox. */
194
- :scope [data-swiper-dots] > button {
194
+ :scope [data-swiper-dots] > button-ui {
195
+ /* Stamped <button-ui variant="ghost" role="tab"> (gh issue 276 wave 4).
196
+ The dot look is bespoke (content-box clip keeps a 1rem hitbox around
197
+ a --swiper-dot-size circle) — these host declarations out-specify
198
+ button-ui's token-driven chrome; the ghost variant keeps its own
199
+ hover fill out of the way. */
200
+ /* button-ui floors min-width/height at --button-height — point it at
201
+ the VISIBLE circle so the padding (not the min-floor) builds the
202
+ 1rem hitbox, exactly like the native dot did. */
203
+ --button-height: var(--swiper-dot-size);
195
204
  width: var(--swiper-dot-size);
196
205
  height: var(--swiper-dot-size);
197
206
  border-radius: var(--swiper-dot-radius);
@@ -208,18 +217,18 @@
208
217
  `background-clip: content-box` from the base rule isn't reset to
209
218
  its initial `border-box` value — the colored area would otherwise
210
219
  fill the full 1rem hitbox and turn into a huge circle. */
211
- :scope [data-swiper-dots] > button:hover {
220
+ :scope [data-swiper-dots] > button-ui:hover {
212
221
  background-color: var(--swiper-dot-bg-active);
213
222
  }
214
223
 
215
224
  /* Focus ring is rendered INSIDE the padding area (inset shadow), so
216
225
  the dot's visual footprint stays the size of every other dot. */
217
- :scope [data-swiper-dots] > button:focus-visible {
226
+ :scope [data-swiper-dots] > button-ui:focus-visible {
218
227
  outline: none;
219
228
  box-shadow: inset 0 0 0 var(--a-focus-width) var(--a-focus-color);
220
229
  }
221
230
 
222
- :scope [data-swiper-dots] > button[aria-current="true"] {
231
+ :scope [data-swiper-dots] > button-ui[aria-current="true"] {
223
232
  background-color: var(--swiper-dot-bg-active);
224
233
  width: var(--swiper-dot-size-active);
225
234
  height: var(--swiper-dot-size-active);
@@ -130,7 +130,7 @@ switch-ui[checked] [slot="thumb"] {
130
130
  }
131
131
 
132
132
  :scope:focus-visible { outline: none; }
133
- :scope:focus-visible [slot="track"] { box-shadow: var(--switch-focus-ring); }
133
+ :scope:focus-visible [slot="track"] { box-shadow: var(--switch-focus-ring); border-color: transparent; }
134
134
 
135
135
  /* Size handled by universal [size] attribute system. */
136
136
  }
@@ -74,7 +74,10 @@
74
74
  "x-adiaui": {
75
75
  "anti_patterns": [],
76
76
  "category": "display",
77
- "composes": [],
77
+ "composes": [
78
+ "button-ui",
79
+ "icon-ui"
80
+ ],
78
81
  "events": {
79
82
  "remove": {
80
83
  "description": "Fired when the dismiss button is activated.",
@@ -55,7 +55,9 @@ export class UITag extends UIElement {
55
55
  };
56
56
 
57
57
  static parts = {
58
- dismiss: '<button slot="dismiss" type="button" aria-label="Remove"><icon-ui name="x" weight="bold"></icon-ui></button>',
58
+ // button-ui, not a native <button> (gh issue 276) — the icon slots in
59
+ // explicitly to keep its bold weight (the icon= prop stamps regular).
60
+ dismiss: '<button-ui slot="dismiss" variant="ghost" size="xs" aria-label="Remove"><icon-ui slot="icon" name="x" weight="bold"></icon-ui></button-ui>',
59
61
  };
60
62
 
61
63
  static template = () => null;
@@ -196,6 +196,7 @@ tag-ui[removable]:not([disabled]):hover {
196
196
  :scope:focus-visible {
197
197
  outline: none;
198
198
  box-shadow: var(--tag-focus-ring);
199
+ border-color: transparent;
199
200
  }
200
201
 
201
202
  /* ── Slotted icons (leading) ──
@@ -222,28 +223,25 @@ tag-ui[removable]:not([disabled]):hover {
222
223
  gap: var(--tag-gap-icon, 0.2em);
223
224
  }
224
225
 
225
- /* ── Dismiss button ── */
226
+ /* ── Dismiss button a stamped <button-ui variant="ghost" size="xs">
227
+ (gh issue 276: no native <button> in stamped DOM); geometry + hover
228
+ ride button token re-points, chrome is button-ui's own. */
226
229
  [slot="dismiss"] {
227
- display: inline-flex;
228
- align-items: center;
229
- justify-content: center;
230
- width: var(--tag-dismiss-size);
231
- height: var(--tag-dismiss-size);
232
- padding: 0;
233
- border: none;
234
- background: none;
235
- cursor: pointer;
236
- border-radius: var(--tag-dismiss-radius);
237
- color: var(--tag-dismiss-fg);
238
- opacity: var(--tag-dismiss-opacity);
230
+ --button-height: var(--tag-dismiss-size);
231
+ --button-radius: var(--tag-dismiss-radius);
232
+ --button-fg: var(--tag-dismiss-fg);
233
+ --button-bg-ghost-hover: var(--tag-dismiss-bg-hover);
234
+ --button-fg-ghost-hover: var(--tag-dismiss-fg);
239
235
  --a-icon-size: 0.875rem;
236
+
237
+ opacity: var(--tag-dismiss-opacity);
240
238
  order: 1; /* push dismiss to end so layout reads [text] [×] */
241
- transition: opacity var(--tag-duration) var(--tag-easing), background var(--tag-duration) var(--tag-easing);
239
+ flex-shrink: 0;
240
+ transition: opacity var(--tag-duration) var(--tag-easing);
242
241
  }
243
242
 
244
243
  [slot="dismiss"]:hover {
245
244
  opacity: var(--tag-dismiss-opacity-hover);
246
- background: var(--tag-dismiss-bg-hover);
247
245
  }
248
246
 
249
247
  /* ── Disabled ── */
@@ -16,6 +16,9 @@ description: |
16
16
  markers — badge-ui has no remove event. For navigation grouping
17
17
  use <nav-group-ui>; for inline command actions use
18
18
  <action-list-ui>.
19
+ composes:
20
+ - button-ui # dismiss affordance (gh issue 276 — no native <button> in stamped DOM)
21
+ - icon-ui # the dismiss x glyph, slotted into the button-ui
19
22
  props:
20
23
  disabled:
21
24
  description: Disables interaction and dims the tag.
@@ -75,6 +75,7 @@
75
75
  :scope[aria-invalid="true"]:focus-within,
76
76
  :scope[error]:focus-within {
77
77
  box-shadow: var(--tags-input-focus-ring-invalid);
78
+ border-color: transparent;
78
79
  }
79
80
 
80
81
  :scope[disabled] {
@@ -96,10 +96,12 @@ textarea-ui:not([disabled]) [slot="text"]:hover {
96
96
  outline: none;
97
97
  box-shadow: var(--textarea-focus-ring);
98
98
  color: var(--textarea-fg-hover);
99
+ border-color: transparent;
99
100
  }
100
101
  :scope[aria-invalid="true"]:not([disabled]) [slot="text"]:focus,
101
102
  :scope[error]:not([disabled]) [slot="text"]:focus {
102
103
  box-shadow: var(--textarea-focus-ring-invalid);
104
+ border-color: transparent;
103
105
  }
104
106
  :scope:not([disabled]) [slot="text"]:focus + [slot="label"],
105
107
  :scope:not([disabled]):focus-within [slot="label"] {
@@ -107,11 +107,13 @@ time-picker-ui:not([disabled]):hover {
107
107
  Canonical L3 ring; consumed via --time-picker-focus-ring (aliases --a-focus-ring). */
108
108
  time-picker-ui:not([disabled]):focus-within {
109
109
  box-shadow: var(--time-picker-focus-ring);
110
+ border-color: transparent;
110
111
  }
111
112
 
112
113
  time-picker-ui[aria-invalid="true"]:not([disabled]):focus-within,
113
114
  time-picker-ui[error]:not([disabled]):focus-within {
114
115
  box-shadow: var(--time-picker-focus-ring-invalid);
116
+ border-color: transparent;
115
117
  }
116
118
 
117
119
  /* Disabled / readonly affordance */
@@ -43,7 +43,8 @@
43
43
  "anti_patterns": [],
44
44
  "category": "display",
45
45
  "composes": [
46
- "icon-ui"
46
+ "icon-ui",
47
+ "button-ui"
47
48
  ],
48
49
  "events": {
49
50
  "timeline-toggle": {
@@ -154,8 +154,10 @@ export class UITimelineItem extends UIElement {
154
154
  // Toggle caret lives on the row — we stamp a button once
155
155
  let toggle = this.querySelector(':scope > [data-timeline-toggle]');
156
156
  if (!toggle) {
157
- toggle = document.createElement('button');
158
- toggle.type = 'button';
157
+ // button-ui, not a native <button> (gh issue 276).
158
+ toggle = document.createElement('button-ui');
159
+ toggle.setAttribute('variant', 'ghost');
160
+ toggle.setAttribute('size', 'xs');
159
161
  toggle.setAttribute('data-timeline-toggle', '');
160
162
  toggle.setAttribute('aria-label', 'Toggle details');
161
163
  toggle.addEventListener('click', (e) => {
@@ -168,12 +170,9 @@ export class UITimelineItem extends UIElement {
168
170
  });
169
171
  this.appendChild(toggle);
170
172
  }
171
- toggle.innerHTML = '';
172
- const caret = document.createElement('icon-ui');
173
- caret.setAttribute('name', this.#expanded ? 'caret-down' : 'caret-right');
174
- caret.setAttribute('color', 'muted');
175
- caret.setAttribute('size', 'sm');
176
- toggle.appendChild(caret);
173
+ // The caret rides button-ui's icon prop — flipping the attribute
174
+ // re-stamps the glyph; color/size are CSS token re-points.
175
+ toggle.setAttribute('icon', this.#expanded ? 'caret-down' : 'caret-right');
177
176
  } else {
178
177
  this.querySelector(':scope > [data-timeline-toggle]')?.remove();
179
178
  body?.remove();
@@ -362,32 +362,22 @@ agent-reasoning-ui timeline-ui:not([orientation="horizontal"]),
362
362
 
363
363
  /* ═══════ Toggle caret (outcomes) ═══════ */
364
364
 
365
+ /* A stamped <button-ui variant="ghost" size="xs" icon="caret-*">
366
+ (gh issue 276: no native <button> in stamped DOM). Geometry/colors
367
+ ride button token re-points; focus ring + hover are button-ui's own. */
365
368
  :scope > [data-timeline-toggle] {
366
369
  position: absolute;
367
370
  inset-inline-end: 0;
368
- /* Place the caret's icon-center on row 1's optical center.
369
- The button has --a-space-0-5 vertical padding, so the icon
370
- sits that much lower inside the box; we subtract that so the
371
- icon (not the box) lines up with the time-slot text center. */
371
+ /* Place the caret's icon-center on row 1's optical center (the box
372
+ is --button-height tall around a 1em-line icon). */
372
373
  top: calc((1.4em - 1em) / 2 - var(--a-space-0-5));
373
- background: none;
374
- border: none;
375
- padding: var(--a-space-0-5) var(--timeline-item-toggle-px);
376
- margin: 0;
377
- cursor: pointer;
378
- color: var(--timeline-item-label-fg-muted, var(--a-fg-muted));
379
- border-radius: var(--a-radius-sm);
380
- display: inline-flex;
381
- align-items: center;
382
- justify-content: center;
383
- line-height: 0;
384
- transition: background var(--timeline-item-duration) var(--timeline-item-easing),
385
- color var(--timeline-item-duration) var(--timeline-item-easing);
386
- }
387
-
388
- :scope > [data-timeline-toggle]:hover {
389
- background: var(--a-bg-subtle);
390
- color: var(--timeline-item-label-fg, var(--md-sys-color-neutral-on-surface));
374
+ --button-height: calc(1em + var(--a-space-1));
375
+ --button-radius: var(--a-radius-sm);
376
+ --button-fg: var(--timeline-item-label-fg-muted, var(--a-fg-muted));
377
+ --button-fg-ghost-hover: var(--timeline-item-label-fg, var(--md-sys-color-neutral-on-surface));
378
+ --button-bg-ghost-hover: var(--a-bg-subtle);
379
+ /* It IS a caret — universal caret size (ruling 2026-07-16). */
380
+ --a-icon-size: var(--a-caret-size);
391
381
  }
392
382
 
393
383
  /* Reserve room so the time isn't overlapped by the caret */
@@ -395,10 +385,8 @@ agent-reasoning-ui timeline-ui:not([orientation="horizontal"]),
395
385
  margin-inline-end: var(--timeline-item-toggle-time-margin);
396
386
  }
397
387
 
398
- :scope > [data-timeline-toggle]:focus-visible {
399
- outline: none;
400
- box-shadow: var(--a-focus-ring);
401
- }
388
+ /* (Focus ring is button-ui's own — the custom :focus-visible rule the
389
+ native button needed is gone with it, gh issue 276.) */
402
390
  }
403
391
 
404
392
  /* When size=sm on the parent, ring is 1.5px so the small dot still looks crisp.
@@ -12,6 +12,7 @@ description: "Timeline and step wizard. Two modes: timeline (per-item state) and
12
12
  # do NOT auto-import them. Consumer (or demo shell) must explicitly import.
13
13
  composes:
14
14
  - icon-ui
15
+ - button-ui # outcomes toggle (gh issue 276 — no native <button> in stamped DOM)
15
16
  props:
16
17
  orientation:
17
18
  description: Layout direction
@@ -90,6 +90,7 @@ toggle-option-ui:not([disabled]):hover {
90
90
  :scope:focus-visible {
91
91
  outline: none;
92
92
  box-shadow: var(--toggle-option-focus-ring);
93
+ border-color: transparent;
93
94
  z-index: 1;
94
95
  }
95
96
 
@@ -114,12 +114,17 @@ export class UIToolbar extends UIElement {
114
114
  connected() {
115
115
  this.setAttribute('role', 'toolbar');
116
116
  this.#ensureSpillover();
117
- this.#ro = new ResizeObserver(() => this.#queueReflow());
118
- this.#ro.observe(this);
119
- this.#mo = new MutationObserver(() => {
120
- if (this.#measuring) return;
121
- this.#queueReflow();
122
- });
117
+ // gh#285 SSR DOM shims (linkedom) have neither observer global.
118
+ if (typeof ResizeObserver !== 'undefined') {
119
+ this.#ro = new ResizeObserver(() => this.#queueReflow());
120
+ this.#ro.observe(this);
121
+ }
122
+ if (typeof MutationObserver !== 'undefined') {
123
+ this.#mo = new MutationObserver(() => {
124
+ if (this.#measuring) return;
125
+ this.#queueReflow();
126
+ });
127
+ }
123
128
  this.#moObserve();
124
129
  this.#queueReflow();
125
130
  }
@@ -324,8 +324,11 @@ export class UITreeItem extends UIElement {
324
324
  // role=button child of the group/tree (axe `aria-required-children`). Adopt
325
325
  // what's present now, then observe for the late arrivals.
326
326
  this.#adoptSlotted();
327
- this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
328
- this.#slotObserver.observe(this, { childList: true, subtree: true });
327
+ // gh#285 SSR DOM shims (linkedom) have no MutationObserver global.
328
+ if (typeof MutationObserver !== 'undefined') {
329
+ this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
330
+ this.#slotObserver.observe(this, { childList: true, subtree: true });
331
+ }
329
332
  }
330
333
 
331
334
  #stamp() {
@@ -7,7 +7,10 @@
7
7
  --tree-row-gap: var(--a-space-1);
8
8
  --tree-actions-gap: var(--a-space-0-5);
9
9
  --tree-indent: var(--a-space-4);
10
- --tree-caret-size: var(--a-space-2);
10
+ /* The universal caret token (14px default) — sidebar carets read one
11
+ size across nav-group / tree / accordion (ruling 2026-07-16); the
12
+ old --a-space-2 read made tree carets an 8px outlier. */
13
+ --tree-caret-size: var(--a-caret-size);
11
14
  --tree-icon-size: var(--a-space-3);
12
15
 
13
16
  /* ── Typography ── */
@@ -47,7 +50,7 @@
47
50
  --tree-item-row-gap: var(--tree-row-gap, var(--a-space-1));
48
51
  --tree-item-actions-gap: var(--tree-actions-gap, var(--a-space-0-5));
49
52
  --tree-item-indent: var(--tree-indent, var(--a-space-4));
50
- --tree-item-caret-size: var(--tree-caret-size, var(--a-space-2));
53
+ --tree-item-caret-size: var(--tree-caret-size, var(--a-caret-size));
51
54
  --tree-item-icon-size: var(--tree-icon-size, var(--a-space-3));
52
55
 
53
56
  --tree-item-fg: var(--tree-fg, var(--md-sys-color-neutral-on-surface));
@@ -75,6 +75,7 @@
75
75
  /* Canonical ring via L3 token (see semantics.css FOCUS block). */
76
76
  outline: none;
77
77
  box-shadow: var(--upload-focus-ring);
78
+ border-color: transparent;
78
79
  }
79
80
  [data-dropzone][data-dragover] {
80
81
  border-color: var(--upload-border-dragover);
@@ -453,24 +453,30 @@ function visitSubtree(root, fn) {
453
453
  }
454
454
  }
455
455
 
456
- const observer = new MutationObserver((mutations) => {
457
- for (const m of mutations) {
458
- if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
459
- const el = m.target;
460
- if (isStreamingEl(el)) start(el);
461
- else stop(el);
462
- continue;
463
- }
464
- if (m.type === 'childList') {
465
- m.addedNodes.forEach(n => visitSubtree(n, start));
466
- m.removedNodes.forEach(n => visitSubtree(n, stop));
467
- }
468
- }
469
- });
456
+ // gh#285 SSR DOM shims (linkedom) have no MutationObserver global.
457
+ // Constructed lazily so import-time evaluation never throws; bootstrap()
458
+ // no-ops the document-level watch when the global is missing (existing
459
+ // stream elements found via querySelectorAll still get started once).
460
+ const observer = typeof MutationObserver !== 'undefined'
461
+ ? new MutationObserver((mutations) => {
462
+ for (const m of mutations) {
463
+ if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
464
+ const el = m.target;
465
+ if (isStreamingEl(el)) start(el);
466
+ else stop(el);
467
+ continue;
468
+ }
469
+ if (m.type === 'childList') {
470
+ m.addedNodes.forEach(n => visitSubtree(n, start));
471
+ m.removedNodes.forEach(n => visitSubtree(n, stop));
472
+ }
473
+ }
474
+ })
475
+ : null;
470
476
 
471
477
  function bootstrap() {
472
478
  if (typeof document === 'undefined') return;
473
- observer.observe(document.documentElement, {
479
+ observer?.observe(document.documentElement, {
474
480
  childList: true, subtree: true,
475
481
  attributes: true, attributeFilter: ATTR_FILTER,
476
482
  });
package/core/element.js CHANGED
@@ -64,6 +64,26 @@ function installProps(el, props) {
64
64
  }
65
65
  }
66
66
 
67
+ // A minimal no-op ElementInternals shim for environments with no real
68
+ // implementation (linkedom and other DOM-shim SSR passes — gh#285). Real
69
+ // form-participation/validation behavior is meaningless server-side (no
70
+ // live user interaction to validate); the shim exists so the ~12
71
+ // call sites across the framework that read `this.internals.setValidity(…)`
72
+ // / `.setFormValue(…)` / `.validity` etc. keep working as inert no-ops
73
+ // instead of throwing on a missing method. Surface matches exactly what
74
+ // the framework's own consumers call (grepped, not guessed).
75
+ const NOOP_INTERNALS = Object.freeze({
76
+ setFormValue() {},
77
+ setValidity() {},
78
+ checkValidity() { return true; },
79
+ reportValidity() { return true; },
80
+ get form() { return null; },
81
+ get labels() { return []; },
82
+ get validity() { return {}; },
83
+ get validationMessage() { return ''; },
84
+ get willValidate() { return false; },
85
+ });
86
+
67
87
  function reflect(el, a, v, t) {
68
88
  t === Boolean
69
89
  ? v ? el.setAttribute(a, '') : el.removeAttribute(a)
@@ -78,6 +98,11 @@ function adoptStyles(ctor) {
78
98
  ctor._sa = true;
79
99
  const sheets = ctor.styles;
80
100
  if (!sheets) return;
101
+ // gh#285 — linkedom's document has no adoptedStyleSheets at all; SSR
102
+ // has no rendered stylesheet to adopt into anyway (styles matter for
103
+ // paint, which doesn't happen server-side), so skipping is a correct
104
+ // no-op, not a degraded feature.
105
+ if (!('adoptedStyleSheets' in document)) return;
81
106
  const list = Array.isArray(sheets) ? sheets : [sheets];
82
107
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...list.filter(s => !document.adoptedStyleSheets.includes(s))];
83
108
  }
@@ -114,7 +139,17 @@ export class UIElement extends HTMLElement {
114
139
 
115
140
  constructor() {
116
141
  super();
117
- this.internals = this.attachInternals();
142
+ // gh#285 — linkedom (and other DOM-shim SSR passes) implement no
143
+ // ElementInternals; an unconditional attachInternals() call threw
144
+ // HERE, before ANY subclass code ran — the single highest-leverage
145
+ // crash site in the whole framework under SSR. Feature-detect and
146
+ // fall back to a no-op shim (form participation is meaningless
147
+ // server-side) rather than leaving `this.internals` undefined,
148
+ // which would just move the crash to every consumer's first
149
+ // `.setValidity(…)` call instead of fixing it.
150
+ this.internals = typeof this.attachInternals === 'function'
151
+ ? this.attachInternals()
152
+ : NOOP_INTERNALS;
118
153
  installProps(this, this.constructor.properties);
119
154
  }
120
155
 
@@ -249,3 +249,70 @@ describe('UIElement — addTrait', () => {
249
249
  expect(disconnects).toBe(1);
250
250
  });
251
251
  });
252
+
253
+ // gh#285 — SSR DOM shims (linkedom et al.) implement no ElementInternals
254
+ // and no document.adoptedStyleSheets. These tests simulate that absence
255
+ // directly (deleting the real APIs) rather than trusting a mock — the
256
+ // bug was an unconditional call crashing the CONSTRUCTOR, before any
257
+ // subclass code ran, so the only real proof is exercising construction
258
+ // with the API genuinely gone.
259
+ describe('UIElement — SSR browser-API absence (gh#285)', () => {
260
+ beforeEach(() => { document.body.innerHTML = ''; });
261
+
262
+ it('constructs without throwing when attachInternals does not exist', () => {
263
+ const original = HTMLElement.prototype.attachInternals;
264
+ delete HTMLElement.prototype.attachInternals;
265
+ try {
266
+ class El extends UIElement {}
267
+ const tag = registerTestElement(El);
268
+ expect(() => mount(tag)).not.toThrow();
269
+ } finally {
270
+ HTMLElement.prototype.attachInternals = original;
271
+ }
272
+ });
273
+
274
+ it('the no-op internals shim answers every call site the framework actually makes', () => {
275
+ const original = HTMLElement.prototype.attachInternals;
276
+ delete HTMLElement.prototype.attachInternals;
277
+ let el;
278
+ try {
279
+ class El extends UIElement {}
280
+ const tag = registerTestElement(El);
281
+ el = mount(tag);
282
+ } finally {
283
+ HTMLElement.prototype.attachInternals = original;
284
+ }
285
+ expect(() => el.internals.setFormValue('x')).not.toThrow();
286
+ expect(() => el.internals.setValidity({})).not.toThrow();
287
+ expect(el.internals.checkValidity()).toBe(true);
288
+ expect(el.internals.reportValidity()).toBe(true);
289
+ expect(el.internals.form).toBeNull();
290
+ expect(el.internals.labels).toEqual([]);
291
+ expect(el.internals.validity).toEqual({});
292
+ expect(el.internals.validationMessage).toBe('');
293
+ expect(el.internals.willValidate).toBe(false);
294
+ });
295
+
296
+ it('a real attachInternals is still used when the browser provides one (no regression)', () => {
297
+ class El extends UIElement {}
298
+ const tag = registerTestElement(El);
299
+ const el = mount(tag);
300
+ // happy-dom's real attachInternals — not the shim.
301
+ expect(el.internals).not.toBe(null);
302
+ expect(typeof el.internals.setFormValue).toBe('function');
303
+ });
304
+
305
+ it('adoptStyles no-ops instead of throwing when adoptedStyleSheets does not exist', () => {
306
+ const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'adoptedStyleSheets');
307
+ delete Document.prototype.adoptedStyleSheets;
308
+ try {
309
+ class El extends UIElement {
310
+ static styles = ['/* fake sheet */'];
311
+ }
312
+ const tag = registerTestElement(El);
313
+ expect(() => mount(tag)).not.toThrow();
314
+ } finally {
315
+ if (desc) Object.defineProperty(Document.prototype, 'adoptedStyleSheets', desc);
316
+ }
317
+ });
318
+ });