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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +15 -7
- data/docs/context.md +7 -9
- data/docs/guardrails.md +12 -5
- data/docs/internals/engine.md +4 -4
- data/docs/internals/participants.md +2 -1
- data/docs/internals/web-frontend.md +19 -9
- data/docs/plugins.md +2 -2
- data/docs/sessions.md +8 -8
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +3 -2
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
- data/lib/samagotchi/guardrails/mise_paths.rb +36 -0
- data/lib/samagotchi/guardrails/outside.rb +5 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
- data/lib/samagotchi/guardrails/pair_command.rb +3 -19
- data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
- data/lib/samagotchi/guardrails/review_paths.rb +3 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/message_display.rb +6 -3
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/public/answer_fold.js +24 -6
- data/lib/samagotchi/web/public/app.js +24 -19
- data/lib/samagotchi/web/public/chains.js +6 -5
- data/lib/samagotchi/web/public/chip_popover.js +20 -42
- data/lib/samagotchi/web/public/composer.css +15 -11
- data/lib/samagotchi/web/public/composer_grip.js +11 -13
- data/lib/samagotchi/web/public/composer_mount.js +2 -2
- data/lib/samagotchi/web/public/context_chips.js +29 -98
- data/lib/samagotchi/web/public/copy.js +8 -5
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/families_view.js +30 -38
- data/lib/samagotchi/web/public/index.html +176 -132
- data/lib/samagotchi/web/public/info_bar.js +19 -8
- data/lib/samagotchi/web/public/info_bar_model.js +3 -1
- data/lib/samagotchi/web/public/lists_view.js +69 -42
- data/lib/samagotchi/web/public/participants_panel.js +4 -4
- data/lib/samagotchi/web/public/popover_modal.js +57 -0
- data/lib/samagotchi/web/public/revise_stack.js +76 -6
- data/lib/samagotchi/web/public/revise_switch.js +55 -0
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/send_turn.js +4 -2
- data/lib/samagotchi/web/public/settings_chip.js +2 -2
- data/lib/samagotchi/web/public/stage_view.js +51 -2
- data/lib/samagotchi/web/public/stale_params.js +26 -0
- data/lib/samagotchi/web/public/start_settings.js +1 -1
- data/lib/samagotchi/web/public/start_toolkit.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +10 -5
- data/lib/samagotchi/web/public/working_set_rail.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- metadata +5 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// The session bar's info line (#infoText)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The session bar's info line (#infoText), three groups with hairlines
|
|
2
|
+
// between them: the setup (the open session's id, a button that copies the
|
|
3
|
+
// full id; status, model, the ⚙ settings chip (thinking, reasoning history,
|
|
4
|
+
// LLM context), the 🧰 toolkit chip, model notes, memories or the working
|
|
5
|
+
// set), the relations (chain links, the parent, delegates, participants)
|
|
6
|
+
// and the telemetry (folder, ctx, speed, the session clock, the worker's
|
|
7
|
+
// version badge). A lit-html template over a
|
|
6
8
|
// plain model (info_bar_model.js, applied by app.js's updateInfoBar), so the markup lives here and
|
|
7
9
|
// runs under node --test (spec/web/public/info_bar.test.js).
|
|
8
10
|
//
|
|
@@ -21,7 +23,7 @@ import { chipTemplate as participantsChip } from "./participants_panel.js";
|
|
|
21
23
|
export const PREV = "←";
|
|
22
24
|
// The session clock's placeholder before its first turn: the chip shows
|
|
23
25
|
// from the start, so the first turn doesn't add it to the line.
|
|
24
|
-
export const CLOCK_NONE = "
|
|
26
|
+
export const CLOCK_NONE = "–";
|
|
25
27
|
export const NEXT = "→";
|
|
26
28
|
|
|
27
29
|
// A chain's ← / → link (chains.js chainNeighbours): its date or time (else
|
|
@@ -71,7 +73,7 @@ export function workingSetChipTemplate(chip) {
|
|
|
71
73
|
const what = chip.n ? `${chip.n} item${chip.n === 1 ? "" : "s"} this session brought into context` : "nothing this session brought into context yet";
|
|
72
74
|
const title = `Working set: ${what} (files, memories, URLs)${chip.changed ? ", changed since you last looked" : ""}. Open the list.`;
|
|
73
75
|
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>`;
|
|
76
|
+
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
77
|
}
|
|
76
78
|
|
|
77
79
|
// The info line for +bar+, nothing without a session (the start page hides
|
|
@@ -90,10 +92,19 @@ export function workingSetChipTemplate(chip) {
|
|
|
90
92
|
// first generation; null: no chip), clock (the session time's text, "" for
|
|
91
93
|
// none: the muted CLOCK_NONE), worker ({badge, busy} or null), participants (the
|
|
92
94
|
// participants chip: participants_panel.js chipModel, or null).
|
|
95
|
+
// Three span.bar-group (setup, relations, telemetry), a span.bar-sep
|
|
96
|
+
// between two drawn ones; the relations only when it has a part.
|
|
93
97
|
export function infoBarHtml(bar) {
|
|
94
98
|
if (!bar) return nothing;
|
|
95
99
|
const memories = bar.memories || [];
|
|
96
|
-
|
|
100
|
+
const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
|
|
101
|
+
const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
|
|
102
|
+
// The parent reads "↑ <short id>"; its title says "delegated by …".
|
|
103
|
+
const relations = related
|
|
104
|
+
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
|
+
: nothing;
|
|
106
|
+
const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
|
|
107
|
+
return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
// Draws +bar+ into +el+ (#infoText), with the memories in its tooltip
|
|
@@ -112,7 +112,9 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
|
|
|
112
112
|
// speed, so it doesn't join the line mid-turn.
|
|
113
113
|
speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`, empty: false }
|
|
114
114
|
: { text: SPEED_NONE, tip: "Decode speed of the last generation: none yet", empty: true },
|
|
115
|
-
|
|
115
|
+
// A session with no turn yet has no time of its own: the placeholder, not "0ms".
|
|
116
|
+
// A running first turn still ticks from 0.
|
|
117
|
+
clock: sessionDuration === null || (!running && sessionDuration === 0) ? "" : formatDuration(sessionDuration),
|
|
116
118
|
worker: badge ? { badge, busy: restarting } : null,
|
|
117
119
|
};
|
|
118
120
|
}
|
|
@@ -20,19 +20,19 @@
|
|
|
20
20
|
// select(id) open a session
|
|
21
21
|
// confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
|
|
22
22
|
// updateSessionTitle(), showToast(text, action)
|
|
23
|
-
// allViewOpen(), openAllView(), leaveAllView(hash)
|
|
23
|
+
// allViewOpen(), openAllView(), closeAllView(), leaveAllView(hash)
|
|
24
24
|
// renameShown(owner) whether a card's title has its ✎ (session_title.js)
|
|
25
25
|
// onRename() the active card's ✎ clicked (session_rename.js open)
|
|
26
26
|
// renaming() whether that rename input is open (the strip holds)
|
|
27
27
|
|
|
28
|
-
import { ownerBadge, relativeTime, delegatedBy, cardTitleSource } from "./format.js";
|
|
28
|
+
import { ownerBadge, relativeTime, delegatedBy, cardTitleSource, previewForCard } from "./format.js";
|
|
29
29
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
30
30
|
import { cardCtxText, ctxBarTip } from "./ctx.js";
|
|
31
31
|
import { allScopeHref, cardFolder } from "./scope.js";
|
|
32
32
|
import { sessionHash } from "./route.js";
|
|
33
33
|
import { stripColumns, stripStatusParts } from "./strip.js";
|
|
34
34
|
import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
|
|
35
|
-
import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
|
|
35
|
+
import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains, isOpener } from "./chains.js";
|
|
36
36
|
import {
|
|
37
37
|
batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
|
|
38
38
|
listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
|
|
@@ -70,10 +70,10 @@ export function allViewFamilies(listed, every, { query = "", includeArchived = f
|
|
|
70
70
|
return { all, shown: waitingFirst(shown), delegates };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// The all view's count: "14
|
|
73
|
+
// The all view's count: "14 · 7 delegates", or "3 / 14" under a search.
|
|
74
74
|
export function allCountText(shown, total, delegates) {
|
|
75
75
|
return shown === total
|
|
76
|
-
? `${total}${delegates ? `
|
|
76
|
+
? `${total}${delegates ? ` · ${delegates} ${delegates === 1 ? "delegate" : "delegates"}` : ""}`
|
|
77
77
|
: `${shown} / ${total}`;
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -85,10 +85,8 @@ export function stripCountText(shown, total) {
|
|
|
85
85
|
: `${total - shown} archived`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
// How long a new card fades in
|
|
89
|
-
// `.card.entering` fadeIn (.28s) and `.card .dot` dot-pulse / attn-pulse (1.6s).
|
|
88
|
+
// How long a new card fades in: index.html's `.card.entering` fadeIn (.28s).
|
|
90
89
|
export const CARD_FADE_MS = 280;
|
|
91
|
-
export const DOT_PULSE_MS = 1600;
|
|
92
90
|
|
|
93
91
|
// Which of a list's cards fade in. Every draw makes new nodes (draw), and a
|
|
94
92
|
// new node starts its CSS animation again: so only a card the list did not
|
|
@@ -109,20 +107,39 @@ export function enteringCards(prev, ids, now, fadeMs = CARD_FADE_MS) {
|
|
|
109
107
|
return { drawn, entering };
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
// A card's inline animation
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const parts = [];
|
|
117
|
-
if (enterMs) parts.push(`--enter-delay:-${Math.round(enterMs)}ms`);
|
|
118
|
-
if (now !== null) parts.push(`--pulse-delay:-${Math.round(now % period)}ms`);
|
|
119
|
-
return parts.join(";");
|
|
110
|
+
// A card's inline animation: its fade's delay (+enterMs+ into it, so a
|
|
111
|
+
// redrawn card carries its fade on). "" for none.
|
|
112
|
+
export function cardAnimStyle({ enterMs = null } = {}) {
|
|
113
|
+
return enterMs ? `--enter-delay:-${Math.round(enterMs)}ms` : "";
|
|
120
114
|
}
|
|
121
115
|
|
|
122
|
-
// A card
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
116
|
+
// A card's two lines: its title (one line) and the line under it. The
|
|
117
|
+
// title is +title+ (cardTitle); an untitled session (its title is only its
|
|
118
|
+
// first message: cardTitleSource "preview", not a chain link inheriting its
|
|
119
|
+
// chain's title, and not "—") reads "Untitled" over that message. The line
|
|
120
|
+
// under it is the recap (unless the title is the recap's first sentence,
|
|
121
|
+
// D5), else the first message when the title isn't it (and it isn't a
|
|
122
|
+
// chain link's opener). untitled: whether the title is the "Untitled"
|
|
123
|
+
// placeholder.
|
|
124
|
+
function cardLines(s, title) {
|
|
125
|
+
if (cardTitleSource(s) === "preview" && title !== "\u2014" && title === previewForCard(s)) {
|
|
126
|
+
return { title: "Untitled", untitled: true, preview: title, previewTip: s.first_preview || s.last_prompt || title };
|
|
127
|
+
}
|
|
128
|
+
if (s.recap && cardTitleSource(s) !== "recap") return { title, untitled: false, preview: s.recap, previewTip: s.recap };
|
|
129
|
+
const first = (s.first_preview || "").trim();
|
|
130
|
+
const shown = first && !(s.continues && isOpener(first)) ? previewForCard({ first_preview: first }) : "";
|
|
131
|
+
return shown && shown !== title && shown !== "\u2014"
|
|
132
|
+
? { title, untitled: false, preview: shown, previewTip: first }
|
|
133
|
+
: { title, untitled: false, preview: null, previewTip: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A card: its title, the line under it (cardLines), then how long ago,
|
|
137
|
+
// owner and memories. The id is in the tooltip (and the search) only. One
|
|
138
|
+
// mark per state, no status dot: a session that waits on the user (a
|
|
139
|
+
// question, an approval, a card with actions) wears the warning colour and
|
|
140
|
+
// says what for (and drops its "live" owner badge: waiting means a worker);
|
|
141
|
+
// an errored one wears an `error` badge; idle, completed and stopped are
|
|
142
|
+
// unmarked.
|
|
126
143
|
// deletable / archivable: the all-sessions view's cards get a delete and an
|
|
127
144
|
// archive (unarchive, on an archived card) button. selectable: select mode's
|
|
128
145
|
// checkbox instead (picked: checked), and skipReason: why a batch skipped it.
|
|
@@ -133,7 +150,7 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
|
|
|
133
150
|
// members. A family in a session chain (chains.js foldChains) carries its
|
|
134
151
|
// `#N` chip, which opens the other links in a popover. entering: a card
|
|
135
152
|
// new to its list (enteringCards: it fades in); anim: its inline animation
|
|
136
|
-
//
|
|
153
|
+
// fade (cardAnimStyle).
|
|
137
154
|
// +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
|
|
138
155
|
// open session), chainOpen (its chain's popover is open), parent (the
|
|
139
156
|
// delegating session, or null).
|
|
@@ -157,7 +174,7 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
|
|
|
157
174
|
// the user (the waiting badge wins: the stop is behind the need).
|
|
158
175
|
const stopped = waiting ? null : stoppedBadge(s);
|
|
159
176
|
const stoppedT = stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing;
|
|
160
|
-
const
|
|
177
|
+
const errorT = s.status === "error" ? html`<span class="error-badge" title=${s.status}>error</span>` : nothing;
|
|
161
178
|
const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
|
|
162
179
|
const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
|
|
163
180
|
const familyWaiting = !!family && familyWaits(family);
|
|
@@ -169,12 +186,13 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
|
|
|
169
186
|
const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
|
|
170
187
|
...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
|
|
171
188
|
const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}${entering ? " entering" : ""}`;
|
|
189
|
+
const lines = cardLines(s, title);
|
|
190
|
+
const titleT = html`<div class=${lines.untitled ? "preview untitled" : "preview"}>${lines.title}</div>`;
|
|
172
191
|
const preview = renamable
|
|
173
|
-
? html`<div class="preview-row"
|
|
174
|
-
:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
|
|
192
|
+
? html`<div class="preview-row">${titleT}<button type="button" class="card-rename" data-rename=${s.id} title="Rename this session" aria-label="Rename this session">\u270e</button></div>`
|
|
193
|
+
: titleT;
|
|
194
|
+
const recap = lines.preview ? html`<div class="recap" title=${lines.previewTip}>${lines.preview}</div>` : nothing;
|
|
195
|
+
return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta">${waitingT}${errorT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${waiting ? nothing : ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
|
|
178
196
|
}
|
|
179
197
|
|
|
180
198
|
function cardToolsTemplate(s, { deletable, archivable }) {
|
|
@@ -208,9 +226,10 @@ export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
|
|
|
208
226
|
return html`<button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button>`;
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
// The
|
|
212
|
-
//
|
|
213
|
-
//
|
|
229
|
+
// The sessions pill: "5 sessions · ● 1 live · ● 1 waiting" (strip.js
|
|
230
|
+
// stripStatusParts), shown while the strip is hidden and in the all view.
|
|
231
|
+
// The all view's toggle (the grip hides and shows the strip): a click
|
|
232
|
+
// opens the view, as the tile does, and closes it again (pressed there).
|
|
214
233
|
// The count (and the dot after it) in its own span: a phone's narrow top
|
|
215
234
|
// bar drops it when live or waiting shows (index.html).
|
|
216
235
|
export function stripPillTemplate({ count, live, waiting }) {
|
|
@@ -232,24 +251,29 @@ export function createListsView(deps) {
|
|
|
232
251
|
const {
|
|
233
252
|
doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
|
|
234
253
|
confirmDelete, setArchived, archiveOne, updateSessionTitle, showToast,
|
|
235
|
-
allViewOpen, openAllView, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
|
|
254
|
+
allViewOpen, openAllView, closeAllView = () => {}, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
|
|
236
255
|
} = deps;
|
|
237
256
|
const $ = (s) => doc.querySelector(s);
|
|
238
257
|
|
|
239
|
-
// The
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
258
|
+
// The sessions pill opens the all-sessions view (as the tile does), saving
|
|
259
|
+
// the step of showing the strip first, and closes it again (as Esc does):
|
|
260
|
+
// it is the view's own button there. Bound once: the pill element stays,
|
|
261
|
+
// only its content is drawn again (draw()).
|
|
262
|
+
$("#stripStatus").addEventListener("click", () => (allViewOpen() ? closeAllView() : openAllView()));
|
|
243
263
|
|
|
244
|
-
// The strip, its count and the
|
|
245
|
-
//
|
|
246
|
-
// them all (parent chips, the open session).
|
|
264
|
+
// The strip, its count and the sessions pill leave archived sessions out;
|
|
265
|
+
// the all view shows them with its "archived" toggle pressed. sessions()
|
|
266
|
+
// keeps them all (parent chips, the open session).
|
|
247
267
|
function shownSessions() {
|
|
248
268
|
return listedSessions(sessions(), false);
|
|
249
269
|
}
|
|
250
270
|
|
|
271
|
+
function archivedOn() {
|
|
272
|
+
return $("#includeArchived").getAttribute("aria-pressed") === "true";
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
function allViewSessions() {
|
|
252
|
-
return listedSessions(sessions(),
|
|
276
|
+
return listedSessions(sessions(), archivedOn());
|
|
253
277
|
}
|
|
254
278
|
|
|
255
279
|
function filterQuery() {
|
|
@@ -333,7 +357,7 @@ export function createListsView(deps) {
|
|
|
333
357
|
const now = win.performance?.now() ?? Date.now();
|
|
334
358
|
const { drawn, entering } = enteringCards(lastDrawn.get(el), ids, now);
|
|
335
359
|
lastDrawn.set(el, drawn);
|
|
336
|
-
return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id)
|
|
360
|
+
return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id) }) });
|
|
337
361
|
}
|
|
338
362
|
|
|
339
363
|
// The strip's cards: stripOrder, held under the pointer, as many as fit.
|
|
@@ -373,6 +397,7 @@ export function createListsView(deps) {
|
|
|
373
397
|
let chainFrom = null; // the list element the open popover's chip is in
|
|
374
398
|
const chainPop = createFamilyPop({
|
|
375
399
|
id: "chainPop",
|
|
400
|
+
label: "Chain links",
|
|
376
401
|
doc,
|
|
377
402
|
win,
|
|
378
403
|
rowsFor: (id) => {
|
|
@@ -565,7 +590,7 @@ export function createListsView(deps) {
|
|
|
565
590
|
const listed = allViewSessions();
|
|
566
591
|
const q = filterQuery();
|
|
567
592
|
const { all, shown, delegates } = allViewFamilies(listed, sessions(), {
|
|
568
|
-
query: q, includeArchived:
|
|
593
|
+
query: q, includeArchived: archivedOn(), matches: (s) => matchesFilter(s, q, cardTitle(s)),
|
|
569
594
|
});
|
|
570
595
|
noteChainFamilies(all);
|
|
571
596
|
const fams = listOrder(allListEl, shown, familyId);
|
|
@@ -684,6 +709,7 @@ export function createListsView(deps) {
|
|
|
684
709
|
|
|
685
710
|
function updateSelectBar() {
|
|
686
711
|
$("#allView").classList.toggle("selecting", selecting.on);
|
|
712
|
+
$("#allHead").classList.toggle("selecting", selecting.on); // the header is in the top bar
|
|
687
713
|
$("#selectBtn").hidden = selecting.on;
|
|
688
714
|
const bar = $("#selectBar");
|
|
689
715
|
bar.hidden = !selecting.on;
|
|
@@ -770,7 +796,8 @@ export function createListsView(deps) {
|
|
|
770
796
|
if (selecting.on && e.shiftKey) e.preventDefault();
|
|
771
797
|
});
|
|
772
798
|
|
|
773
|
-
$("#includeArchived").addEventListener("
|
|
799
|
+
$("#includeArchived").addEventListener("click", (e) => {
|
|
800
|
+
e.currentTarget.setAttribute("aria-pressed", String(!archivedOn()));
|
|
774
801
|
if (allViewOpen()) renderAll();
|
|
775
802
|
});
|
|
776
803
|
$("#filter").addEventListener("input", () => {
|
|
@@ -78,13 +78,13 @@ export function rowModel(grant, now) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// The chip: "invite" with nobody yet, else the active
|
|
82
|
-
// and a dot while one is here.
|
|
81
|
+
// The chip: "participants · invite" with nobody yet, else the active
|
|
82
|
+
// participants' count and a dot while one is here.
|
|
83
83
|
export function chipModel(grants, now) {
|
|
84
84
|
if (!Array.isArray(grants)) return null;
|
|
85
85
|
const active = grants.map((g) => rowModel(g, now)).filter((row) => row.active);
|
|
86
86
|
if (active.length === 0) {
|
|
87
|
-
return { text: "invite", count: 0, present: false, title: "Invite a participant to review this session" };
|
|
87
|
+
return { text: "participants · invite", count: 0, present: false, title: "Invite a participant to review this session" };
|
|
88
88
|
}
|
|
89
89
|
const present = active.filter((row) => row.present).map((row) => row.name);
|
|
90
90
|
const names = active.map((row) => row.name).join(", ");
|
|
@@ -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
|
+
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// verdict cards, the recap. A time-gap divider between two folded stubs
|
|
12
12
|
// folds into the later stub (it shows the divider's words);
|
|
13
13
|
// - the top answer gets a version chip, "v3 · 2 earlier ▾", listing the
|
|
14
|
-
// versions by their prompts; picking one reveals its stub;
|
|
14
|
+
// versions by their prompts; picking one reveals its stub; and a ‹ 3/3 ›
|
|
15
|
+
// switcher before it shows an earlier version's answer in the top
|
|
16
|
+
// answer's place (revise_switch.js; view only, not stored);
|
|
15
17
|
// - the final answer of an ended turn (verify_target.js's rule, a delegate
|
|
16
18
|
// report's reply excluded), unstacked or a stack's top, and not a Verify
|
|
17
19
|
// turn's, gets a Revise button: it arms the composer (app.js).
|
|
@@ -28,6 +30,7 @@ import { bubbleCopyText } from "./copy.js";
|
|
|
28
30
|
import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
|
|
29
31
|
import { clip } from "./format.js";
|
|
30
32
|
import { NOTE_BOUNDARY } from "./turn_range.js";
|
|
33
|
+
import { SWAPPED_CLASS, VIEW_CLASS, stepVersion, switcherTemplate, viewOf } from "./revise_switch.js";
|
|
31
34
|
|
|
32
35
|
// The classes and attributes this module puts on the history.
|
|
33
36
|
export const HIDDEN_CLASS = "stack-hidden";
|
|
@@ -146,7 +149,7 @@ export function historyTurns(historyEl) {
|
|
|
146
149
|
while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
|
|
147
150
|
};
|
|
148
151
|
for (const el of historyEl.children) {
|
|
149
|
-
if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
|
|
152
|
+
if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS) || el.classList.contains(VIEW_CLASS)) continue;
|
|
150
153
|
if (el.classList.contains(FOLD_ROW) && el.nextElementSibling?.matches(OPENING)) continue;
|
|
151
154
|
const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
|
|
152
155
|
if (opening || el.matches(NOTE_BOUNDARY)) {
|
|
@@ -187,6 +190,10 @@ export function installReviseStacks({
|
|
|
187
190
|
const rows = new Map();
|
|
188
191
|
// Each row's answer bubble (its label at a click).
|
|
189
192
|
const rowAnswer = new WeakMap();
|
|
193
|
+
// The switcher: each stack's version on view (0-based, by its top's id;
|
|
194
|
+
// none: the latest), and the copy shown for it ({el, source, hidden}).
|
|
195
|
+
const shown = new Map();
|
|
196
|
+
const views = new Map();
|
|
190
197
|
let observer = null;
|
|
191
198
|
|
|
192
199
|
function stubFor(id, opening, stored) {
|
|
@@ -234,7 +241,8 @@ export function installReviseStacks({
|
|
|
234
241
|
: nothing;
|
|
235
242
|
const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
|
|
236
243
|
row.classList.toggle("stacked", !!chain);
|
|
237
|
-
|
|
244
|
+
const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
|
|
245
|
+
render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
|
|
238
246
|
rowAnswer.set(row, answer);
|
|
239
247
|
}
|
|
240
248
|
|
|
@@ -287,6 +295,7 @@ export function installReviseStacks({
|
|
|
287
295
|
keepRows.add(turn.id);
|
|
288
296
|
drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
|
|
289
297
|
}
|
|
298
|
+
syncViews(tops, keepRows, new Map(turns.filter((t) => t.id).map((t) => [t.id, t.answer])));
|
|
290
299
|
// A divider between two folded stubs folds into the later one.
|
|
291
300
|
for (const turn of turns) {
|
|
292
301
|
if (!turn.stub) continue;
|
|
@@ -307,13 +316,67 @@ export function installReviseStacks({
|
|
|
307
316
|
for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
|
|
308
317
|
for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
|
|
309
318
|
// A stale row left by a re-render (its map entry gone with the nodes).
|
|
310
|
-
|
|
311
|
-
|
|
319
|
+
const viewEls = new Set([...views.values()].map((v) => v.el));
|
|
320
|
+
for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}, :scope > .${VIEW_CLASS}`)) {
|
|
321
|
+
if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el && !viewEls.has(el)) el.remove();
|
|
312
322
|
}
|
|
313
323
|
// What we changed isn't news.
|
|
314
324
|
observer?.takeRecords();
|
|
315
325
|
}
|
|
316
326
|
|
|
327
|
+
// The switcher's copies: a stack on an earlier version (its top's row
|
|
328
|
+
// drawn, that version's answer there) shows a copy of that answer just
|
|
329
|
+
// before the top answer and its fold row, which hide; any other stack's
|
|
330
|
+
// copy goes and its top shows again.
|
|
331
|
+
function syncViews(tops, keepRows, answers) {
|
|
332
|
+
const wanted = (id) => {
|
|
333
|
+
const chain = tops.get(id);
|
|
334
|
+
const k = shown.get(id);
|
|
335
|
+
if (!chain || k === undefined || k >= chain.length - 1 || !keepRows.has(id)) return null;
|
|
336
|
+
const source = answers.get(chain[k]);
|
|
337
|
+
const top = answers.get(id);
|
|
338
|
+
return source && top ? { source, top, version: k + 1 } : null;
|
|
339
|
+
};
|
|
340
|
+
for (const [id, view] of views) {
|
|
341
|
+
const want = wanted(id);
|
|
342
|
+
if (want && view.source === want.source && view.top === want.top && view.el.isConnected) continue;
|
|
343
|
+
view.el.remove();
|
|
344
|
+
for (const el of view.hidden) el.classList.remove(SWAPPED_CLASS);
|
|
345
|
+
views.delete(id);
|
|
346
|
+
}
|
|
347
|
+
for (const id of [...shown.keys()]) {
|
|
348
|
+
const want = wanted(id);
|
|
349
|
+
if (!want) {
|
|
350
|
+
shown.delete(id);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
let view = views.get(id);
|
|
354
|
+
if (!view) {
|
|
355
|
+
view = { el: viewOf(want.source, want.version), source: want.source, top: want.top, hidden: [] };
|
|
356
|
+
views.set(id, view);
|
|
357
|
+
}
|
|
358
|
+
const head = want.top.previousElementSibling?.classList.contains(FOLD_ROW) ? want.top.previousElementSibling : null;
|
|
359
|
+
for (const el of view.hidden) if (el !== want.top && el !== head) el.classList.remove(SWAPPED_CLASS);
|
|
360
|
+
view.hidden = head ? [want.top, head] : [want.top];
|
|
361
|
+
for (const el of view.hidden) el.classList.add(SWAPPED_CLASS);
|
|
362
|
+
const before = head || want.top;
|
|
363
|
+
if (before.previousElementSibling !== view.el) historyEl.insertBefore(view.el, before);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The switcher's ‹ or › on its row's stack.
|
|
368
|
+
function step(row, delta) {
|
|
369
|
+
const id = row.dataset.reviseFor;
|
|
370
|
+
const count = row.querySelectorAll("[data-stack-pick]").length;
|
|
371
|
+
if (!id || count < 2) return;
|
|
372
|
+
const was = shown.get(id) ?? count - 1;
|
|
373
|
+
const k = stepVersion(was, count, delta);
|
|
374
|
+
if (k === was) return;
|
|
375
|
+
if (k === count - 1) shown.delete(id);
|
|
376
|
+
else shown.set(id, k);
|
|
377
|
+
keepRowInView(historyEl, row, decorate);
|
|
378
|
+
}
|
|
379
|
+
|
|
317
380
|
function setOpen(stub, open, { save }) {
|
|
318
381
|
keepRowInView(historyEl, stub, () => {
|
|
319
382
|
stub.classList.toggle("open", open);
|
|
@@ -326,7 +389,7 @@ export function installReviseStacks({
|
|
|
326
389
|
// answer's key or its end (its class).
|
|
327
390
|
observer = new MutationObserver((records) => {
|
|
328
391
|
const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
|
|
329
|
-
&& [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !
|
|
392
|
+
&& [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && ![STUB_CLASS, ROW_CLASS, VIEW_CLASS].some((c) => n.classList.contains(c)))
|
|
330
393
|
: r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
|
|
331
394
|
if (news) decorate();
|
|
332
395
|
});
|
|
@@ -340,6 +403,13 @@ export function installReviseStacks({
|
|
|
340
403
|
setOpen(stub, !stub.classList.contains("open"), { save: true });
|
|
341
404
|
return;
|
|
342
405
|
}
|
|
406
|
+
const stepper = e.target.closest?.("[data-stack-step]");
|
|
407
|
+
if (stepper) {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
const row = stepper.closest(`.${ROW_CLASS}`);
|
|
410
|
+
if (row && stepper.getAttribute("aria-disabled") !== "true") step(row, Number(stepper.dataset.stackStep));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
343
413
|
const pick = e.target.closest?.("[data-stack-pick]");
|
|
344
414
|
if (pick) {
|
|
345
415
|
e.preventDefault();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The ‹ N/M › switcher on a Revise stack's top answer (revise_stack.js draws
|
|
2
|
+
// it in the top answer's row, before the version chip): the owner reads an
|
|
3
|
+
// earlier version's answer in place of the top one. View only: no server
|
|
4
|
+
// call, nothing stored (a reload shows the latest again).
|
|
5
|
+
//
|
|
6
|
+
// An earlier version is shown as a copy of its answer bubble (the bubble
|
|
7
|
+
// itself stays hidden in its superseded turn): the copy goes just before the
|
|
8
|
+
// top answer (its fold row, answer_fold.js, kept with it), and the top answer
|
|
9
|
+
// and its fold row are hidden by a class while it shows. The copy keeps its
|
|
10
|
+
// kept source (data-copy-source) and its copy buttons, so Copy copies the
|
|
11
|
+
// version on view; it has no answer key (data-answer-turn), so no fold row,
|
|
12
|
+
// claim key or Revise row is drawn for it, and the top answer stays its
|
|
13
|
+
// turn's last answer (verify_target.js, cards_place.js).
|
|
14
|
+
//
|
|
15
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
16
|
+
// export on an old module would blank the page.
|
|
17
|
+
import { html } from "./vendor/lit-html.js";
|
|
18
|
+
|
|
19
|
+
// The copy of an earlier answer on view, and what hides the top one.
|
|
20
|
+
export const VIEW_CLASS = "stack-view";
|
|
21
|
+
export const SWAPPED_CLASS = "stack-swapped";
|
|
22
|
+
|
|
23
|
+
// The switcher's words for version +k+ (0-based) of +count+: the counter
|
|
24
|
+
// ("2/3") and its spoken label ("version 2 of 3").
|
|
25
|
+
export function switchText(k, count) {
|
|
26
|
+
return { text: `${k + 1}/${count}`, label: `version ${k + 1} of ${count}` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Version +k+ moved by +delta+, kept in 0…count-1.
|
|
30
|
+
export function stepVersion(k, count, delta) {
|
|
31
|
+
return Math.min(Math.max(k + delta, 0), Math.max(count - 1, 0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The switcher for the stack +id+ (its top turn's id) showing version +k+
|
|
35
|
+
// of +count+. The buttons stay focusable at either end (aria-disabled, not
|
|
36
|
+
// disabled: a press there keeps the focus where it is).
|
|
37
|
+
export function switcherTemplate(id, k, count) {
|
|
38
|
+
const { text, label } = switchText(k, count);
|
|
39
|
+
const first = k <= 0;
|
|
40
|
+
const last = k >= count - 1;
|
|
41
|
+
return html`<span class="stack-switch" role="group" aria-label="Answer versions" data-stack-switch=${id}><button type="button" class="stack-switch-btn" data-stack-step="-1" aria-label="Previous version" title="Read the previous version" aria-disabled=${first ? "true" : "false"}>‹</button><span class="stack-switch-n" title=${label}><span aria-hidden="true">${text}</span><span class="stack-switch-sr" aria-live="polite">${label}</span></span><button type="button" class="stack-switch-btn" data-stack-step="1" aria-label="Next version" title="Read the next version" aria-disabled=${last ? "true" : "false"}>›</button></span>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A copy of +answer+ (an earlier version's answer bubble) to show in the top
|
|
45
|
+
// answer's place, titled as version +version+: no answer key,
|
|
46
|
+
// none of the history's state classes.
|
|
47
|
+
export function viewOf(answer, version) {
|
|
48
|
+
const view = answer.cloneNode(true);
|
|
49
|
+
for (const attr of ["id", "data-answer-turn", "data-reply-key", "data-stack-of", "data-line-key"]) view.removeAttribute(attr);
|
|
50
|
+
view.classList.remove("stack-hidden", "folded", SWAPPED_CLASS);
|
|
51
|
+
view.classList.add(VIEW_CLASS);
|
|
52
|
+
view.dataset.stackView = String(version);
|
|
53
|
+
for (const btn of view.querySelectorAll(".copy-btn[data-copied]")) delete btn.dataset.copied;
|
|
54
|
+
return view;
|
|
55
|
+
}
|
|
@@ -50,13 +50,13 @@ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "" }
|
|
|
50
50
|
const at = where();
|
|
51
51
|
const allHref = () => allScopeHref(where().hash, scope.dir);
|
|
52
52
|
const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
|
|
53
|
-
if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
|
|
54
|
-
if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
|
|
53
|
+
if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" aria-expanded="false" title=${error}>folder not found</button>${all}`;
|
|
54
|
+
if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
|
|
55
55
|
const dir = scope.dir || scope.serverDir;
|
|
56
56
|
const backHref = () => projectScopeHref(scope.backDir, where().hash);
|
|
57
57
|
const back = scope.backName
|
|
58
58
|
? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
|
|
59
59
|
: nothing;
|
|
60
60
|
const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
|
|
61
|
-
return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
|
|
61
|
+
return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title="Sessions of every project">all projects</button>${back}${newSession}`;
|
|
62
62
|
}
|