@adia-ai/web-modules 0.8.25 → 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.
@@ -0,0 +1,167 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: UIAdminRoster
4
+ tag: admin-roster-ui
5
+ status: stable
6
+ component: AdminRoster
7
+ category: container
8
+ version: 1
9
+ description: |
10
+ L4 persona roster (CHAT-HARNESS.md §Components row L4, gh#600 / WCH-5) —
11
+ one row per persona with exactly-one-active menu semantics
12
+ (`role="menu"` of `role="menuitemradio"` rows, `aria-checked` mirroring
13
+ `[active-id]`). Clicking a row fires `persona-change` with the clicked
14
+ persona's id; clicking the already-active row is a no-op. Import/export
15
+ buttons ride `@adia-ai/persona`'s persona-file functions verbatim —
16
+ export downloads the active persona as JSON, import reads a chosen file
17
+ and re-emits the minted persona as `persona-import` for the host to
18
+ append and persist.
19
+
20
+ Lives in `packages/web-modules/agent-admin/` alongside
21
+ `<admin-settings-ui>` and `<agent-admin-ui>` — the L4 admin composite
22
+ family over `@adia-ai/persona`'s L2 store contract.
23
+
24
+ composes:
25
+ - button-ui
26
+ - icon-ui
27
+
28
+ props:
29
+ personas:
30
+ description: |
31
+ Array of `{ id, label, category? }` persona summaries. Empty array
32
+ renders "No personas yet."
33
+ type: array
34
+ default: []
35
+ required: true
36
+ dynamic: true
37
+ active-id:
38
+ description: |
39
+ The currently-active persona's id. Reflects so a host can deep-link;
40
+ drives `aria-checked` + the visible check glyph on the matching row.
41
+ type: string
42
+ default: ""
43
+ reflect: true
44
+ dynamic: true
45
+
46
+ events:
47
+ persona-change:
48
+ description: A non-active row was clicked.
49
+ detail:
50
+ personaId:
51
+ type: string
52
+ description: The id of the persona the user switched to.
53
+ persona-import:
54
+ description: A persona file was chosen and parsed successfully.
55
+ detail:
56
+ persona:
57
+ type: object
58
+ description: The minted persona (fresh, collision-safe id) from `importPersona`.
59
+ persona-import-error:
60
+ description: A chosen file failed to parse or validate as a persona file.
61
+ detail:
62
+ error:
63
+ type: string
64
+ description: The error message from `importPersona`.
65
+
66
+ slots: {}
67
+
68
+ states:
69
+ - name: idle
70
+ description: Default; renders the persona list.
71
+ - name: empty
72
+ description: "`personas` is an empty array."
73
+
74
+ traits: []
75
+
76
+ tokens:
77
+ --admin-roster-bg:
78
+ description: Panel background.
79
+ default: var(--a-bg)
80
+ --admin-roster-fg:
81
+ description: Primary foreground.
82
+ default: var(--a-fg)
83
+ --admin-roster-fg-muted:
84
+ description: Secondary foreground (category text, empty state).
85
+ default: var(--a-fg-muted)
86
+ --admin-roster-px:
87
+ description: Horizontal padding.
88
+ default: var(--a-space-4)
89
+ --admin-roster-py:
90
+ description: Vertical padding.
91
+ default: var(--a-space-4)
92
+ --admin-roster-gap:
93
+ description: Vertical gap between header / menu / toolbar.
94
+ default: var(--a-space-3)
95
+ --admin-roster-row-gap:
96
+ description: Gap between rows.
97
+ default: var(--a-space-1)
98
+ --admin-roster-row-radius:
99
+ description: Row border radius.
100
+ default: var(--a-radius-md)
101
+ --admin-roster-active-bg:
102
+ description: Active/hover row background.
103
+ default: var(--a-bg-muted)
104
+ --admin-roster-active-fg:
105
+ description: Active row foreground.
106
+ default: var(--a-fg-strong)
107
+
108
+ a2ui:
109
+ rules:
110
+ - rule: |
111
+ Use <admin-roster-ui> for the canonical persona-switch list in an
112
+ agent-admin surface. Supply the `personas` array; the composite
113
+ owns menuitemradio semantics and the active-row check glyph.
114
+ reason: One composite replaces a hand-rolled persona list.
115
+ - rule: |
116
+ Listen for `persona-change` to swap the bound `SettingsStore`
117
+ (CHAT-HARNESS §Interfaces 5) — a genuine store reference change
118
+ resets the conversation + open surfaces; this composite never
119
+ touches the store itself.
120
+ reason: Store-swap is the host composition's job, not the roster's.
121
+
122
+ anti_patterns:
123
+ - wrong: |
124
+ {"component": "AdminRoster", "personas": [{"id": "a", "label": "A"}], "children": [
125
+ {"component": "Button", "text": "Switch"}
126
+ ]}
127
+ why: Rows are generated from the `personas` prop — slotted children are ignored.
128
+ fix: |
129
+ {"component": "AdminRoster", "personas": [{"id": "a", "label": "A"}]}
130
+
131
+ examples:
132
+ - name: two-personas
133
+ description: Roster with one active persona.
134
+ a2ui: |
135
+ [{"id": "roster", "component": "AdminRoster",
136
+ "active-id": "support",
137
+ "personas": [
138
+ {"id": "support", "label": "Support agent", "category": "Customer"},
139
+ {"id": "writer", "label": "Writer", "category": "Content"}
140
+ ]}]
141
+ - name: empty
142
+ description: No personas yet.
143
+ a2ui: |
144
+ [{"id": "roster", "component": "AdminRoster", "personas": []}]
145
+
146
+ keywords:
147
+ - admin
148
+ - persona
149
+ - roster
150
+ - agent
151
+ - switch
152
+ - menu
153
+
154
+ synonyms:
155
+ roster:
156
+ - list
157
+ - personas
158
+ - agents
159
+ persona:
160
+ - agent
161
+ - config
162
+ - profile
163
+
164
+ related:
165
+ - AdminSettings
166
+ - AgentAdmin
167
+ - Menu
@@ -0,0 +1,156 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/AdminSettings.json",
4
+ "title": "AdminSettings",
5
+ "description": "L4 settings pane over ONE bound per-persona store (CHAT-HARNESS.md\n§Components row L4 + §Interfaces 5, gh#600 / WCH-5). Sections group by\n`@adia-ai/persona`'s `KNOWN_KINDS` — DISPLAY grouping only; this\ncomposite never switches on `entry.kind` for logic (law 7: Entry is one\ngeneric primitive). Each Entry renders through the same row: label +\ndescription, an enabled toggle, and a collapsible content textarea\n(authoring). Both write through `store.set(...)` immediately — the\nstore is the state, persisted per persona.\n\n`persona` and `store` are plain JS properties (no attribute path);\n`store` is an opaque `SettingsStore` object from\n`@adia-ai/persona`'s `personaStore`.\n\nLives in `packages/web-modules/agent-admin/` alongside\n`<admin-roster-ui>` and `<agent-admin-ui>`.\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
+ "component": {
17
+ "const": "AdminSettings"
18
+ },
19
+ "persona": {
20
+ "description": "The `Persona` (from `@adia-ai/persona`) whose entries this pane\nrenders, grouped by `KNOWN_KINDS`. JS property only — no attribute.\n",
21
+ "type": "object"
22
+ },
23
+ "store": {
24
+ "description": "The `SettingsStore` bound to `persona` (typically\n`personaStore(persona)`). Toggle + content edits write through it\nimmediately; external `store.set()` calls (e.g. a live-apply probe)\nre-render this pane via `store.subscribe`. JS property only.\n",
25
+ "type": "object"
26
+ }
27
+ },
28
+ "required": [
29
+ "component",
30
+ "persona",
31
+ "store"
32
+ ],
33
+ "unevaluatedProperties": false,
34
+ "x-adiaui": {
35
+ "anti_patterns": [
36
+ {
37
+ "fix": "const el = document.querySelector('admin-settings-ui');\nel.persona = myPersona;\nel.store = personaStore(myPersona);\n",
38
+ "why": "Renders \"No persona selected.\" — `persona` and `store` must be set as JS properties before use.",
39
+ "wrong": "{\"component\": \"AdminSettings\"}\n"
40
+ }
41
+ ],
42
+ "category": "container",
43
+ "composes": [
44
+ "switch-ui",
45
+ "textarea-ui",
46
+ "text-ui",
47
+ "icon-ui"
48
+ ],
49
+ "events": {
50
+ "entry-change": {
51
+ "description": "An entry's `enabled` or `content` field was written through the store.",
52
+ "detail": {
53
+ "entryId": {
54
+ "type": "string"
55
+ },
56
+ "field": {
57
+ "description": "'enabled' or 'content'.",
58
+ "type": "string"
59
+ },
60
+ "value": {
61
+ "description": "The new value (boolean for enabled, string for content).",
62
+ "type": "string"
63
+ }
64
+ }
65
+ }
66
+ },
67
+ "examples": [
68
+ {
69
+ "description": "Settings pane bound to a persona + its store (JS-driven; not expressible as static a2ui JSON since `store` is an opaque object).",
70
+ "a2ui": "[{\"id\": \"settings\", \"component\": \"AdminSettings\"}]\n",
71
+ "name": "bound-to-a-persona"
72
+ }
73
+ ],
74
+ "keywords": [
75
+ "admin",
76
+ "settings",
77
+ "persona",
78
+ "entry",
79
+ "store",
80
+ "agent"
81
+ ],
82
+ "name": "UIAdminSettings",
83
+ "related": [
84
+ "AdminRoster",
85
+ "AgentAdmin"
86
+ ],
87
+ "slots": {},
88
+ "states": [
89
+ {
90
+ "description": "Default; renders `persona`'s entries grouped by kind.",
91
+ "name": "idle"
92
+ },
93
+ {
94
+ "description": "No `persona` set.",
95
+ "name": "empty"
96
+ }
97
+ ],
98
+ "status": "stable",
99
+ "synonyms": {
100
+ "persona": [
101
+ "agent",
102
+ "profile"
103
+ ],
104
+ "settings": [
105
+ "config",
106
+ "preferences",
107
+ "entries"
108
+ ]
109
+ },
110
+ "tag": "admin-settings-ui",
111
+ "tokens": {
112
+ "--admin-settings-bg": {
113
+ "description": "Panel background.",
114
+ "default": "var(--a-bg)"
115
+ },
116
+ "--admin-settings-border": {
117
+ "description": "Entry card border.",
118
+ "default": "var(--a-border-subtle)"
119
+ },
120
+ "--admin-settings-entry-gap": {
121
+ "description": "Gap between sibling entries in a section.",
122
+ "default": "var(--a-space-1)"
123
+ },
124
+ "--admin-settings-fg": {
125
+ "description": "Primary foreground.",
126
+ "default": "var(--a-fg)"
127
+ },
128
+ "--admin-settings-fg-muted": {
129
+ "description": "Secondary foreground (description, empty state).",
130
+ "default": "var(--a-fg-muted)"
131
+ },
132
+ "--admin-settings-gap": {
133
+ "description": "Vertical gap between header and sections.",
134
+ "default": "var(--a-space-5)"
135
+ },
136
+ "--admin-settings-px": {
137
+ "description": "Horizontal padding.",
138
+ "default": "var(--a-space-4)"
139
+ },
140
+ "--admin-settings-py": {
141
+ "description": "Vertical padding.",
142
+ "default": "var(--a-space-4)"
143
+ },
144
+ "--admin-settings-radius": {
145
+ "description": "Entry card border radius.",
146
+ "default": "var(--a-radius-md)"
147
+ },
148
+ "--admin-settings-section-gap": {
149
+ "description": "Gap between a section's heading and its entries.",
150
+ "default": "var(--a-space-2)"
151
+ }
152
+ },
153
+ "traits": [],
154
+ "version": 1
155
+ }
156
+ }
@@ -0,0 +1,114 @@
1
+ /* ═══════════════════════════════════════════════════════════════
2
+ admin-settings-ui — Module CSS
3
+
4
+ Two-block @scope:
5
+ Block 1 — TOKENS (:where(:scope))
6
+ Block 2 — BASE (:scope + descendant rules)
7
+
8
+ Composition note: layout + spacing only; child primitives (switch-ui,
9
+ textarea-ui, text-ui, icon-ui) carry their own chrome.
10
+ ═══════════════════════════════════════════════════════════════ */
11
+
12
+ @scope (admin-settings-ui) {
13
+
14
+ :where(:scope) {
15
+ --admin-settings-bg: var(--a-bg);
16
+ --admin-settings-fg: var(--a-fg);
17
+ --admin-settings-fg-muted: var(--a-fg-muted);
18
+ --admin-settings-px: var(--a-space-4);
19
+ --admin-settings-py: var(--a-space-4);
20
+ --admin-settings-gap: var(--a-space-5);
21
+ --admin-settings-section-gap: var(--a-space-2);
22
+ --admin-settings-entry-gap: var(--a-space-1);
23
+ --admin-settings-border: var(--a-border-subtle);
24
+ --admin-settings-radius: var(--a-radius-md);
25
+ }
26
+
27
+ :scope {
28
+ box-sizing: border-box;
29
+ display: grid;
30
+ gap: var(--admin-settings-gap);
31
+ padding: var(--admin-settings-py) var(--admin-settings-px);
32
+ background: var(--admin-settings-bg);
33
+ color: var(--admin-settings-fg);
34
+ min-width: 0;
35
+ }
36
+
37
+ :scope > [data-part="header"] > [data-part="title"] {
38
+ margin: 0;
39
+ font-size: var(--a-text-lg);
40
+ font-weight: var(--a-weight-medium);
41
+ }
42
+
43
+ :scope [data-part="sections"] {
44
+ display: grid;
45
+ gap: var(--a-space-6);
46
+ }
47
+
48
+ :scope [data-part="section"] {
49
+ display: grid;
50
+ gap: var(--admin-settings-section-gap);
51
+ }
52
+
53
+ :scope [data-part="entries"] {
54
+ display: grid;
55
+ gap: var(--admin-settings-entry-gap);
56
+ }
57
+
58
+ :scope [data-part="entry"] {
59
+ border: 1px solid var(--admin-settings-border);
60
+ border-radius: var(--admin-settings-radius);
61
+ padding: var(--a-space-2) var(--a-space-3);
62
+ }
63
+
64
+ :scope [data-part="entry-row"] {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--a-space-2);
68
+ }
69
+
70
+ :scope [data-part="entry-meta"] {
71
+ flex: 1 1 auto;
72
+ min-width: 0;
73
+ display: grid;
74
+ gap: 2px;
75
+ }
76
+
77
+ :scope [data-part="entry-label"] {
78
+ font-weight: var(--a-weight-medium);
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ }
83
+
84
+ :scope [data-part="entry-description"] {
85
+ font-size: var(--a-ui-sm);
86
+ color: var(--admin-settings-fg-muted);
87
+ }
88
+
89
+ :scope [data-action="collapse-toggle"] {
90
+ flex: 0 0 auto;
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ border: none;
95
+ background: transparent;
96
+ color: var(--admin-settings-fg-muted);
97
+ cursor: pointer;
98
+ padding: var(--a-space-1);
99
+ }
100
+
101
+ :scope [data-part="entry-enabled"] {
102
+ flex: 0 0 auto;
103
+ }
104
+
105
+ :scope [data-part="entry-content"] {
106
+ margin-top: var(--a-space-2);
107
+ }
108
+
109
+ :scope [data-part="empty"] {
110
+ padding: var(--a-space-6);
111
+ text-align: center;
112
+ color: var(--admin-settings-fg-muted);
113
+ }
114
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `<admin-settings-ui>` — L4 settings pane over ONE bound per-persona
3
+ * store (CHAT-HARNESS.md §Components row L4 + §Interfaces 5, gh#600 /
4
+ * WCH-5).
5
+ *
6
+ * Hand-authored alongside `<name>.yaml`; regenerate when the props /
7
+ * events shape changes.
8
+ */
9
+
10
+ import { UIElement } from '../../../web-components/core/element.js';
11
+ import type { Persona, SettingsStore } from '@adia-ai/persona';
12
+
13
+ export interface AdminSettingsEntryChangeDetail {
14
+ entryId: string;
15
+ field: 'enabled' | 'content';
16
+ value: boolean | string;
17
+ }
18
+ export type AdminSettingsEntryChangeEvent = CustomEvent<AdminSettingsEntryChangeDetail>;
19
+
20
+ export class UIAdminSettings extends UIElement {
21
+ persona: Persona | null;
22
+ store: SettingsStore | null;
23
+
24
+ addEventListener<K extends keyof HTMLElementEventMap>(
25
+ type: K,
26
+ listener: (this: UIAdminSettings, ev: HTMLElementEventMap[K]) => unknown,
27
+ options?: boolean | AddEventListenerOptions,
28
+ ): void;
29
+ addEventListener(type: 'entry-change', listener: (ev: AdminSettingsEntryChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
30
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * <admin-settings-ui> — L4 settings pane over ONE bound per-persona store
3
+ * (CHAT-HARNESS.md §Components row L4 + §Interfaces 5, gh#600 / WCH-5).
4
+ *
5
+ * <admin-settings-ui id="settings"></admin-settings-ui>
6
+ * <script>
7
+ * const el = document.querySelector('#settings');
8
+ * el.persona = myPersona; // @adia-ai/persona Persona
9
+ * el.store = personaStore(myPersona); // @adia-ai/persona SettingsStore
10
+ * </script>
11
+ *
12
+ * Sections are grouped by `KNOWN_KINDS` (display grouping ONLY — this file
13
+ * never switches on `entry.kind` for logic, per CHAT-HARNESS law 7: Entry
14
+ * is ONE generic primitive, six kinds are seed data). Each Entry renders
15
+ * through the SAME markup: a label + description, an enabled toggle, and a
16
+ * collapsible content textarea (authoring, M-scope 2). Both the toggle and
17
+ * the textarea write through `store.set(...)` IMMEDIATELY on change — this
18
+ * composite holds no local edit buffer; the store IS the state, persisted
19
+ * per persona (`@adia-ai/persona`'s `personaStore`).
20
+ *
21
+ * `persona` / `store` are plain JS properties (no attribute path for
22
+ * either; `store` is an opaque `SettingsStore` object, never
23
+ * attribute-serializable) that force a re-render through `_v`, an
24
+ * internal reflected counter declared in `static properties` — the same
25
+ * "reassign a declared reactive prop to force a repaint" trick
26
+ * `<integrations-page-ui>` uses for its own non-attribute state.
27
+ *
28
+ * Per ADR-0027 — composites that programmatically create primitives in
29
+ * render() do NOT auto-import those primitives; a consumer imports
30
+ * <switch-ui>, <textarea-ui>, <text-ui>, <icon-ui>, <button-ui> alongside this module.
31
+ */
32
+
33
+ import { UIElement, html } from '../../../web-components/core/element.js';
34
+ import { KNOWN_KINDS } from '@adia-ai/persona';
35
+ import { entryStoreKey } from '../live-persona.js';
36
+
37
+ const GROUP_LABEL = {
38
+ 'prompt-section': 'Prompt sections',
39
+ skill: 'Skills',
40
+ workflow: 'Workflows',
41
+ resource: 'Resources',
42
+ tool: 'Tools',
43
+ 'pattern-source': 'Pattern sources',
44
+ };
45
+
46
+ class UIAdminSettings extends UIElement {
47
+ // `_v` is an internal repaint counter — not a public prop, never meant
48
+ // to be read or set by a consumer. Bumping it (an ordinary declared-
49
+ // property write) is how persona/store/collapse-state changes reach the
50
+ // framework's own render effect, without inventing a second reactivity
51
+ // mechanism alongside it (that duplication is what caused a signals
52
+ // drain-loop warning in an earlier draft of this file).
53
+ static properties = {
54
+ _v: { type: Number, default: 0 },
55
+ };
56
+
57
+ static template = (host) => host._renderTemplate();
58
+
59
+ #headerId = `admin-settings-h-${Math.random().toString(36).slice(2, 8)}`;
60
+ #persona = null;
61
+ #store = null;
62
+ #expanded = new Set();
63
+ #unsubscribe = null;
64
+
65
+ connected() {
66
+ this.setAttribute('role', 'region');
67
+ this.setAttribute('aria-labelledby', this.#headerId);
68
+ this.addEventListener('change', this.#onChange);
69
+ this.addEventListener('input', this.#onContentInput);
70
+ this.addEventListener('press', this.#onToggleCollapse);
71
+ }
72
+
73
+ disconnected() {
74
+ this.removeEventListener('change', this.#onChange);
75
+ this.removeEventListener('input', this.#onContentInput);
76
+ this.removeEventListener('press', this.#onToggleCollapse);
77
+ if (this.#unsubscribe) { this.#unsubscribe(); this.#unsubscribe = null; }
78
+ }
79
+
80
+ get persona() { return this.#persona; }
81
+ set persona(value) {
82
+ this.#persona = value;
83
+ this._v++;
84
+ }
85
+
86
+ get store() { return this.#store; }
87
+ set store(value) {
88
+ if (this.#unsubscribe) { this.#unsubscribe(); this.#unsubscribe = null; }
89
+ this.#store = value;
90
+ if (value?.subscribe) {
91
+ this.#unsubscribe = value.subscribe(() => { this._v++; });
92
+ }
93
+ this._v++;
94
+ }
95
+
96
+ _renderTemplate() {
97
+ // Touch `_v` unconditionally so this render subscribes to it even on
98
+ // the empty-state branch below.
99
+ void this._v;
100
+ const persona = this.#persona;
101
+ const store = this.#store;
102
+
103
+ if (!persona) {
104
+ return html`<div data-part="empty" role="status">No persona selected.</div>`;
105
+ }
106
+ const groups = this.#groupedEntries(persona);
107
+ return html`
108
+ <header data-part="header">
109
+ <h2 data-part="title" id=${this.#headerId}>${persona.label ?? persona.id} — Settings</h2>
110
+ </header>
111
+ <div data-part="sections">
112
+ ${groups.map((g) => this.#renderSection(g, store))}
113
+ </div>
114
+ `;
115
+ }
116
+
117
+ #groupedEntries(persona) {
118
+ const byKind = new Map();
119
+ for (const entry of persona.entries) {
120
+ const list = byKind.get(entry.kind);
121
+ if (list) list.push(entry);
122
+ else byKind.set(entry.kind, [entry]);
123
+ }
124
+ return KNOWN_KINDS
125
+ .filter((kind) => byKind.has(kind))
126
+ .map((kind) => ({ kind, label: GROUP_LABEL[kind] ?? kind, entries: byKind.get(kind).sort((a, b) => a.order - b.order) }));
127
+ }
128
+
129
+ #renderSection(group, store) {
130
+ return html`
131
+ <section data-part="section" data-kind=${group.kind} aria-label=${group.label}>
132
+ <text-ui data-part="section-heading" variant="kicker">${group.label}</text-ui>
133
+ <div data-part="entries">
134
+ ${group.entries.map((entry) => this.#renderEntry(entry, store))}
135
+ </div>
136
+ </section>
137
+ `;
138
+ }
139
+
140
+ #renderEntry(entry, store) {
141
+ const enabled = this.#readEnabled(entry, store);
142
+ const content = this.#readContent(entry, store);
143
+ const isOpen = this.#expanded.has(entry.id);
144
+ return html`
145
+ <div data-part="entry" data-entry-id=${entry.id}>
146
+ <div data-part="entry-row">
147
+ <div data-part="entry-meta">
148
+ <span data-part="entry-label">${entry.label}</span>
149
+ ${entry.description ? html`<span data-part="entry-description">${entry.description}</span>` : null}
150
+ </div>
151
+ <button-ui data-action="collapse-toggle" data-entry-id=${entry.id}
152
+ variant="ghost" size="sm" icon=${isOpen ? 'caret-up' : 'caret-down'}
153
+ aria-expanded=${String(isOpen)} aria-label=${`Edit ${entry.label} content`}></button-ui>
154
+ <switch-ui data-part="entry-enabled" data-entry-id=${entry.id} .checked=${enabled}
155
+ aria-label=${`Enable ${entry.label}`}></switch-ui>
156
+ </div>
157
+ ${isOpen ? html`
158
+ <div data-part="entry-content">
159
+ <textarea-ui data-part="entry-content-input" data-entry-id=${entry.id}
160
+ .value=${content} rows="4"></textarea-ui>
161
+ </div>
162
+ ` : null}
163
+ </div>
164
+ `;
165
+ }
166
+
167
+ #readEnabled(entry, store) {
168
+ const stored = store?.get(entryStoreKey(entry.id, 'enabled'));
169
+ return typeof stored === 'boolean' ? stored : entry.enabled;
170
+ }
171
+
172
+ #readContent(entry, store) {
173
+ const stored = store?.get(entryStoreKey(entry.id, 'content'));
174
+ return typeof stored === 'string' ? stored : entry.content;
175
+ }
176
+
177
+ // ── event delegation ────────────────────────────────────────────
178
+
179
+ #onChange = (e) => {
180
+ const sw = e.target.closest?.('[data-part="entry-enabled"]');
181
+ if (sw && this.contains(sw)) {
182
+ const entryId = sw.getAttribute('data-entry-id');
183
+ const checked = !!e.detail?.checked;
184
+ this.store?.set(entryStoreKey(entryId, 'enabled'), checked);
185
+ this.dispatchEvent(new CustomEvent('entry-change', {
186
+ bubbles: true,
187
+ detail: { entryId, field: 'enabled', value: checked },
188
+ }));
189
+ }
190
+ };
191
+
192
+ #onContentInput = (e) => {
193
+ const ta = e.target.closest?.('[data-part="entry-content-input"]');
194
+ if (ta && this.contains(ta)) {
195
+ const entryId = ta.getAttribute('data-entry-id');
196
+ const value = e.detail?.value ?? ta.value ?? '';
197
+ this.store?.set(entryStoreKey(entryId, 'content'), value);
198
+ this.dispatchEvent(new CustomEvent('entry-change', {
199
+ bubbles: true,
200
+ detail: { entryId, field: 'content', value },
201
+ }));
202
+ }
203
+ };
204
+
205
+ #onToggleCollapse = (e) => {
206
+ const btn = e.target.closest?.('[data-action="collapse-toggle"]');
207
+ if (!btn || !this.contains(btn)) return;
208
+ const entryId = btn.getAttribute('data-entry-id');
209
+ if (this.#expanded.has(entryId)) this.#expanded.delete(entryId);
210
+ else this.#expanded.add(entryId);
211
+ this._v++;
212
+ };
213
+ }
214
+
215
+ if (!customElements.get('admin-settings-ui')) {
216
+ customElements.define('admin-settings-ui', UIAdminSettings);
217
+ }
218
+
219
+ export { UIAdminSettings };