a2acalling 0.6.58 → 0.6.59

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,1273 @@
1
+ # A2A-41: Dashboard Permissions Tab Overhaul — Implementation Plan (Revision 1)
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Rename the Settings tab to Permissions, remove the dead disclosure field (frontend + backend), replace plain textareas with rich interactive UI (expandable topic/goal rows, tool checkboxes, three-column drag-and-drop, validation warnings, caller preview dialog).
6
+
7
+ **Architecture:** All changes are in the existing dashboard SPA (vanilla JS + Shoelace v2.19 CDN) and the Express backend routes. No new dependencies, no build step. Frontend changes in 3 files (index.html, app.js, style.css), backend changes in 5 files (config.js, tokens.js, a2a.js, dashboard.js, cli.js). The dashboard reads tier data from `GET /api/a2a/dashboard/settings` which already returns both flat config arrays AND manifest objects with descriptions — we leverage the manifest data for rich topic/goal display.
8
+
9
+ **Tech Stack:** Vanilla JS, Shoelace v2.19 web components (CDN), HTML5 Drag and Drop API, Express 4
10
+
11
+ **Revision Notes (from reviewer cycles 1+2):**
12
+ - Split into single branch but builder monitors diff size per-commit and flags if approaching 500 lines
13
+ - Use event delegation for topic/goal list events (not per-row binding) to prevent listener leaks
14
+ - Use `Promise.all()` for drag-and-drop saves with rollback on failure
15
+ - Fix topic count warning to avoid double-counting (use `manifest || flat`, not `manifest + flat`)
16
+ - Use `topic` key (NOT `objective`) for manifest objectives in save handler — `parseTopicObjects()` only reads `entry.topic`
17
+ - Fix renderTierWarnings empty-array truthiness: use `.length > 0` gate instead of `||` (empty arrays are truthy in JS)
18
+ - Add responsive CSS for three-column layout under 720px
19
+ - Document that HTML5 DnD does not work on touch devices
20
+
21
+ ---
22
+
23
+ ## Reuse Map
24
+
25
+ These existing code elements MUST be used — do NOT recreate them:
26
+
27
+ | What | Where | Why |
28
+ |------|-------|-----|
29
+ | `esc()` | `app.js:203` | HTML escaping in template literals |
30
+ | `request(path, opts)` | `app.js:171` | All dashboard API calls |
31
+ | `showNotice(msg)` | `app.js:25` | User feedback messages |
32
+ | `toLines(arr)` / `fromLines(str)` | `app.js:183-190` | Array↔textarea conversion (keep for fallback) |
33
+ | `parseTopicObjects(values)` | `dashboard.js:145` | Backend already parses `{topic, description}` objects |
34
+ | `sanitizeString()` | `dashboard.js:91` | Input sanitization |
35
+ | `sanitizeStringArray()` | `dashboard.js:129` | Array sanitization |
36
+ | `loadManifest()` / `saveManifest()` | imported from `disclosure.js` in `dashboard.js:18` | Manifest persistence |
37
+ | `state.settings` | `app.js:2` | Client-side settings cache (tiers, manifest, defaults) |
38
+ | `fillTierSelects()` | `app.js:1098` | Populates all tier dropdowns |
39
+ | `renderTierEditor(tierId)` | `app.js:1123` | Renders the tier edit form |
40
+ | `bindSettingsActions()` | `app.js:1136` | Event binding for settings tab |
41
+ | `loadSettings()` | `app.js:1211` | Fetches settings from API |
42
+
43
+ ## Anti-Patterns (Do NOT)
44
+
45
+ - Do NOT use `console.log` in app.js — use `showNotice()` for user feedback
46
+ - Do NOT add npm dependencies — Shoelace is CDN-loaded, drag-and-drop is native
47
+ - Do NOT modify `src/lib/disclosure.js` — the disclosure SYSTEM stays; only the per-tier `disclosure` field is removed
48
+ - Do NOT touch `src/server.js` — route mounting is automatic
49
+ - Do NOT break the `a2a create --disclosure` CLI flag in the onboarding flow (`bin/cli.js:680-697`) — that code patches tiers during quickstart and still uses `disclosure` as a tier config field. Since we're removing the field from `validateTierPatch()`, the `config.setTier()` calls will simply ignore `disclosure` gracefully
50
+ - Do NOT bind per-row event listeners — use event delegation on container elements (same pattern as contacts panel at `app.js:564`)
51
+
52
+ ## File Change Map
53
+
54
+ | Action | File | What changes |
55
+ |--------|------|-------------|
56
+ | Modify | `src/dashboard/public/index.html` | Rename tab/panel `settings`→`permissions`, remove tier-id + disclosure inputs, replace textareas with container divs, add warnings div, preview button + dialog, tier-columns layout, toggle checkbox |
57
+ | Modify | `src/dashboard/public/app.js` | Rewrite `fillTierSelects()` (emojis, default public), rewrite `renderTierEditor()` (no disclosure/tier-id, call new render functions), add `renderTopicList()`, `renderGoalList()`, `renderToolCheckboxes()`, `renderTierColumns()`, `renderTierWarnings()`, `openCallerPreview()`, `getPreviewData()`, drag handlers via event delegation, update `bindSettingsActions()`, update `tabLoaders` key, update `bindTabs()` hash alias |
58
+ | Modify | `src/dashboard/public/style.css` | Add topic-row, drag-handle, tier-columns, tier-drop-zone, tools-checklist, tier-warnings, preview-dialog styles, responsive breakpoint |
59
+ | Modify | `src/lib/config.js` | Remove `disclosure` from 4 tier defaults (lines 194, 204, 214, 224), remove disclosure validation block (lines 109-118) |
60
+ | Modify | `src/lib/tokens.js` | Remove `disclosure` param from `create()` (line 197), remove from record (line 276), remove from `validate()` return (line 362) |
61
+ | Modify | `src/routes/a2a.js` | Remove `disclosure: validation.disclosure` (line 353) |
62
+ | Modify | `src/routes/dashboard.js` | Remove `disclosure` from GET /settings tier response (line 1279), remove from PUT handler (line 1313), remove from POST handler (line 1378) |
63
+ | Modify | `bin/cli.js` | Remove `disclosure` from `create()` call (line 880), remove disclosure console.log (line 925), add `permissions` alias in gui `--tab` (line 1690), remove `--disclosure` from help text (line 2990) |
64
+
65
+ ---
66
+
67
+ ## Task 1: Branch Setup + Backend Disclosure Removal
68
+
69
+ **Files:**
70
+ - Modify: `src/lib/config.js:109-118,194,204,214,224`
71
+ - Modify: `src/lib/tokens.js:197,276,362`
72
+ - Modify: `src/routes/a2a.js:353`
73
+ - Modify: `src/routes/dashboard.js:1279,1313,1378`
74
+ - Modify: `bin/cli.js:680-697,880,925,2990`
75
+
76
+ **Step 1: Create feature branch**
77
+
78
+ ```bash
79
+ git fetch origin main
80
+ git checkout origin/main
81
+ git checkout -b feature/a2a-41
82
+ ```
83
+
84
+ **Step 2: Remove disclosure from config.js tier defaults**
85
+
86
+ In `src/lib/config.js`, remove the `disclosure` key from all 4 tier objects in `DEFAULT_CONFIG.tiers` (lines 194, 204, 214, 224). Add a comment at the top of the tiers block:
87
+
88
+ ```javascript
89
+ // A2A-41: disclosure field removed from tiers. It was intended to control
90
+ // HOW info is shared (freely/minimally/none) but was never consumed by
91
+ // prompt templates. The disclosure SYSTEM (disclosure.js, manifest) remains.
92
+ ```
93
+
94
+ Remove the disclosure validation block (lines 109-118) from `validateTierPatch()`. Replace with:
95
+
96
+ ```javascript
97
+ // A2A-41: disclosure field intentionally removed. If present in input,
98
+ // it is silently ignored for backward compatibility with older clients.
99
+ ```
100
+
101
+ **Step 3: Remove disclosure from tokens.js**
102
+
103
+ In `src/lib/tokens.js`:
104
+ - Line 197: Remove `disclosure = 'minimal',` from destructuring in `create()`
105
+ - Line 276: Remove `disclosure,` from the record object
106
+ - Line 362: Remove `disclosure: record.disclosure,` from validate() return
107
+
108
+ Add comment near top of `create()`:
109
+
110
+ ```javascript
111
+ // A2A-41: disclosure field removed from tokens. Was never used by
112
+ // prompt generation. Existing tokens with disclosure field are harmless
113
+ // — the field is simply not read.
114
+ ```
115
+
116
+ **Step 4: Remove disclosure from a2a.js**
117
+
118
+ In `src/routes/a2a.js` line 353: Remove `disclosure: validation.disclosure,` from the `a2aContext` object.
119
+
120
+ **Step 5: Remove disclosure from dashboard.js**
121
+
122
+ In `src/routes/dashboard.js`:
123
+ - Line 1279: Remove `disclosure: configTier.disclosure || 'minimal',` from GET /settings response
124
+ - Line 1313: Remove `if (body.disclosure !== undefined) update.disclosure = sanitizeString(body.disclosure, 40) || 'minimal';` from PUT handler
125
+ - Line 1378: Remove `disclosure: sanitizeString(body.disclosure || 'minimal', 40),` from POST handler
126
+
127
+ **Step 6: Remove disclosure from CLI**
128
+
129
+ In `bin/cli.js`:
130
+ - Lines 685, 691, 697: Remove `disclosure: 'minimal'`, `disclosure: 'standard'`, `disclosure: 'full'` from the tier patches
131
+ - Line 880: Remove `disclosure: args.flags.disclosure || args.flags.d || 'minimal',` from `store.create()` call
132
+ - Line 925: Remove `console.log(\`Disclosure: ${record.disclosure}\`);`
133
+ - Line 2990: Remove `--disclosure, -d Disclosure level (public, minimal, none)` from help text
134
+
135
+ **Step 7: Run tests**
136
+
137
+ ```bash
138
+ npm test
139
+ ```
140
+
141
+ Expected: 328 passing, 2 failing (pre-existing). No NEW failures.
142
+
143
+ **Step 8: Commit**
144
+
145
+ ```bash
146
+ git add src/lib/config.js src/lib/tokens.js src/routes/a2a.js src/routes/dashboard.js bin/cli.js
147
+ git commit -m "feat(a2a-41): remove disclosure field from tiers, tokens, and routes"
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Task 2: Rename Settings → Permissions + Tier Dropdown Polish
153
+
154
+ Combines the rename and dropdown polish into one task since they both touch the same files.
155
+
156
+ **Files:**
157
+ - Modify: `src/dashboard/public/index.html:21,67-68,76,79`
158
+ - Modify: `src/dashboard/public/app.js:252-278,1098-1134,1136-1161,1584`
159
+ - Modify: `bin/cli.js:1689-1691,3034`
160
+
161
+ **Step 1: Rename tab, panel, heading in index.html**
162
+
163
+ - Line 21: `<sl-tab slot="nav" panel="settings">Settings</sl-tab>` → `<sl-tab slot="nav" panel="permissions">Permissions</sl-tab>`
164
+ - Line 67: `<sl-tab-panel name="settings">` → `<sl-tab-panel name="permissions">`
165
+ - Line 68: `<h2>Tier Settings</h2>` → `<h2>Permission Tiers</h2>`
166
+
167
+ **Step 2: Remove tier-id and disclosure inputs from index.html**
168
+
169
+ - Delete line 76: `<sl-input id="tier-id" label="Tier ID" readonly></sl-input>`
170
+ - Delete line 79: `<sl-input id="tier-disclosure" label="Disclosure" placeholder="minimal"></sl-input>`
171
+
172
+ **Step 3: Update tabLoaders and hash alias in app.js**
173
+
174
+ Line 1584: Change `settings: () => {},` to `permissions: () => {},`
175
+
176
+ In `bindTabs()` (app.js ~line 263), update `activateFromHash()`:
177
+
178
+ ```javascript
179
+ const activateFromHash = () => {
180
+ let hash = window.location.hash.slice(1);
181
+ // A2A-41: backward-compat alias — old bookmarks/links using #settings
182
+ // still work after rename to #permissions
183
+ if (hash === 'settings') hash = 'permissions';
184
+ if (hash) {
185
+ try { tabGroup.show(hash); } catch (err) {}
186
+ }
187
+ };
188
+ ```
189
+
190
+ **Step 4: Rewrite fillTierSelects() with emojis and default-to-public**
191
+
192
+ Replace the existing function (lines 1098-1121) with:
193
+
194
+ ```javascript
195
+ // A2A-41: emoji map for visual tier differentiation. Standard tiers get
196
+ // recognizable icons; custom/user-created tiers get a wrench.
197
+ const TIER_EMOJIS = { public: '\u{1F310}', friends: '\u{1F46B}', family: '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}' };
198
+
199
+ function fillTierSelects() {
200
+ const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
201
+ const tierSelect = document.getElementById('tier-select');
202
+ const copyFrom = document.getElementById('copy-from-tier');
203
+ const newTierCopy = document.getElementById('new-tier-copy-from');
204
+ const inviteTier = document.getElementById('invite-tier');
205
+
206
+ const optionsHtml = tiers.map(tier => {
207
+ const emoji = TIER_EMOJIS[tier.id] || '\u{1F527}';
208
+ return `<sl-option value="${esc(tier.id)}">${emoji} ${esc(tier.name || tier.id)}</sl-option>`;
209
+ }).join('');
210
+
211
+ tierSelect.innerHTML = optionsHtml;
212
+ copyFrom.innerHTML = optionsHtml;
213
+ inviteTier.innerHTML = optionsHtml;
214
+ newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
215
+
216
+ // A2A-41: default to 'public' — it's the base tier and most commonly edited
217
+ const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
218
+ if (defaultTier) {
219
+ tierSelect.value = defaultTier;
220
+ copyFrom.value = defaultTier;
221
+ inviteTier.value = defaultTier;
222
+ renderTierEditor(defaultTier);
223
+ }
224
+ }
225
+ ```
226
+
227
+ **Step 5: Update renderTierEditor() — remove disclosure/tier-id lines**
228
+
229
+ Remove:
230
+ - `document.getElementById('tier-id').value = tier.id;` (line 1127)
231
+ - `document.getElementById('tier-disclosure').value = tier.disclosure || 'minimal';` (line 1130)
232
+
233
+ **Step 6: Update save handler — remove disclosure, use tier-select for ID**
234
+
235
+ In the tier-form submit handler (line 1143): Change `document.getElementById('tier-id').value` to `document.getElementById('tier-select').value`
236
+
237
+ Remove `disclosure: document.getElementById('tier-disclosure').value,` (line 1147)
238
+
239
+ Update copy-tier handler (line 1161): Change `document.getElementById('tier-id').value` to `document.getElementById('tier-select').value`
240
+
241
+ **Step 7: Update CLI gui --tab**
242
+
243
+ In `bin/cli.js` line 1689-1691:
244
+
245
+ ```javascript
246
+ const tab = (args.flags.tab || args.flags.t || '').trim().toLowerCase();
247
+ // A2A-41: 'settings' remains as backward-compat alias for 'permissions'
248
+ const tabAliases = { settings: 'permissions' };
249
+ const resolvedTab = tabAliases[tab] || tab;
250
+ const allowedTabs = new Set(['contacts', 'calls', 'logs', 'permissions', 'invites']);
251
+ const hash = allowedTabs.has(resolvedTab) ? `#${resolvedTab}` : '';
252
+ ```
253
+
254
+ Update help text (line 3034) to say `contacts|calls|logs|permissions|invites`
255
+
256
+ **Step 8: Run tests**
257
+
258
+ ```bash
259
+ npm test
260
+ ```
261
+
262
+ **Step 9: Commit**
263
+
264
+ ```bash
265
+ git add src/dashboard/public/index.html src/dashboard/public/app.js bin/cli.js
266
+ git commit -m "feat(a2a-41): rename Settings to Permissions, tier emojis, default public, remove dead fields"
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Task 3: CSS Styles + Rich Topic/Goal/Tool UI
272
+
273
+ **Files:**
274
+ - Modify: `src/dashboard/public/style.css`
275
+ - Modify: `src/dashboard/public/index.html:80-82`
276
+ - Modify: `src/dashboard/public/app.js` — add constants, render functions, event delegation, update save handler
277
+
278
+ **Step 1: Add all new CSS to style.css**
279
+
280
+ Insert before the existing `@media (max-width: 720px)` block at line 204:
281
+
282
+ ```css
283
+ /* ── A2A-41: Topic/Goal list rows ──────────────────────────── */
284
+ .topic-row {
285
+ display: flex;
286
+ align-items: flex-start;
287
+ gap: 0.5rem;
288
+ padding: 0.5rem 0.6rem;
289
+ border: 1px solid var(--line);
290
+ border-radius: 6px;
291
+ margin-bottom: 0.4rem;
292
+ background: var(--panel);
293
+ cursor: grab;
294
+ transition: box-shadow 0.15s ease, border-color 0.15s ease;
295
+ }
296
+
297
+ .topic-row:hover {
298
+ border-color: var(--accent);
299
+ box-shadow: 0 1px 4px rgba(20, 102, 193, 0.1);
300
+ }
301
+
302
+ .topic-row.dragging {
303
+ opacity: 0.5;
304
+ border-style: dashed;
305
+ }
306
+
307
+ .topic-row.inherited {
308
+ opacity: 0.55;
309
+ background: #f8fafc;
310
+ cursor: default;
311
+ border-style: dashed;
312
+ border-color: #c5cfd9;
313
+ }
314
+
315
+ .topic-row.inherited .drag-handle,
316
+ .topic-row.inherited .topic-delete-btn {
317
+ display: none;
318
+ }
319
+
320
+ .drag-handle {
321
+ color: #9ca8b5;
322
+ cursor: grab;
323
+ user-select: none;
324
+ font-size: 1.1rem;
325
+ line-height: 1;
326
+ padding-top: 2px;
327
+ }
328
+
329
+ .topic-content {
330
+ flex: 1;
331
+ min-width: 0;
332
+ }
333
+
334
+ .topic-header {
335
+ display: flex;
336
+ align-items: center;
337
+ gap: 0.4rem;
338
+ }
339
+
340
+ .topic-label {
341
+ flex: 1;
342
+ font-size: 0.9rem;
343
+ }
344
+
345
+ .topic-description {
346
+ margin-top: 0.35rem;
347
+ padding-left: 0.1rem;
348
+ }
349
+
350
+ .topic-desc-text {
351
+ font-size: 0.82rem;
352
+ color: #4b5d73;
353
+ margin: 0 0 0.3rem;
354
+ line-height: 1.4;
355
+ }
356
+
357
+ .inherited-badge {
358
+ font-size: 0.72rem;
359
+ color: #7a8da0;
360
+ font-style: italic;
361
+ margin-left: auto;
362
+ }
363
+
364
+ .add-item-btn {
365
+ width: 100%;
366
+ margin-top: 0.3rem;
367
+ border: 1px dashed var(--line);
368
+ border-radius: 6px;
369
+ padding: 0.4rem;
370
+ background: transparent;
371
+ color: #7a8da0;
372
+ cursor: pointer;
373
+ font-size: 0.85rem;
374
+ transition: border-color 0.15s, color 0.15s;
375
+ }
376
+
377
+ .add-item-btn:hover {
378
+ border-color: var(--accent);
379
+ color: var(--accent);
380
+ }
381
+
382
+ /* ── A2A-41: Tools checklist ───────────────────────────────── */
383
+ .tools-checklist {
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 0.35rem;
387
+ }
388
+
389
+ .tools-checklist sl-checkbox {
390
+ margin-bottom: 0;
391
+ }
392
+
393
+ .tools-checklist sl-checkbox::part(label) {
394
+ font-size: 0.88rem;
395
+ }
396
+
397
+ .tool-desc {
398
+ color: #4b5d73;
399
+ font-weight: normal;
400
+ }
401
+
402
+ /* ── A2A-41: Three-column tier layout ──────────────────────── */
403
+ .tier-columns {
404
+ display: grid;
405
+ grid-template-columns: 1fr 1fr 1fr;
406
+ gap: 0.8rem;
407
+ margin-bottom: 1.2rem;
408
+ }
409
+
410
+ .tier-column {
411
+ border: 1px solid var(--line);
412
+ border-radius: 8px;
413
+ padding: 0.6rem;
414
+ background: #fbfdff;
415
+ min-height: 120px;
416
+ }
417
+
418
+ .tier-column h4 {
419
+ margin: 0 0 0.5rem;
420
+ font-size: 0.9rem;
421
+ color: var(--ink);
422
+ padding-bottom: 0.4rem;
423
+ border-bottom: 1px solid var(--line);
424
+ }
425
+
426
+ .tier-drop-zone {
427
+ min-height: 60px;
428
+ transition: background 0.15s ease;
429
+ border-radius: 4px;
430
+ padding: 0.2rem;
431
+ }
432
+
433
+ .tier-drop-zone.drag-over {
434
+ background: rgba(20, 102, 193, 0.06);
435
+ outline: 2px dashed var(--accent);
436
+ outline-offset: -2px;
437
+ }
438
+
439
+ /* ── A2A-41: Validation warnings ───────────────────────────── */
440
+ .tier-warnings {
441
+ margin-bottom: 0.8rem;
442
+ }
443
+
444
+ .tier-warning {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 0.5rem;
448
+ padding: 0.45rem 0.7rem;
449
+ border-radius: 6px;
450
+ font-size: 0.82rem;
451
+ margin-bottom: 0.3rem;
452
+ }
453
+
454
+ .tier-warning.warn {
455
+ background: #fef3c7;
456
+ border: 1px solid #f59e0b;
457
+ color: #92400e;
458
+ }
459
+
460
+ .tier-warning.danger {
461
+ background: #fee2e2;
462
+ border: 1px solid #ef4444;
463
+ color: #991b1b;
464
+ }
465
+
466
+ .tier-warning.info {
467
+ background: #dbeafe;
468
+ border: 1px solid #3b82f6;
469
+ color: #1e40af;
470
+ }
471
+
472
+ /* ── A2A-41: Preview dialog ────────────────────────────────── */
473
+ #preview-content h4 {
474
+ font-size: 0.88rem;
475
+ margin: 0.8rem 0 0.3rem;
476
+ color: var(--ink);
477
+ }
478
+
479
+ #preview-content h4:first-child {
480
+ margin-top: 0;
481
+ }
482
+
483
+ #preview-content ul {
484
+ margin: 0;
485
+ padding-left: 1.2rem;
486
+ }
487
+
488
+ #preview-content li {
489
+ font-size: 0.85rem;
490
+ margin-bottom: 0.25rem;
491
+ line-height: 1.4;
492
+ }
493
+
494
+ #preview-content li strong {
495
+ color: var(--ink);
496
+ }
497
+ ```
498
+
499
+ Then update the existing `@media (max-width: 720px)` block to include responsive collapse:
500
+
501
+ ```css
502
+ @media (max-width: 720px) {
503
+ sl-tab-group::part(nav) {
504
+ overflow-x: auto;
505
+ }
506
+ /* A2A-41: collapse three-column layout to single column on narrow screens */
507
+ .tier-columns {
508
+ grid-template-columns: 1fr;
509
+ }
510
+ }
511
+ ```
512
+
513
+ **Step 2: Replace textareas in index.html**
514
+
515
+ Replace the 3 textarea lines (80-82):
516
+ ```html
517
+ <sl-textarea id="tier-tools" label="Allowed Tools (one per line)" rows="5" placeholder="Read&#10;Grep&#10;Glob"></sl-textarea>
518
+ <sl-textarea id="tier-topics" label="Topics (one per line)" rows="6"></sl-textarea>
519
+ <sl-textarea id="tier-goals" label="Goals (one per line)" rows="6"></sl-textarea>
520
+ ```
521
+
522
+ With:
523
+ ```html
524
+ <label>Allowed Tools</label>
525
+ <div id="tier-tools-list" class="tools-checklist"></div>
526
+ <label>Topics</label>
527
+ <div id="tier-topics-list"></div>
528
+ <label>Goals</label>
529
+ <div id="tier-goals-list"></div>
530
+ ```
531
+
532
+ **Step 3: Add constants to app.js**
533
+
534
+ Near the top (after `state` declaration):
535
+
536
+ ```javascript
537
+ // A2A-41: tool descriptions for the checkbox UI. These match the tools
538
+ // available in Claude Code that an agent owner might want to expose to callers.
539
+ const TOOL_DESCRIPTIONS = {
540
+ 'Bash': 'Execute shell commands \u2014 full access, can run anything',
541
+ 'Bash(readonly)': 'Execute read-only shell commands \u2014 no writes, no installs',
542
+ 'Read': 'Read files from the workspace',
543
+ 'Grep': 'Search file contents with regex patterns',
544
+ 'Glob': 'Find files by name patterns',
545
+ 'WebSearch': 'Search the web for information',
546
+ 'WebFetch': 'Fetch and read web page content'
547
+ };
548
+
549
+ // A2A-41: standard tier order for inheritance. Custom tiers are not in this list.
550
+ const TIER_ORDER = ['public', 'friends', 'family'];
551
+ ```
552
+
553
+ **Step 4: Add renderToolCheckboxes()**
554
+
555
+ ```javascript
556
+ // A2A-41: renders tool checkboxes instead of a textarea. Each tool gets
557
+ // a checkbox with its description. Checked state comes from tier.allowed_tools.
558
+ function renderToolCheckboxes(allowedTools) {
559
+ const container = document.getElementById('tier-tools-list');
560
+ container.innerHTML = Object.entries(TOOL_DESCRIPTIONS).map(([tool, desc]) => {
561
+ const checked = (allowedTools || []).includes(tool) ? 'checked' : '';
562
+ return `<sl-checkbox value="${esc(tool)}" ${checked}><strong>${esc(tool)}</strong> \u2014 <span class="tool-desc">${esc(desc)}</span></sl-checkbox>`;
563
+ }).join('');
564
+ }
565
+ ```
566
+
567
+ **Step 5: Add renderTopicList() — uses event delegation**
568
+
569
+ ```javascript
570
+ // A2A-41: renders topics as expandable card rows with descriptions.
571
+ // Data comes from tier.manifest.topics (array of {topic, description} objects).
572
+ // Falls back to tier.topics (flat string array) for topics without manifest data.
573
+ function renderTopicList(tier) {
574
+ const container = document.getElementById('tier-topics-list');
575
+ const manifestTopics = tier.manifest?.topics || [];
576
+ const flatTopics = tier.topics || [];
577
+
578
+ // A2A-41: prefer manifest data (has descriptions), fall back to flat array
579
+ const allTopics = manifestTopics.length > 0
580
+ ? manifestTopics.map(t => ({ label: t.topic, desc: t.description || '' }))
581
+ : flatTopics.map(t => ({ label: t, desc: '' }));
582
+
583
+ const rowsHtml = allTopics.map(t => `
584
+ <div class="topic-row" data-topic="${esc(t.label)}" data-type="topic">
585
+ <span class="drag-handle">\u2807</span>
586
+ <div class="topic-content">
587
+ <div class="topic-header">
588
+ <strong class="topic-label">${esc(t.label)}</strong>
589
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
590
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
591
+ </div>
592
+ <div class="topic-description" style="display:none;">
593
+ <p class="topic-desc-text">${esc(t.desc) || '<em>No description</em>'}</p>
594
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(t.desc)}"></sl-input>
595
+ </div>
596
+ </div>
597
+ </div>
598
+ `).join('');
599
+
600
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="topic">+ Add topic</button>`;
601
+ }
602
+ ```
603
+
604
+ **Step 6: Add renderGoalList()**
605
+
606
+ ```javascript
607
+ // A2A-41: renders goals as expandable card rows, identical pattern to topics.
608
+ // Data from tier.manifest.objectives (array of {objective, description}).
609
+ function renderGoalList(tier) {
610
+ const container = document.getElementById('tier-goals-list');
611
+ const manifestGoals = tier.manifest?.objectives || [];
612
+ const flatGoals = tier.goals || [];
613
+
614
+ const allGoals = manifestGoals.length > 0
615
+ ? manifestGoals.map(g => ({ label: g.objective || g.topic, desc: g.description || '' }))
616
+ : flatGoals.map(g => ({ label: g, desc: '' }));
617
+
618
+ const rowsHtml = allGoals.map(g => `
619
+ <div class="topic-row" data-topic="${esc(g.label)}" data-type="goal">
620
+ <span class="drag-handle">\u2807</span>
621
+ <div class="topic-content">
622
+ <div class="topic-header">
623
+ <strong class="topic-label">${esc(g.label)}</strong>
624
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
625
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
626
+ </div>
627
+ <div class="topic-description" style="display:none;">
628
+ <p class="topic-desc-text">${esc(g.desc) || '<em>No description</em>'}</p>
629
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(g.desc)}"></sl-input>
630
+ </div>
631
+ </div>
632
+ </div>
633
+ `).join('');
634
+
635
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="goal">+ Add goal</button>`;
636
+ }
637
+ ```
638
+
639
+ **Step 7: Add event delegation for topic/goal lists**
640
+
641
+ CRITICAL: Use event delegation on the containers, NOT per-row binding. This prevents the listener leak the reviewer flagged.
642
+
643
+ ```javascript
644
+ // A2A-41: event delegation for topic and goal list interactions.
645
+ // Uses a single click handler on each container instead of per-row binding,
646
+ // preventing listener accumulation when topics are added dynamically.
647
+ function bindItemListDelegation() {
648
+ ['tier-topics-list', 'tier-goals-list'].forEach(containerId => {
649
+ const container = document.getElementById(containerId);
650
+ if (!container) return;
651
+
652
+ container.addEventListener('click', (e) => {
653
+ // Expand/collapse
654
+ const expandBtn = e.target.closest('.topic-expand-btn');
655
+ if (expandBtn) {
656
+ const row = expandBtn.closest('.topic-row');
657
+ const desc = row.querySelector('.topic-description');
658
+ if (desc) {
659
+ const isHidden = desc.style.display === 'none';
660
+ desc.style.display = isHidden ? '' : 'none';
661
+ expandBtn.name = isHidden ? 'chevron-up' : 'chevron-down';
662
+ }
663
+ return;
664
+ }
665
+
666
+ // Delete
667
+ const deleteBtn = e.target.closest('.topic-delete-btn');
668
+ if (deleteBtn) {
669
+ deleteBtn.closest('.topic-row').remove();
670
+ return;
671
+ }
672
+
673
+ // Add new item
674
+ const addBtn = e.target.closest('.add-item-btn');
675
+ if (addBtn) {
676
+ const type = addBtn.dataset.type;
677
+ const label = type === 'topic' ? 'Topic name' : 'Goal name';
678
+ const newRow = document.createElement('div');
679
+ newRow.className = 'topic-row';
680
+ newRow.dataset.type = type;
681
+ newRow.innerHTML = `
682
+ <span class="drag-handle">\u2807</span>
683
+ <div class="topic-content">
684
+ <sl-input class="new-item-label" size="small" placeholder="${label}" autofocus></sl-input>
685
+ <sl-input class="new-item-desc" size="small" placeholder="Description (optional)"></sl-input>
686
+ <div class="row" style="margin-top:0.3rem;">
687
+ <sl-button size="small" variant="primary" class="confirm-add-btn">Add</sl-button>
688
+ <sl-button size="small" class="cancel-add-btn">Cancel</sl-button>
689
+ </div>
690
+ </div>
691
+ `;
692
+ container.insertBefore(newRow, addBtn);
693
+ return;
694
+ }
695
+
696
+ // Confirm add
697
+ const confirmBtn = e.target.closest('.confirm-add-btn');
698
+ if (confirmBtn) {
699
+ const row = confirmBtn.closest('.topic-row');
700
+ const nameInput = row.querySelector('.new-item-label');
701
+ const descInput = row.querySelector('.new-item-desc');
702
+ const name = nameInput.value.trim();
703
+ if (!name) { nameInput.focus(); return; }
704
+
705
+ row.dataset.topic = name;
706
+ row.innerHTML = `
707
+ <span class="drag-handle">\u2807</span>
708
+ <div class="topic-content">
709
+ <div class="topic-header">
710
+ <strong class="topic-label">${esc(name)}</strong>
711
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
712
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
713
+ </div>
714
+ <div class="topic-description" style="display:none;">
715
+ <p class="topic-desc-text">${esc(descInput.value)}</p>
716
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(descInput.value)}"></sl-input>
717
+ </div>
718
+ </div>
719
+ `;
720
+ return;
721
+ }
722
+
723
+ // Cancel add
724
+ const cancelBtn = e.target.closest('.cancel-add-btn');
725
+ if (cancelBtn) {
726
+ cancelBtn.closest('.topic-row').remove();
727
+ return;
728
+ }
729
+ });
730
+
731
+ // Description edit via sl-change (Shoelace event, delegated)
732
+ container.addEventListener('sl-change', (e) => {
733
+ const input = e.target.closest('.topic-desc-edit');
734
+ if (input) {
735
+ const row = input.closest('.topic-row');
736
+ const textEl = row.querySelector('.topic-desc-text');
737
+ if (textEl) textEl.textContent = input.value || '';
738
+ }
739
+ });
740
+ });
741
+ }
742
+ ```
743
+
744
+ **Step 8: Update renderTierEditor()**
745
+
746
+ ```javascript
747
+ function renderTierEditor(tierId) {
748
+ const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
749
+ if (!tier) return;
750
+
751
+ document.getElementById('tier-name').value = tier.name || tier.id;
752
+ document.getElementById('tier-description').value = tier.description || '';
753
+ renderToolCheckboxes(tier.allowed_tools);
754
+ renderTopicList(tier);
755
+ renderGoalList(tier);
756
+ renderTierWarnings(tier);
757
+ renderTierColumns();
758
+ }
759
+ ```
760
+
761
+ **Step 9: Update save handler — collect from new UI**
762
+
763
+ Replace the tier-form submit handler body:
764
+
765
+ ```javascript
766
+ document.getElementById('tier-form').addEventListener('submit', async (e) => {
767
+ e.preventDefault();
768
+ const tierId = document.getElementById('tier-select').value;
769
+
770
+ // A2A-41: collect tools from checkboxes
771
+ const toolCheckboxes = document.querySelectorAll('#tier-tools-list sl-checkbox');
772
+ const allowed_tools = Array.from(toolCheckboxes)
773
+ .filter(cb => cb.checked)
774
+ .map(cb => cb.value);
775
+
776
+ // A2A-41: collect topics from row elements
777
+ const topicRows = document.querySelectorAll('#tier-topics-list .topic-row[data-topic]');
778
+ const topics = Array.from(topicRows).map(row => row.dataset.topic).filter(Boolean);
779
+ const manifestTopics = Array.from(topicRows).map(row => ({
780
+ topic: row.dataset.topic,
781
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
782
+ })).filter(t => t.topic);
783
+
784
+ // A2A-41: collect goals from row elements. IMPORTANT: use 'topic' key (NOT
785
+ // 'objective') because parseTopicObjects() in dashboard.js:160 only reads
786
+ // entry.topic. The semantic distinction 'objective' vs 'topic' is UI-only;
787
+ // the storage layer uses {topic, description} uniformly for both.
788
+ const goalRows = document.querySelectorAll('#tier-goals-list .topic-row[data-topic]');
789
+ const goals = Array.from(goalRows).map(row => row.dataset.topic).filter(Boolean);
790
+ const manifestObjectives = Array.from(goalRows).map(row => ({
791
+ topic: row.dataset.topic,
792
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
793
+ })).filter(g => g.topic);
794
+
795
+ const body = {
796
+ name: document.getElementById('tier-name').value,
797
+ description: document.getElementById('tier-description').value,
798
+ allowed_tools,
799
+ topics,
800
+ goals,
801
+ manifest: {
802
+ topics: manifestTopics,
803
+ objectives: manifestObjectives
804
+ }
805
+ };
806
+ await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
807
+ method: 'PUT',
808
+ body: JSON.stringify(body)
809
+ });
810
+ showNotice(`Saved tier "${tierId}"`);
811
+ await loadSettings();
812
+ });
813
+ ```
814
+
815
+ **Step 10: Wire event delegation in bootstrap**
816
+
817
+ In `bindSettingsActions()` (or in `bootstrap()` after `bindSettingsActions()`), add:
818
+
819
+ ```javascript
820
+ bindItemListDelegation();
821
+ ```
822
+
823
+ **Step 11: Run tests**
824
+
825
+ ```bash
826
+ npm test
827
+ ```
828
+
829
+ **Step 12: Commit**
830
+
831
+ ```bash
832
+ git add src/dashboard/public/style.css src/dashboard/public/index.html src/dashboard/public/app.js
833
+ git commit -m "feat(a2a-41): rich topic/goal lists, tool checkboxes, and all CSS styles"
834
+ ```
835
+
836
+ ---
837
+
838
+ ## Task 4: Three-Column Drag-and-Drop + Validation Warnings + Caller Preview
839
+
840
+ **Files:**
841
+ - Modify: `src/dashboard/public/index.html` — add tier-columns, toggle, warnings, preview button + dialog
842
+ - Modify: `src/dashboard/public/app.js` — add `renderTierColumns()`, `bindDragEvents()`, `renderTierWarnings()`, `getPreviewData()`, `openCallerPreview()`
843
+
844
+ **Step 1: Add HTML elements to index.html permissions panel**
845
+
846
+ After the tier dropdown `.row` div and before `<form id="tier-form">`, add:
847
+
848
+ ```html
849
+ <div class="row">
850
+ <label for="tier-select">Tier</label>
851
+ <sl-select id="tier-select" size="small" style="min-width:200px;"></sl-select>
852
+ <sl-button id="new-tier-btn" size="small">New Tier</sl-button>
853
+ <sl-button id="preview-caller-btn" size="small" variant="neutral">\u{1F441} Preview as Caller</sl-button>
854
+ </div>
855
+
856
+ <div id="tier-warnings" class="tier-warnings"></div>
857
+
858
+ <sl-checkbox id="show-drag-columns">Show all tiers side-by-side</sl-checkbox>
859
+ <div id="tier-columns" class="tier-columns"></div>
860
+ ```
861
+
862
+ Note: the `.row` div already exists — just add the preview button into it and add the new elements below it.
863
+
864
+ At the end of the permissions panel (before `</sl-tab-panel>`), add:
865
+
866
+ ```html
867
+ <sl-dialog id="preview-dialog" label="Caller Preview" style="--width: 540px;">
868
+ <div id="preview-content"></div>
869
+ <sl-button slot="footer" variant="primary" id="preview-close-btn">Close</sl-button>
870
+ </sl-dialog>
871
+ ```
872
+
873
+ **Step 2: Add renderTierColumns() to app.js**
874
+
875
+ ```javascript
876
+ // A2A-41: renders the three-column drag zone showing all standard tiers
877
+ // side-by-side. Inherited topics shown as grayed-out non-draggable rows.
878
+ // Custom tiers are not shown here — they don't have a defined inheritance
879
+ // hierarchy. HTML5 drag-and-drop does NOT work on touch devices (mobile).
880
+ function renderTierColumns() {
881
+ const container = document.getElementById('tier-columns');
882
+ if (!container) return;
883
+ const tiers = state.settings?.tiers || [];
884
+ const toggle = document.getElementById('show-drag-columns');
885
+ container.style.display = toggle?.checked ? '' : 'none';
886
+
887
+ const html = TIER_ORDER.map(tierId => {
888
+ const tier = tiers.find(t => t.id === tierId);
889
+ if (!tier) return '';
890
+
891
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
892
+ const tierIdx = TIER_ORDER.indexOf(tierId);
893
+
894
+ // Inherited topics from lower tiers
895
+ let inheritedRows = '';
896
+ for (let i = 0; i < tierIdx; i++) {
897
+ const lowerTier = tiers.find(t => t.id === TIER_ORDER[i]);
898
+ if (!lowerTier) continue;
899
+ const lowerTopics = lowerTier.manifest?.topics?.length
900
+ ? lowerTier.manifest.topics
901
+ : (lowerTier.topics || []).map(t => ({ topic: t, description: '' }));
902
+ lowerTopics.forEach(t => {
903
+ inheritedRows += `
904
+ <div class="topic-row inherited" data-topic="${esc(t.topic)}" data-tier="${esc(TIER_ORDER[i])}">
905
+ <div class="topic-content">
906
+ <div class="topic-header">
907
+ <strong class="topic-label">${esc(t.topic)}</strong>
908
+ <span class="inherited-badge">from ${esc(TIER_ORDER[i])}</span>
909
+ </div>
910
+ </div>
911
+ </div>`;
912
+ });
913
+ }
914
+
915
+ // Own topics — draggable
916
+ const ownTopics = tier.manifest?.topics?.length
917
+ ? tier.manifest.topics
918
+ : (tier.topics || []).map(t => ({ topic: t, description: '' }));
919
+ const ownRows = ownTopics.map(t => `
920
+ <div class="topic-row" draggable="true" data-topic="${esc(t.topic)}" data-tier="${esc(tierId)}">
921
+ <span class="drag-handle">\u2807</span>
922
+ <div class="topic-content">
923
+ <div class="topic-header">
924
+ <strong class="topic-label">${esc(t.topic)}</strong>
925
+ </div>
926
+ </div>
927
+ </div>
928
+ `).join('');
929
+
930
+ return `
931
+ <div class="tier-column" data-tier="${esc(tierId)}">
932
+ <h4>${emoji} ${esc(tier.name || tierId)}</h4>
933
+ <div class="tier-drop-zone" data-tier="${esc(tierId)}">
934
+ ${inheritedRows}${ownRows}
935
+ </div>
936
+ </div>`;
937
+ }).join('');
938
+
939
+ container.innerHTML = html;
940
+ bindDragEvents();
941
+ }
942
+ ```
943
+
944
+ **Step 3: Add bindDragEvents() with Promise.all and error recovery**
945
+
946
+ ```javascript
947
+ // A2A-41: HTML5 drag-and-drop handlers for moving topics between tier columns.
948
+ // On drop, both tiers are saved via Promise.all() to prevent data loss if one
949
+ // request fails. On error, state is reloaded from server to reset UI.
950
+ function bindDragEvents() {
951
+ const zones = document.querySelectorAll('.tier-drop-zone');
952
+
953
+ document.querySelectorAll('.tier-columns .topic-row[draggable="true"]').forEach(row => {
954
+ row.addEventListener('dragstart', (e) => {
955
+ e.dataTransfer.setData('application/json', JSON.stringify({
956
+ topic: row.dataset.topic,
957
+ sourceTier: row.dataset.tier
958
+ }));
959
+ row.classList.add('dragging');
960
+ });
961
+ row.addEventListener('dragend', () => row.classList.remove('dragging'));
962
+ });
963
+
964
+ zones.forEach(zone => {
965
+ zone.addEventListener('dragover', (e) => {
966
+ e.preventDefault();
967
+ zone.classList.add('drag-over');
968
+ });
969
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
970
+ zone.addEventListener('drop', async (e) => {
971
+ e.preventDefault();
972
+ zone.classList.remove('drag-over');
973
+
974
+ let data;
975
+ try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
976
+ const { topic, sourceTier } = data;
977
+ const targetTier = zone.dataset.tier;
978
+
979
+ if (!topic || !sourceTier || !targetTier || sourceTier === targetTier) return;
980
+
981
+ const sourceTierData = (state.settings?.tiers || []).find(t => t.id === sourceTier);
982
+ const targetTierData = (state.settings?.tiers || []).find(t => t.id === targetTier);
983
+ if (!sourceTierData || !targetTierData) return;
984
+
985
+ const sourceTopics = (sourceTierData.topics || []).filter(t => t !== topic);
986
+ const sourceManifestTopics = (sourceTierData.manifest?.topics || []).filter(t => t.topic !== topic);
987
+ const movedManifest = (sourceTierData.manifest?.topics || []).find(t => t.topic === topic);
988
+ const targetTopics = [...(targetTierData.topics || []), topic];
989
+ const targetManifestTopics = [...(targetTierData.manifest?.topics || []), movedManifest || { topic, description: '' }];
990
+
991
+ // A2A-41: save both tiers atomically with Promise.all to prevent
992
+ // data loss if one request fails. On error, reload from server.
993
+ try {
994
+ await Promise.all([
995
+ request(`/settings/tiers/${encodeURIComponent(sourceTier)}`, {
996
+ method: 'PUT',
997
+ body: JSON.stringify({
998
+ topics: sourceTopics,
999
+ manifest: { topics: sourceManifestTopics, objectives: sourceTierData.manifest?.objectives || [] }
1000
+ })
1001
+ }),
1002
+ request(`/settings/tiers/${encodeURIComponent(targetTier)}`, {
1003
+ method: 'PUT',
1004
+ body: JSON.stringify({
1005
+ topics: targetTopics,
1006
+ manifest: { topics: targetManifestTopics, objectives: targetTierData.manifest?.objectives || [] }
1007
+ })
1008
+ })
1009
+ ]);
1010
+ showNotice(`Moved "${topic}" from ${sourceTier} to ${targetTier}`);
1011
+ } catch (err) {
1012
+ showNotice(`Move failed: ${err.message}. Reloading...`);
1013
+ }
1014
+ await loadSettings();
1015
+ });
1016
+ });
1017
+ }
1018
+ ```
1019
+
1020
+ **Step 4: Add renderTierWarnings() — fixed topic count logic**
1021
+
1022
+ ```javascript
1023
+ // A2A-41: contextual validation warnings for the currently selected tier.
1024
+ // Warns about empty tiers, dangerous tool grants, and inverted tier sizes.
1025
+ function renderTierWarnings(tier) {
1026
+ const container = document.getElementById('tier-warnings');
1027
+ if (!container) return;
1028
+ const warnings = [];
1029
+
1030
+ // A2A-41: use manifest OR flat topics (not both) to avoid double-counting.
1031
+ // Manifest is preferred when non-empty; flat array is the fallback.
1032
+ // NOTE: can't use || for this because empty arrays are truthy in JS.
1033
+ const mTopics = tier.manifest?.topics;
1034
+ const topicCount = (mTopics && mTopics.length > 0 ? mTopics : (tier.topics || [])).length;
1035
+ if (topicCount === 0) {
1036
+ warnings.push({ level: 'warn', text: "This tier has no topics \u2014 callers won't have conversation context." });
1037
+ }
1038
+
1039
+ if (tier.id === 'public' && (tier.allowed_tools || []).includes('Bash')) {
1040
+ warnings.push({ level: 'danger', text: 'Bash (full access) is granted to the public tier \u2014 any caller can execute commands.' });
1041
+ }
1042
+
1043
+ if (tier.id === 'family') {
1044
+ const allTiers = state.settings?.tiers || [];
1045
+ const friends = allTiers.find(t => t.id === 'friends');
1046
+ if (friends) {
1047
+ const mFam = tier.manifest?.topics;
1048
+ const familyOwn = (mFam && mFam.length > 0 ? mFam : (tier.topics || [])).length;
1049
+ const mFri = friends.manifest?.topics;
1050
+ const friendsOwn = (mFri && mFri.length > 0 ? mFri : (friends.topics || [])).length;
1051
+ if (familyOwn < friendsOwn) {
1052
+ warnings.push({ level: 'info', text: 'Family tier has fewer topics than Friends \u2014 usually Family is the most open tier.' });
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ container.innerHTML = warnings.map(w =>
1058
+ `<div class="tier-warning ${w.level}">${esc(w.text)}</div>`
1059
+ ).join('');
1060
+ }
1061
+ ```
1062
+
1063
+ **Step 5: Add getPreviewData() and openCallerPreview()**
1064
+
1065
+ ```javascript
1066
+ // A2A-41: merges topics/goals/tools from the selected tier and all lower tiers,
1067
+ // mirroring the backend's getTopicsForTier() inheritance. Used by the preview dialog.
1068
+ function getPreviewData(tierId) {
1069
+ const selectedIndex = TIER_ORDER.indexOf(tierId);
1070
+ const tiers = state.settings?.tiers || [];
1071
+ const merged = { topics: [], objectives: [], tools: new Set(), do_not_discuss: [], never_disclose: [] };
1072
+
1073
+ // A2A-41: for custom tiers not in TIER_ORDER, show only own data.
1074
+ // No inheritance is applied because custom tiers have no defined hierarchy.
1075
+ if (selectedIndex < 0) {
1076
+ const t = tiers.find(t => t.id === tierId);
1077
+ if (t) {
1078
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: tierId }));
1079
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: tierId }));
1080
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1081
+ }
1082
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1083
+ return merged;
1084
+ }
1085
+
1086
+ for (let i = 0; i <= selectedIndex; i++) {
1087
+ const t = tiers.find(t => t.id === TIER_ORDER[i]);
1088
+ if (!t) continue;
1089
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: TIER_ORDER[i] }));
1090
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: TIER_ORDER[i] }));
1091
+ (t.manifest?.do_not_discuss || []).forEach(item => {
1092
+ if (!merged.do_not_discuss.includes(item)) merged.do_not_discuss.push(item);
1093
+ });
1094
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1095
+ }
1096
+
1097
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1098
+ return merged;
1099
+ }
1100
+
1101
+ // A2A-41: opens the caller preview dialog showing the merged effective view
1102
+ // for the selected tier. Helps the agent owner understand what a caller sees.
1103
+ function openCallerPreview() {
1104
+ const tierId = document.getElementById('tier-select').value;
1105
+ const data = getPreviewData(tierId);
1106
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1107
+ const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
1108
+
1109
+ const dialog = document.getElementById('preview-dialog');
1110
+ dialog.label = `\u{1F441} Caller Preview \u2014 ${emoji} ${tierName}`;
1111
+
1112
+ const topicsList = data.topics.length > 0
1113
+ ? data.topics.map(t => `<li><strong>${esc(t.topic)}</strong>${t.description ? ` \u2014 ${esc(t.description)}` : ''}</li>`).join('')
1114
+ : '<li><em>None configured</em></li>';
1115
+
1116
+ const goalsList = data.objectives.length > 0
1117
+ ? data.objectives.map(g => `<li><strong>${esc(g.objective || g.topic)}</strong>${g.description ? ` \u2014 ${esc(g.description)}` : ''}</li>`).join('')
1118
+ : '<li><em>None configured</em></li>';
1119
+
1120
+ const toolsList = data.tools.size > 0
1121
+ ? Array.from(data.tools).map(t => `<li><strong>${esc(t)}</strong>${TOOL_DESCRIPTIONS[t] ? ` \u2014 ${esc(TOOL_DESCRIPTIONS[t])}` : ''}</li>`).join('')
1122
+ : '<li><em>None configured</em></li>';
1123
+
1124
+ const dndList = data.do_not_discuss.length > 0
1125
+ ? data.do_not_discuss.map(d => `<li>${esc(typeof d === 'string' ? d : d.topic || '')}</li>`).join('')
1126
+ : '<li><em>None configured</em></li>';
1127
+
1128
+ const neverList = data.never_disclose.length > 0
1129
+ ? data.never_disclose.map(n => `<li>${esc(n)}</li>`).join('')
1130
+ : '<li><em>None configured</em></li>';
1131
+
1132
+ document.getElementById('preview-content').innerHTML = `
1133
+ <h4>Topics this caller can discuss:</h4>
1134
+ <ul>${topicsList}</ul>
1135
+ <h4>Goals:</h4>
1136
+ <ul>${goalsList}</ul>
1137
+ <h4>Tools available:</h4>
1138
+ <ul>${toolsList}</ul>
1139
+ <h4>Will not discuss:</h4>
1140
+ <ul>${dndList}</ul>
1141
+ <h4>Never disclosed (any tier):</h4>
1142
+ <ul>${neverList}</ul>
1143
+ `;
1144
+
1145
+ dialog.show();
1146
+ }
1147
+ ```
1148
+
1149
+ **Step 6: Wire toggle, preview, and initial render in bindSettingsActions()**
1150
+
1151
+ Add to `bindSettingsActions()`:
1152
+
1153
+ ```javascript
1154
+ // A2A-41: toggle for three-column tier view
1155
+ document.getElementById('show-drag-columns')?.addEventListener('sl-change', () => {
1156
+ renderTierColumns();
1157
+ });
1158
+
1159
+ document.getElementById('preview-caller-btn')?.addEventListener('click', openCallerPreview);
1160
+ document.getElementById('preview-close-btn')?.addEventListener('click', () => {
1161
+ document.getElementById('preview-dialog').hide();
1162
+ });
1163
+ ```
1164
+
1165
+ Update `loadSettings()` to call `renderTierColumns()` after `fillTierSelects()`.
1166
+
1167
+ **Step 7: Run tests**
1168
+
1169
+ ```bash
1170
+ npm test
1171
+ ```
1172
+
1173
+ **Step 8: Check diff size**
1174
+
1175
+ ```bash
1176
+ git diff --stat origin/main..HEAD
1177
+ git diff origin/main..HEAD | wc -l
1178
+ ```
1179
+
1180
+ If approaching 500 lines, note it but continue — the ticket is one coherent feature.
1181
+
1182
+ **Step 9: Commit**
1183
+
1184
+ ```bash
1185
+ git add src/dashboard/public/index.html src/dashboard/public/app.js
1186
+ git commit -m "feat(a2a-41): tier columns, drag-and-drop, validation warnings, caller preview"
1187
+ ```
1188
+
1189
+ ---
1190
+
1191
+ ## Task 5: Final Verification + Push + PR
1192
+
1193
+ **Step 1: Run full test suite**
1194
+
1195
+ ```bash
1196
+ npm test
1197
+ ```
1198
+
1199
+ Expected: 328 passing, 2 failing (pre-existing only).
1200
+
1201
+ **Step 2: Push and open PR**
1202
+
1203
+ ```bash
1204
+ git push origin feature/a2a-41
1205
+ ```
1206
+
1207
+ Open PR via `gh pr create` with the required template:
1208
+
1209
+ ```markdown
1210
+ ## Summary
1211
+ Overhaul the dashboard Settings tab: rename to Permissions, remove dead disclosure field from frontend and all backend paths, replace textareas with rich interactive topic/goal rows and tool checkboxes, add three-column drag-and-drop tier layout, validation warnings, and caller preview dialog.
1212
+
1213
+ ## Ticket
1214
+ A2A-41
1215
+
1216
+ ## Reused Code
1217
+ - `esc()` (app.js:203) for HTML escaping
1218
+ - `request()` (app.js:171) for all API calls
1219
+ - `showNotice()` (app.js:25) for user feedback
1220
+ - `parseTopicObjects()` (dashboard.js:145) for backend manifest parsing
1221
+ - `loadManifest()/saveManifest()` (disclosure.js) for manifest persistence
1222
+ - `sanitizeString()/sanitizeStringArray()` (dashboard.js) for input validation
1223
+
1224
+ ## New Abstractions
1225
+ - `renderTopicList()`, `renderGoalList()` — render expandable card rows from manifest data
1226
+ - `renderToolCheckboxes()` — renders Shoelace checkboxes from TOOL_DESCRIPTIONS map
1227
+ - `renderTierColumns()` — three-column drag zone with inheritance display
1228
+ - `bindDragEvents()` — HTML5 DnD with Promise.all save and error recovery
1229
+ - `bindItemListDelegation()` — event delegation for topic/goal list interactions
1230
+ - `renderTierWarnings()` — contextual validation warnings
1231
+ - `getPreviewData()`, `openCallerPreview()` — merged effective view dialog
1232
+
1233
+ ## Alternatives Considered
1234
+ - Per-row event binding: rejected due to listener accumulation on dynamic adds
1235
+ - Sequential drag-and-drop saves: rejected due to data loss risk on partial failure
1236
+ - Framework (React/Lit): rejected to maintain zero-build-step vanilla JS approach
1237
+
1238
+ ## Test Coverage
1239
+ - Backend tests (328 passing) verify disclosure removal does not break token/config flows
1240
+ - Frontend changes are visual — verified via browser inspection
1241
+ ```
1242
+
1243
+ **Step 3: Update Linear**
1244
+
1245
+ Update ticket to "In Review".
1246
+
1247
+ ---
1248
+
1249
+ ## Acceptance Criteria Mapping
1250
+
1251
+ | Criterion | Task |
1252
+ |-----------|------|
1253
+ | Tab renamed Settings → Permissions | Task 2 |
1254
+ | Heading renamed | Task 2 |
1255
+ | Public tier pre-selected | Task 2 |
1256
+ | Tier dropdown emojis | Task 2 |
1257
+ | Tier ID field removed | Task 2 |
1258
+ | Disclosure field removed (frontend + backend) | Tasks 1, 2 |
1259
+ | Topics as expandable rows | Task 3 |
1260
+ | Goals as expandable rows | Task 3 |
1261
+ | Add topic/goal inline forms | Task 3 |
1262
+ | Tools as checkbox list | Task 3 |
1263
+ | Three-column tier layout | Task 4 |
1264
+ | Drag-and-drop topics between tiers | Task 4 |
1265
+ | Inherited items grayed out with badge | Task 4 |
1266
+ | Validation warnings | Task 4 |
1267
+ | Preview as Caller dialog | Task 4 |
1268
+ | All code has inline comments | All tasks |
1269
+ | Backward-compat --tab settings alias | Task 2 |
1270
+
1271
+ ## Scope Exclusions
1272
+
1273
+ The ticket mentions "Native macOS app rebuilt and published to GitHub releases" — this is a release concern, NOT an implementation concern. The native app wraps the dashboard SPA, so dashboard changes are automatically visible in the app. Rebuilding and publishing the app is handled by the release process.