samagotchi 0.133.0 → 0.134.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/cli.md +11 -0
  4. data/docs/configuration.md +8 -4
  5. data/docs/guardrails.md +102 -13
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/participants.md +6 -5
  8. data/docs/internals/tool-guardrails.md +2 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -2
  11. data/docs/testing.md +7 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/chi_home.rb +12 -2
  14. data/lib/samagotchi/desktop/macos.rb +19 -4
  15. data/lib/samagotchi/engine.rb +13 -0
  16. data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +493 -60
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
  20. data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
  21. data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
  22. data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
  23. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  24. data/lib/samagotchi/guardrails/targets.rb +8 -3
  25. data/lib/samagotchi/home_command.rb +22 -6
  26. data/lib/samagotchi/home_doctor.rb +36 -3
  27. data/lib/samagotchi/home_notice.rb +8 -1
  28. data/lib/samagotchi/hooks/loader.rb +5 -0
  29. data/lib/samagotchi/log_line.rb +12 -0
  30. data/lib/samagotchi/project_env.rb +3 -11
  31. data/lib/samagotchi/self_report.rb +3 -1
  32. data/lib/samagotchi/system_one/self_protect.rb +58 -2
  33. data/lib/samagotchi/testing/waiting.rb +13 -0
  34. data/lib/samagotchi/toolkit_binding.rb +16 -0
  35. data/lib/samagotchi/update_command.rb +7 -3
  36. data/lib/samagotchi/update_hint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  39. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  40. data/lib/samagotchi/web/public/app.js +18 -1
  41. data/lib/samagotchi/web/public/cards_place.js +5 -2
  42. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  43. data/lib/samagotchi/web/public/claim_link.js +4 -2
  44. data/lib/samagotchi/web/public/composer.css +3 -1
  45. data/lib/samagotchi/web/public/history_reveal.js +23 -0
  46. data/lib/samagotchi/web/public/index.html +44 -28
  47. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  48. data/lib/samagotchi/web/public/refs.js +6 -7
  49. data/lib/samagotchi/web/public/review_start.js +1 -1
  50. data/lib/samagotchi/web/public/start_page.js +4 -4
  51. data/lib/samagotchi/web/public/start_settings.js +29 -18
  52. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  53. data/lib/samagotchi/web/public/timing.js +5 -1
  54. data/lib/samagotchi/web/public/turn_html.js +12 -6
  55. data/lib/samagotchi/web/public/turn_range.js +32 -0
  56. data/lib/samagotchi/web/public/turn_view.js +21 -11
  57. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  58. data/lib/samagotchi/web/public/verify_target.js +7 -10
  59. metadata +5 -1
@@ -0,0 +1,262 @@
1
+ // Folding a long answer by hand (per viewer). A completed answer bubble
2
+ // longer than FOLD_LINES lines or FOLD_CHARS characters of its message text
3
+ // gets a fold row: a sibling just before the bubble, never inside it (the
4
+ // bubble's copy text, its claims and a live end's innerHTML stay the
5
+ // bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
6
+ // no inline style: hold.js strips that) and the row reads its first line
7
+ // and its size, "‹first line› · N lines ▸"; unfolded, the row stays as a
8
+ // slim "▾ N lines" header.
9
+ //
10
+ // Which answers are folded is this browser's choice (localStorage through
11
+ // storage.js): one key per session holding the folded answers' keys, the
12
+ // last FOLD_KEEP of them. An answer's key is its turn's id
13
+ // (data-answer-turn), a delegate report's reply its own (data-reply-key);
14
+ // a bubble with neither (an answer before any prompt, a canceled turn's
15
+ // partial) gets no row. The keys of sessions gone from the session list go
16
+ // when the list loads (sweepAnswerFolds).
17
+ //
18
+ // One observer on #history decorates every path that fills it (a reload's
19
+ // render, a live turn's end, the stage's hand-off), as copy.js does. A jump
20
+ // into a folded answer unfolds it (history_reveal.js); the row follows the
21
+ // bubble's class.
22
+ //
23
+ // A module of its own: static JS is cached at unversioned URLs, and a new
24
+ // export on an old module would blank the page.
25
+ import { bubbleCopyText } from "./copy.js";
26
+ import { clip } from "./format.js";
27
+ import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
28
+
29
+ // The fold threshold: an answer longer than either gets a fold row.
30
+ export const FOLD_LINES = 25;
31
+ export const FOLD_CHARS = 2000;
32
+ // How many folded answers a session's key keeps (the oldest go first).
33
+ export const FOLD_KEEP = 200;
34
+ export const FOLD_PREFIX = "chi_answer_fold:";
35
+ // A folded row's label, at most this long (CSS cuts it to the row's width).
36
+ const LABEL_CHARS = 160;
37
+
38
+ const ROW_CLASS = "answer-fold";
39
+ const ANSWERS = ".bubble.output";
40
+ // Not done yet: still streaming, or held by the answer pop (hold.js).
41
+ const UNFINISHED = ".streaming, .boxed";
42
+
43
+ // +text+'s lines (a trailing newline ends the last one, it adds none).
44
+ export function lineCount(text) {
45
+ const s = String(text ?? "").replace(/\s+$/, "");
46
+ return s ? s.split("\n").length : 0;
47
+ }
48
+
49
+ // Whether an answer of +text+ gets a fold row.
50
+ export function foldable(text) {
51
+ const s = String(text ?? "");
52
+ return lineCount(s) > FOLD_LINES || s.length > FOLD_CHARS;
53
+ }
54
+
55
+ // A markdown line as plain words: no heading marks, list or quote lead, no
56
+ // emphasis or code ticks.
57
+ function plainLine(line) {
58
+ return line
59
+ .replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, "")
60
+ .replace(/(\*\*|__|`)/g, "")
61
+ .trim();
62
+ }
63
+
64
+ // A folded answer's label: the first line of what a hook presented in its
65
+ // place (+display+: the text is that), else its first markdown heading,
66
+ // else its first text line (a code fence's marks skipped); one line.
67
+ export function foldLabel(text, { display = false } = {}) {
68
+ const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
69
+ const texts = lines.filter((l) => l.trim() && !/^\s*(```|~~~)/.test(l));
70
+ const heading = display ? null : texts.find((l) => /^\s{0,3}#{1,6}\s+\S/.test(l));
71
+ return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
72
+ }
73
+
74
+ // The row's words: folded, the label and the size; unfolded, the size.
75
+ export function foldRowText(text, { display = false, folded = true } = {}) {
76
+ const n = lineCount(text);
77
+ const size = `${n} line${n === 1 ? "" : "s"}`;
78
+ if (!folded) return { label: "", size };
79
+ const label = foldLabel(text, { display });
80
+ return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
81
+ }
82
+
83
+ // ---- The stored choice ----
84
+
85
+ export function foldStoreKey(sessionId) {
86
+ return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
87
+ }
88
+
89
+ function readEntry(key) {
90
+ try {
91
+ const entry = JSON.parse(readStored(key, "null"));
92
+ if (!entry || !Array.isArray(entry.turns)) return null;
93
+ return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns.filter((t) => typeof t === "string") };
94
+ } catch (_) {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // The session's folded answers' keys, oldest first; [] when none or they
100
+ // can't be read.
101
+ export function readFolded(sessionId) {
102
+ const key = foldStoreKey(sessionId);
103
+ return (key && readEntry(key)?.turns) || [];
104
+ }
105
+
106
+ // +list+ with +answerKey+ folded (moved to the end) or not, at most
107
+ // FOLD_KEEP long. Pure.
108
+ export function withFold(list, answerKey, folded) {
109
+ const rest = list.filter((k) => k !== answerKey);
110
+ return (folded ? [...rest, answerKey] : rest).slice(-FOLD_KEEP);
111
+ }
112
+
113
+ // Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
114
+ // project root, kept for the sweep of a project's list.
115
+ export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
116
+ const key = foldStoreKey(sessionId);
117
+ if (!key || !answerKey) return;
118
+ const turns = withFold(readFolded(sessionId), answerKey, folded);
119
+ if (!turns.length) removeStored(key);
120
+ else writeStored(key, JSON.stringify({ root, turns }));
121
+ }
122
+
123
+ // The session list loaded (+sessions+, each {id, project_root}): the fold
124
+ // keys of sessions not in it go. A project's list (+scoped+) only knows its
125
+ // own project's sessions, so there a key goes only when its project root is
126
+ // one the list shows; an unreadable key goes.
127
+ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
128
+ const list = Array.isArray(sessions) ? sessions : [];
129
+ const ids = new Set(list.map((s) => s?.id).filter(Boolean));
130
+ const roots = new Set(list.map((s) => s?.project_root).filter(Boolean));
131
+ for (const key of storedKeys(FOLD_PREFIX)) {
132
+ if (ids.has(key.slice(FOLD_PREFIX.length))) continue;
133
+ const entry = readEntry(key);
134
+ if (!entry || !scoped || (entry.root && roots.has(entry.root))) removeStored(key);
135
+ }
136
+ }
137
+
138
+ // ---- The rows ----
139
+
140
+ // The key an answer bubble is folded by, or null.
141
+ export function answerKey(bubble) {
142
+ return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
143
+ }
144
+
145
+ function answerText(bubble) {
146
+ return bubbleCopyText(bubble);
147
+ }
148
+
149
+ function drawRow(row, bubble) {
150
+ const folded = bubble.classList.contains("folded");
151
+ const text = answerText(bubble);
152
+ const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
153
+ row.setAttribute("aria-expanded", folded ? "false" : "true");
154
+ row.title = folded ? "Show the answer" : "Fold the answer";
155
+ row.classList.toggle("folded", folded);
156
+ const [labelEl, sizeEl] = row.children;
157
+ if (labelEl.textContent !== label) labelEl.textContent = label;
158
+ if (sizeEl.textContent !== size) sizeEl.textContent = size;
159
+ }
160
+
161
+ function makeRow(doc) {
162
+ const row = doc.createElement("button");
163
+ row.type = "button";
164
+ row.className = ROW_CLASS;
165
+ const label = doc.createElement("span");
166
+ label.className = "answer-fold-label";
167
+ const size = doc.createElement("span");
168
+ size.className = "answer-fold-size";
169
+ row.append(label, size);
170
+ return row;
171
+ }
172
+
173
+ // Runs +mutate+ and scrolls +scroller+ so +row+ stays where it was on
174
+ // screen (folding a long answer above the view would pull the page up), in
175
+ // sight at least.
176
+ export function keepRowInView(scroller, row, mutate) {
177
+ const before = row.getBoundingClientRect().top;
178
+ mutate();
179
+ const after = row.getBoundingClientRect().top;
180
+ if (scroller && after !== before) scroller.scrollTop += after - before;
181
+ const box = row.getBoundingClientRect();
182
+ const view = scroller?.getBoundingClientRect?.();
183
+ if (view && (box.top < view.top || box.bottom > view.bottom)) row.scrollIntoView?.({ block: "nearest" });
184
+ }
185
+
186
+ // @param historyEl the history (#history), also its scroller
187
+ // @param sessionId () => the open session's id
188
+ // @param projectRoot () => the open session's project root, or null
189
+ // @return {decorate(root)} (the observer runs it; specs call it)
190
+ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
191
+ // Each bubble's row, and each row's bubble.
192
+ const rows = new WeakMap();
193
+ const bubbleOf = new WeakMap();
194
+
195
+ function decorateBubble(bubble, stored) {
196
+ if (bubble.parentElement !== historyEl) return;
197
+ const key = answerKey(bubble);
198
+ let row = rows.get(bubble);
199
+ if (!key || bubble.matches(UNFINISHED) || (!row && !foldable(answerText(bubble)))) return;
200
+ if (!row) {
201
+ row = makeRow(historyEl.ownerDocument);
202
+ rows.set(bubble, row);
203
+ bubbleOf.set(row, bubble);
204
+ // The stored choice, once: after that the bubble's class is the state
205
+ // (a jump that unfolds it, history_reveal.js, isn't saved).
206
+ if (stored().includes(key)) bubble.classList.add("folded");
207
+ }
208
+ if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
209
+ drawRow(row, bubble);
210
+ }
211
+
212
+ // Rows whose bubble left (a re-render, a replaced bubble) go with it.
213
+ function dropOrphans() {
214
+ for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
215
+ const bubble = bubbleOf.get(row);
216
+ if (!bubble || bubble.parentElement !== historyEl) row.remove();
217
+ else if (row.nextElementSibling !== bubble) historyEl.insertBefore(row, bubble);
218
+ }
219
+ }
220
+
221
+ function decorate(root) {
222
+ let folded = null;
223
+ const stored = () => (folded ??= readFolded(sessionId()));
224
+ if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
225
+ for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
226
+ }
227
+
228
+ // Synchronous (not a frame later, as copy buttons are): the row lands in
229
+ // the same frame as its bubble, so nothing below it shifts after a paint.
230
+ new MutationObserver((records) => {
231
+ const roots = new Set();
232
+ let removed = false;
233
+ for (const r of records) {
234
+ if (r.type === "attributes") {
235
+ if (r.target.matches?.(ANSWERS)) roots.add(r.target);
236
+ continue;
237
+ }
238
+ if (r.removedNodes.length) removed = true;
239
+ const answer = r.target.closest?.(ANSWERS);
240
+ if (answer) roots.add(answer);
241
+ for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
242
+ }
243
+ if (removed) dropOrphans();
244
+ for (const el of roots) if (el.isConnected) decorate(el);
245
+ }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
246
+ decorate(historyEl);
247
+
248
+ historyEl.addEventListener("click", (e) => {
249
+ const row = e.target.closest?.(`.${ROW_CLASS}`);
250
+ const bubble = row && bubbleOf.get(row);
251
+ if (!bubble) return;
252
+ e.preventDefault();
253
+ const folded = !bubble.classList.contains("folded");
254
+ keepRowInView(historyEl, row, () => {
255
+ bubble.classList.toggle("folded", folded);
256
+ drawRow(row, bubble);
257
+ });
258
+ writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
259
+ });
260
+
261
+ return { decorate };
262
+ }
@@ -67,6 +67,8 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
67
67
  if (typeof message.html === "string") {
68
68
  bubble.classList.add("markdown");
69
69
  bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ // A hook's display in the answer's place (answer_fold.js's label).
71
+ bubble.toggleAttribute("data-answer-display", typeof message.display === "string");
70
72
  // The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
71
73
  bubble.innerHTML = message.html;
72
74
  }
@@ -68,6 +68,7 @@ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify
68
68
  import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
69
69
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
70
70
  import { copyText, installCopy } from "./copy.js";
71
+ import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
71
72
  import { createQuestionCards } from "./question_cards.js";
72
73
  import { splitSnapshotCards } from "./card.js";
73
74
  import { PROMPT_LINE_PX, PROMPT_MIN_PX } from "./composer_size.js";
@@ -443,6 +444,14 @@ const scope = {
443
444
  };
444
445
  // The server refused ?dir (a folder deleted since the link was made).
445
446
  let scopeError = null;
447
+ // The list's first load sweeps the folded answers of sessions gone since
448
+ // (answer_fold.js), once per page.
449
+ let foldsSwept = false;
450
+ function sweepFoldsOnce() {
451
+ if (foldsSwept) return;
452
+ foldsSwept = true;
453
+ sweepAnswerFolds(allSessions, { scoped: !!scope.dir });
454
+ }
446
455
 
447
456
  // The list by fetching: the fallback when there is no events stream (a
448
457
  // chi web without a hub, or ?dir the server refused: the 400 is read here).
@@ -450,6 +459,7 @@ async function refresh() {
450
459
  try {
451
460
  allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
452
461
  notifications.noticeList(allSessions);
462
+ sweepFoldsOnce();
453
463
  } catch (e) {
454
464
  if (e.status !== 400 || !scope.dir) throw e;
455
465
  scopeError = e.message;
@@ -470,6 +480,7 @@ function openSessionEvents() {
470
480
  if (data.chi_update) updateFrame(data.chi_update, { snapshot: true });
471
481
  notifications.noticeSessions("snapshot", data);
472
482
  allSessions = applySessionEvent(allSessions, "snapshot", data);
483
+ sweepFoldsOnce();
473
484
  lists.render();
474
485
  const sess = allSessions.find((s) => s.id === selected);
475
486
  if (sess) followSelectedSession(sess);
@@ -1305,7 +1316,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1305
1316
  // A completed turn with no answer: its notice where the answer goes
1306
1317
  // (the turn view places it), else after the turn; before the timing.
1307
1318
  const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
1308
- const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
1319
+ const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer, turnId: liveTurn.timing.turnId }) || [];
1309
1320
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
1310
1321
  liveTurn.timing.finish({ canceled: kind === "canceled" });
1311
1322
  if (endText) addEndBubble(endText, { stopped });
@@ -2024,6 +2035,12 @@ historyEl.addEventListener("click", (e) => {
2024
2035
  });
2025
2036
  turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
2026
2037
  turnRoots().forEach((root) => installCopy(root));
2038
+ // Long answers fold by hand (answer_fold.js), per viewer and session.
2039
+ installAnswerFold({
2040
+ historyEl,
2041
+ sessionId: () => selected,
2042
+ projectRoot: () => allSessions.find((s) => s.id === selected)?.project_root ?? null,
2043
+ });
2027
2044
 
2028
2045
  // Notifications (notifications.js): an OS notification and a title count
2029
2046
  // when a session needs the user while this tab is not in front. On a
@@ -25,10 +25,13 @@ export function reloadedTurnEls(historyEl, before) {
25
25
  return turnEls;
26
26
  }
27
27
 
28
- // What goes after a reloaded turn's own rows and cards: its answer, else
28
+ // What goes after a reloaded turn's own rows and cards: its answer (its
29
+ // fold row, answer_fold.js, when it has one: the two stay together), else
29
30
  // its timing line; null when it has neither.
30
31
  export function turnAnswerEl(turnEls) {
31
- return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
32
+ const answer = turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop();
33
+ if (answer) return answer.previousElementSibling?.matches(".answer-fold") ? answer.previousElementSibling : answer;
34
+ return turnEls.find((el) => el.matches(".turn-timing")) || null;
32
35
  }
33
36
 
34
37
  // @param historyEl the history
@@ -5,7 +5,9 @@
5
5
  // click, its × (data-act="close"), a click outside or Esc; focus goes back
6
6
  // to the opener. Another session's summary (refresh) closes it. What it
7
7
  // draws, and what a Set or another data-act does, is the caller's. With
8
- // +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet.
8
+ // +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet. An
9
+ // open one is placed again when the window (or a phone's visual viewport:
10
+ // its keyboard) resizes.
9
11
 
10
12
  import { nothing, render } from "./vendor/lit-html.js";
11
13
 
@@ -29,11 +31,28 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
29
31
  return height > room ? { ...place, maxHeight: room } : place;
30
32
  }
31
33
 
34
+ // The share of a phone's window a bottom sheet takes at most (the rest
35
+ // scrolls); a caller's taller view asks for SHEET_TALL.
36
+ export const SHEET_SHARE = 0.6;
37
+ export const SHEET_TALL = 0.85;
38
+
32
39
  // A bottom sheet's place in a phone's window: the window's width less its
33
- // edges, at its bottom, at most 60 % of its height (the rest scrolls).
40
+ // edges, at its bottom, at most +share+ of its height. +viewport+: the
41
+ // visual viewport ({height, offsetTop}) when it is shorter than the window
42
+ // (a phone's keyboard over the page): the sheet sits on it, sized to it.
34
43
  // @return {left, bottom, width, maxHeight}
35
- export function sheetPlace({ innerWidth, innerHeight }) {
36
- return { left: SHEET_EDGE, bottom: SHEET_EDGE, width: innerWidth - 2 * SHEET_EDGE, maxHeight: Math.round(innerHeight * 0.6) };
44
+ export function sheetPlace({ innerWidth, innerHeight }, { share = SHEET_SHARE, viewport = null } = {}) {
45
+ const height = viewport ? Math.min(innerHeight, viewport.height) : innerHeight;
46
+ const below = viewport ? Math.max(0, innerHeight - viewport.offsetTop - viewport.height) : 0;
47
+ return { left: SHEET_EDGE, bottom: SHEET_EDGE + Math.round(below), width: innerWidth - 2 * SHEET_EDGE,
48
+ maxHeight: Math.round(height * share) };
49
+ }
50
+
51
+ const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
52
+
53
+ // The elements Tab reaches in +root+, in order (none hidden or disabled).
54
+ export function tabStops(root) {
55
+ return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
37
56
  }
38
57
 
39
58
  // id, className: the popover's; width: its widest (px); el: where the chip
@@ -47,27 +66,42 @@ export function sheetPlace({ innerWidth, innerHeight }) {
47
66
  // sheet: a bottom sheet in a narrow window (sheetPlace). label: the
48
67
  // dialog's accessible name (aria-label), none by default. onOpen(): after
49
68
  // an open has shown and placed it (where focus can go into it: show runs
50
- // while it is still hidden).
69
+ // while it is still hidden). expands: every opener carries aria-expanded
70
+ // ("true" while open, "false" once closed; set "false" in the markup too),
71
+ // for openers that are fixed buttons (a lit-drawn chip owns its own).
72
+ // modal: a dialog that holds the focus while open (aria-modal; Tab and
73
+ // Shift+Tab wrap inside it, a Tab from outside goes in). sheetShare: the
74
+ // share of a phone's window the sheet takes at most (SHEET_SHARE).
51
75
  // @return {pop, open, close, refresh, isOpen, opener}
52
76
  export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
53
77
  session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {}, onOpen = () => {},
54
- sheet = false, label = null, doc = document, win = window }) {
78
+ sheet = false, label = null, expands = false, modal = false, sheetShare = SHEET_SHARE,
79
+ doc = document, win = window }) {
55
80
  const pop = doc.createElement("div");
56
81
  pop.id = id;
57
82
  pop.className = className;
58
83
  pop.setAttribute("role", "dialog");
59
84
  if (label) pop.setAttribute("aria-label", label);
85
+ if (modal) pop.setAttribute("aria-modal", "true");
60
86
  pop.hidden = true;
61
87
  doc.body.appendChild(pop);
62
- // The session the open form is for, and the element that opened it.
88
+ // The session the open form is for, the element it is placed at, and the
89
+ // one focus goes back to (the opener clicked).
63
90
  let openFor = null;
64
91
  let opener = null;
92
+ let returnTo = null;
93
+
94
+ const expanded = (value) => {
95
+ if (expands) doc.querySelectorAll(openers).forEach((e) => e.setAttribute("aria-expanded", value));
96
+ };
65
97
 
66
98
  function place(anchor) {
67
99
  const narrow = sheet && win.innerWidth < SHEET_MAX;
68
100
  pop.classList.toggle("sheet", narrow);
69
101
  if (narrow) {
70
- const at = sheetPlace({ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
102
+ const vv = win.visualViewport;
103
+ const at = sheetPlace({ innerWidth: win.innerWidth, innerHeight: win.innerHeight },
104
+ { share: sheetShare, viewport: vv ? { height: vv.height, offsetTop: vv.offsetTop } : null });
71
105
  pop.style.width = `${at.width}px`;
72
106
  pop.style.left = `${at.left}px`;
73
107
  pop.style.top = "";
@@ -86,12 +120,16 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
86
120
  if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
87
121
  }
88
122
 
89
- // Opens at +anchor+ (open already: drawn anew there).
90
- function open(anchor) {
123
+ // Opens at +anchor+ (open already: drawn anew there); focus goes back to
124
+ // +from+ on close (an opener that isn't the anchor; the anchor when +from+
125
+ // is gone or hidden by then).
126
+ function open(anchor, { from = anchor } = {}) {
91
127
  const data = summary(anchor);
92
128
  if (!data) return;
93
129
  openFor = session();
94
130
  opener = anchor;
131
+ returnTo = from;
132
+ expanded("true");
95
133
  show(data, true);
96
134
  pop.hidden = false;
97
135
  place(anchor);
@@ -103,10 +141,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
103
141
  // its opener.
104
142
  function close({ refocus = false } = {}) {
105
143
  const had = refocus || pop.contains(doc.activeElement);
106
- const back = opener;
144
+ const back = returnTo?.isConnected && !returnTo.hidden ? returnTo : opener;
145
+ expanded("false");
107
146
  pop.hidden = true;
108
147
  openFor = null;
109
148
  opener = null;
149
+ returnTo = null;
110
150
  onClose();
111
151
  render(nothing, pop);
112
152
  if (had && back?.isConnected) back.focus?.();
@@ -156,7 +196,29 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
156
196
  });
157
197
  doc.addEventListener("keydown", (e) => {
158
198
  if (e.key === "Escape" && isOpen()) close({ refocus: true });
199
+ else if (e.key === "Tab" && modal && isOpen()) holdFocus(e);
159
200
  });
201
+ // A resize (a rotation, a phone's keyboard) places an open one again: a
202
+ // sheet's height follows the window, a popover its chip.
203
+ win.addEventListener?.("resize", replace);
204
+ win.visualViewport?.addEventListener?.("resize", replace);
205
+
206
+ // Tab past the last stop goes to the first, Shift+Tab before the first to
207
+ // the last; a Tab from outside (focus lost to a redraw) goes in.
208
+ function holdFocus(e) {
209
+ const stops = tabStops(pop);
210
+ if (!stops.length) return;
211
+ const first = stops[0];
212
+ const last = stops[stops.length - 1];
213
+ const at = doc.activeElement;
214
+ let to = null;
215
+ if (!pop.contains(at)) to = e.shiftKey ? last : first;
216
+ else if (e.shiftKey && at === first) to = last;
217
+ else if (!e.shiftKey && at === last) to = first;
218
+ if (!to) return;
219
+ e.preventDefault();
220
+ to.focus();
221
+ }
160
222
 
161
223
  return { pop, open, close, refresh, isOpen, replace, opener: () => opener };
162
224
  }
@@ -28,6 +28,8 @@
28
28
  // static JS is cached at unversioned URLs, and a new export on an old
29
29
  // module would blank the page.
30
30
 
31
+ import { revealInHistory } from "./history_reveal.js";
32
+
31
33
  export const QUOTE_MAX = 200;
32
34
  export const PREFIX_MAX = 32;
33
35
  export const TURN_CHARS = 8;
@@ -311,7 +313,7 @@ export function selectionClaim(root, range, opts = {}, doc = globalThis.document
311
313
 
312
314
  // Finds the claim +claim+ ({turn, q, p}) in +container+ and shows it: the
313
315
  // match's root (or the turn's opening) scrolled to the middle and marked
314
- // HIT_CLASS, its closed <details> opened, the quote highlighted (CSS
316
+ // HIT_CLASS, what hides it unfolded (history_reveal.js), the quote highlighted (CSS
315
317
  // Highlight API). Without the turn (no opening carries its id: a
316
318
  // non-person turn), the quote in any root of the page. @return "quote",
317
319
  // "turn" or null (neither the turn nor the quote).
@@ -323,7 +325,7 @@ export function showClaim(container, claim, { roots: rootSelector, opts = {}, do
323
325
  const hit = findClaim(texts.map((t) => t.text), claim, opts);
324
326
  if (!hit && !turn) return null;
325
327
  const el = hit ? roots[hit.at] : turn.anchor;
326
- for (let d = el.parentElement?.closest("details"); d; d = d.parentElement?.closest("details")) d.open = true;
328
+ revealInHistory(el);
327
329
  el.classList.add(HIT_CLASS);
328
330
  el.scrollIntoView?.({ block: "center" });
329
331
  if (hit) {
@@ -77,7 +77,9 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
77
77
  .set: something under it is set, an accent dot on a phone (the override chips say it on a wider row). */
78
78
  #composer .actions-col .icon-btn{order:2;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
79
79
  #composer .actions-col .icon-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
80
- #composer .actions-col .icon-btn:hover,#composer .actions-col .icon-btn[aria-expanded="true"]{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
80
+ #composer .actions-col .icon-btn:hover{filter:none}
81
+ #composer .actions-col .icon-btn[aria-expanded="true"]{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
82
+ @media(hover:hover){#composer .actions-col .icon-btn:hover{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}}
81
83
  #composer .actions-col .icon-btn[hidden],body.has-session #composer .actions-col .icon-btn{display:none}
82
84
  #composer .actions-col .settings-btn{--icon:var(--icon-settings)}
83
85
  #composer .actions-col .toolkit-btn{--icon:var(--icon-toolkit)}
@@ -0,0 +1,23 @@
1
+ // The one way a jump into the history shows what it lands on: whatever
2
+ // hides +el+ unfolds: a closed <details> around it (a turn's steps block, a
3
+ // step, a diff), an answer folded by hand (answer_fold.js: its row follows
4
+ // the bubble's class; the jump's unfold isn't saved). Every jump goes through it (claim_link.js
5
+ // showClaim, refs.js showRow and so the working set's step tags,
6
+ // verdict_card.js "View check"), so a new way to hide history is opened in
7
+ // one place.
8
+ //
9
+ // Pure DOM, so it runs under `node --test` with happy-dom. A module of its
10
+ // own: static JS is cached at unversioned URLs, and a new export on an old
11
+ // module would blank the page.
12
+
13
+ // Opens each closed <details> that holds +el+ (not +el+ itself: a fold the
14
+ // caller means to open it opens itself) and unfolds the folded answer that
15
+ // is or holds it. +el+, or null.
16
+ export function revealInHistory(el) {
17
+ if (!el) return null;
18
+ el.closest?.(".bubble.output.folded")?.classList.remove("folded");
19
+ for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
20
+ details.open = true;
21
+ }
22
+ return el;
23
+ }