@adia-ai/web-components 0.2.4 → 0.3.0

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/README.md CHANGED
@@ -64,12 +64,12 @@ web-components/
64
64
  │ pages. Full contract in docs/specs/traits.md.
65
65
 
66
66
  ├── a2ui/ — deprecation shim for one release
67
- │ └── index.js Re-exports @adia-ai/a2ui-utils with a
67
+ │ └── index.js Re-exports @adia-ai/a2ui-runtime with a
68
68
  │ one-time console.warn. Removed in 0.1.0.
69
69
  │ All actual A2UI runtime code (renderer,
70
70
  │ registry, streams, surface manifest,
71
71
  │ wiring, dockables, controllers) lives in
72
- │ `@adia-ai/a2ui-utils` at packages/a2ui/utils/.
72
+ │ `@adia-ai/a2ui-runtime` at packages/a2ui/runtime/.
73
73
 
74
74
  └── styles/ — Global tokens and CSS layering
75
75
  ├── tokens.css all --a-* design tokens
@@ -139,7 +139,7 @@ examples.
139
139
  ## A2UI runtime
140
140
 
141
141
  ```javascript
142
- import { A2UIRenderer } from '@adia-ai/a2ui-utils';
142
+ import { A2UIRenderer } from '@adia-ai/a2ui-runtime';
143
143
  // (The old `@adia-ai/web-components/a2ui` subpath still resolves in 0.0.4
144
144
  // via a deprecation shim that prints a console.warn; removed in 0.1.0.)
145
145
 
@@ -198,7 +198,7 @@ the `streams` registry export from `core/data-stream.js`.
198
198
 
199
199
  Implementation: `core/data-stream.js` (~360 lines). Full
200
200
  attribute table + live demos:
201
- [`/site/components/chart#data-stream`](../../site/pages/components/chart/index.html).
201
+ [`/site/components/chart#data-stream`](./components/chart/chart.html).
202
202
 
203
203
  ## Build
204
204
 
@@ -0,0 +1,106 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/Fields.json",
4
+ "title": "Fields",
5
+ "description": "Container for a group of <field-ui> children, laid out on a shared 6-column grid. Each <field-ui> spans the full row by default; opt into a narrower span via [rows=\"1..6\"]. Setting [inline] on the host propagates the inline mode to every direct <field-ui> child so a whole sub-form can switch label-position without per-field edits. The grid alignment lets siblings on the same row line up cleanly — consistent label columns + consistent control columns — without the wrap-flex jitter of <row-ui wrap>.",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "columns": {
17
+ "description": "Number of grid columns the row uses. Defaults to 6 — common multiples (1/2/3/6) divide cleanly. <field-ui rows=\"N\"> spans `N` of these columns. Override per-instance for tighter 4-column or wider 12-column compositions.",
18
+ "type": "number",
19
+ "default": 6
20
+ },
21
+ "component": {
22
+ "const": "Fields"
23
+ },
24
+ "inline": {
25
+ "description": "Propagate the inline layout mode to every direct <field-ui> child. Equivalent to authoring `inline` on each child but drives the whole group from one place. Toggle is reactive — flipping the attribute on the host re-syncs all children.",
26
+ "type": "boolean",
27
+ "default": false
28
+ }
29
+ },
30
+ "required": [
31
+ "component"
32
+ ],
33
+ "unevaluatedProperties": false,
34
+ "x-adiaui": {
35
+ "anti_patterns": [
36
+ {
37
+ "description": "Don't wrap <field-ui> children in <row-ui wrap> when grid alignment is desired. row-ui is flex-wrap, which lets each field size to its content — labels and controls won't share a column with siblings.",
38
+ "severity": "high"
39
+ },
40
+ {
41
+ "description": "Don't set `inline` on individual <field-ui> children when the whole group should switch — set it on the parent <fields-ui> and let the propagation handle every child.",
42
+ "severity": "medium"
43
+ }
44
+ ],
45
+ "category": "form",
46
+ "events": {},
47
+ "examples": [
48
+ {
49
+ "description": "A 3-up stacked field row — Status / Priority / Group — each taking 2 of 6 columns. Equivalent to a Bootstrap \"col-md-4\" pattern (12-col grid, span 4) but with chat-ui's 6-col default.",
50
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Fields\",\n \"children\": [\"status\", \"priority\", \"group\"]\n },\n { \"id\": \"status\", \"component\": \"Field\", \"label\": \"Status\", \"rows\": 2, \"children\": [\"status-sel\"] },\n { \"id\": \"priority\", \"component\": \"Field\", \"label\": \"Priority\", \"rows\": 2, \"children\": [\"priority-sel\"] },\n { \"id\": \"group\", \"component\": \"Field\", \"label\": \"Group\", \"rows\": 2, \"children\": [\"group-sel\"] },\n { \"id\": \"status-sel\", \"component\": \"Select\", \"value\": \"todo\" },\n { \"id\": \"priority-sel\", \"component\": \"Select\", \"value\": \"0\" },\n { \"id\": \"group-sel\", \"component\": \"Select\", \"value\": \"\" }\n]",
51
+ "name": "three-up-stacked"
52
+ },
53
+ {
54
+ "description": "An inline form — every field renders label-beside-control. Single [inline] on the host drives all children.",
55
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Fields\",\n \"inline\": true,\n \"children\": [\"q\", \"kind\"]\n },\n { \"id\": \"q\", \"component\": \"Field\", \"label\": \"Search\", \"children\": [\"q-input\"] },\n { \"id\": \"kind\", \"component\": \"Field\", \"label\": \"Kind\", \"children\": [\"kind-sel\"] },\n { \"id\": \"q-input\", \"component\": \"Input\", \"type\": \"search\" },\n { \"id\": \"kind-sel\", \"component\": \"Select\", \"value\": \"all\" }\n]",
56
+ "name": "inline-search-form"
57
+ }
58
+ ],
59
+ "keywords": [
60
+ "fields",
61
+ "form",
62
+ "grid",
63
+ "layout",
64
+ "group"
65
+ ],
66
+ "name": "UIFields",
67
+ "related": [
68
+ "field",
69
+ "input",
70
+ "select",
71
+ "textarea"
72
+ ],
73
+ "slots": {
74
+ "default": {
75
+ "description": "<field-ui> children. Non-field children (separators, headings) are placed in the grid too — span them by setting `style=\"grid-column: 1 / -1\"` for full-width content. Nested <fields-ui> works (an outer 6-col grid hosting inner 3-col grids inside a single cell)."
76
+ }
77
+ },
78
+ "states": [
79
+ {
80
+ "description": "Default, ready for interaction.",
81
+ "name": "idle"
82
+ }
83
+ ],
84
+ "synonyms": {
85
+ "form": [
86
+ "fields",
87
+ "group",
88
+ "layout"
89
+ ]
90
+ },
91
+ "tag": "fields-ui",
92
+ "tokens": {
93
+ "--fields-column-gap": {
94
+ "description": "Override the column-gap independently of row-gap."
95
+ },
96
+ "--fields-gap": {
97
+ "description": "Gap between adjacent fields (row + column)."
98
+ },
99
+ "--fields-row-gap": {
100
+ "description": "Override the row-gap independently of column-gap."
101
+ }
102
+ },
103
+ "traits": [],
104
+ "version": 1
105
+ }
106
+ }
@@ -0,0 +1,60 @@
1
+ @scope (fields-ui) {
2
+ :where(:scope) {
3
+ /* ── Tokens ── */
4
+ --fields-gap: var(--a-space-3);
5
+ --fields-row-gap: var(--fields-gap);
6
+ --fields-column-gap: var(--fields-gap);
7
+ }
8
+
9
+ /* ── Base — 6-column grid ──
10
+ Children are placed into the grid by their [rows="N"] attr (pure
11
+ CSS, no JS). Default span (no attr) is the full row. The columns
12
+ count is configurable via [columns="N"] on the host — defaults
13
+ handle [columns] absent.
14
+
15
+ `minmax(0, 1fr)` is required so children with overflow content
16
+ (long labels, long select values) don't blow out the grid. */
17
+ :scope {
18
+ box-sizing: border-box;
19
+ display: grid;
20
+ grid-template-columns: repeat(var(--fields-columns, 6), minmax(0, 1fr));
21
+ column-gap: var(--fields-column-gap);
22
+ row-gap: var(--fields-row-gap);
23
+ align-items: start;
24
+ }
25
+
26
+ /* Per-instance column count from [columns="N"] — unrolled for the
27
+ common values 1..12. Beyond 12 the host can set
28
+ `--fields-columns: N` inline. */
29
+ :scope[columns="1"] { --fields-columns: 1; }
30
+ :scope[columns="2"] { --fields-columns: 2; }
31
+ :scope[columns="3"] { --fields-columns: 3; }
32
+ :scope[columns="4"] { --fields-columns: 4; }
33
+ :scope[columns="5"] { --fields-columns: 5; }
34
+ :scope[columns="6"] { --fields-columns: 6; }
35
+ :scope[columns="8"] { --fields-columns: 8; }
36
+ :scope[columns="12"] { --fields-columns: 12; }
37
+
38
+ /* ── field-ui spans ──
39
+ `rows="N"` reads as "this field takes N grid cells". Without the
40
+ attr, the field spans the full row (`grid-column: 1 / -1`) so
41
+ authors can mix narrow + full-width fields without per-row
42
+ wrappers. The attribute is exposed up to 12 to cover larger
43
+ [columns] grids; 13+ also collapses to full-row. */
44
+ :scope > field-ui {
45
+ grid-column: 1 / -1;
46
+ min-width: 0; /* allow children to shrink within the cell */
47
+ }
48
+ :scope > field-ui[rows="1"] { grid-column: span 1; }
49
+ :scope > field-ui[rows="2"] { grid-column: span 2; }
50
+ :scope > field-ui[rows="3"] { grid-column: span 3; }
51
+ :scope > field-ui[rows="4"] { grid-column: span 4; }
52
+ :scope > field-ui[rows="5"] { grid-column: span 5; }
53
+ :scope > field-ui[rows="6"] { grid-column: span 6; }
54
+ :scope > field-ui[rows="7"] { grid-column: span 7; }
55
+ :scope > field-ui[rows="8"] { grid-column: span 8; }
56
+ :scope > field-ui[rows="9"] { grid-column: span 9; }
57
+ :scope > field-ui[rows="10"] { grid-column: span 10; }
58
+ :scope > field-ui[rows="11"] { grid-column: span 11; }
59
+ :scope > field-ui[rows="12"] { grid-column: span 12; }
60
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * fields-ui — Container for a group of <field-ui> children laid out
3
+ * on a shared grid.
4
+ *
5
+ * <fields-ui> <!-- 6-col grid -->
6
+ * <field-ui label="Title">…</field-ui> <!-- rows=6 (full row) -->
7
+ * <field-ui label="Status" rows="2">…</field-ui>
8
+ * <field-ui label="Priority" rows="2">…</field-ui>
9
+ * <field-ui label="Group" rows="2">…</field-ui>
10
+ * </fields-ui>
11
+ *
12
+ * <fields-ui inline> <!-- propagate inline to every child -->
13
+ * <field-ui label="Search">…</field-ui>
14
+ * <field-ui label="Kind">…</field-ui>
15
+ * </fields-ui>
16
+ *
17
+ * The shared grid keeps siblings on the same row aligned. The [inline]
18
+ * attribute is mirrored onto every direct <field-ui> child so a whole
19
+ * sub-form switches layout mode from one host attribute. A small
20
+ * MutationObserver re-syncs when children are added/removed.
21
+ *
22
+ * Per-field column span is driven by the child's [rows="1..6"]
23
+ * attribute — pure CSS via attribute selectors in fields.css. The
24
+ * default span (no attr) is the full row.
25
+ */
26
+ import { UIElement } from '../../core/element.js';
27
+
28
+ class UIFields extends UIElement {
29
+ static properties = {
30
+ inline: { type: Boolean, default: false, reflect: true },
31
+ columns: { type: Number, default: 6, reflect: true },
32
+ };
33
+
34
+ static template = () => null;
35
+
36
+ /** @type {MutationObserver | null} */
37
+ #mo = null;
38
+
39
+ connected() {
40
+ this.#syncInline();
41
+ this.#mo = new MutationObserver((records) => {
42
+ // Only re-sync on childList changes that involve direct <field-ui>
43
+ // children — attribute mutations on grandchildren aren't ours to
44
+ // care about.
45
+ for (const r of records) {
46
+ if (r.type !== 'childList') continue;
47
+ if (this.#hasFieldChild(r.addedNodes) || this.#hasFieldChild(r.removedNodes)) {
48
+ this.#syncInline();
49
+ return;
50
+ }
51
+ }
52
+ });
53
+ this.#mo.observe(this, { childList: true });
54
+ }
55
+
56
+ disconnected() {
57
+ this.#mo?.disconnect();
58
+ this.#mo = null;
59
+ }
60
+
61
+ render() {
62
+ // UIElement calls render() on attribute → property reflection. The
63
+ // inline attr's effect is propagation-to-children, not host markup.
64
+ this.#syncInline();
65
+ }
66
+
67
+ // ── Private ────────────────────────────────────────────────────
68
+
69
+ #syncInline() {
70
+ const inline = this.hasAttribute('inline');
71
+ for (const child of this.children) {
72
+ if (child.localName !== 'field-ui') continue;
73
+ if (inline) {
74
+ if (!child.hasAttribute('inline')) child.setAttribute('inline', '');
75
+ } else {
76
+ // Only remove if WE set it. We can't tell, so we always remove —
77
+ // that's the documented contract: <fields-ui inline> is the
78
+ // single source of truth for child inline-mode within the group.
79
+ if (child.hasAttribute('inline')) child.removeAttribute('inline');
80
+ }
81
+ }
82
+ }
83
+
84
+ /** @param {NodeList} nodes */
85
+ #hasFieldChild(nodes) {
86
+ for (const n of nodes) {
87
+ if (n.nodeType === 1 && /** @type {Element} */ (n).localName === 'field-ui') {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ }
94
+
95
+ customElements.define('fields-ui', UIFields);
96
+ export { UIFields };
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './fields.js';
4
+ import '../field/field.js';
5
+
6
+ const tick = () => new Promise((r) => queueMicrotask(r));
7
+
8
+ function mount(html) {
9
+ const wrap = document.createElement('div');
10
+ wrap.innerHTML = html;
11
+ document.body.appendChild(wrap);
12
+ return wrap.firstElementChild;
13
+ }
14
+
15
+ describe('fields-ui', () => {
16
+ beforeEach(() => { document.body.innerHTML = ''; });
17
+
18
+ it('registers the custom element', () => {
19
+ expect(customElements.get('fields-ui')).toBeDefined();
20
+ });
21
+
22
+ it('hosts <field-ui> children without altering them by default', () => {
23
+ const f = mount(`
24
+ <fields-ui>
25
+ <field-ui label="A"><input id="a" /></field-ui>
26
+ <field-ui label="B"><input id="b" /></field-ui>
27
+ </fields-ui>
28
+ `);
29
+ const fields = f.querySelectorAll('field-ui');
30
+ expect(fields).toHaveLength(2);
31
+ // Default — neither child has [inline] propagated.
32
+ expect(fields[0].hasAttribute('inline')).toBe(false);
33
+ expect(fields[1].hasAttribute('inline')).toBe(false);
34
+ });
35
+
36
+ it('propagates [inline] to every direct <field-ui> child on connect', async () => {
37
+ const f = mount(`
38
+ <fields-ui inline>
39
+ <field-ui label="A"><input id="a" /></field-ui>
40
+ <field-ui label="B"><input id="b" /></field-ui>
41
+ </fields-ui>
42
+ `);
43
+ await tick();
44
+ const fields = f.querySelectorAll('field-ui');
45
+ for (const child of fields) {
46
+ expect(child.hasAttribute('inline')).toBe(true);
47
+ }
48
+ });
49
+
50
+ it('removes inline from children when [inline] is removed from host', async () => {
51
+ const f = mount(`
52
+ <fields-ui inline>
53
+ <field-ui label="A"><input id="a" /></field-ui>
54
+ <field-ui label="B"><input id="b" /></field-ui>
55
+ </fields-ui>
56
+ `);
57
+ await tick();
58
+ f.removeAttribute('inline');
59
+ await tick();
60
+ const fields = f.querySelectorAll('field-ui');
61
+ for (const child of fields) {
62
+ expect(child.hasAttribute('inline')).toBe(false);
63
+ }
64
+ });
65
+
66
+ it('re-syncs inline when a new <field-ui> is appended later', async () => {
67
+ const f = mount(`<fields-ui inline></fields-ui>`);
68
+ await tick();
69
+ const child = document.createElement('field-ui');
70
+ child.setAttribute('label', 'Late');
71
+ f.appendChild(child);
72
+ // MutationObserver fires async — give it a microtask.
73
+ await new Promise((r) => setTimeout(r, 0));
74
+ expect(child.hasAttribute('inline')).toBe(true);
75
+ });
76
+
77
+ it('does not propagate [inline] to non-field children', async () => {
78
+ const f = mount(`
79
+ <fields-ui inline>
80
+ <field-ui label="A"><input id="a" /></field-ui>
81
+ <span class="separator">—</span>
82
+ </fields-ui>
83
+ `);
84
+ await tick();
85
+ const span = f.querySelector('.separator');
86
+ expect(span.hasAttribute('inline')).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,120 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: UIFields
4
+ tag: fields-ui
5
+ component: Fields
6
+ category: form
7
+ version: 1
8
+ description: >-
9
+ Container for a group of <field-ui> children, laid out on a shared
10
+ 6-column grid. Each <field-ui> spans the full row by default; opt
11
+ into a narrower span via [rows="1..6"]. Setting [inline] on the host
12
+ propagates the inline mode to every direct <field-ui> child so a
13
+ whole sub-form can switch label-position without per-field edits.
14
+ The grid alignment lets siblings on the same row line up cleanly —
15
+ consistent label columns + consistent control columns — without
16
+ the wrap-flex jitter of <row-ui wrap>.
17
+ props:
18
+ inline:
19
+ description: >-
20
+ Propagate the inline layout mode to every direct <field-ui>
21
+ child. Equivalent to authoring `inline` on each child but
22
+ drives the whole group from one place. Toggle is reactive —
23
+ flipping the attribute on the host re-syncs all children.
24
+ type: boolean
25
+ default: false
26
+ reflect: true
27
+ columns:
28
+ description: >-
29
+ Number of grid columns the row uses. Defaults to 6 — common
30
+ multiples (1/2/3/6) divide cleanly. <field-ui rows="N"> spans
31
+ `N` of these columns. Override per-instance for tighter
32
+ 4-column or wider 12-column compositions.
33
+ type: number
34
+ default: 6
35
+ reflect: true
36
+ slots:
37
+ default:
38
+ description: >-
39
+ <field-ui> children. Non-field children (separators, headings)
40
+ are placed in the grid too — span them by setting `style="grid-column:
41
+ 1 / -1"` for full-width content. Nested <fields-ui> works (an
42
+ outer 6-col grid hosting inner 3-col grids inside a single cell).
43
+ states:
44
+ - name: idle
45
+ description: Default, ready for interaction.
46
+ traits: []
47
+ tokens:
48
+ --fields-gap:
49
+ description: Gap between adjacent fields (row + column).
50
+ --fields-row-gap:
51
+ description: Override the row-gap independently of column-gap.
52
+ --fields-column-gap:
53
+ description: Override the column-gap independently of row-gap.
54
+ a2ui:
55
+ rules: []
56
+ anti_patterns:
57
+ - description: >-
58
+ Don't wrap <field-ui> children in <row-ui wrap> when grid
59
+ alignment is desired. row-ui is flex-wrap, which lets each
60
+ field size to its content — labels and controls won't share
61
+ a column with siblings.
62
+ severity: high
63
+ - description: >-
64
+ Don't set `inline` on individual <field-ui> children when the
65
+ whole group should switch — set it on the parent <fields-ui>
66
+ and let the propagation handle every child.
67
+ severity: medium
68
+ examples:
69
+ - name: three-up-stacked
70
+ description: >-
71
+ A 3-up stacked field row — Status / Priority / Group — each
72
+ taking 2 of 6 columns. Equivalent to a Bootstrap "col-md-4"
73
+ pattern (12-col grid, span 4) but with chat-ui's 6-col default.
74
+ a2ui: >-
75
+ [
76
+ {
77
+ "id": "root",
78
+ "component": "Fields",
79
+ "children": ["status", "priority", "group"]
80
+ },
81
+ { "id": "status", "component": "Field", "label": "Status", "rows": 2, "children": ["status-sel"] },
82
+ { "id": "priority", "component": "Field", "label": "Priority", "rows": 2, "children": ["priority-sel"] },
83
+ { "id": "group", "component": "Field", "label": "Group", "rows": 2, "children": ["group-sel"] },
84
+ { "id": "status-sel", "component": "Select", "value": "todo" },
85
+ { "id": "priority-sel", "component": "Select", "value": "0" },
86
+ { "id": "group-sel", "component": "Select", "value": "" }
87
+ ]
88
+ - name: inline-search-form
89
+ description: >-
90
+ An inline form — every field renders label-beside-control. Single
91
+ [inline] on the host drives all children.
92
+ a2ui: >-
93
+ [
94
+ {
95
+ "id": "root",
96
+ "component": "Fields",
97
+ "inline": true,
98
+ "children": ["q", "kind"]
99
+ },
100
+ { "id": "q", "component": "Field", "label": "Search", "children": ["q-input"] },
101
+ { "id": "kind", "component": "Field", "label": "Kind", "children": ["kind-sel"] },
102
+ { "id": "q-input", "component": "Input", "type": "search" },
103
+ { "id": "kind-sel", "component": "Select", "value": "all" }
104
+ ]
105
+ keywords:
106
+ - fields
107
+ - form
108
+ - grid
109
+ - layout
110
+ - group
111
+ synonyms:
112
+ form:
113
+ - fields
114
+ - group
115
+ - layout
116
+ related:
117
+ - field
118
+ - input
119
+ - select
120
+ - textarea
@@ -56,6 +56,7 @@ export { UITag } from './tag/tag.js';
56
56
  export { UISwatch } from './swatch/swatch.js';
57
57
  export { UICol } from './col/col.js';
58
58
  export { UIField } from './field/field.js';
59
+ export { UIFields } from './fields/fields.js';
59
60
  export { UIRow } from './row/row.js';
60
61
  export { UIGrid } from './grid/grid.js';
61
62
  export { UIStack } from './stack/stack.js';
@@ -1,5 +1,5 @@
1
1
  import { UIElement } from '../../core/element.js';
2
- import { registry } from '@adia-ai/a2ui-utils';
2
+ import { registry } from '@adia-ai/a2ui-runtime';
3
3
  import '../tabs/tabs.js';
4
4
  import '../tabs/tab.js';
5
5
  import '../code/code.js';
@@ -12,7 +12,7 @@
12
12
  * the dependency direction is web-components → a2ui-utils, not the
13
13
  * reverse. The bridge duck-types the renderer (it needs `.process()`,
14
14
  * nothing else), so it works with any A2UI-protocol consumer — the
15
- * actual `A2UIRenderer` from `@adia-ai/a2ui-utils`, a custom renderer,
15
+ * actual `A2UIRenderer` from `@adia-ai/a2ui-runtime`, a custom renderer,
16
16
  * or a test stub.
17
17
  *
18
18
  * Contract:
package/package.json CHANGED
@@ -1,25 +1,32 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.2.4",
4
- "description": "AdiaUI web components vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
3
+ "version": "0.3.0",
4
+ "description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./index.js",
8
- "./css": "./index.css",
9
- "./core": "./core/index.js",
10
- "./core/*": "./core/*.js",
11
- "./components": "./components/index.js",
7
+ ".": "./index.js",
8
+ "./css": "./index.css",
9
+ "./core": "./core/index.js",
10
+ "./core/*": "./core/*.js",
11
+ "./components": "./components/index.js",
12
12
  "./components/*": "./components/*/*.js",
13
- "./styles/*": "./styles/*",
14
- "./traits": "./traits/index.js",
15
- "./traits/*": "./traits/*.js",
13
+ "./styles/*": "./styles/*",
14
+ "./traits": "./traits/index.js",
15
+ "./traits/*": "./traits/*.js",
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "files": [
19
19
  "core/",
20
20
  "components/",
21
+ "!components/**/*.html",
22
+ "!components/**/*.examples.js",
21
23
  "styles/",
24
+ "!styles/**/*.html",
25
+ "!styles/**/*.examples.js",
22
26
  "traits/",
27
+ "!traits/**/*.html",
28
+ "!traits/**/*.examples.js",
29
+ "!traits/_api-table.js",
23
30
  "index.js",
24
31
  "index.css"
25
32
  ],
@@ -30,7 +37,7 @@
30
37
  "./core/provider.js"
31
38
  ],
32
39
  "dependencies": {
33
- "@adia-ai/a2ui-utils": "^0.2.0"
40
+ "@adia-ai/a2ui-runtime": "^0.3.0"
34
41
  },
35
42
  "publishConfig": {
36
43
  "access": "public",
@@ -54,6 +54,7 @@
54
54
  @import "../components/swatch/swatch.css";
55
55
  @import "../components/col/col.css";
56
56
  @import "../components/field/field.css";
57
+ @import "../components/fields/fields.css";
57
58
  @import "../components/row/row.css";
58
59
  @import "../components/grid/grid.css";
59
60
  @import "../components/stack/stack.css";
@@ -256,7 +256,7 @@
256
256
 
257
257
  /* section — h2/h3, major division within content */
258
258
  --a-section-family: var(--a-font-family-heading);
259
- --a-section-weight: var(--a-weight-normal);
259
+ --a-section-weight: var(--a-weight-semibold);
260
260
  --a-section-sm: 16px;
261
261
  --a-section-md: 17px;
262
262
  --a-section-lg: 19px;
@@ -599,7 +599,9 @@
599
599
  "dnd-drop",
600
600
  "dnd-drop-cancel"
601
601
  ],
602
- "config": []
602
+ "config": [
603
+ "data-draggable-list-item-ghost"
604
+ ]
603
605
  },
604
606
  {
605
607
  "name": "drop-target",
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
3
3
  /**
4
4
  * `draggable-list-item` — pointer-driven list-reorder lifter.
5
5
  *
6
- * Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
6
+ * Authored for the Tasks UI playground (apps/tasks/spec/).
7
7
  * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'motion-positioning').
8
8
  *
9
9
  * Different mental model than the existing `draggable` trait:
@@ -65,6 +65,8 @@ function announceImmediate(message) {
65
65
 
66
66
  const ATTR_LIFTING = 'data-draggable-list-item-lifting';
67
67
  const ATTR_SOURCE_ID = 'data-draggable-list-item-id';
68
+ const ATTR_GHOST = 'data-draggable-list-item-ghost';
69
+ const ATTR_GHOST_ACTIVE = 'data-draggable-list-item-ghost-active';
68
70
  const DRAG_THRESHOLD_PX = 4;
69
71
 
70
72
  /** @type {{ container: Element; index: number } | null} */
@@ -99,16 +101,80 @@ function dropTargetFromPoint(x, y) {
99
101
  return null;
100
102
  }
101
103
 
102
- function indexWithinTarget(target, x, y) {
103
- // Compute the insertion index by counting how many sibling list-items
104
- // start above the pointer's y. Cheap and good-enough for v1; a future
105
- // pass can compute by midpoint of each child rect.
106
- const items = Array.from(target.querySelectorAll('[data-draggable-list-item-id]'));
107
- for (let i = 0; i < items.length; i++) {
108
- const r = items[i].getBoundingClientRect();
104
+ /**
105
+ * Compute the column-relative insertion index at pointer (x, y) within
106
+ * a vertical-list droppable target. The returned index is in
107
+ * POST-REMOVAL coordinates i.e., it counts items as if the dragging
108
+ * source had already been removed from the list.
109
+ *
110
+ * Why post-removal: the consumer's command (Move/Reorder) splices the
111
+ * source out FIRST, then splices in at `to_position`. If the trait
112
+ * counted the source in pre-removal coordinates, downward intra-list
113
+ * drags would land one slot too far (the source's current slot
114
+ * disappears post-removal, shifting indices down by one). Pre-removal
115
+ * cross-list drags happen to be immune, but the math is identical.
116
+ *
117
+ * Items are filtered to DIRECT CHILDREN with the trait's id attribute —
118
+ * not descendants — to keep nested droppables from poisoning the count.
119
+ * Zero-layout items (display:none, off-flow) are skipped.
120
+ *
121
+ * @param {Element} target — the droppable container ([data-droppable-id])
122
+ * @param {number} _x — pointer client X (unused for vertical-list math; kept for future horizontal-list support)
123
+ * @param {number} y — pointer client Y
124
+ * @param {string | null} sourceId — the dragging source's data-draggable-list-item-id; this item is
125
+ * skipped while counting so the result is in post-removal coordinates
126
+ * @returns {number} insertion index in [0, count_after_removal]
127
+ */
128
+ function indexWithinTarget(target, _x, y, sourceId) {
129
+ // Find all `[data-draggable-list-item-id]` descendants whose nearest
130
+ // `[data-droppable-id]` ancestor IS the target. This supports both
131
+ // structural patterns:
132
+ // • Items as DIRECT children of the droppable host
133
+ // (e.g. `<list-section data-droppable-id> <task-row-ui>...`).
134
+ // • Items nested inside an intermediate container
135
+ // (e.g. `<task-column-ui data-droppable-id> <div class="col-list">
136
+ // <task-card-ui>...` — the column's CSS owns chrome on the host
137
+ // while cards live in a flow-styled inner list).
138
+ //
139
+ // The closest-ancestor filter keeps nested droppables from poisoning
140
+ // the count: a child droppable's items have a different closest
141
+ // `[data-droppable-id]`, so they're skipped here.
142
+ //
143
+ // Pre-filter to source-skipped, layout-having items so we know which
144
+ // is truly last in post-removal coordinates.
145
+ const visible = [];
146
+ const candidates = target.querySelectorAll('[data-draggable-list-item-id]');
147
+ for (const child of candidates) {
148
+ if (child.closest('[data-droppable-id]') !== target) continue;
149
+ if (sourceId && child.getAttribute('data-draggable-list-item-id') === sourceId) continue;
150
+ const r = child.getBoundingClientRect();
151
+ if (r.height === 0 && r.width === 0) continue;
152
+ visible.push(r);
153
+ }
154
+ if (visible.length === 0) return 0;
155
+
156
+ for (let i = 0; i < visible.length; i++) {
157
+ const r = visible[i];
158
+ // Standard midline test (Pragmatic-DnD / Linear / Notion convention):
159
+ // cursor above this item's mid-line → insert before it; below → fall
160
+ // through to the next item. After the loop, if no hit, drop after
161
+ // all items (cursor was below every mid-line).
162
+ //
163
+ // History: v0.2.5 introduced a "last-item generous zone" that
164
+ // returned `visible.length` whenever the cursor was anywhere ON the
165
+ // last item — meant to make the 3-item-list "drop on last to send
166
+ // it to the bottom" gesture more permissive. Reverted: it broke the
167
+ // common "drop between second-to-last and last" path (hovering the
168
+ // last item's upper-half no longer resolved to "before last"). With
169
+ // the v0.2.6 indicator-visibility upgrade (3 px stroke + terminal
170
+ // caps + accent glow) the lower-half-targets-end behavior is now
171
+ // visually obvious, so the standard midline test reads cleanly
172
+ // without the carve-out.
109
173
  if (y < r.top + r.height / 2) return i;
110
174
  }
111
- return items.length;
175
+ // Cursor is below every item's midline (and below last's top — only
176
+ // possible if the section has padding-bottom and cursor is in that gap).
177
+ return visible.length;
112
178
  }
113
179
 
114
180
  export const draggableListItem = defineTrait({
@@ -117,7 +183,12 @@ export const draggableListItem = defineTrait({
117
183
  description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
118
184
  attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
119
185
  events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
120
- config: [],
186
+ // Opt-in: when present, the trait renders a positioned clone of the
187
+ // host that follows the cursor during the drag (Pragmatic-DnD style
188
+ // ghost preview). Backward-compatible — absent = no ghost, current
189
+ // behavior. Style the ghost via consumer CSS using the
190
+ // [data-draggable-list-item-ghost-active] attribute.
191
+ config: [ATTR_GHOST],
121
192
  setup({ host }) {
122
193
  let id = host.getAttribute(ATTR_SOURCE_ID);
123
194
  if (!id) {
@@ -131,6 +202,61 @@ export const draggableListItem = defineTrait({
131
202
  /** @type {string | null} */
132
203
  let lastTargetId = null;
133
204
  let lastTargetIndex = -1;
205
+ /** @type {HTMLElement | null} */
206
+ let ghost = null;
207
+
208
+ function makeGhost() {
209
+ if (!host.hasAttribute(ATTR_GHOST)) return;
210
+ const r = host.getBoundingClientRect();
211
+ ghost = /** @type {HTMLElement} */ (host.cloneNode(true));
212
+ // Strip everything that could trigger interactive state on the clone:
213
+ // - trait IDs (registry hygiene)
214
+ // - tabindex (would attract focus when appended to body, painting
215
+ // a focus-visible outline that double-traces the ghost)
216
+ // - aria-* state (clone is presentational; the source remains the
217
+ // accessible focus target during drag)
218
+ // - data-selected (visual selection state belongs to the source)
219
+ // - id (no DOM-id duplicates)
220
+ ghost.removeAttribute(ATTR_SOURCE_ID);
221
+ ghost.removeAttribute(ATTR_LIFTING);
222
+ ghost.removeAttribute('data-keyboard-reorderable-id');
223
+ ghost.removeAttribute('data-keyboard-reorderable-lifting');
224
+ ghost.removeAttribute('data-selected');
225
+ ghost.removeAttribute('id');
226
+ ghost.removeAttribute('tabindex');
227
+ ghost.removeAttribute('aria-selected');
228
+ ghost.setAttribute('aria-hidden', 'true');
229
+ Object.assign(ghost.style, {
230
+ position: 'fixed',
231
+ left: `${r.left}px`,
232
+ top: `${r.top}px`,
233
+ width: `${r.width}px`,
234
+ margin: '0',
235
+ pointerEvents: 'none',
236
+ // Belt-and-suspenders: explicitly suppress outline + animation
237
+ // so the cloned element never paints a focus ring or competes
238
+ // with the consumer's [data-…-ghost-active] CSS for animation.
239
+ outline: 'none',
240
+ // Top-layer-ish; avoid 2147483647 to leave room for genuine top-layer
241
+ // surfaces (toasts, dialogs that may fire during drag).
242
+ zIndex: '10000',
243
+ transition: 'none',
244
+ });
245
+ ghost.setAttribute(ATTR_GHOST_ACTIVE, '');
246
+ document.body.appendChild(ghost);
247
+ }
248
+
249
+ function moveGhost(x, y) {
250
+ if (!ghost || !down) return;
251
+ // Translate the ghost by the cursor delta from the original pointerdown
252
+ // so the cursor stays at the same relative position inside the ghost.
253
+ ghost.style.translate = `${x - down.x}px ${y - down.y}px`;
254
+ }
255
+
256
+ function removeGhost() {
257
+ if (ghost?.parentNode) ghost.parentNode.removeChild(ghost);
258
+ ghost = null;
259
+ }
134
260
 
135
261
  function isDisabled() {
136
262
  return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
@@ -145,7 +271,54 @@ export const draggableListItem = defineTrait({
145
271
  lastTargetId = null;
146
272
  lastTargetIndex = -1;
147
273
  host.removeAttribute(ATTR_LIFTING);
274
+ removeGhost();
275
+ // Always remove the keydown listener — even on disconnect — so a
276
+ // disposed trait can never keep firing Esc-cancel handlers against
277
+ // stale closures.
148
278
  document.removeEventListener('keydown', onKeyDown, true);
279
+ // Always remove the document-level fallback listeners too. They
280
+ // catch the case where pointerup fires outside the host's bounds
281
+ // and pointer capture didn't deliver it (browser quirks under
282
+ // tab-blur, popup-open, focus-loss). Re-armed on the next lift.
283
+ document.removeEventListener('pointerup', onDocPointerUp, true);
284
+ document.removeEventListener('pointercancel', onDocPointerCancel, true);
285
+ window.removeEventListener('blur', onWindowBlur);
286
+ }
287
+
288
+ /**
289
+ * Pointer-event safety net — if the host's own pointerup doesn't
290
+ * fire (browser bug, focus loss mid-drag, popup steals events),
291
+ * the document-level handler catches the release and runs the
292
+ * same cancel-or-drop logic. Idempotent with the host handler:
293
+ * whichever fires first runs reset(); the other is gated by the
294
+ * `down=null` short-circuit.
295
+ */
296
+ function onDocPointerUp(e) {
297
+ if (!down || down.pointerId !== e.pointerId) return;
298
+ onPointerUp(e);
299
+ }
300
+ function onDocPointerCancel(e) {
301
+ if (!down || down.pointerId !== e.pointerId) return;
302
+ if (lifted) {
303
+ document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
304
+ bubbles: true,
305
+ composed: false,
306
+ detail: { source_id: id, reason: 'pointer-cancel' },
307
+ }));
308
+ }
309
+ reset();
310
+ }
311
+ /** Window blur during a drag → cancel. Covers Cmd-Tab / popup-blocker / fullscreen escape. */
312
+ function onWindowBlur() {
313
+ if (!down) return;
314
+ if (lifted) {
315
+ document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
316
+ bubbles: true,
317
+ composed: false,
318
+ detail: { source_id: id, reason: 'blur' },
319
+ }));
320
+ }
321
+ reset();
149
322
  }
150
323
 
151
324
  function onPointerDown(e) {
@@ -165,6 +338,13 @@ export const draggableListItem = defineTrait({
165
338
  // Hold the pointer so we keep getting move/up even outside the host.
166
339
  try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
167
340
  document.addEventListener('keydown', onKeyDown, true);
341
+ // Document-level safety net for pointerup / pointercancel. Capture
342
+ // phase (true) so we beat any consumer that calls stopPropagation.
343
+ // host's own pointerup/cancel still fire normally; whichever lands
344
+ // first runs the cleanup, the other no-ops via the down-null guard.
345
+ document.addEventListener('pointerup', onDocPointerUp, true);
346
+ document.addEventListener('pointercancel', onDocPointerCancel, true);
347
+ window.addEventListener('blur', onWindowBlur);
168
348
  }
169
349
 
170
350
  function onPointerMove(e) {
@@ -176,6 +356,12 @@ export const draggableListItem = defineTrait({
176
356
  if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
177
357
  lifted = true;
178
358
  host.setAttribute(ATTR_LIFTING, '');
359
+ // Stamp the ghost preview AFTER setting the lifting attribute so
360
+ // the host's lifted state (opacity, etc.) is captured in the
361
+ // clone snapshot — except: we explicitly removed ATTR_LIFTING from
362
+ // the clone in makeGhost() so the ghost itself renders as a
363
+ // non-lifted card. Net effect: source dims, ghost looks "alive".
364
+ makeGhost();
179
365
  host.dispatchEvent(new CustomEvent('dnd-lift', {
180
366
  bubbles: true,
181
367
  composed: false,
@@ -188,9 +374,12 @@ export const draggableListItem = defineTrait({
188
374
  announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
189
375
  }
190
376
 
377
+ // Update the ghost on every move (cheap — single style write).
378
+ moveGhost(e.clientX, e.clientY);
379
+
191
380
  const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
192
381
  const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
193
- const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY) : -1;
382
+ const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY, id) : -1;
194
383
 
195
384
  if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
196
385
  lastTargetId = targetId;
@@ -224,7 +413,7 @@ export const draggableListItem = defineTrait({
224
413
  }));
225
414
  announceImmediate(`Drag cancelled. Item returned to its original position.`);
226
415
  } else {
227
- const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY);
416
+ const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY, id);
228
417
  document.dispatchEvent(new CustomEvent('dnd-drop', {
229
418
  bubbles: true,
230
419
  composed: false,
@@ -253,10 +442,7 @@ export const draggableListItem = defineTrait({
253
442
  }
254
443
  }
255
444
 
256
- host.addEventListener('pointerdown', onPointerDown);
257
- host.addEventListener('pointermove', onPointerMove);
258
- host.addEventListener('pointerup', onPointerUp);
259
- host.addEventListener('pointercancel', () => {
445
+ function onHostPointerCancel() {
260
446
  if (lifted) {
261
447
  document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
262
448
  bubbles: true,
@@ -265,13 +451,20 @@ export const draggableListItem = defineTrait({
265
451
  }));
266
452
  }
267
453
  reset();
268
- });
454
+ }
455
+
456
+ host.addEventListener('pointerdown', onPointerDown);
457
+ host.addEventListener('pointermove', onPointerMove);
458
+ host.addEventListener('pointerup', onPointerUp);
459
+ host.addEventListener('pointercancel', onHostPointerCancel);
269
460
 
270
461
  return () => {
271
462
  host.removeEventListener('pointerdown', onPointerDown);
272
463
  host.removeEventListener('pointermove', onPointerMove);
273
464
  host.removeEventListener('pointerup', onPointerUp);
274
- document.removeEventListener('keydown', onKeyDown, true);
465
+ host.removeEventListener('pointercancel', onHostPointerCancel);
466
+ // reset() removes the document-level + keydown + window-blur
467
+ // listeners and the host-level lifting/ghost state.
275
468
  reset();
276
469
  host.removeAttribute(ATTR_SOURCE_ID);
277
470
  };
@@ -3,7 +3,7 @@ import { draggableListItem } from './draggable-list-item.js';
3
3
  import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  // Minimal smoke coverage. The draggable-list-item trait is the pointer
6
- // driver for the docs/projects/tasks-playground/ DnD model. The cases
6
+ // driver for the apps/tasks/ DnD model. The cases
7
7
  // below exercise only its own surface (schema, attribute lifecycle,
8
8
  // id assignment, idempotent disconnect). Full pointer-flow tests need a
9
9
  // laid-out DOM with sibling droppables and live elementFromPoint —
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
3
3
  /**
4
4
  * `droppable-collection` — coordinator over a tree of `droppable` children.
5
5
  *
6
- * Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
6
+ * Authored for the Tasks UI playground (apps/tasks/spec/).
7
7
  * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
8
8
  *
9
9
  * Pairs with `droppable` (per-target) and `draggable-list-item` (motion-
@@ -3,7 +3,7 @@ import { droppableCollection } from './droppable-collection.js';
3
3
  import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
4
4
 
5
5
  // Minimal smoke coverage. The droppable-collection trait coordinates
6
- // child droppables of the docs/projects/tasks-playground/ DnD model;
6
+ // child droppables of the apps/tasks/ DnD model;
7
7
  // the cases below verify only the trait's own surface (schema, attribute
8
8
  // lifecycle, child-drop re-emission) so the verify:traits gate stays
9
9
  // green. End-to-end multi-column behavior belongs in the playground tests.
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
3
3
  /**
4
4
  * `droppable` — marks an element as a drop target for `draggable-list-item`.
5
5
  *
6
- * Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
6
+ * Authored for the Tasks UI playground (apps/tasks/spec/).
7
7
  * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
8
8
  *
9
9
  * Pairs with `draggable-list-item` (motion-positioning) which lifts a list
@@ -57,24 +57,35 @@ export const droppable = defineTrait({
57
57
 
58
58
  // Listen for drag-in-flight signals on the document so this droppable
59
59
  // knows when it should self-mark as the target.
60
+ //
61
+ // Bug guard (2026-05-04): per-host attribute is the source of truth.
62
+ // The previous implementation gated the leave branch on a shared
63
+ // module variable CURRENT_TARGET_ID, which made the result dependent
64
+ // on listener-execution order — when the target swap fired and the
65
+ // NEW target's listener ran before the OLD target's, the old one
66
+ // saw CURRENT_TARGET_ID already moved and skipped clearing its
67
+ // ATTR_OVER, leaving multiple columns visually marked as the drop
68
+ // target. Per-host hasAttribute() is independent of order.
60
69
  function onTargetChange(/** @type {CustomEvent} */ e) {
61
70
  const detail = e.detail;
62
- const isMe = detail && detail.target_container_id === id;
63
- if (isMe && CURRENT_TARGET_ID !== id) {
64
- CURRENT_TARGET_ID = id;
71
+ if (!detail) return;
72
+ const isMe = detail.target_container_id === id;
73
+ const wasOver = host.hasAttribute(ATTR_OVER);
74
+ if (isMe && !wasOver) {
65
75
  host.setAttribute(ATTR_OVER, '');
76
+ CURRENT_TARGET_ID = id;
66
77
  host.dispatchEvent(new CustomEvent('dnd-drop-enter', {
67
78
  bubbles: true,
68
79
  composed: false,
69
80
  detail: { source_id: detail.source_id, pointer_position: detail.pointer ?? { x: 0, y: 0 } },
70
81
  }));
71
- } else if (!isMe && CURRENT_TARGET_ID === id) {
72
- CURRENT_TARGET_ID = null;
82
+ } else if (!isMe && wasOver) {
73
83
  host.removeAttribute(ATTR_OVER);
84
+ if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
74
85
  host.dispatchEvent(new CustomEvent('dnd-drop-leave', {
75
86
  bubbles: true,
76
87
  composed: false,
77
- detail: { source_id: detail?.source_id ?? null },
88
+ detail: { source_id: detail.source_id ?? null },
78
89
  }));
79
90
  }
80
91
  }
@@ -3,7 +3,7 @@ import { droppable } from './droppable.js';
3
3
  import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  // Minimal smoke coverage. The droppable trait is authored against the
6
- // docs/projects/tasks-playground/ DnD coordinator and assumes a sibling
6
+ // apps/tasks/ DnD coordinator and assumes a sibling
7
7
  // `draggable-list-item` trait dispatches `dnd:drop-target-change` /
8
8
  // `dnd:drop` on the document. The cases below only exercise the trait's
9
9
  // own surface (schema, attribute lifecycle, registry entry, cleanup) so
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
3
3
  /**
4
4
  * `keyboard-reorderable` — keyboard alternative to `draggable-list-item`.
5
5
  *
6
- * Authored for the Tasks UI playground (docs/projects/tasks-playground/spec/).
6
+ * Authored for the Tasks UI playground (apps/tasks/spec/).
7
7
  * Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'keyboard-navigation').
8
8
  *
9
9
  * Implements the WCAG 2.5.7 single-pointer alternative + ARIA 1.1
@@ -4,7 +4,7 @@ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
4
4
 
5
5
  // Minimal smoke coverage. Keyboard-reorderable is the WCAG 2.5.7 +
6
6
  // ARIA 1.1 alternative to draggable-list-item for the
7
- // docs/projects/tasks-playground/ DnD model. Cases below verify
7
+ // apps/tasks/ DnD model. Cases below verify
8
8
  // only the trait's own surface (schema, attribute lifecycle, id
9
9
  // assignment, tabindex injection). Multi-container keyboard flow
10
10
  // belongs in the playground integration suite.