samagotchi 0.136.1 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -7
  5. data/docs/context.md +7 -9
  6. data/docs/guardrails.md +12 -5
  7. data/docs/internals/engine.md +4 -4
  8. data/docs/internals/participants.md +2 -1
  9. data/docs/internals/web-frontend.md +19 -9
  10. data/docs/plugins.md +2 -2
  11. data/docs/sessions.md +8 -8
  12. data/docs/testing.md +2 -1
  13. data/lib/samagotchi/bridge.rb +4 -0
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/desktop/macos.rb +3 -2
  16. data/lib/samagotchi/engine.rb +6 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  19. data/lib/samagotchi/guardrails/mise_paths.rb +36 -0
  20. data/lib/samagotchi/guardrails/outside.rb +5 -5
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  22. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  23. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  24. data/lib/samagotchi/guardrails/review_paths.rb +3 -2
  25. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  26. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/message_display.rb +6 -3
  29. data/lib/samagotchi/web/participant_review.rb +2 -1
  30. data/lib/samagotchi/web/public/answer_fold.js +24 -6
  31. data/lib/samagotchi/web/public/app.js +24 -19
  32. data/lib/samagotchi/web/public/chains.js +6 -5
  33. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  34. data/lib/samagotchi/web/public/composer.css +15 -11
  35. data/lib/samagotchi/web/public/composer_grip.js +11 -13
  36. data/lib/samagotchi/web/public/composer_mount.js +2 -2
  37. data/lib/samagotchi/web/public/context_chips.js +29 -98
  38. data/lib/samagotchi/web/public/copy.js +8 -5
  39. data/lib/samagotchi/web/public/data.js +2 -0
  40. data/lib/samagotchi/web/public/families_view.js +30 -38
  41. data/lib/samagotchi/web/public/index.html +176 -132
  42. data/lib/samagotchi/web/public/info_bar.js +19 -8
  43. data/lib/samagotchi/web/public/info_bar_model.js +3 -1
  44. data/lib/samagotchi/web/public/lists_view.js +69 -42
  45. data/lib/samagotchi/web/public/participants_panel.js +4 -4
  46. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  47. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  48. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  49. data/lib/samagotchi/web/public/scope.js +3 -3
  50. data/lib/samagotchi/web/public/send_turn.js +4 -2
  51. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  52. data/lib/samagotchi/web/public/stage_view.js +51 -2
  53. data/lib/samagotchi/web/public/stale_params.js +26 -0
  54. data/lib/samagotchi/web/public/start_settings.js +1 -1
  55. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  56. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  57. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  58. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  59. data/lib/samagotchi/web/session_summary.rb +1 -1
  60. metadata +5 -1
@@ -3,15 +3,15 @@
3
3
  // red for a failing refresh), and a popover with its summary, hint link,
4
4
  // text and detach. The chips refresh when a context note arrives in the
5
5
  // stream and when the page gets focus (no event of their own), and their
6
- // ages tick every minute. A "+ URL" chip (when an installed bundle's
7
- // provider can attach one: GET …/context's can_add_url) opens a form that
8
- // attaches a URL (a GitHub PR); the web never adds a command.
6
+ // ages tick every minute. The web attaches nothing itself (`chi context
7
+ // add` does; GET …/context's can_add_url is left unread).
9
8
 
10
9
  import { html, nothing, render } from "./vendor/lit-html.js";
11
10
  import { keepFocus } from "./keep_focus.js";
12
11
  import { popoverPlace } from "./chip_popover.js";
12
+ import { focusBack, modalDialog } from "./popover_modal.js";
13
13
  import { cantText } from "./format.js";
14
- import { createLatest, stillOn } from "./latest.js";
14
+ import { createLatest } from "./latest.js";
15
15
 
16
16
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
17
17
  export function ageText(iso, now = Date.now()) {
@@ -44,30 +44,13 @@ export function chipModel(rows, now = Date.now()) {
44
44
  });
45
45
  }
46
46
 
47
- const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about when it changes";
48
-
49
47
  // The chips (a lit-html template; attributes after a bound one are bound
50
- // too, so they keep their order). addUrl: end with the "+ URL" chip;
51
- // open: the chip whose popover is open (a source's name), addOpen: the
52
- // "+ URL" form is.
53
- export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
48
+ // too, so they keep their order). open: the chip whose popover is open (a
49
+ // source's name).
50
+ export function chipsTemplate(chips, { open = null } = {}) {
54
51
  return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
55
- data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
56
- chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
57
- addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
58
- aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
59
- }
60
-
61
- // The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
62
- // try that failed, +busy+ while one runs.
63
- export function addFormTemplate({ error = null, busy = false } = {}) {
64
- return html`<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong
65
- ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
66
- ><div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div
67
- ><input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" required autocomplete="off"
68
- ><input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off"
69
- >${error ? html`<div class="ctx-pop-error" role="alert">${error}</div>` : nothing
70
- }<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit" ?disabled=${busy}>Attach</button></div></form>`;
52
+ data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"} aria-expanded=${chip.name === open ? "true" : "false"}>${
53
+ chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}`;
71
54
  }
72
55
 
73
56
  // The popover's inside for one row (GET …/context's); +text+ the row's
@@ -99,43 +82,36 @@ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
99
82
  }
100
83
 
101
84
  // The chips for the session bar. el: #contextChips; api: {list(id),
102
- // show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
103
- // Chips, popover and form are lit templates: a redraw (a refresh, the
104
- // minute's tick) keeps their nodes, so focus and typed text stay.
85
+ // show(id, name), detach(id, name)}; toast(text). Chips and popover are
86
+ // lit templates: a redraw (a refresh, the minute's tick) keeps their nodes,
87
+ // so focus stays. The
88
+ // popover behaves as every popover does (popover_modal.js): a modal dialog, its chip's
89
+ // aria-expanded, Esc closes it and focus goes back to the chip.
105
90
  export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
106
91
  let sessionId = null;
107
92
  let rows = [];
108
- let canAdd = false;
109
93
  let openName = null;
110
94
  // Only the newest list read for the open session is drawn (latest.js).
111
95
  const latest = createLatest(() => sessionId);
112
- // The open row's text once read (view text), and the "+ URL" form's
113
- // state ({error, busy}).
96
+ // The open row's text once read (view text).
114
97
  let text = null;
115
- let addState = {};
116
98
  const pop = doc.createElement("div");
117
99
  pop.id = "contextPopover";
118
100
  pop.className = "ctx-popover";
119
- pop.setAttribute("role", "dialog");
120
101
  pop.hidden = true;
121
102
  doc.body.appendChild(pop);
122
-
123
- // openName ADD: the "+ URL" form is open.
124
- const ADD = "\u0000add";
103
+ modalDialog(pop, { isOpen: () => openName !== null, doc });
125
104
 
126
105
  function drawChips() {
127
106
  const chips = chipModel(rows);
128
107
  // A source inserted before the focused chip: the focus stays on its source.
129
- keepFocus(el, "data-name", () => render(chipsTemplate(chips, { addUrl: canAdd, open: openName === ADD ? null : openName,
130
- addOpen: openName === ADD }), el));
131
- el.hidden = chips.length === 0 && !canAdd;
132
- // The "+ URL" chip alone sits inline, not on a row of its own.
133
- el.classList.toggle("only-add", chips.length === 0 && canAdd);
108
+ keepFocus(el, "data-name", () => render(chipsTemplate(chips, { open: openName }), el));
109
+ el.hidden = chips.length === 0;
134
110
  }
135
111
 
136
112
  // The chips, and the open popover closed when what it is for has gone.
137
113
  function draw() {
138
- if (openName === ADD ? !canAdd : openName && !chipModel(rows).some((chip) => chip.name === openName)) close();
114
+ if (openName && !chipModel(rows).some((chip) => chip.name === openName)) close();
139
115
  else drawChips();
140
116
  }
141
117
 
@@ -147,16 +123,14 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
147
123
  const data = await api.list(id);
148
124
  if (!fresh()) return;
149
125
  rows = data.sources || [];
150
- canAdd = data.can_add_url === true;
151
126
  } catch {
152
127
  // A session the server doesn't know (gone, or an older chi web): no chips.
153
128
  if (!fresh()) return;
154
129
  rows = [];
155
- canAdd = false;
156
130
  }
157
131
  draw();
158
132
  // An open popover follows its row, but not while it shows the text.
159
- if (openName && openName !== ADD && text === null) {
133
+ if (openName && text === null) {
160
134
  const row = rows.find((r) => r.name === openName);
161
135
  if (row) fill(row);
162
136
  }
@@ -165,7 +139,6 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
165
139
  function load(id) {
166
140
  sessionId = id;
167
141
  rows = [];
168
- canAdd = false;
169
142
  close();
170
143
  return refresh();
171
144
  }
@@ -173,7 +146,6 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
173
146
  function clear() {
174
147
  sessionId = null;
175
148
  rows = [];
176
- canAdd = false;
177
149
  close();
178
150
  }
179
151
 
@@ -208,62 +180,28 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
208
180
  render(nothing, pop);
209
181
  openName = name;
210
182
  text = null;
183
+ pop.setAttribute("aria-label", name);
211
184
  fill(row);
212
185
  pop.hidden = false;
213
186
  place(chip);
214
187
  drawChips();
215
188
  }
216
189
 
217
- const drawAdd = (changes = {}) => {
218
- addState = { ...addState, ...changes };
219
- render(addFormTemplate(addState), pop);
220
- };
221
-
222
- function openAdd(chip) {
223
- render(nothing, pop);
224
- openName = ADD;
225
- text = null;
226
- addState = {};
227
- drawAdd();
228
- pop.hidden = false;
229
- place(chip);
230
- drawChips();
231
- pop.querySelector('input[name="url"]')?.focus();
232
- }
233
-
234
- async function submitAdd(form) {
235
- const id = sessionId;
236
- const url = form.elements.url.value.trim();
237
- const why = form.elements.why.value.trim();
238
- if (!id || !url) return;
239
- const still = stillOn(() => sessionId);
240
- drawAdd({ busy: true });
241
- try {
242
- const data = await api.add(id, { url, why: why || undefined });
243
- if (!still()) return;
244
- close();
245
- await refresh();
246
- toast(`Attached ${data.name}: the agent gets a note before your next message`);
247
- } catch (e) {
248
- if (!still() || openName !== ADD) return;
249
- // The same form again: what the user typed stays; the URL gets focus.
250
- drawAdd({ busy: false, error: e.message });
251
- pop.querySelector('input[name="url"]')?.focus();
252
- }
253
- }
254
-
255
- function close() {
190
+ // Focus that was in it (or +refocus+: Esc) goes back to its chip.
191
+ function close({ refocus = false } = {}) {
192
+ const back = focusBack(pop, { refocus, doc });
193
+ const name = openName;
256
194
  openName = null;
257
195
  text = null;
258
- addState = {};
259
196
  pop.hidden = true;
260
197
  render(nothing, pop);
261
198
  drawChips();
199
+ if (name !== null) back([...el.querySelectorAll(".ctx-chip[data-name]")].find((chip) => chip.dataset.name === name));
262
200
  }
263
201
 
264
202
  async function viewText() {
265
203
  const name = openName;
266
- if (!name || name === ADD) return;
204
+ if (!name) return;
267
205
  try {
268
206
  const data = await api.show(sessionId, name);
269
207
  if (name !== openName) return;
@@ -298,16 +236,9 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
298
236
  if (!chip) return;
299
237
  // On to the document: a session-bar chip's popover closes (the close
300
238
  // below leaves clicks in +el+ alone).
301
- if (chip.dataset.act === "add") {
302
- if (openName === ADD) close();
303
- else openAdd(chip);
304
- } else if (openName === chip.dataset.name) close();
239
+ if (openName === chip.dataset.name) close();
305
240
  else open(chip.dataset.name, chip);
306
241
  });
307
- pop.addEventListener("submit", (e) => {
308
- e.preventDefault();
309
- submitAdd(e.target);
310
- });
311
242
  pop.addEventListener("click", (e) => {
312
243
  const act = e.target.closest("[data-act]")?.dataset.act;
313
244
  if (act === "close") close();
@@ -318,7 +249,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
318
249
  if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
319
250
  });
320
251
  doc.addEventListener("keydown", (e) => {
321
- if (e.key === "Escape" && openName) close();
252
+ if (e.key === "Escape" && openName) close({ refocus: true });
322
253
  });
323
254
  win.addEventListener("resize", placeAtOpenChip);
324
255
  win.addEventListener("focus", () => refresh());
@@ -1,8 +1,9 @@
1
1
  // Copy buttons: one on every prompt and answer bubble, one on every code
2
- // block of a rendered answer. An answer copies its markdown source (the
3
- // saved message's `display` when a hook presented one, else its `content`,
4
- // kept on the bubble as data-copy-source when the page shows rendered
5
- // html), a prompt the text as typed, a code block just its code.
2
+ // block of a rendered answer or delegate report. An answer copies its
3
+ // markdown source (the saved message's `display` when a hook presented one,
4
+ // else its `content`, kept on the bubble as data-copy-source when the page
5
+ // shows rendered html), a prompt the text as typed, a code block just its
6
+ // code.
6
7
  //
7
8
  // The buttons carry no text (an icon; "copied ✓" is CSS content), so a
8
9
  // bubble's textContent, the turn output dedupe and an annotation's selected
@@ -78,7 +79,9 @@ export function showsAnswer(bubble, message, normalize) {
78
79
  const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>`;
79
80
 
80
81
  const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
81
- const CODE = ".bubble.output pre";
82
+ // An answer's code blocks and a delegate report's (a user bubble chi
83
+ // brought in, rendered as markdown; answer_fold.js REPORTS).
84
+ const CODE = '.bubble.output pre, .bubble.user[data-source="delegate_report"] pre';
82
85
 
83
86
  // The button as a lit template, for a templated part (a tool row's
84
87
  // command block, command_view.js); the same click handler answers it.
@@ -397,6 +397,8 @@ export function getContext(id, name, opts = {}) {
397
397
  }
398
398
 
399
399
  // A URL an installed bundle's provider attaches: {url, why} → {name}.
400
+ // Unused since the web's "+ URL" went; kept while an older app.js that
401
+ // imports it may still be cached (internals/web-frontend.md, new exports).
400
402
  export function addContext(id, body, opts = {}) {
401
403
  return api(`/api/sessions/${encodeURIComponent(id)}/context`, {
402
404
  method: "POST", body: JSON.stringify(body), ...opts,
@@ -8,40 +8,39 @@ import { ownerBadge, previewForCard, relativeTime } from "./format.js";
8
8
  import { familySummary, stoppedBadge, waitingBadge } from "./sessions_list.js";
9
9
  import { html, nothing, render } from "./vendor/lit-html.js";
10
10
  import { keepFocus } from "./keep_focus.js";
11
+ import { focusBack, modalDialog } from "./popover_modal.js";
11
12
 
12
13
  // The chip in a parent's meta row: "▸ 3 delegates · ● 1 live · 1 waiting"
13
14
  // (a waiting one in the attention colour), its tooltip the delegates by
14
- // state. nothing for a family with no members.
15
+ // state. Open, it is just "▾ 3 delegates": the rows carry the marks then.
16
+ // nothing for a family with no members.
15
17
  // @param open whether the list is open (aria-expanded; the arrow turns)
16
18
  export function familyChipTemplate(f, { open = false } = {}) {
17
19
  const sum = familySummary(f);
18
20
  if (!sum) return nothing;
19
- const waiting = sum.counts.waiting;
20
- const live = sum.live ? html`<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : nothing;
21
+ const waiting = open ? 0 : sum.counts.waiting;
22
+ const live = sum.live && !open ? html`<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : nothing;
21
23
  const waitingT = waiting ? html` · <span class="fc-waiting">${waiting} waiting</span>` : nothing;
22
24
  return html`<button type="button" class=${`family-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
23
25
  title=${sum.title}><span class="fc-arrow" aria-hidden="true">▸</span>${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}${
24
26
  live}${waitingT}</button>`;
25
27
  }
26
28
 
27
- // A row's status dot (a family's or a chain's): the waiting title over the
28
- // status when the session waits on the user.
29
- export function rowDotTemplate(s, waiting) {
30
- const word = (waiting ? waiting.title : s.status) || "";
31
- return html`<span class=${`status dot ${s.status || ""}${waiting ? " waiting" : ""}`} title=${word} aria-label=${word}></span>`;
32
- }
33
-
34
- // A row's waiting or stopped badge, and the archived one.
35
- export function rowBadgesTemplate(waiting, stopped) {
29
+ // A row's waiting or stopped badge (a family's or a chain's; no status
30
+ // dot: one mark per state, as a card's), its `error` badge when +status+
31
+ // is "error", and the archived one.
32
+ export function rowBadgesTemplate(waiting, stopped, status = null) {
36
33
  return html`${waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing}${
34
+ status === "error" ? html`<span class="error-badge" title=${status}>error</span>` : nothing}${
37
35
  stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing}`;
38
36
  }
39
37
 
40
38
  export const archivedBadge = html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>`;
41
39
 
42
40
  // The family's list: one row per member, depth first, indented one step
43
- // per depth (--depth): status dot, preview, time, the waiting / stopped
44
- // badge, the owner badge, archived. A row's click opens that session.
41
+ // per depth (--depth): preview, the waiting / error / stopped badge, time,
42
+ // the owner badge (not on a waiting row: waiting means a worker), archived.
43
+ // The status is in the row's tooltip. A row's click opens that session.
45
44
  // @param activeId the open session's id (its row is .active)
46
45
  // @param matchIds a search's matching members (.match; the rest .dim), or
47
46
  // null without a search
@@ -55,15 +54,15 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
55
54
  const ts = s.updated_at || s.created_at || "";
56
55
  const waiting = waitingBadge(s, { inFamily: true });
57
56
  const stopped = waiting ? null : stoppedBadge(s);
58
- const owner = ownerBadge(s.owner, s.id);
57
+ const owner = waiting ? null : ownerBadge(s.owner, s.id);
59
58
  const ago = relativeTime(ts, now);
60
59
  const short = s.short_id || String(s.id).slice(0, 8);
61
60
  const arch = !archivable ? nothing : s.archived
62
61
  ? html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Unarchive: back to the lists"} aria-label=${`Unarchive session ${short}`}></button>`
63
62
  : html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Archive: hide from the lists, keep for good"} aria-label=${`Archive session ${short}`}></button>`;
64
63
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${`--depth:${depth}`} role=${"listitem"}><button type="button" class="fr-open"
65
- title=${`${s.id} · ${s.status || ""}`}>${rowDotTemplate(s, waiting)}<span class="fr-preview">${previewForCard(s)}</span>${
66
- rowBadgesTemplate(waiting, stopped)}${ago ? html`<span class="time">${ago}</span>` : nothing}${
64
+ title=${`${s.id} · ${s.status || ""}`}><span class="fr-preview">${previewForCard(s)}</span>${
65
+ rowBadgesTemplate(waiting, stopped, s.status)}${ago ? html`<span class="time">${ago}</span>` : nothing}${
67
66
  owner ? html`<span class=${`owner ${owner.kind}`} title=${owner.title}>${owner.text}</span>` : nothing}${
68
67
  s.archived ? archivedBadge : nothing}</button>${arch}</div>`;
69
68
  });
@@ -73,20 +72,6 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
73
72
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
74
73
  const PHONE_MAX_PX = 600;
75
74
 
76
- // The status dots' pulse in +root+ on the document timeline's clock (each
77
- // animation's start time 0), the phase the cards' --pulse-delay gives
78
- // theirs (lists_view.js cardAnimStyle): a popover keeps its row nodes
79
- // across draws, reused by position, so a dot that moves to another node
80
- // or starts pulsing there (a member inserted above, a status flip) would
81
- // otherwise start its pulse over, out of step with the rest.
82
- export function syncPulses(root) {
83
- root.querySelectorAll(".dot").forEach((dot) => {
84
- for (const a of dot.getAnimations?.() || []) {
85
- if (a.startTime !== 0) a.startTime = 0;
86
- }
87
- });
88
- }
89
-
90
75
  // The strip's family popover: one #familyPop in body (position: fixed, so
91
76
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
92
77
  // it was opened from and as wide as that card (300 px at least, inside the
@@ -101,7 +86,11 @@ export function syncPulses(root) {
101
86
  // @param onPick (id) a row's click
102
87
  // @param onClose () after it closed (the strip's held order catches up)
103
88
  // @param id the element's id (a chain's popover, chains.js, is another one)
104
- export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
89
+ // @param label its accessible name
90
+ // It behaves as every popover does (popover_modal.js): a modal dialog (Tab wraps in
91
+ // its rows; a Tab from the chip goes in), the chip's aria-expanded, Esc
92
+ // closes it and focus goes back to the chip.
93
+ export function createFamilyPop({ id = "familyPop", label = "Delegates", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
105
94
  let el = null;
106
95
  let headId = null;
107
96
 
@@ -111,6 +100,7 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
111
100
  el.id = id;
112
101
  el.className = "family-pop";
113
102
  el.hidden = true;
103
+ modalDialog(el, { label, isOpen: () => headId != null, doc });
114
104
  el.addEventListener("click", (e) => {
115
105
  const row = e.target?.closest?.(".family-row[data-id]");
116
106
  if (row) onPick(row.dataset.id);
@@ -142,7 +132,6 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
142
132
  }
143
133
  // A row inserted before the focused one: the focus stays on its session.
144
134
  keepFocus(el, "data-id", () => render(rows, el));
145
- syncPulses(el);
146
135
  anchor.setAttribute("aria-expanded", "true");
147
136
  place(anchor);
148
137
  return true;
@@ -155,13 +144,18 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
155
144
  draw();
156
145
  }
157
146
 
158
- function close() {
147
+ // Focus that was in it (or +refocus+: Esc) goes back to the chip as
148
+ // drawn after onClose (a held strip catches up).
149
+ function close({ refocus = false } = {}) {
159
150
  if (!headId) return;
160
- anchorFor(headId)?.setAttribute("aria-expanded", "false");
151
+ const back = focusBack(el, { refocus, doc });
152
+ const id = headId;
153
+ anchorFor(id)?.setAttribute("aria-expanded", "false");
161
154
  headId = null;
162
155
  el.hidden = true;
163
156
  render(nothing, el);
164
157
  onClose();
158
+ back(anchorFor(id));
165
159
  }
166
160
 
167
161
  return {
@@ -181,12 +175,10 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
181
175
  // capture phase): true when it did.
182
176
  onKeydown(e) {
183
177
  if (!headId || e.key !== "Escape") return false;
184
- const id = headId;
185
178
  e.preventDefault();
186
179
  e.stopImmediatePropagation();
187
180
  e.stopPropagation();
188
- close();
189
- anchorFor(id)?.focus(); // the chip as drawn after close() (a held strip catches up)
181
+ close({ refocus: true });
190
182
  return true;
191
183
  },
192
184
  // A pointer down outside it and its chip closes it.