@adia-ai/web-components 0.2.0 → 0.2.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 (102) hide show
  1. package/README.md +5 -2
  2. package/components/chat-thread/chat-input.css +107 -19
  3. package/components/index.js +2 -1
  4. package/components/table/cell-types.js +1 -1
  5. package/core/element.js +63 -2
  6. package/package.json +1 -3
  7. package/styles/colors/semantics.css +4 -4
  8. package/styles/components.css +1 -1
  9. package/traits/_catalog.json +509 -0
  10. package/traits/_motion.js +57 -0
  11. package/traits/_smoke.test.js +111 -0
  12. package/traits/_test-helpers.js +82 -0
  13. package/traits/active-state.js +2 -0
  14. package/traits/active-state.test.js +28 -0
  15. package/traits/anchor-positioning.js +2 -0
  16. package/traits/anchor-positioning.test.js +49 -0
  17. package/traits/attention-pulse.js +11 -0
  18. package/traits/attention-pulse.test.js +26 -0
  19. package/traits/confetti-burst.js +27 -0
  20. package/traits/confetti-burst.test.js +38 -0
  21. package/traits/confetti.js +18 -0
  22. package/traits/confetti.test.js +27 -0
  23. package/traits/count-up.js +17 -0
  24. package/traits/count-up.test.js +54 -0
  25. package/traits/declarative.test.js +138 -0
  26. package/traits/define.js +43 -3
  27. package/traits/dirty-state.js +2 -0
  28. package/traits/dirty-state.test.js +45 -0
  29. package/traits/drag-ghost.js +2 -0
  30. package/traits/drag-ghost.test.js +19 -0
  31. package/traits/draggable.js +2 -0
  32. package/traits/draggable.test.js +60 -0
  33. package/traits/fade-presence.js +2 -0
  34. package/traits/fade-presence.test.js +20 -0
  35. package/traits/focus-trap.js +2 -0
  36. package/traits/focus-trap.test.js +42 -0
  37. package/traits/focusable.js +2 -0
  38. package/traits/focusable.test.js +53 -0
  39. package/traits/glow-focus.js +6 -1
  40. package/traits/glow-focus.test.js +31 -0
  41. package/traits/gradient-shift.js +9 -0
  42. package/traits/gradient-shift.test.js +22 -0
  43. package/traits/haptic-feedback.js +2 -0
  44. package/traits/haptic-feedback.test.js +52 -0
  45. package/traits/hotkey.js +2 -0
  46. package/traits/hotkey.test.js +61 -0
  47. package/traits/hoverable.js +2 -0
  48. package/traits/hoverable.test.js +24 -0
  49. package/traits/index.js +50 -37
  50. package/traits/inertia-drag.js +2 -0
  51. package/traits/inertia-drag.test.js +33 -0
  52. package/traits/intersection-observer.js +2 -0
  53. package/traits/intersection-observer.test.js +38 -0
  54. package/traits/keyboard-nav.js +2 -0
  55. package/traits/keyboard-nav.test.js +41 -0
  56. package/traits/magnetic-hover.js +8 -0
  57. package/traits/magnetic-hover.test.js +30 -0
  58. package/traits/noise-texture.js +2 -0
  59. package/traits/noise-texture.test.js +20 -0
  60. package/traits/parallax.js +9 -0
  61. package/traits/parallax.test.js +26 -0
  62. package/traits/portal.js +2 -0
  63. package/traits/portal.test.js +30 -0
  64. package/traits/pressable.js +2 -0
  65. package/traits/pressable.test.js +73 -0
  66. package/traits/resettable.js +40 -0
  67. package/traits/resettable.test.js +67 -0
  68. package/traits/resizable.js +2 -0
  69. package/traits/resizable.test.js +20 -0
  70. package/traits/resize-observer.js +2 -0
  71. package/traits/resize-observer.test.js +38 -0
  72. package/traits/ripple.js +9 -0
  73. package/traits/ripple.test.js +32 -0
  74. package/traits/roving-tabindex.js +2 -0
  75. package/traits/roving-tabindex.test.js +28 -0
  76. package/traits/scale-press.js +2 -0
  77. package/traits/scale-press.test.js +39 -0
  78. package/traits/scroll-lock.js +2 -0
  79. package/traits/scroll-lock.test.js +45 -0
  80. package/traits/shimmer-loading.js +20 -0
  81. package/traits/shimmer-loading.test.js +43 -0
  82. package/traits/snap-to-grid.js +2 -0
  83. package/traits/snap-to-grid.test.js +40 -0
  84. package/traits/sound-feedback.js +2 -0
  85. package/traits/sound-feedback.test.js +26 -0
  86. package/traits/spring-animate.js +2 -0
  87. package/traits/spring-animate.test.js +28 -0
  88. package/traits/tilt-hover.js +8 -0
  89. package/traits/tilt-hover.test.js +32 -0
  90. package/traits/tossable.js +2 -0
  91. package/traits/tossable.test.js +31 -0
  92. package/traits/traits-host.js +53 -0
  93. package/traits/traits-host.test.js +73 -0
  94. package/traits/typeahead.js +2 -0
  95. package/traits/typeahead.test.js +38 -0
  96. package/traits/typewriter.js +17 -0
  97. package/traits/typewriter.test.js +47 -0
  98. package/traits/validation.js +2 -0
  99. package/traits/validation.test.js +93 -0
  100. package/a2ui/index.js +0 -25
  101. /package/components/stat/{stat.css → stat-ui.css} +0 -0
  102. /package/components/stat/{stat.js → stat-ui.js} +0 -0
@@ -0,0 +1,509 @@
1
+ {
2
+ "generated": "scripts/build/traits-catalog.mjs",
3
+ "note": "Generated from packages/web-components/traits/*.js. Do not hand-edit. Run `node scripts/build/traits-catalog.mjs`.",
4
+ "count": 41,
5
+ "categories": [
6
+ "animation-feedback",
7
+ "audio-haptics-sensory",
8
+ "forms-data",
9
+ "input-interaction",
10
+ "interaction-delight",
11
+ "keyboard-navigation",
12
+ "layout-measurement",
13
+ "motion-positioning",
14
+ "visual-dynamics"
15
+ ],
16
+ "traits": [
17
+ {
18
+ "name": "fade-presence",
19
+ "category": "animation-feedback",
20
+ "description": "Enter/exit fade with lifecycle",
21
+ "attributes": [
22
+ "data-fade-presence-entering",
23
+ "data-fade-presence-exiting"
24
+ ],
25
+ "events": [
26
+ "fade-in-done",
27
+ "fade-out-done"
28
+ ],
29
+ "config": []
30
+ },
31
+ {
32
+ "name": "ripple",
33
+ "category": "animation-feedback",
34
+ "description": "Material-style press ripple effect",
35
+ "attributes": [
36
+ "data-ripple-active"
37
+ ],
38
+ "events": [],
39
+ "config": []
40
+ },
41
+ {
42
+ "name": "scale-press",
43
+ "category": "animation-feedback",
44
+ "description": "Subtle scale transform on press",
45
+ "attributes": [
46
+ "data-scale-press-active"
47
+ ],
48
+ "events": [],
49
+ "config": []
50
+ },
51
+ {
52
+ "name": "spring-animate",
53
+ "category": "animation-feedback",
54
+ "description": "Spring-based motion transitions",
55
+ "attributes": [
56
+ "data-spring-animate-active"
57
+ ],
58
+ "events": [],
59
+ "config": [
60
+ "data-spring-stiffness",
61
+ "data-spring-damping"
62
+ ]
63
+ },
64
+ {
65
+ "name": "tilt-hover",
66
+ "category": "animation-feedback",
67
+ "description": "Tilt based on pointer position",
68
+ "attributes": [
69
+ "data-tilt-hover-active"
70
+ ],
71
+ "events": [],
72
+ "config": []
73
+ },
74
+ {
75
+ "name": "attention-pulse",
76
+ "category": "audio-haptics-sensory",
77
+ "description": "Periodic pulse to draw attention",
78
+ "attributes": [
79
+ "data-attention-pulse-active"
80
+ ],
81
+ "events": [],
82
+ "config": [
83
+ "data-pulse-interval"
84
+ ]
85
+ },
86
+ {
87
+ "name": "count-up",
88
+ "category": "audio-haptics-sensory",
89
+ "description": "Animated numeric transitions",
90
+ "attributes": [
91
+ "data-count-up-active"
92
+ ],
93
+ "events": [
94
+ "count-up-done"
95
+ ],
96
+ "config": [
97
+ "data-count-up-target",
98
+ "data-count-duration"
99
+ ]
100
+ },
101
+ {
102
+ "name": "haptic-feedback",
103
+ "category": "audio-haptics-sensory",
104
+ "description": "Vibration API feedback",
105
+ "attributes": [
106
+ "data-haptic-feedback-active"
107
+ ],
108
+ "events": [],
109
+ "config": [
110
+ "data-haptic-pattern"
111
+ ]
112
+ },
113
+ {
114
+ "name": "sound-feedback",
115
+ "category": "audio-haptics-sensory",
116
+ "description": "Synthesized tones via Web Audio API",
117
+ "attributes": [
118
+ "data-sound-feedback-active"
119
+ ],
120
+ "events": [],
121
+ "config": [
122
+ "data-sound-type"
123
+ ]
124
+ },
125
+ {
126
+ "name": "typewriter",
127
+ "category": "audio-haptics-sensory",
128
+ "description": "Animated text reveal character by character",
129
+ "attributes": [
130
+ "data-typewriter-active"
131
+ ],
132
+ "events": [
133
+ "typewriter-done"
134
+ ],
135
+ "config": [
136
+ "data-typewriter-speed"
137
+ ]
138
+ },
139
+ {
140
+ "name": "dirty-state",
141
+ "category": "forms-data",
142
+ "description": "Tracks modified vs initial value",
143
+ "attributes": [
144
+ "data-dirty-state-dirty",
145
+ "data-dirty-state-pristine"
146
+ ],
147
+ "events": [],
148
+ "config": []
149
+ },
150
+ {
151
+ "name": "resettable",
152
+ "category": "forms-data",
153
+ "description": "Snaps the host value back to its initial value on form reset",
154
+ "attributes": [
155
+ "data-resettable-active"
156
+ ],
157
+ "events": [
158
+ "reset-applied"
159
+ ],
160
+ "config": []
161
+ },
162
+ {
163
+ "name": "validation",
164
+ "category": "forms-data",
165
+ "description": "Validation rules: required, minlength, pattern, email",
166
+ "attributes": [
167
+ "data-validation-invalid",
168
+ "data-validation-valid",
169
+ "data-validation-message"
170
+ ],
171
+ "events": [
172
+ "validated"
173
+ ],
174
+ "config": [
175
+ "data-validate"
176
+ ]
177
+ },
178
+ {
179
+ "name": "active-state",
180
+ "category": "input-interaction",
181
+ "description": "Tracks pointer-down / active interaction state",
182
+ "attributes": [
183
+ "data-active-state-active"
184
+ ],
185
+ "events": [],
186
+ "config": []
187
+ },
188
+ {
189
+ "name": "focusable",
190
+ "category": "input-interaction",
191
+ "description": "Keyboard-only focus ring, ignores pointer focus",
192
+ "attributes": [
193
+ "data-focusable-keyboard"
194
+ ],
195
+ "events": [],
196
+ "config": []
197
+ },
198
+ {
199
+ "name": "hoverable",
200
+ "category": "input-interaction",
201
+ "description": "Pointer hover enter/leave state tracking",
202
+ "attributes": [
203
+ "data-hoverable-hover"
204
+ ],
205
+ "events": [],
206
+ "config": []
207
+ },
208
+ {
209
+ "name": "pressable",
210
+ "category": "input-interaction",
211
+ "description": "Normalizes click/tap/keyboard into a single \"press\" event",
212
+ "attributes": [
213
+ "data-pressable-pressed"
214
+ ],
215
+ "events": [
216
+ "press"
217
+ ],
218
+ "config": []
219
+ },
220
+ {
221
+ "name": "confetti",
222
+ "category": "interaction-delight",
223
+ "description": "Radial particle burst on press",
224
+ "attributes": [
225
+ "data-confetti-active"
226
+ ],
227
+ "events": [],
228
+ "config": []
229
+ },
230
+ {
231
+ "name": "confetti-burst",
232
+ "category": "interaction-delight",
233
+ "description": "Upward fountain particle burst",
234
+ "attributes": [
235
+ "data-confetti-burst-active"
236
+ ],
237
+ "events": [
238
+ "confetti-burst-done"
239
+ ],
240
+ "config": []
241
+ },
242
+ {
243
+ "name": "magnetic-hover",
244
+ "category": "interaction-delight",
245
+ "description": "Element subtly follows cursor",
246
+ "attributes": [
247
+ "data-magnetic-hover-active"
248
+ ],
249
+ "events": [],
250
+ "config": []
251
+ },
252
+ {
253
+ "name": "focus-trap",
254
+ "category": "keyboard-navigation",
255
+ "description": "Traps Tab/Shift+Tab within a container",
256
+ "attributes": [
257
+ "data-focus-trap-active"
258
+ ],
259
+ "events": [
260
+ "focus-trap-escape"
261
+ ],
262
+ "config": []
263
+ },
264
+ {
265
+ "name": "hotkey",
266
+ "category": "keyboard-navigation",
267
+ "description": "Global or scoped keyboard shortcuts",
268
+ "attributes": [],
269
+ "events": [
270
+ "hotkey"
271
+ ],
272
+ "config": [
273
+ "data-hotkey",
274
+ "data-hotkey-global"
275
+ ]
276
+ },
277
+ {
278
+ "name": "keyboard-nav",
279
+ "category": "keyboard-navigation",
280
+ "description": "Arrow keys, Enter, Escape — semantic navigation events",
281
+ "attributes": [],
282
+ "events": [
283
+ "nav-up",
284
+ "nav-down",
285
+ "nav-left",
286
+ "nav-right",
287
+ "nav-enter",
288
+ "nav-escape",
289
+ "nav-home",
290
+ "nav-end"
291
+ ],
292
+ "config": []
293
+ },
294
+ {
295
+ "name": "roving-tabindex",
296
+ "category": "keyboard-navigation",
297
+ "description": "Focus management within composite widgets",
298
+ "attributes": [],
299
+ "events": [],
300
+ "config": []
301
+ },
302
+ {
303
+ "name": "typeahead",
304
+ "category": "keyboard-navigation",
305
+ "description": "Incremental search within a collection",
306
+ "attributes": [
307
+ "data-typeahead-match"
308
+ ],
309
+ "events": [
310
+ "typeahead-match"
311
+ ],
312
+ "config": []
313
+ },
314
+ {
315
+ "name": "anchor-positioning",
316
+ "category": "layout-measurement",
317
+ "description": "Positions relative to an anchor element",
318
+ "attributes": [
319
+ "data-anchor-positioning-placed",
320
+ "data-anchor-placement-actual"
321
+ ],
322
+ "events": [
323
+ "anchor-placed"
324
+ ],
325
+ "config": [
326
+ "data-anchor",
327
+ "data-anchor-placement",
328
+ "data-anchor-gap"
329
+ ]
330
+ },
331
+ {
332
+ "name": "intersection-observer",
333
+ "category": "layout-measurement",
334
+ "description": "Visibility detection (viewport)",
335
+ "attributes": [
336
+ "data-intersection-visible",
337
+ "data-intersection-ratio"
338
+ ],
339
+ "events": [
340
+ "element-visible",
341
+ "element-hidden"
342
+ ],
343
+ "config": [
344
+ "data-intersection-threshold"
345
+ ]
346
+ },
347
+ {
348
+ "name": "portal",
349
+ "category": "layout-measurement",
350
+ "description": "Renders content in a different DOM root",
351
+ "attributes": [
352
+ "data-portal-active"
353
+ ],
354
+ "events": [],
355
+ "config": [
356
+ "data-portal-target"
357
+ ]
358
+ },
359
+ {
360
+ "name": "resize-observer",
361
+ "category": "layout-measurement",
362
+ "description": "Reacts to element size changes",
363
+ "attributes": [
364
+ "data-resize-observer-observed",
365
+ "data-resize-width",
366
+ "data-resize-height"
367
+ ],
368
+ "events": [
369
+ "element-resize"
370
+ ],
371
+ "config": []
372
+ },
373
+ {
374
+ "name": "scroll-lock",
375
+ "category": "layout-measurement",
376
+ "description": "Disables background scrolling",
377
+ "attributes": [
378
+ "data-scroll-lock-active"
379
+ ],
380
+ "events": [],
381
+ "config": []
382
+ },
383
+ {
384
+ "name": "drag-ghost",
385
+ "category": "motion-positioning",
386
+ "description": "Ghost clone at origin during drag",
387
+ "attributes": [
388
+ "data-drag-ghost-active"
389
+ ],
390
+ "events": [],
391
+ "config": []
392
+ },
393
+ {
394
+ "name": "draggable",
395
+ "category": "motion-positioning",
396
+ "description": "Pointer drag to reposition",
397
+ "attributes": [
398
+ "data-draggable-dragging"
399
+ ],
400
+ "events": [
401
+ "drag-end"
402
+ ],
403
+ "config": []
404
+ },
405
+ {
406
+ "name": "inertia-drag",
407
+ "category": "motion-positioning",
408
+ "description": "Momentum-based dragging, smooth deceleration",
409
+ "attributes": [
410
+ "data-inertia-drag-coasting"
411
+ ],
412
+ "events": [
413
+ "drag-end"
414
+ ],
415
+ "config": []
416
+ },
417
+ {
418
+ "name": "resizable",
419
+ "category": "motion-positioning",
420
+ "description": "Drag edges/corners to resize",
421
+ "attributes": [
422
+ "data-resizable-resizing",
423
+ "data-resizable-edge"
424
+ ],
425
+ "events": [
426
+ "resize-end"
427
+ ],
428
+ "config": []
429
+ },
430
+ {
431
+ "name": "snap-to-grid",
432
+ "category": "motion-positioning",
433
+ "description": "Snaps position to configurable grid",
434
+ "attributes": [
435
+ "data-snap-to-grid-snapped"
436
+ ],
437
+ "events": [],
438
+ "config": [
439
+ "data-snap-grid"
440
+ ]
441
+ },
442
+ {
443
+ "name": "tossable",
444
+ "category": "motion-positioning",
445
+ "description": "Flick with momentum + viewport bounce",
446
+ "attributes": [
447
+ "data-tossable-flying"
448
+ ],
449
+ "events": [
450
+ "toss-end"
451
+ ],
452
+ "config": [
453
+ "data-tossable-bounds"
454
+ ]
455
+ },
456
+ {
457
+ "name": "glow-focus",
458
+ "category": "visual-dynamics",
459
+ "description": "Animated pulsing glow on focus",
460
+ "attributes": [
461
+ "data-glow-focus-active"
462
+ ],
463
+ "events": [],
464
+ "config": []
465
+ },
466
+ {
467
+ "name": "gradient-shift",
468
+ "category": "visual-dynamics",
469
+ "description": "Animated rainbow gradient backgrounds",
470
+ "attributes": [
471
+ "data-gradient-shift-active"
472
+ ],
473
+ "events": [],
474
+ "config": []
475
+ },
476
+ {
477
+ "name": "noise-texture",
478
+ "category": "visual-dynamics",
479
+ "description": "Procedural grain overlay",
480
+ "attributes": [
481
+ "data-noise-texture-active"
482
+ ],
483
+ "events": [],
484
+ "config": []
485
+ },
486
+ {
487
+ "name": "parallax",
488
+ "category": "visual-dynamics",
489
+ "description": "Layered motion relative to pointer",
490
+ "attributes": [
491
+ "data-parallax-active"
492
+ ],
493
+ "events": [],
494
+ "config": [
495
+ "data-parallax-strength"
496
+ ]
497
+ },
498
+ {
499
+ "name": "shimmer-loading",
500
+ "category": "visual-dynamics",
501
+ "description": "Skeleton shimmer effect",
502
+ "attributes": [
503
+ "data-shimmer-loading-active"
504
+ ],
505
+ "events": [],
506
+ "config": []
507
+ }
508
+ ]
509
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared motion helpers — reduced-motion preference detection.
3
+ *
4
+ * Per WCAG 2.1 SC 2.3.3, autonomous motion (animations users didn't
5
+ * explicitly initiate) must respect `prefers-reduced-motion: reduce`.
6
+ * Traits that fire timer-driven or decorative animation should consult
7
+ * `prefersReducedMotion()` in their setup() and either bail or render
8
+ * a static fallback.
9
+ *
10
+ * User-initiated motion (drag, resize, toss, inertia) does NOT need
11
+ * this guard — those traits respond to direct user input and are not
12
+ * subject to the autonomous-animation rule.
13
+ */
14
+
15
+ let cached = null;
16
+
17
+ export function prefersReducedMotion() {
18
+ if (cached !== null) return cached;
19
+ if (typeof window === 'undefined' || !window.matchMedia) {
20
+ cached = false;
21
+ return cached;
22
+ }
23
+ try {
24
+ cached = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
25
+ } catch {
26
+ cached = false;
27
+ }
28
+ return cached;
29
+ }
30
+
31
+ /**
32
+ * Subscribe to reduced-motion changes — returns a cleanup fn.
33
+ * Useful when a trait wants to switch behavior live as the user toggles
34
+ * the OS preference.
35
+ */
36
+ export function onReducedMotionChange(callback) {
37
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
38
+ let mql;
39
+ try {
40
+ mql = window.matchMedia('(prefers-reduced-motion: reduce)');
41
+ } catch {
42
+ return () => {};
43
+ }
44
+ const handler = (e) => {
45
+ cached = e.matches;
46
+ callback(e.matches);
47
+ };
48
+ mql.addEventListener?.('change', handler);
49
+ return () => mql.removeEventListener?.('change', handler);
50
+ }
51
+
52
+ /**
53
+ * Test-only: reset the cached result so tests can mock matchMedia between cases.
54
+ */
55
+ export function _resetMotionCache() {
56
+ cached = null;
57
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Universal trait contract — runs against every trait imported from
3
+ * traits/index.js. Asserts schema integrity, attribute cleanup on
4
+ * disconnect, and idempotent connect/disconnect cycles.
5
+ *
6
+ * Per-trait behavior tests live in `<trait>.test.js` siblings.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest';
10
+ import * as traits from './index.js';
11
+ import { mountHost, connectTrait, expectValidSchema, resetDOM } from './_test-helpers.js';
12
+
13
+ const KNOWN_CATEGORIES = new Set([
14
+ 'input-interaction',
15
+ 'keyboard-navigation',
16
+ 'forms-data',
17
+ 'layout-measurement',
18
+ 'motion-positioning',
19
+ 'animation-feedback',
20
+ 'visual-dynamics',
21
+ 'interaction-delight',
22
+ 'audio-haptics-sensory',
23
+ ]);
24
+
25
+ const ALL_TRAITS = Object.entries(traits).filter(([, fn]) => typeof fn === 'function' && fn.schema);
26
+
27
+ describe('trait registry — universal contract', () => {
28
+ beforeEach(resetDOM);
29
+
30
+ it('exports at least 40 traits', () => {
31
+ expect(ALL_TRAITS.length).toBeGreaterThanOrEqual(40);
32
+ });
33
+
34
+ it('every trait declares a known category', () => {
35
+ for (const [exportName, trait] of ALL_TRAITS) {
36
+ expect(KNOWN_CATEGORIES.has(trait.schema.category), `${exportName} category=${trait.schema.category}`).toBe(true);
37
+ }
38
+ });
39
+
40
+ it('every trait has a non-empty description', () => {
41
+ for (const [exportName, trait] of ALL_TRAITS) {
42
+ expect(trait.schema.description, `${exportName} missing description`).toBeTruthy();
43
+ expect(trait.schema.description.length, `${exportName} description too short`).toBeGreaterThan(10);
44
+ }
45
+ });
46
+
47
+ it('every trait passes the schema-shape contract', () => {
48
+ for (const [, trait] of ALL_TRAITS) expectValidSchema(trait);
49
+ });
50
+
51
+ it('attribute, event, and config names use kebab-case', () => {
52
+ const KEBAB_OR_DATA = /^(?:data-)?[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
53
+ for (const [exportName, trait] of ALL_TRAITS) {
54
+ for (const a of trait.schema.attributes) {
55
+ expect(KEBAB_OR_DATA.test(a), `${exportName} attribute "${a}" not kebab-case`).toBe(true);
56
+ expect(a.startsWith('data-'), `${exportName} attribute "${a}" must start with data-`).toBe(true);
57
+ }
58
+ for (const e of trait.schema.events) {
59
+ expect(KEBAB_OR_DATA.test(e), `${exportName} event "${e}" not kebab-case`).toBe(true);
60
+ }
61
+ for (const c of trait.schema.config) {
62
+ expect(c.startsWith('data-'), `${exportName} config "${c}" must start with data-`).toBe(true);
63
+ }
64
+ }
65
+ });
66
+
67
+ it('connect → disconnect leaves no managed attributes behind', () => {
68
+ for (const [exportName, trait] of ALL_TRAITS) {
69
+ const host = mountHost();
70
+ const inst = connectTrait(trait, host);
71
+ inst.disconnect(host);
72
+ for (const attr of trait.schema.attributes) {
73
+ expect(host.hasAttribute(attr), `${exportName} left "${attr}" after disconnect`).toBe(false);
74
+ }
75
+ host.remove();
76
+ }
77
+ });
78
+
79
+ it('connect → disconnect → connect again does not throw', () => {
80
+ for (const [exportName, trait] of ALL_TRAITS) {
81
+ const host = mountHost();
82
+ const inst1 = connectTrait(trait, host);
83
+ inst1.disconnect(host);
84
+ expect(() => {
85
+ const inst2 = connectTrait(trait, host);
86
+ inst2.disconnect(host);
87
+ }, `${exportName} reconnect threw`).not.toThrow();
88
+ host.remove();
89
+ }
90
+ });
91
+
92
+ it('two parallel instances on different hosts do not interfere', () => {
93
+ for (const [exportName, trait] of ALL_TRAITS) {
94
+ const a = mountHost();
95
+ const b = mountHost();
96
+ const instA = connectTrait(trait, a);
97
+ const instB = connectTrait(trait, b);
98
+ // Disconnect one — the other should still clean up correctly.
99
+ expect(() => instA.disconnect(a), `${exportName} disconnect-A threw`).not.toThrow();
100
+ expect(() => instB.disconnect(b), `${exportName} disconnect-B threw`).not.toThrow();
101
+ a.remove();
102
+ b.remove();
103
+ }
104
+ });
105
+
106
+ it('catalog count matches export count', async () => {
107
+ const mod = await import('./_catalog.json', { with: { type: 'json' } });
108
+ const catalog = mod.default || mod;
109
+ expect(catalog.count, `catalog says ${catalog.count}, exports has ${ALL_TRAITS.length}`).toBe(ALL_TRAITS.length);
110
+ });
111
+ });