samagotchi 0.136.0 → 0.137.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -6
  5. data/docs/guardrails.md +12 -5
  6. data/docs/internals/engine.md +4 -4
  7. data/docs/internals/web-frontend.md +20 -10
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge.rb +4 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/desktop/macos.rb +3 -2
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  16. data/lib/samagotchi/guardrails/outside.rb +5 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  19. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  20. data/lib/samagotchi/guardrails/review_paths.rb +32 -2
  21. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  22. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/message_display.rb +6 -3
  25. data/lib/samagotchi/web/participant_review.rb +2 -1
  26. data/lib/samagotchi/web/public/answer_fold.js +44 -15
  27. data/lib/samagotchi/web/public/app.js +12 -1
  28. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  29. data/lib/samagotchi/web/public/context_chips.js +16 -6
  30. data/lib/samagotchi/web/public/copy.js +8 -5
  31. data/lib/samagotchi/web/public/details_motion.js +74 -0
  32. data/lib/samagotchi/web/public/families_view.js +15 -6
  33. data/lib/samagotchi/web/public/index.html +67 -49
  34. data/lib/samagotchi/web/public/info_bar.js +1 -1
  35. data/lib/samagotchi/web/public/lists_view.js +1 -0
  36. data/lib/samagotchi/web/public/participants_panel.js +1 -1
  37. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  38. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  39. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  40. data/lib/samagotchi/web/public/scope.js +3 -3
  41. data/lib/samagotchi/web/public/send_turn.js +4 -2
  42. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  43. data/lib/samagotchi/web/public/stale_params.js +26 -0
  44. data/lib/samagotchi/web/public/start_settings.js +1 -1
  45. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  46. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  47. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  48. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  49. metadata +5 -1
@@ -0,0 +1,74 @@
1
+ // Collapsibles keep their shape: a steps block, a thinking block, the recap
2
+ // and the answer fold are the same box open or closed (the bar, the border,
3
+ // the width), and a click only changes the height. This module is the
4
+ // motion: a click on one of their summaries animates the height between the
5
+ // two states (Web Animations, MOTION_MS) instead of snapping, and the
6
+ // answer fold borrows animateHeight for its bubble. Nothing moves when the
7
+ // viewer prefers reduced motion or the element cannot animate (an old
8
+ // engine, a spec's DOM): the click is the native toggle then.
9
+ //
10
+ // Only a hand's click animates: the page's own opens and closes (a turn's
11
+ // end closing a block, a reload drawing the outline) stay instant, as they
12
+ // are not a change the viewer watches.
13
+ //
14
+ // A module of its own: static JS is cached at unversioned URLs, and a new
15
+ // export on an old module would blank the page.
16
+
17
+ export const MOTION_MS = 220;
18
+
19
+ // Whether +el+ can animate for this viewer.
20
+ export function canAnimate(el) {
21
+ if (typeof el?.animate !== "function") return false;
22
+ const win = el.ownerDocument?.defaultView;
23
+ return !win?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
24
+ }
25
+
26
+ // Animates +el+'s height from +from+ to +to+ (px), its overflow clipped
27
+ // meanwhile; +fade+ fades it with the height. Resolves when the animation
28
+ // is over (or canceled).
29
+ export async function animateHeight(el, from, to, { fade = false, ms = MOTION_MS } = {}) {
30
+ const at = (h, o) => (fade ? { height: `${h}px`, opacity: String(o) } : { height: `${h}px` });
31
+ const anim = el.animate([at(from, from ? 1 : 0), at(to, to ? 1 : 0)], { duration: ms, easing: "ease" });
32
+ el.style.overflow = "hidden";
33
+ try {
34
+ await anim.finished;
35
+ } catch {
36
+ // canceled: the element is where its class puts it
37
+ } finally {
38
+ el.style.overflow = "";
39
+ }
40
+ }
41
+
42
+ // A <details>' height closed: its summary and its own padding and borders.
43
+ function closedHeight(details, summary) {
44
+ const win = details.ownerDocument?.defaultView;
45
+ const cs = win?.getComputedStyle?.(details);
46
+ const px = (v) => Number.parseFloat(v) || 0;
47
+ const frame = cs ? px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth) : 0;
48
+ return summary.offsetHeight + frame;
49
+ }
50
+
51
+ // Installs the motion on +root+ for the <details> matching +selector+ whose
52
+ // own summary is clicked. +motion+: whether to animate +el+ (canAnimate).
53
+ export function installDetailsMotion({ root, selector, motion = canAnimate }) {
54
+ const busy = new WeakSet();
55
+ root.addEventListener("click", (e) => {
56
+ const summary = e.target.closest?.("summary");
57
+ const details = summary?.parentElement;
58
+ if (!details || !details.matches?.(selector) || summary.parentElement !== details) return;
59
+ if (e.target.closest("a, button, input, label") !== null && e.target !== summary) return;
60
+ if (!motion(details)) return;
61
+ e.preventDefault();
62
+ if (busy.has(details)) return;
63
+ busy.add(details);
64
+ const done = () => busy.delete(details);
65
+ if (details.open) {
66
+ const from = details.offsetHeight;
67
+ animateHeight(details, from, closedHeight(details, summary)).then(() => { details.open = false; done(); });
68
+ } else {
69
+ const from = closedHeight(details, summary);
70
+ details.open = true;
71
+ animateHeight(details, from, details.offsetHeight).then(done);
72
+ }
73
+ });
74
+ }
@@ -8,6 +8,7 @@ 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
@@ -101,7 +102,11 @@ export function syncPulses(root) {
101
102
  // @param onPick (id) a row's click
102
103
  // @param onClose () after it closed (the strip's held order catches up)
103
104
  // @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 = () => {} }) {
105
+ // @param label its accessible name
106
+ // It behaves as every popover does (popover_modal.js): a modal dialog (Tab wraps in
107
+ // its rows; a Tab from the chip goes in), the chip's aria-expanded, Esc
108
+ // closes it and focus goes back to the chip.
109
+ export function createFamilyPop({ id = "familyPop", label = "Delegates", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
105
110
  let el = null;
106
111
  let headId = null;
107
112
 
@@ -111,6 +116,7 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
111
116
  el.id = id;
112
117
  el.className = "family-pop";
113
118
  el.hidden = true;
119
+ modalDialog(el, { label, isOpen: () => headId != null, doc });
114
120
  el.addEventListener("click", (e) => {
115
121
  const row = e.target?.closest?.(".family-row[data-id]");
116
122
  if (row) onPick(row.dataset.id);
@@ -155,13 +161,18 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
155
161
  draw();
156
162
  }
157
163
 
158
- function close() {
164
+ // Focus that was in it (or +refocus+: Esc) goes back to the chip as
165
+ // drawn after onClose (a held strip catches up).
166
+ function close({ refocus = false } = {}) {
159
167
  if (!headId) return;
160
- anchorFor(headId)?.setAttribute("aria-expanded", "false");
168
+ const back = focusBack(el, { refocus, doc });
169
+ const id = headId;
170
+ anchorFor(id)?.setAttribute("aria-expanded", "false");
161
171
  headId = null;
162
172
  el.hidden = true;
163
173
  render(nothing, el);
164
174
  onClose();
175
+ back(anchorFor(id));
165
176
  }
166
177
 
167
178
  return {
@@ -181,12 +192,10 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
181
192
  // capture phase): true when it did.
182
193
  onKeydown(e) {
183
194
  if (!headId || e.key !== "Escape") return false;
184
- const id = headId;
185
195
  e.preventDefault();
186
196
  e.stopImmediatePropagation();
187
197
  e.stopPropagation();
188
- close();
189
- anchorFor(id)?.focus(); // the chip as drawn after close() (a held strip catches up)
198
+ close({ refocus: true });
190
199
  return true;
191
200
  },
192
201
  // A pointer down outside it and its chip closes it.
@@ -57,24 +57,25 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
57
57
  /* A generation opens its answer bubble empty: hidden until text streams (the thinking box shows progress meanwhile). */
58
58
  .bubble.output.streaming:empty{display:none}
59
59
  /* The answer's pop: the promoted bubble is held at the narration box's height (inline max-height, scrolled to its tail) until the rendered markdown lands, then grows to its content while the content fades in; both classes go 400 ms later. */
60
- /* A long answer folded by hand (answer_fold.js): the row is the bubble's sibling just before it, styled as a closed turn's steps, reading the answer's first line and size; folded, the bubble is hidden; unfolded, the row is the bubble's header. */
60
+ /* A long answer folded by hand (answer_fold.js): the row is the bubble's sibling just before it, in the bubble's own shape. Folded, the row is the answer's box closed ("▸ first line · N lines") and the bubble is hidden; unfolded, the row is that box's top edge ("▾ N lines") and the bubble its rest, the two one block. The shape never changes; a click changes the height (details_motion.js). */
61
61
  /* Folded: the bubble hidden, an answer or a delegate report (#history's: outranks .bubble.user's display). */
62
62
  #history>.bubble.folded{display:none}
63
- .answer-fold{display:flex;align-items:baseline;gap:0;align-self:flex-start;max-width:100%;min-width:0;margin:0;padding:3px 10px;font:inherit;font-size:12px;font-weight:500;line-height:1.4;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:transparent;border:1px solid transparent;border-left:3px solid var(--accent-strong);border-radius:6px;transition:background .12s ease}
64
- .answer-fold:hover{background:var(--bg-panel)}
63
+ .answer-fold{display:flex;align-items:baseline;gap:0;align-self:stretch;max-width:100%;min-width:0;margin:0;padding:12px 14px;font:inherit;font-size:13px;font-weight:600;line-height:1.5;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent-strong);border-radius:12px}
64
+ .answer-fold:hover .answer-fold-size{color:var(--text-2)}
65
65
  .answer-fold:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
66
66
  .answer-fold::before{content:"▾\00a0";color:var(--accent);flex:none}
67
67
  .answer-fold.folded::before{content:"▸\00a0"}
68
- /* Open: the bubble's header, flush on top of it (-12px: #history's gap), its bar running on into the bubble's. */
69
- .answer-fold:not(.folded){margin-bottom:-12px;border-bottom-left-radius:0}
70
- /* A delegate report's row: where the report sits (right, the user accent; dashed when merged mid-turn). Open, the row and
71
- the report share one width, so the row's bar runs on into the report's. */
72
- .answer-fold.report{align-self:flex-end;max-width:85%;border-left-color:var(--accent)}
68
+ /* Open: the box's top edge (-12px: #history's gap), the bubble under it its rest. */
69
+ .answer-fold:not(.folded){margin-bottom:-12px;padding-bottom:0;border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}
70
+ .answer-fold:not(.folded)+.bubble{border-top:0;border-top-left-radius:0;border-top-right-radius:0;padding-top:8px}
71
+ /* A delegate report's row: the report's box (right, its tint, the user accent; dashed when merged mid-turn), closed or
72
+ its top edge; a long prompt's (answer_fold.js PROMPTS, open by default) the same box. */
73
+ .answer-fold:is(.report,.prompt){align-self:flex-end;width:85%;background:var(--accent-bg);border-left-color:var(--accent)}
73
74
  .answer-fold.report.steered{border-left-style:dashed}
74
- .answer-fold.report:not(.folded),.answer-fold.report:not(.folded)+.bubble.user{width:85%}
75
+ .answer-fold:is(.report,.prompt):not(.folded)+.bubble.user{width:85%;align-items:flex-start}
75
76
  .answer-fold-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-2);font-size:13px}
76
77
  .answer-fold-label:empty{display:none}
77
- .answer-fold-size{flex:none;white-space:nowrap;font-variant-numeric:tabular-nums}
78
+ .answer-fold-size{flex:none;white-space:nowrap;font-variant-numeric:tabular-nums;transition:color .12s ease}
78
79
  /* Revise stacks (revise_stack.js): a superseded version folds to its stub; the Revise row under a final answer
79
80
  (zero height: its button sits on the bubble's bottom edge, clear of the copy button, shown on hover) and a stack
80
81
  top's version chip. */
@@ -106,6 +107,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
106
107
  .stack-version:hover:not(:disabled){background:var(--bg-hover);filter:none}
107
108
  .stack-version.current{color:var(--text-muted);cursor:default}
108
109
  .stack-version-n{font-weight:600;color:var(--accent);font-variant-numeric:tabular-nums}
110
+ /* The ‹ N/M › switcher (revise_switch.js): an earlier version's answer read in the top one's place; its copy dashed, the top hidden. */
111
+ .stack-switch{display:inline-flex;align-items:center;gap:2px;font-size:12px;color:var(--text-2);font-variant-numeric:tabular-nums}
112
+ .stack-switch-btn{min-height:0;height:22px;min-width:24px;padding:0 6px;border-radius:6px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2);font:inherit;font-size:14px;line-height:20px;cursor:pointer}
113
+ .stack-switch-btn:hover:not([aria-disabled="true"]){color:var(--text-bright);filter:none}
114
+ .stack-switch-btn:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
115
+ .stack-switch-btn[aria-disabled="true"]{opacity:.4;cursor:default}
116
+ .stack-switch-n{min-width:2.6em;text-align:center}
117
+ .stack-switch-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
118
+ @media (pointer:coarse){.stack-switch-btn{height:30px;min-width:34px;line-height:28px}}
119
+ .bubble.output.stack-view{border-left-style:dashed}
120
+ .stack-swapped{display:none!important}
109
121
  /* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
110
122
  .time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
111
123
  .turn-timing{margin-top:8px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
@@ -320,11 +332,10 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
320
332
  .activity-row>.activity-command{margin-left:0}
321
333
  }
322
334
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
323
- .bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
324
- .bubble.thinking:not([open]){border-left-color:var(--think)}
335
+ .bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
325
336
  .bubble.activity:not([open]){border-left-color:var(--accent)}
326
- .bubble.thinking:not([open]):hover,.bubble.activity:not([open]):hover{background:var(--bg-panel)}
327
- .bubble.thinking:not([open])>summary,.bubble.activity:not([open])>summary{font-size:12px;font-weight:500}
337
+ .bubble.activity:not([open]):hover{background:var(--bg-panel)}
338
+ .bubble.activity:not([open])>summary{font-size:12px;font-weight:500}
328
339
  .bubble.thinking>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;font-style:italic;user-select:none;list-style:none}
329
340
  .bubble.thinking>summary::-webkit-details-marker{display:none}
330
341
  .bubble.thinking>summary::before{content:"▸ ";color:var(--think)}
@@ -336,9 +347,7 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
336
347
  .turn-work>summary::-webkit-details-marker{display:none}
337
348
  .turn-work>summary::before{content:"▸ ";color:var(--accent)}
338
349
  .turn-work[open]>summary::before{content:"▾ "}
339
- .turn-work:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);align-self:flex-start;border-radius:6px;transition:background .12s ease}
340
- .turn-work:not([open]):hover{background:var(--bg-panel)}
341
- .turn-work:not([open])>summary{font-size:12px;font-weight:500}
350
+ /* Closed, a block keeps its box (the bar, the border, the width): a click changes its height only (details_motion.js); thinking and the recap the same. */
342
351
  /* A step's body (thinking, narration, rows, cards) sits --step-indent right of its summary, so the thinking
343
352
  chevron and the row marks share one column under the step's own chevron. The summary takes it back with a
344
353
  negative margin: a child's own margin shorthand can't undo the padding. */
@@ -621,6 +630,8 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
621
630
  .bubble.output.markdown .code-wrap>pre,.bubble.user>.user-message.markdown .code-wrap>pre{margin:0}
622
631
  .code-wrap>.copy-btn{top:5px;right:5px}
623
632
  @media (hover:none){.copy-btn{opacity:.55}}
633
+ /* Where the code button always shows (touch) or a phone's narrow block, the code starts under it: a strip above the first line holds the button (a line as wide as the block, or one that scrolls, would run under it). */
634
+ @media (max-width:600px),(hover:none){.bubble.output.markdown .code-wrap>pre,.bubble.user>.user-message.markdown .code-wrap>pre{padding-top:32px}}
624
635
  /* The dock is the composer card (composer.css .composer-card): the text field, a toolbar row, the info bar as its footer. */
625
636
  #dock{flex-shrink:0;margin:0 12px 12px}
626
637
  /* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from dock_reserve.js). */
@@ -666,7 +677,7 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
666
677
  body:not(.has-session) #composer .actions-col{flex-wrap:nowrap}
667
678
  body:not(.has-session) #composer .actions-col .model-pick{flex:0 100000 auto;min-width:min(84px,20vw)}
668
679
  body:not(.has-session) #composer .actions-col .llmctx-new{flex:0 1 auto;min-width:40px}
669
- #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
680
+ #composer .actions-col .model-pick:hover{filter:none}#composer .actions-col .model-pick[aria-expanded="true"]{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}@media(hover:hover){#composer .actions-col .model-pick:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}}
670
681
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
671
682
  .model-pick-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
672
683
  #composer .actions-col .model-pick.unavailable .model-pick-name{color:var(--text-faint)}
@@ -677,7 +688,7 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
677
688
  @media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
678
689
  /* The new session's override chips (start_page.js: what the ⚙ panel has set, only then), right of the key hint next to ⚙: accent; a click opens the panel. A phone has ⚙'s dot instead (below). */
679
690
  #composer .actions-col .llmctx-new{order:1;flex:0 1 auto;min-width:0;max-width:min(30%,220px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid var(--accent-border);background:transparent;color:var(--accent);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
680
- #composer .actions-col .llmctx-new:hover{filter:none;color:var(--accent);background:var(--accent-bg)}
691
+ #composer .actions-col .llmctx-new:hover{filter:none}#composer .actions-col .llmctx-new[aria-expanded="true"]{color:var(--accent);background:var(--accent-bg)}@media(hover:hover){#composer .actions-col .llmctx-new:hover{color:var(--accent);background:var(--accent-bg)}}
681
692
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
682
693
  /* Phones (the key hint is gone there): still one row, with tighter gaps; the override chips go (⚙'s dot and label say what is set). */
683
694
  @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}body:not(.has-session) #composer .actions-col{gap:4px}body:not(.has-session) #composer .actions-col .model-pick{flex-shrink:20;min-width:min(84px,20vw)}#composer .actions-col .llmctx-new{display:none}}
@@ -827,6 +838,11 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
827
838
  .ts-extras>.bubble.user{max-width:none;margin:0;padding:4px 10px;font-size:13px;line-height:1.45;background:transparent;color:var(--text-2)}
828
839
  .ts-extras>.bubble.user>.copy-btn{display:none}
829
840
  .ts-extras>.bubble.user .user-message{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;white-space:normal}
841
+ /* A line's markdown (a delegate report's) reads as a plain row in the clamped rows: its blocks run inline at the
842
+ row's own size, a space between them, rules and copy buttons gone. The prompt line shown whole keeps it. */
843
+ :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(p,h1,h2,h3,h4,h5,h6,ul,ol,li,blockquote,pre,table,thead,tbody,tr,th,td,.code-wrap){display:inline;margin:0;padding:0;border:0;border-radius:0;background:transparent;font-size:inherit;font-weight:inherit;line-height:inherit;color:inherit;white-space:normal}
844
+ :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th)::after{content:" "}
845
+ :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(hr,img,.copy-btn){display:none}
830
846
  /* Inside the dock's own blur a second backdrop-filter sees nothing: the cards are opaque here (as #commandList is). */
831
847
  .turn-stage .bubble.question,.turn-stage .bubble.plugin-card{-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
832
848
  .turn-stage .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}
@@ -882,7 +898,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
882
898
  body.working-set .turn-stage{container-type:inline-size}
883
899
  body.working-set .ts-live{position:relative;padding-right:264px}
884
900
  .ts-ws{--ws-line:18px;position:absolute;top:0;right:0;bottom:0;width:248px;box-sizing:border-box;padding:0 4px 0 12px;border-left:1px solid var(--ws-rule);border-radius:0 6px 6px 0;display:flex;flex-direction:column;overflow:hidden;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:var(--ws-line);color:var(--text-2);cursor:pointer}
885
- .ts-ws:hover{background:var(--bg-hover)}
901
+ @media(hover:hover){.ts-ws:hover{background:var(--bg-hover)}}
886
902
  .ts-ws:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
887
903
  .ws-head{flex:none;display:flex;align-items:center;gap:6px;min-width:0;height:var(--ws-line);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ws-muted);white-space:nowrap}
888
904
  .ws-count{margin-right:auto;font-weight:400;letter-spacing:0;color:var(--ws-faint);font-variant-numeric:tabular-nums}
@@ -949,7 +965,7 @@ body.working-set .turn-stage.waiting .ts-live{min-height:calc(var(--ts-live-h) +
949
965
  body.working-set .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(calc(var(--ts-live-h) + var(--ws-row-h,0px)),var(--live-held,0px))}
950
966
  /* A finished turn's footprint, under its closed block (working_set_view.js): one faint line; a click opens its list. */
951
967
  .ws-foot{display:block;width:fit-content;max-width:calc(100% - 13px);box-sizing:border-box;margin:-4px 0 0 13px;padding:0 6px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:18px;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
952
- .ws-foot:hover{color:var(--text-muted);background:var(--bg-hover)}
968
+ @media(hover:hover){.ws-foot:hover{color:var(--text-muted);background:var(--bg-hover)}}
953
969
  .ws-foot:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
954
970
  .ws-foot .w{font-family:ui-sans-serif,system-ui,sans-serif}
955
971
  .ws-foot .sep{color:var(--border-strong)}
@@ -961,25 +977,25 @@ body.working-set .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(cal
961
977
  .ws-step{display:inline-block;padding:0 4px;border:0;border-radius:4px;background:var(--badge-bg);font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:14px;font-weight:400;color:var(--ws-faint);font-variant-numeric:tabular-nums;vertical-align:1px}
962
978
  .ws-step.p{color:var(--think-text)}
963
979
  button.ws-step{cursor:pointer}
964
- button.ws-step:hover:not(:disabled){filter:none;background:var(--accent-bg);color:var(--accent)}
980
+ button.ws-step:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-step:hover:not(:disabled){background:var(--accent-bg);color:var(--accent)}}
965
981
  /* The expanded view (a chip popover; a bottom sheet on a phone). */
966
982
  .ws-popover{gap:0}
967
983
  .ws-popover .ctx-pop-head{align-items:center;flex-wrap:wrap;margin-bottom:4px}
968
984
  .ws-scope{display:inline-flex;gap:2px}
969
985
  .ws-scope button{padding:1px 8px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--text-muted);font-size:11px;line-height:16px}
970
986
  .ws-scope button.on{color:var(--text-2);border-color:var(--border-strong);background:var(--bg-raised)}
971
- .ws-scope button:hover:not(:disabled){filter:none;color:var(--text)}
987
+ .ws-scope button:hover:not(:disabled){filter:none}@media(hover:hover){.ws-scope button:hover:not(:disabled){color:var(--text)}}
972
988
  .ws-popover .ctx-pop-meta{margin-top:6px;color:var(--ws-muted)}
973
989
  .ws-popover .ws-hint{margin-top:10px;color:var(--ws-faint)}
974
990
  .ws-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0;height:28px;box-sizing:border-box;padding:0 6px;border:0;border-radius:6px;background:transparent;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:28px;color:var(--text-2);text-align:left}
975
- .ws-row:hover{background:var(--bg-hover)}
976
- button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
991
+ @media(hover:hover){.ws-row:hover{background:var(--bg-hover)}}
992
+ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row:hover:not(:disabled){background:var(--bg-hover)}}
977
993
  .ws-row .k{flex:none;width:12px;text-align:center;color:var(--ws-faint)}
978
994
  .ws-row.memory .k{color:var(--think-text)}
979
995
  .ws-row.url .k{color:var(--accent)}
980
996
  .ws-row .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
981
997
  .ws-row a.t{text-decoration:none}
982
- .ws-row a.t:hover{text-decoration:underline}
998
+ @media(hover:hover){.ws-row a.t:hover{text-decoration:underline}}
983
999
  .ws-row .e{flex:none;color:var(--accent);font-weight:600}
984
1000
  .ws-row .n{flex:none;color:var(--ws-faint)}
985
1001
  .ws-row .st{flex:0 4 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--ws-faint)}
@@ -996,12 +1012,12 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
996
1012
  .ws-row:hover .ws-act,.ws-row:focus-within .ws-act,.ws-ghead:hover .ws-act,.ws-ghead:focus-within .ws-act,.ws-row.pending .ws-act{opacity:1}
997
1013
  @media (hover:none){.ws-act{opacity:1}}
998
1014
  .ws-act-b{flex:none;padding:0 6px;border:1px solid var(--ws-act-border);border-radius:4px;background:transparent;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;font-weight:400;line-height:16px;color:var(--ws-muted);cursor:pointer}
999
- .ws-act-b:hover:not(:disabled){filter:none;color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
1015
+ .ws-act-b:hover:not(:disabled){filter:none}@media(hover:hover){.ws-act-b:hover:not(:disabled){color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}}
1000
1016
  .ws-act-b:disabled{opacity:.5;cursor:default}
1001
1017
  .ws-ghead{display:flex;align-items:center;gap:4px}
1002
1018
  .ws-ghead>.ws-group-head{flex:1;min-width:0}
1003
1019
  .ws-row.pending{border:1px dashed var(--warn-border);background:var(--warn-bg)}
1004
- .ws-row.pending:hover{background:var(--warn-bg)}
1020
+ @media(hover:hover){.ws-row.pending:hover{background:var(--warn-bg)}}
1005
1021
  /* A pending row: its pill and undo take the tokens' and the step tag's room, so the name stays readable. */
1006
1022
  .ws-row.pending .tk,.ws-row.pending .ws-step{display:none}
1007
1023
  .ws-pend{flex:none;padding:0 6px;border:1px solid var(--warn-border);border-radius:999px;background:var(--warn-bg);color:var(--warn-text);font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:15px;white-space:nowrap}
@@ -1030,7 +1046,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1030
1046
  .ws-popover.sheet .ws-add .ws-act-b{line-height:32px;padding:0 14px;font-size:12.5px;border-radius:6px}
1031
1047
  /* The session bar's chip, in the mem chip's place: a dot (the accent: changed since its list was last opened) and the count. */
1032
1048
  #infoBar .meta .ws-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border:1px solid transparent;cursor:pointer;font-family:inherit;font-weight:400;white-space:nowrap;font-variant-numeric:tabular-nums}
1033
- #infoBar .meta .ws-chip:hover:not(:disabled){filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
1049
+ #infoBar .meta .ws-chip:hover:not(:disabled){filter:none}#infoBar .meta .ws-chip[aria-expanded="true"]{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}@media(hover:hover){#infoBar .meta .ws-chip:hover:not(:disabled){color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}}
1034
1050
  .ws-dot{width:6px;height:6px;border-radius:50%;background:var(--text-faint);opacity:.6}
1035
1051
  #infoBar .meta .ws-chip.empty{color:var(--text-muted);background:transparent;border-color:var(--border)}
1036
1052
  .ws-chip.changed .ws-dot{background:var(--accent);opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
@@ -1096,7 +1112,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1096
1112
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
1097
1113
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
1098
1114
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
1099
- #infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
1115
+ @media(hover:hover){#infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}}
1100
1116
  #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
1101
1117
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
1102
1118
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
@@ -1109,7 +1125,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1109
1125
  #infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
1110
1126
  /* The ⚙ chip (settings_chip.js): a model-like chip that opens the session's settings panel; accent when the session set its own (capped like the model chip in the min-width:601px rule above, the full text in its tooltip; a phone shows ⚙ with a dot). */
1111
1127
  #infoBar .meta .settings-chip{position:relative;padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
1112
- #infoBar .meta .settings-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
1128
+ #infoBar .meta .settings-chip[aria-expanded="true"]{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}@media(hover:hover){#infoBar .meta .settings-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}}
1113
1129
  #infoBar .meta .settings-chip.own{color:var(--accent);border-color:var(--accent-border)}
1114
1130
  /* A chip's icon (⚙ here, the start page's own SVG from composer.css): the glyph stays the text, the mask draws it. */
1115
1131
  #infoBar .meta :is(.settings-chip,.tools-chip){display:inline-flex;align-items:center;gap:4px}
@@ -1134,7 +1150,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1134
1150
  .ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:inherit;font-size:13px;line-height:1.4}
1135
1151
  .ctx-add-input:focus{outline:none;border-color:var(--accent-border)}
1136
1152
  #infoBar .ctx-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font:600 11px/1.5 ui-monospace,monospace;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3);border:1px solid var(--border-strong);white-space:nowrap}
1137
- #infoBar .ctx-chip:hover,#infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
1153
+ #infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}@media(hover:hover){#infoBar .ctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}}
1138
1154
  #infoBar .ctx-chip.empty{color:var(--text-faint);border-style:dashed}
1139
1155
  #infoBar .ctx-chip.error{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent);background:var(--danger-bg-dim)}
1140
1156
  .ctx-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
@@ -1142,7 +1158,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1142
1158
  .ctx-popover[hidden]{display:none}
1143
1159
  /* The participants chip (info bar) and its panel (participants_panel.js). */
1144
1160
  #infoBar .meta .participants-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border:1px solid transparent;cursor:pointer;font-family:inherit;font-weight:400;white-space:nowrap}
1145
- #infoBar .meta .participants-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
1161
+ #infoBar .meta .participants-chip[aria-expanded="true"]{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}@media(hover:hover){#infoBar .meta .participants-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}}
1146
1162
  #infoBar .meta .participants-chip.none{color:var(--text-muted);background:transparent;border-color:var(--border)}
1147
1163
  #infoBar .meta .participants-chip.none .pc-dot{display:none}
1148
1164
  .participants-chip .pc-dot{width:7px;height:7px;border-radius:50%;background:var(--text-faint)}
@@ -1161,7 +1177,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1161
1177
  .pp-capped{color:var(--warn-text);font-weight:600}
1162
1178
  .pp-actions{grid-column:2;grid-row:1/3;display:flex;align-items:center;gap:4px}
1163
1179
  .pp-actions button,.pp-invite button,.pp-link button,.pp-foot button{padding:3px 9px;border-radius:6px;font-size:12px;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);cursor:pointer}
1164
- .pp-actions button:hover,.pp-invite button:hover,.pp-link button:hover,.pp-foot button:hover{color:var(--text-hover)}
1180
+ @media(hover:hover){.pp-actions button:hover,.pp-invite button:hover,.pp-link button:hover,.pp-foot button:hover{color:var(--text-hover)}}
1165
1181
  .pp-actions .pp-revoke{color:var(--danger-text)}
1166
1182
  .pp-cap-form{display:flex;gap:4px}
1167
1183
  .pp-cap-form input{width:58px}
@@ -1203,8 +1219,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1203
1219
  .sset-back{margin:0 2px 0 -6px;padding:0 6px;border:0;border-radius:5px;background:transparent;color:var(--text-muted);font-size:18px;line-height:1}
1204
1220
  .sset-back:hover{filter:none}
1205
1221
  .start-settings.sheet{border-radius:14px;padding:14px 16px}
1206
- /* Tints only where a pointer hovers: on a touch screen a tapped control would keep it (the ⚙/🧰 panels' rows and links;
1207
- older popovers' :hover rules aren't gated yet). */
1222
+ /* Tints only where a pointer hovers: on a touch screen a tapped control would keep it. Every popover's (chip_popover.js)
1223
+ controls and its opener's :hover tint sit in @media(hover:hover); an open opener shows it by aria-expanded instead. */
1208
1224
  @media(hover:hover){.sset-seg button:hover{color:var(--text);border-color:var(--accent-border)}.sset-link:hover{background:var(--accent-bg)}.sset-back:hover{color:var(--text);background:var(--bg-hover)}.st-row:hover{color:var(--text);background:var(--bg-hover)}}
1209
1225
  @media(max-width:400px){.sset-row{flex-direction:column;gap:4px}.sset-label{flex:none;line-height:1.4}.sset-ctx-more,.sset-ctx-form{margin-left:0}}
1210
1226
  @media(pointer:coarse){.sset-seg button{height:36px;min-height:36px;line-height:34px;padding:0 12px;font-size:13px}.sset-link,.sset-back{min-height:36px}.sset-label{line-height:36px}}
@@ -1232,7 +1248,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1232
1248
  .participants-popover.sheet .pp-qr{display:none}
1233
1249
  .pp-foot{display:flex;align-items:center;gap:8px;justify-content:flex-end;padding-top:4px}
1234
1250
  .pp-view{font-size:12px;color:var(--accent);text-decoration:none}
1235
- .pp-view:hover{text-decoration:underline}
1251
+ @media(hover:hover){.pp-view:hover{text-decoration:underline}}
1236
1252
  /* The Home Screen app's notifications sheet (push_devices.js): the bell opens it; a bottom sheet on a phone. */
1237
1253
  .push-devices .ctx-pop-head strong{font-family:inherit}
1238
1254
  .push-devices.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
@@ -1246,7 +1262,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1246
1262
  .pd-error{color:var(--danger-text)}
1247
1263
  .pd-done{grid-column:1/3;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;padding-top:6px;border-top:1px solid var(--border);font-size:12px;color:var(--text-3)}
1248
1264
  .pd-remove{padding:3px 10px;border-radius:7px;font-size:12px;background:var(--bg-raised);color:var(--danger-text);border:1px solid var(--border-strong);cursor:pointer}
1249
- .pd-remove:hover{color:var(--text-hover)}
1265
+ @media(hover:hover){.pd-remove:hover{color:var(--text-hover)}}
1250
1266
  .pd-switch{appearance:none;-webkit-appearance:none;position:relative;flex-shrink:0;width:34px;height:20px;margin:0;border-radius:999px;background:var(--bg-raised);border:1px solid var(--border-strong);cursor:pointer;transition:background .15s}
1251
1267
  .pd-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-muted);transition:transform .15s,background .15s}
1252
1268
  .pd-switch:checked{background:var(--accent-strong);border-color:var(--accent-strong)}
@@ -1274,9 +1290,9 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1274
1290
  .ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
1275
1291
  .ss-acts{display:flex;gap:8px;flex-wrap:wrap}
1276
1292
  .ss-acts button{padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2)}
1277
- .ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
1293
+ @media(hover:hover){.ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}}
1278
1294
  .ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
1279
- .ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
1295
+ @media(hover:hover){.ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}}
1280
1296
  .ss-cancel{background:transparent}
1281
1297
  .stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
1282
1298
  .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
@@ -1301,7 +1317,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1301
1317
  @media(max-width:600px){#updateSheet{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(80dvh,calc(100dvh - 16px));margin:0}}
1302
1318
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
1303
1319
  .ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
1304
- .ctx-pop-close:hover{color:var(--text)}
1320
+ @media(hover:hover){.ctx-pop-close:hover{color:var(--text)}}
1305
1321
  /* A finger's × (the other sheet controls are 36-40 px): the negative margins keep the head's height. */
1306
1322
  @media(pointer:coarse){.ctx-pop-close{min-width:36px;min-height:36px;margin:-10px -10px -10px auto;font-size:18px}}
1307
1323
  .ctx-pop-why{color:var(--text)}
@@ -1312,9 +1328,9 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1312
1328
  .ctx-pop-text{margin:0;max-height:240px;overflow:auto;padding:8px;border-radius:8px;background:var(--bg-inset);font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;color:var(--text-2)}
1313
1329
  .ctx-pop-actions{display:flex;gap:8px;justify-content:flex-end}
1314
1330
  .ctx-pop-actions button{padding:3px 10px;border-radius:7px;font-size:12px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
1315
- .ctx-pop-actions button:hover{color:var(--text-hover)}
1331
+ @media(hover:hover){.ctx-pop-actions button:hover{color:var(--text-hover)}}
1316
1332
  .ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
1317
- .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
1333
+ @media(hover:hover){.ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}}
1318
1334
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable). */
1319
1335
  @media(max-width:600px){
1320
1336
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
@@ -1333,6 +1349,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1333
1349
  #infoBar .meta .children-chip{order:-1}
1334
1350
  /* A chain's ← → links too: the way to the other days. */
1335
1351
  #infoBar .meta .chain-link{order:-1}
1352
+ /* ⚙ too (an icon here): after the model chip a long model name would push it out of sight. */
1353
+ #infoBar .meta .settings-chip{order:-1}
1336
1354
  /* The chips get a second line of their own (only when there are some), scrolling sideways. */
1337
1355
  #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
1338
1356
  #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
@@ -1385,7 +1403,7 @@ body.has-session #newBtn .brand-new{display:inline}
1385
1403
  .project-picker .ctx-pop-head strong{font-family:inherit}
1386
1404
  .project-list{display:flex;flex-direction:column;gap:6px;margin:2px 0;padding:0}
1387
1405
  .project-row{display:flex;align-items:center;gap:12px;padding:8px 10px;border:1px solid transparent;border-radius:9px;background:var(--bg-inset);color:var(--text);text-decoration:none}
1388
- .project-row:hover{background:var(--bg-hover);color:var(--text-hover)}
1406
+ @media(hover:hover){.project-row:hover{background:var(--bg-hover);color:var(--text-hover)}}
1389
1407
  .project-row:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1390
1408
  .project-row[aria-current="page"]{border-color:var(--accent-border);background:var(--accent-bg)}
1391
1409
  .project-main{display:flex;flex:1;align-items:center;gap:7px;min-width:0;flex-wrap:wrap}
@@ -1447,7 +1465,7 @@ body.has-session #scope .scope-where{display:none}
1447
1465
  }
1448
1466
  #notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
1449
1467
  #notifyBtn[hidden]{display:none}
1450
- #notifyBtn:hover{color:var(--text-2);border-color:var(--border)}
1468
+ @media(hover:hover){#notifyBtn:hover{color:var(--text-2);border-color:var(--border)}}
1451
1469
  #notifyBtn svg{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round}
1452
1470
  #notifyBtn .slash{display:none}
1453
1471
  #notifyBtn[data-state="on"]{color:var(--accent)}
@@ -1468,7 +1486,7 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1468
1486
  /* A chain's chip (chains.js): the card is the latest link of a session chain; a click lists the other links in a popover. */
1469
1487
  .card .meta:has(.chain-chip){flex-wrap:wrap;row-gap:4px}
1470
1488
  .card .chain-chip{font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;white-space:nowrap;flex-shrink:0;padding:1px 7px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-tint);color:var(--accent);cursor:pointer}
1471
- .card .chain-chip:hover:not(:disabled){filter:none;border-color:var(--accent);background:var(--accent-bg)}
1489
+ .card .chain-chip:hover:not(:disabled){filter:none}@media(hover:hover){.card .chain-chip:hover:not(:disabled){border-color:var(--accent);background:var(--accent-bg)}}
1472
1490
  .card .chain-chip[aria-expanded="true"]{background:var(--accent-bg);border-color:var(--accent)}
1473
1491
  .card .chain-chip.waiting{color:var(--warn-text);background:var(--warn-bg);border-color:var(--warn-border)}
1474
1492
  .chain-chip .cc-arrow{display:inline-block;margin-right:4px}
@@ -1491,9 +1509,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1491
1509
  /* A card with a family chip lets its meta row wrap: the chip goes to a line of its own when the card is narrow. */
1492
1510
  .card .meta:has(.family-chip){flex-wrap:wrap;row-gap:4px}
1493
1511
  .card .family-chip{font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;white-space:nowrap;flex-shrink:0;padding:1px 7px;border-radius:999px;border:1px solid var(--border-strong);background:transparent;color:var(--text-3);cursor:pointer}
1494
- .card .family-chip:hover:not(:disabled){filter:none;color:var(--accent);border-color:var(--accent-border);background:var(--accent-bg)}
1512
+ .card .family-chip:hover:not(:disabled){filter:none}@media(hover:hover){.card .family-chip:hover:not(:disabled){color:var(--accent);border-color:var(--accent-border);background:var(--accent-bg)}}
1495
1513
  .card .family-chip.waiting{color:var(--warn-text);background:var(--warn-bg);border-color:var(--warn-border)}
1496
- .card .family-chip.waiting:hover:not(:disabled){color:var(--warn-bright);border-color:var(--warn);background:var(--warn-bg)}
1514
+ @media(hover:hover){.card .family-chip.waiting:hover:not(:disabled){color:var(--warn-bright);border-color:var(--warn);background:var(--warn-bg)}}
1497
1515
  .family-chip .fc-arrow{display:inline-block;margin-right:4px;transition:transform .15s ease}
1498
1516
  .family-chip[aria-expanded="true"] .fc-arrow{transform:rotate(90deg)}
1499
1517
  .family-chip .live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok);margin-right:3px;vertical-align:1px}
@@ -1501,7 +1519,7 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1501
1519
  /* The family's list: one compact row per delegate, indented per depth. */
1502
1520
  .family-rows{display:flex;flex-direction:column;gap:1px;border-top:1px solid var(--border);margin:2px -6px 0;padding-top:4px;cursor:default}
1503
1521
  .family-row{display:flex;align-items:center;border-radius:6px;padding-left:calc((var(--depth,1) - 1) * 14px)}
1504
- .family-row:hover{background:var(--bg-hover)}
1522
+ @media(hover:hover){.family-row:hover{background:var(--bg-hover)}}
1505
1523
  .family-row .fr-open{flex:1;min-width:0;display:flex;align-items:center;gap:6px;padding:6px;background:transparent;color:var(--text-2);border-radius:6px;font-size:12px;line-height:1.3;text-align:left}
1506
1524
  .family-row .fr-open:hover:not(:disabled){filter:none}
1507
1525
  .family-row .fr-preview{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -71,7 +71,7 @@ export function workingSetChipTemplate(chip) {
71
71
  const what = chip.n ? `${chip.n} item${chip.n === 1 ? "" : "s"} this session brought into context` : "nothing this session brought into context yet";
72
72
  const title = `Working set: ${what} (files, memories, URLs)${chip.changed ? ", changed since you last looked" : ""}. Open the list.`;
73
73
  const cls = `model ws-chip${chip.changed ? " changed" : ""}${chip.n ? "" : " empty"}`;
74
- return html`<button type="button" class=${cls} title=${title} aria-label=${title} aria-haspopup="dialog"><span class="ws-dot" aria-hidden="true"></span>set ${chip.n}</button>`;
74
+ return html`<button type="button" class=${cls} title=${title} aria-label=${title} aria-haspopup="dialog" aria-expanded="false"><span class="ws-dot" aria-hidden="true"></span>set ${chip.n}</button>`;
75
75
  }
76
76
 
77
77
  // The info line for +bar+, nothing without a session (the start page hides
@@ -373,6 +373,7 @@ export function createListsView(deps) {
373
373
  let chainFrom = null; // the list element the open popover's chip is in
374
374
  const chainPop = createFamilyPop({
375
375
  id: "chainPop",
376
+ label: "Chain links",
376
377
  doc,
377
378
  win,
378
379
  rowsFor: (id) => {
@@ -99,7 +99,7 @@ export function chipModel(grants, now) {
99
99
  export function chipTemplate(chip) {
100
100
  if (!chip) return nothing;
101
101
  return html`<button type="button" class=${`model participants-chip${chip.count ? "" : " none"}${chip.present ? " present" : ""}`}
102
- title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
102
+ title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog" aria-expanded="false"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
103
103
  }
104
104
 
105
105
  // A cap field's dollars (a leading $ allowed): {value}, {value: null} when
@@ -0,0 +1,57 @@
1
+ // What every popover does alike (chip_popover.js' shell, and the ones that
2
+ // place themselves: context_chips.js, families_view.js' family and chain
3
+ // lists): a modal dialog (modalDialog: aria-modal, Tab and Shift+Tab wrap
4
+ // inside it, a Tab from outside goes in), its opener's aria-expanded, Esc
5
+ // closes it and focus goes back to the opener (focusBack). Their :hover
6
+ // tints are gated on (hover:hover) in index.html, so a tap on a phone
7
+ // leaves none behind. A module of its own: its exports are new (see
8
+ // docs/internals/web-frontend.md, "New exports go in new modules").
9
+ //
10
+ // Not popovers in this sense, so not modal: model_picker.js and
11
+ // command_list.js (a combobox's listbox: the focus stays in its input),
12
+ // ref_menu.js (a menu: arrow keys, Tab leaves it), update_sheet.js (a
13
+ // native <dialog> shown modal: the browser holds the focus).
14
+
15
+ const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
16
+
17
+ // The elements Tab reaches in +root+, in order (none hidden or disabled).
18
+ export function tabStops(root) {
19
+ return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
20
+ }
21
+
22
+ // Tab past +root+'s last stop goes to its first, Shift+Tab before the first
23
+ // to the last; a Tab from outside (the opener kept the focus, or a redraw
24
+ // dropped it) goes in. +e+: the Tab's keydown.
25
+ export function holdFocus(e, root, doc = document) {
26
+ const stops = tabStops(root);
27
+ if (!stops.length) return;
28
+ const first = stops[0];
29
+ const last = stops[stops.length - 1];
30
+ const at = doc.activeElement;
31
+ let to = null;
32
+ if (!root.contains(at)) to = e.shiftKey ? last : first;
33
+ else if (e.shiftKey && at === first) to = last;
34
+ else if (!e.shiftKey && at === last) to = first;
35
+ if (!to) return;
36
+ e.preventDefault();
37
+ to.focus();
38
+ }
39
+
40
+ // +pop+ as every popover is: a dialog (named +label+) that holds the focus
41
+ // while +isOpen()+ and on the page (aria-modal; Tab and Shift+Tab wrap inside it).
42
+ export function modalDialog(pop, { label = null, isOpen, doc = document }) {
43
+ pop.setAttribute("role", "dialog");
44
+ pop.setAttribute("aria-modal", "true");
45
+ if (label) pop.setAttribute("aria-label", label);
46
+ doc.addEventListener("keydown", (e) => {
47
+ if (e.key === "Tab" && isOpen() && pop.isConnected) holdFocus(e, pop, doc);
48
+ });
49
+ }
50
+
51
+ // Called before +pop+ closes: whether the focus was in it (or +refocus+:
52
+ // an Esc), and so goes back(to) its opener once it has closed.
53
+ // @return (to) => focuses +to+ when it should and still can
54
+ export function focusBack(pop, { refocus = false, doc = document } = {}) {
55
+ const had = refocus || pop.contains(doc.activeElement);
56
+ return (to) => { if (had && to?.isConnected) to.focus?.(); };
57
+ }