@adia-ai/web-modules 0.8.24 → 0.8.26

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog — @adia-ai/web-modules
2
2
 
3
+ ## [0.8.26] — 2026-08-05
4
+
5
+ ### Added
6
+ - **`chat-surfaces` (WCH-4, gh#599)** — the L3 surface-host registry: `createSurfaceRegistry` (per-surfaceId hosts with persistent bubble identity across turns; protocol-level close with a visible non-removable annotation) + `wireAgentEvents` (folds an `AgentEvent` stream into chat-shell's external-drive API with a code-owned progress-label table). Transport-blind; the renderer is dependency-injected.
7
+ - **`agent-admin` composite family (WCH-5, gh#600)** — `admin-roster-ui` (exactly-one-active persona roster, import/export), `admin-settings-ui` (settings over one bound store, KNOWN_KINDS display grouping, zero per-kind logic), `agent-admin-ui` (roster + settings + chat; the store-swap law: a genuine store-reference change resets conversation/surfaces/session, a same-store reconnect resets nothing; live-apply by fresh read). Peer-depends on `@adia-ai/persona` + `@adia-ai/agent` (exact-pinned).
8
+
9
+ ### Fixed
10
+ - **theme-panel sliders trust applied state** (gh#578, PR #609) — theme clicks no longer round-trip `getComputedStyle` (which showed the theme's baked shape values); the one legitimate initial read is isolated in `#readInitialShape` with its rationale.
11
+
12
+ ### Maintenance
13
+ - **`dist/` bundles rebuilt** in this cut's window (1 file(s)) — regenerated from the source changes described above, not independent edits.
14
+
15
+ ## [0.8.25] — 2026-07-31
16
+
17
+ ### Fixed
18
+ - **`theme-panel[scheme-toggle]` gains a System option** (operator report). The segmented control only offered Light/Dark, so a user who picked either couldn't return to OS-following `auto` from the UI — and the control silently collapsed "System, currently resolving light" to just "Light", hiding that auto was even active. Added a System segment; every sync point (initial resolve, live `prefers-color-scheme` changes, reset, programmatic `.apply()`) now reflects the RAW scheme intent (`auto`/`light`/`dark`) on the segment, not the resolved paint — System stays selected across an OS theme flip.
19
+ - **theme-panel's scoped-target demo used a stale pre-ADR-0035 token** (`--a-radius-2`, doesn't exist post the v0.6.48 foundation reorg) — its preview pane's border-radius was permanently invalid-at-computed-value, always rendering 0px regardless of the radius slider. Root-target (default) parametric radius was never broken — verified via a real mouse drag (value 1→2, a live button's radius 9.3px→14px, correctly clamped). The scoped-target demo itself was also asserting a capability the framework didn't have at the time: `[parametric]` density/radius only reactively recomputed for `target=":root"` (gh#570 — the derived tokens were declared once on `:root`, so a scoped-target override never triggered their re-derivation). **gh#570 is fixed in this same release** (see `@adia-ai/web-components`'s `theme-panel[parametric]` entry above) via a `data-parametric-scope` marker that re-derives the chain for a scoped target too — the demo's scoped instance keeps `[parametric]` and now works correctly. `[theme]`/`color-scheme` scoping to a non-root target was unaffected throughout and continues to work.
20
+
21
+ ### Maintenance
22
+ - **`billing/` touched in this release window** (6 file(s), e.g. `billing-overview/billing-overview.examples.html`) — carried by the entries above.
23
+ - **`dashboard/` touched in this release window** (2 file(s), e.g. `dashboard-layout/dashboard-layout.examples.html`) — carried by the entries above.
24
+ - **`dist/` bundles rebuilt** in this cut's window (1 file(s)) — regenerated from the source changes described above, not independent edits.
25
+ - **`feedback/` touched in this release window** (1 file(s), e.g. `confirm-dialog/confirm-dialog.examples.html`) — carried by the entries above.
26
+ - **`form/` touched in this release window** (1 file(s), e.g. `form-popover/form-popover.examples.html`) — carried by the entries above.
27
+ - **`onboarding/` touched in this release window** (1 file(s), e.g. `onboarding-checklist/onboarding-checklist.examples.html`) — carried by the entries above.
28
+ - **`settings/` touched in this release window** (2 file(s), e.g. `integrations-page/integrations-page.examples.html`) — carried by the entries above.
29
+
3
30
  ## [0.8.24] — 2026-07-30
4
31
 
5
32
  ### Maintenance
@@ -0,0 +1,171 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/AdminRoster.json",
4
+ "title": "AdminRoster",
5
+ "description": "L4 persona roster (CHAT-HARNESS.md §Components row L4, gh#600 / WCH-5) —\none row per persona with exactly-one-active menu semantics\n(`role=\"menu\"` of `role=\"menuitemradio\"` rows, `aria-checked` mirroring\n`[active-id]`). Clicking a row fires `persona-change` with the clicked\npersona's id; clicking the already-active row is a no-op. Import/export\nbuttons ride `@adia-ai/persona`'s persona-file functions verbatim —\nexport downloads the active persona as JSON, import reads a chosen file\nand re-emits the minted persona as `persona-import` for the host to\nappend and persist.\n\nLives in `packages/web-modules/agent-admin/` alongside\n`<admin-settings-ui>` and `<agent-admin-ui>` — the L4 admin composite\nfamily over `@adia-ai/persona`'s L2 store contract.\n",
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
+ "active-id": {
17
+ "description": "The currently-active persona's id. Reflects so a host can deep-link;\ndrives `aria-checked` + the visible check glyph on the matching row.\n",
18
+ "$ref": "common_types.json#/$defs/DynamicString"
19
+ },
20
+ "component": {
21
+ "const": "AdminRoster"
22
+ },
23
+ "personas": {
24
+ "description": "Array of `{ id, label, category? }` persona summaries. Empty array\nrenders \"No personas yet.\"\n",
25
+ "$ref": "common_types.json#/$defs/DynamicStringList"
26
+ }
27
+ },
28
+ "required": [
29
+ "component",
30
+ "personas"
31
+ ],
32
+ "unevaluatedProperties": false,
33
+ "x-adiaui": {
34
+ "anti_patterns": [
35
+ {
36
+ "fix": "{\"component\": \"AdminRoster\", \"personas\": [{\"id\": \"a\", \"label\": \"A\"}]}\n",
37
+ "why": "Rows are generated from the `personas` prop — slotted children are ignored.",
38
+ "wrong": "{\"component\": \"AdminRoster\", \"personas\": [{\"id\": \"a\", \"label\": \"A\"}], \"children\": [\n {\"component\": \"Button\", \"text\": \"Switch\"}\n]}\n"
39
+ }
40
+ ],
41
+ "category": "container",
42
+ "composes": [
43
+ "button-ui",
44
+ "icon-ui"
45
+ ],
46
+ "events": {
47
+ "persona-change": {
48
+ "description": "A non-active row was clicked.",
49
+ "detail": {
50
+ "personaId": {
51
+ "description": "The id of the persona the user switched to.",
52
+ "type": "string"
53
+ }
54
+ }
55
+ },
56
+ "persona-import": {
57
+ "description": "A persona file was chosen and parsed successfully.",
58
+ "detail": {
59
+ "persona": {
60
+ "description": "The minted persona (fresh, collision-safe id) from `importPersona`.",
61
+ "type": "object"
62
+ }
63
+ }
64
+ },
65
+ "persona-import-error": {
66
+ "description": "A chosen file failed to parse or validate as a persona file.",
67
+ "detail": {
68
+ "error": {
69
+ "description": "The error message from `importPersona`.",
70
+ "type": "string"
71
+ }
72
+ }
73
+ }
74
+ },
75
+ "examples": [
76
+ {
77
+ "description": "Roster with one active persona.",
78
+ "a2ui": "[{\"id\": \"roster\", \"component\": \"AdminRoster\",\n \"active-id\": \"support\",\n \"personas\": [\n {\"id\": \"support\", \"label\": \"Support agent\", \"category\": \"Customer\"},\n {\"id\": \"writer\", \"label\": \"Writer\", \"category\": \"Content\"}\n ]}]\n",
79
+ "name": "two-personas"
80
+ },
81
+ {
82
+ "description": "No personas yet.",
83
+ "a2ui": "[{\"id\": \"roster\", \"component\": \"AdminRoster\", \"personas\": []}]\n",
84
+ "name": "empty"
85
+ }
86
+ ],
87
+ "keywords": [
88
+ "admin",
89
+ "persona",
90
+ "roster",
91
+ "agent",
92
+ "switch",
93
+ "menu"
94
+ ],
95
+ "name": "UIAdminRoster",
96
+ "related": [
97
+ "AdminSettings",
98
+ "AgentAdmin",
99
+ "Menu"
100
+ ],
101
+ "slots": {},
102
+ "states": [
103
+ {
104
+ "description": "Default; renders the persona list.",
105
+ "name": "idle"
106
+ },
107
+ {
108
+ "description": "`personas` is an empty array.",
109
+ "name": "empty"
110
+ }
111
+ ],
112
+ "status": "stable",
113
+ "synonyms": {
114
+ "persona": [
115
+ "agent",
116
+ "config",
117
+ "profile"
118
+ ],
119
+ "roster": [
120
+ "list",
121
+ "personas",
122
+ "agents"
123
+ ]
124
+ },
125
+ "tag": "admin-roster-ui",
126
+ "tokens": {
127
+ "--admin-roster-active-bg": {
128
+ "description": "Active/hover row background.",
129
+ "default": "var(--a-bg-muted)"
130
+ },
131
+ "--admin-roster-active-fg": {
132
+ "description": "Active row foreground.",
133
+ "default": "var(--a-fg-strong)"
134
+ },
135
+ "--admin-roster-bg": {
136
+ "description": "Panel background.",
137
+ "default": "var(--a-bg)"
138
+ },
139
+ "--admin-roster-fg": {
140
+ "description": "Primary foreground.",
141
+ "default": "var(--a-fg)"
142
+ },
143
+ "--admin-roster-fg-muted": {
144
+ "description": "Secondary foreground (category text, empty state).",
145
+ "default": "var(--a-fg-muted)"
146
+ },
147
+ "--admin-roster-gap": {
148
+ "description": "Vertical gap between header / menu / toolbar.",
149
+ "default": "var(--a-space-3)"
150
+ },
151
+ "--admin-roster-px": {
152
+ "description": "Horizontal padding.",
153
+ "default": "var(--a-space-4)"
154
+ },
155
+ "--admin-roster-py": {
156
+ "description": "Vertical padding.",
157
+ "default": "var(--a-space-4)"
158
+ },
159
+ "--admin-roster-row-gap": {
160
+ "description": "Gap between rows.",
161
+ "default": "var(--a-space-1)"
162
+ },
163
+ "--admin-roster-row-radius": {
164
+ "description": "Row border radius.",
165
+ "default": "var(--a-radius-md)"
166
+ }
167
+ },
168
+ "traits": [],
169
+ "version": 1
170
+ }
171
+ }
@@ -0,0 +1,112 @@
1
+ /* ═══════════════════════════════════════════════════════════════
2
+ admin-roster-ui — Module CSS
3
+
4
+ Two-block @scope:
5
+ Block 1 — TOKENS (:where(:scope))
6
+ Block 2 — BASE (:scope + descendant rules)
7
+
8
+ Tokens consumed (substrate):
9
+ --a-bg, --a-fg, --a-fg-muted, --a-fg-strong, --a-border-subtle,
10
+ --a-bg-muted, --a-radius-md, --a-space-1..6, --a-ui-sm, --a-text-lg,
11
+ --a-weight-medium, --a-accent, --a-accent-contrast
12
+ ═══════════════════════════════════════════════════════════════ */
13
+
14
+ @scope (admin-roster-ui) {
15
+
16
+ :where(:scope) {
17
+ --admin-roster-bg: var(--a-bg);
18
+ --admin-roster-fg: var(--a-fg);
19
+ --admin-roster-fg-muted: var(--a-fg-muted);
20
+ --admin-roster-px: var(--a-space-4);
21
+ --admin-roster-py: var(--a-space-4);
22
+ --admin-roster-gap: var(--a-space-3);
23
+ --admin-roster-row-gap: var(--a-space-1);
24
+ --admin-roster-row-radius: var(--a-radius-md);
25
+ --admin-roster-active-bg: var(--a-bg-muted);
26
+ --admin-roster-active-fg: var(--a-fg-strong);
27
+ }
28
+
29
+ :scope {
30
+ box-sizing: border-box;
31
+ display: grid;
32
+ gap: var(--admin-roster-gap);
33
+ padding: var(--admin-roster-py) var(--admin-roster-px);
34
+ background: var(--admin-roster-bg);
35
+ color: var(--admin-roster-fg);
36
+ min-width: 0;
37
+ }
38
+
39
+ :scope > [data-part="header"] > [data-part="title"] {
40
+ margin: 0;
41
+ font-size: var(--a-ui-sm);
42
+ font-weight: var(--a-weight-medium);
43
+ text-transform: uppercase;
44
+ letter-spacing: 0.04em;
45
+ color: var(--admin-roster-fg-muted);
46
+ }
47
+
48
+ :scope [data-part="menu"] {
49
+ display: grid;
50
+ gap: var(--admin-roster-row-gap);
51
+ }
52
+
53
+ :scope [data-part="row"] {
54
+ box-sizing: border-box;
55
+ display: flex;
56
+ align-items: center;
57
+ gap: var(--a-space-2);
58
+ width: 100%;
59
+ padding: var(--a-space-2) var(--a-space-3);
60
+ border: 1px solid transparent;
61
+ border-radius: var(--admin-roster-row-radius);
62
+ background: transparent;
63
+ color: var(--admin-roster-fg);
64
+ font: inherit;
65
+ text-align: left;
66
+ cursor: pointer;
67
+ }
68
+
69
+ :scope [data-part="row"]:hover {
70
+ background: var(--admin-roster-active-bg);
71
+ }
72
+
73
+ :scope [data-part="row"][data-active="true"] {
74
+ background: var(--admin-roster-active-bg);
75
+ color: var(--admin-roster-active-fg);
76
+ font-weight: var(--a-weight-medium);
77
+ }
78
+
79
+ :scope [data-part="row-label"] {
80
+ flex: 1 1 auto;
81
+ min-width: 0;
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ white-space: nowrap;
85
+ }
86
+
87
+ :scope [data-part="row-category"] {
88
+ flex: 0 0 auto;
89
+ font-size: var(--a-ui-sm);
90
+ color: var(--admin-roster-fg-muted);
91
+ }
92
+
93
+ :scope [data-part="row-check"] {
94
+ flex: 0 0 auto;
95
+ visibility: hidden;
96
+ }
97
+
98
+ :scope [data-part="row"][data-active="true"] [data-part="row-check"] {
99
+ visibility: visible;
100
+ }
101
+
102
+ :scope [data-part="empty"] {
103
+ padding: var(--a-space-4);
104
+ color: var(--admin-roster-fg-muted);
105
+ text-align: center;
106
+ }
107
+
108
+ :scope [data-part="toolbar"] {
109
+ display: flex;
110
+ gap: var(--a-space-2);
111
+ }
112
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `<admin-roster-ui>` — L4 persona roster (CHAT-HARNESS.md §Components row
3
+ * L4, gh#600 / WCH-5).
4
+ *
5
+ * Hand-authored alongside `<name>.yaml`; regenerate when the props /
6
+ * events shape changes.
7
+ */
8
+
9
+ import { UIElement } from '../../../web-components/core/element.js';
10
+
11
+ export interface AdminRosterPersonaSummary {
12
+ id: string;
13
+ label: string;
14
+ category?: string;
15
+ }
16
+
17
+ export interface AdminRosterPersonaChangeDetail {
18
+ personaId: string;
19
+ }
20
+ export type AdminRosterPersonaChangeEvent = CustomEvent<AdminRosterPersonaChangeDetail>;
21
+
22
+ export interface AdminRosterPersonaImportDetail {
23
+ persona: import('@adia-ai/persona').Persona;
24
+ }
25
+ export type AdminRosterPersonaImportEvent = CustomEvent<AdminRosterPersonaImportDetail>;
26
+
27
+ export interface AdminRosterPersonaImportErrorDetail {
28
+ error: string;
29
+ }
30
+ export type AdminRosterPersonaImportErrorEvent = CustomEvent<AdminRosterPersonaImportErrorDetail>;
31
+
32
+ export class UIAdminRoster extends UIElement {
33
+ personas: AdminRosterPersonaSummary[];
34
+ 'active-id': string;
35
+
36
+ addEventListener<K extends keyof HTMLElementEventMap>(
37
+ type: K,
38
+ listener: (this: UIAdminRoster, ev: HTMLElementEventMap[K]) => unknown,
39
+ options?: boolean | AddEventListenerOptions,
40
+ ): void;
41
+ addEventListener(type: 'persona-change', listener: (ev: AdminRosterPersonaChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
42
+ addEventListener(type: 'persona-import', listener: (ev: AdminRosterPersonaImportEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
43
+ addEventListener(type: 'persona-import-error', listener: (ev: AdminRosterPersonaImportErrorEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
44
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * <admin-roster-ui> — L4 persona roster (CHAT-HARNESS.md §Components row L4,
3
+ * gh#600 / WCH-5).
4
+ *
5
+ * <admin-roster-ui id="roster"></admin-roster-ui>
6
+ * <script>
7
+ * const roster = document.querySelector('#roster');
8
+ * roster.personas = [
9
+ * { id: 'support', label: 'Support agent', category: 'Customer' },
10
+ * { id: 'writer', label: 'Writer', category: 'Content' },
11
+ * ];
12
+ * roster['active-id'] = 'support';
13
+ * roster.addEventListener('persona-change', (e) => { ... e.detail.personaId });
14
+ * </script>
15
+ *
16
+ * One row per persona, exactly-one-active menu semantics: WAI-ARIA
17
+ * `role="menu"` of `role="menuitemradio"` rows, `aria-checked` mirrors
18
+ * `[active-id]`. Clicking the already-active row is a no-op (radio
19
+ * semantics — a radio group never fires a change back to its own value).
20
+ *
21
+ * Import/export rides `@adia-ai/persona`'s persona-file functions
22
+ * verbatim — this composite never re-implements the file format. Export
23
+ * downloads a `.json` via a throwaway object URL; import reads a chosen
24
+ * file through `input[type=file]`, calls `importPersona` (which always
25
+ * MINTS a new persona — never overwrites an existing one, even on an id
26
+ * collision) and re-emits it upward as `persona-import` so a host can
27
+ * append it to its own persona list and persist it.
28
+ *
29
+ * Per ADR-0027 — composites that programmatically create primitives in
30
+ * render() do NOT auto-import those primitives; a consumer imports
31
+ * <button-ui> + <icon-ui> alongside this module.
32
+ */
33
+
34
+ import { UIElement, html } from '../../../web-components/core/element.js';
35
+ import { exportPersona, importPersona } from '@adia-ai/persona';
36
+
37
+ class UIAdminRoster extends UIElement {
38
+ static properties = {
39
+ personas: { type: Array, default: [] },
40
+ 'active-id': { type: String, default: '', reflect: true },
41
+ };
42
+
43
+ static template = (host) => host._renderTemplate();
44
+
45
+ #headerId = `admin-roster-h-${Math.random().toString(36).slice(2, 8)}`;
46
+ #fileInput = null;
47
+
48
+ attributeChangedCallback(name, oldVal, newVal) {
49
+ if (name === 'personas') {
50
+ if (newVal == null) {
51
+ this.personas = [];
52
+ } else {
53
+ try {
54
+ const parsed = JSON.parse(newVal);
55
+ this.personas = Array.isArray(parsed) ? parsed : [];
56
+ } catch {
57
+ this.personas = [];
58
+ }
59
+ }
60
+ return;
61
+ }
62
+ super.attributeChangedCallback(name, oldVal, newVal);
63
+ }
64
+
65
+ connected() {
66
+ this.setAttribute('role', 'region');
67
+ this.setAttribute('aria-labelledby', this.#headerId);
68
+ if (this.hasAttribute('personas') && !Array.isArray(this.personas)) {
69
+ try { this.personas = JSON.parse(this.getAttribute('personas')); }
70
+ catch { this.personas = []; }
71
+ }
72
+ this.addEventListener('click', this.#onClick);
73
+ this.addEventListener('change', this.#onImportFileChange);
74
+ }
75
+
76
+ disconnected() {
77
+ this.removeEventListener('click', this.#onClick);
78
+ this.removeEventListener('change', this.#onImportFileChange);
79
+ }
80
+
81
+ _renderTemplate() {
82
+ const list = Array.isArray(this.personas) ? this.personas : [];
83
+ const activeId = this['active-id'] || '';
84
+ return html`
85
+ <header data-part="header">
86
+ <h2 data-part="title" id=${this.#headerId}>Personas</h2>
87
+ </header>
88
+ <div data-part="menu" role="menu" aria-label="Personas">
89
+ ${list.length === 0 ? this.#renderEmpty() : list.map((p) => this.#renderRow(p, activeId))}
90
+ </div>
91
+ <div data-part="toolbar">
92
+ <button-ui data-action="export" text="Export" icon="download-simple" variant="ghost" size="sm"
93
+ .disabled=${!activeId}></button-ui>
94
+ <button-ui data-action="import" text="Import" icon="upload-simple" variant="ghost" size="sm"></button-ui>
95
+ </div>
96
+ `;
97
+ }
98
+
99
+ #renderRow(persona, activeId) {
100
+ const isActive = persona.id === activeId;
101
+ return html`
102
+ <button
103
+ type="button"
104
+ data-part="row"
105
+ data-persona-id=${persona.id}
106
+ role="menuitemradio"
107
+ aria-checked=${String(isActive)}
108
+ data-active=${isActive}
109
+ >
110
+ <span data-part="row-label">${persona.label ?? persona.id}</span>
111
+ ${persona.category ? html`<span data-part="row-category">${persona.category}</span>` : null}
112
+ <icon-ui data-part="row-check" name="check" aria-hidden="true"></icon-ui>
113
+ </button>
114
+ `;
115
+ }
116
+
117
+ #renderEmpty() {
118
+ return html`<div data-part="empty" role="status">No personas yet.</div>`;
119
+ }
120
+
121
+ // ── event delegation ────────────────────────────────────────────
122
+
123
+ #onClick = (e) => {
124
+ const row = e.target.closest?.('[data-part="row"]');
125
+ if (row && this.contains(row)) {
126
+ const personaId = row.getAttribute('data-persona-id');
127
+ if (personaId && personaId !== this['active-id']) {
128
+ this['active-id'] = personaId;
129
+ this.dispatchEvent(new CustomEvent('persona-change', {
130
+ bubbles: true,
131
+ detail: { personaId },
132
+ }));
133
+ }
134
+ return;
135
+ }
136
+
137
+ const exportBtn = e.target.closest?.('[data-action="export"]');
138
+ if (exportBtn && this.contains(exportBtn)) {
139
+ this.#exportActive();
140
+ return;
141
+ }
142
+
143
+ const importBtn = e.target.closest?.('[data-action="import"]');
144
+ if (importBtn && this.contains(importBtn)) {
145
+ this.#triggerImport();
146
+ }
147
+ };
148
+
149
+ #exportActive() {
150
+ const activeId = this['active-id'];
151
+ const persona = (Array.isArray(this.personas) ? this.personas : []).find((p) => p.id === activeId);
152
+ if (!persona) return;
153
+ const json = exportPersona(persona);
154
+ const blob = new Blob([json], { type: 'application/json' });
155
+ const url = URL.createObjectURL(blob);
156
+ const a = this.ownerDocument.createElement('a');
157
+ a.href = url;
158
+ a.download = `${persona.id}.persona.json`;
159
+ a.click();
160
+ URL.revokeObjectURL(url);
161
+ }
162
+
163
+ #triggerImport() {
164
+ if (!this.#fileInput) {
165
+ this.#fileInput = this.ownerDocument.createElement('input');
166
+ this.#fileInput.type = 'file';
167
+ this.#fileInput.accept = 'application/json';
168
+ this.#fileInput.setAttribute('data-part', 'import-file');
169
+ this.#fileInput.style.display = 'none';
170
+ this.appendChild(this.#fileInput);
171
+ }
172
+ this.#fileInput.value = '';
173
+ this.#fileInput.click();
174
+ }
175
+
176
+ #onImportFileChange = async (e) => {
177
+ const input = e.target;
178
+ if (!input || input !== this.#fileInput) return;
179
+ const file = input.files?.[0];
180
+ if (!file) return;
181
+ try {
182
+ const text = await file.text();
183
+ const existingIds = (Array.isArray(this.personas) ? this.personas : []).map((p) => p.id);
184
+ const persona = importPersona(text, existingIds);
185
+ this.dispatchEvent(new CustomEvent('persona-import', {
186
+ bubbles: true,
187
+ detail: { persona },
188
+ }));
189
+ } catch (err) {
190
+ this.dispatchEvent(new CustomEvent('persona-import-error', {
191
+ bubbles: true,
192
+ detail: { error: err instanceof Error ? err.message : String(err) },
193
+ }));
194
+ }
195
+ };
196
+ }
197
+
198
+ if (!customElements.get('admin-roster-ui')) {
199
+ customElements.define('admin-roster-ui', UIAdminRoster);
200
+ }
201
+
202
+ export { UIAdminRoster };