chamba 0.6.1 → 0.7.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 (35) hide show
  1. package/README.md +13 -6
  2. package/dist/lib/agent-context.js +33 -7
  3. package/dist/lib/dockerfile-builder.js +2 -1
  4. package/dist/lib/safe-rm.js +13 -3
  5. package/package.json +3 -3
  6. package/templates/Dockerfile +20 -1
  7. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  8. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  9. package/templates/pane-apps/client/specs/index.html +13 -0
  10. package/templates/pane-apps/server/specs.mjs +1588 -0
  11. package/templates/skills/dx-spec/SKILL.md +365 -0
  12. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  13. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  14. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  15. package/templates/skills/dx-spec/references/stages.md +399 -0
  16. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  17. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  18. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  19. package/templates/specs.sh +106 -0
  20. package/templates/webterm/README.md +42 -6
  21. package/templates/webterm/config.js +43 -0
  22. package/templates/webterm/public/app/composer.js +4 -1
  23. package/templates/webterm/public/app/dom.js +13 -5
  24. package/templates/webterm/public/app/frames.js +7 -0
  25. package/templates/webterm/public/app/main.js +7 -1
  26. package/templates/webterm/public/app/pane-shell.js +315 -0
  27. package/templates/webterm/public/app/pane.js +58 -183
  28. package/templates/webterm/public/app/specs-host.js +222 -0
  29. package/templates/webterm/public/app/terminal.js +8 -0
  30. package/templates/webterm/public/index.html +51 -27
  31. package/templates/webterm/public/styles.css +144 -30
  32. package/templates/webterm/server.js +273 -0
  33. package/templates/webterm/specs.js +358 -0
  34. package/templates/webterm/tool-document.js +67 -0
  35. package/templates/webterm/typed-line.js +85 -0
@@ -0,0 +1,315 @@
1
+ // pane-shell.js - the pane itself: which tool is open in it, and how wide it is.
2
+ //
3
+ // The pane is furniture. What sits in it are tools - Pages (pane.js), Specs (specs-host.js) - and the line
4
+ // between them is the whole point of this module: a tool draws inside its own panel and nothing else, while
5
+ // the tab bar that says which tool is open, and the controls that say how wide the pane is, are drawn out
6
+ // here. A tool can therefore never move the pane, and never lie about which tool the user is looking at.
7
+ //
8
+ // Width has three states, and only one of them is a width. Between the two ends the divider drags anything,
9
+ // clamped as it always was. Past the near end the pane is put away and becomes the spine. Past the far end it
10
+ // takes the whole row and the terminal is squeezed out - which is a state rather than a wider drag, so the
11
+ // drag's own clamps stay where they are. The grip is reachable in every one of them, and dragging it is
12
+ // always the way back.
13
+ //
14
+ // What the pane is belongs to the window - how wide, which tool, how big a page is drawn - and survives a
15
+ // session switch and a reload. What is in a tool belongs to the session, and the tool keeps that itself.
16
+ //
17
+ // This module imports nothing but dom.js, deliberately: the tools import it and register themselves as they
18
+ // load, so it has to be finished before any of them runs.
19
+
20
+ import { grip, pane, panebar, paneCollapse, paneFull, spine, spineBadge, toolPages, toolSpecs, toolTabs } from "./dom.js";
21
+
22
+ // The tools, in the order their tabs are drawn. A tool is a tab, a panel, and a render function it hands over
23
+ // when it loads; the pane knows nothing else about any of them.
24
+ export const PAGES = "pages";
25
+ export const SPECS = "specs";
26
+ const TOOLS = [
27
+ { id: PAGES, label: "Pages", panel: toolPages },
28
+ { id: SPECS, label: "Specs", panel: toolSpecs },
29
+ ];
30
+
31
+ // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
32
+ // threshold, and the width the spine itself takes.
33
+ const COLLAPSE_AT = 150;
34
+ // What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
35
+ const DEFAULT_FRACTION = 0.44;
36
+ const MAX_FRACTION = 0.7;
37
+ // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
38
+ // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
39
+ const MIN_OPEN_FRACTION = 1 / 3;
40
+ // The window's own pane preferences: how wide, whether it is collapsed, whether it has the whole row, which
41
+ // tool is open, and how big a page is drawn in it. Not the session's - a pane is furniture, and furniture
42
+ // does not move because you looked at another tab.
43
+ const GEOMETRY_KEY = "webterm-pane";
44
+
45
+ let width = null;
46
+ // null means nobody has said. An untouched pane keeps out of the way while the tool it is open on has nothing
47
+ // in it, and is open once that tool has something - which is the first page of a session opening on arrival,
48
+ // as it always did. A click on the spine or on the collapse button is an answer, and from then on it is the
49
+ // only one that counts.
50
+ let collapsed = null;
51
+ // The far end: the pane has the whole row and the terminal is squeezed out.
52
+ let full = false;
53
+ let openTool = PAGES;
54
+ // How big a page is drawn. The pane applies it, so it is stored here with the rest of the furniture, and the
55
+ // Pages tool owns the control and the steps.
56
+ let pageSize = null;
57
+ // What the last render decided, for the two things that only need to know whether the pane is showing.
58
+ let shut = true;
59
+
60
+ // id -> what that tool has to say for itself on its tab: how many things are waiting in it, and whether it
61
+ // has anything at all. A tool nobody has heard from yet is empty and waiting on nothing.
62
+ const EMPTY_MARK = { unread: 0, empty: true };
63
+ const marks = new Map();
64
+ // id -> the tool's own render function, handed over as it loads.
65
+ const renderers = new Map();
66
+
67
+ // --- What the window remembers -----------------------------------------------------------------------------------------------------------
68
+
69
+ try {
70
+ const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
71
+ if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
72
+ if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
73
+ if (typeof stored.full === "boolean") full = stored.full;
74
+ if (TOOLS.some((tool) => tool.id === stored.tool)) openTool = stored.tool;
75
+ // The multiplier is stored rather than its position, so a window that was left at a size this version no
76
+ // longer offers falls back to the default instead of landing between two steps.
77
+ if (typeof stored.size === "number") pageSize = stored.size;
78
+ } catch {
79
+ // A disabled or full store only means the pane opens at its default width, on its default tool.
80
+ }
81
+
82
+ function remember() {
83
+ try {
84
+ localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, full, tool: openTool, size: pageSize }));
85
+ } catch {
86
+ // The pane still works for this page's lifetime.
87
+ }
88
+ }
89
+
90
+ /** How big a page was last drawn in this window, or null when nobody has said. */
91
+ export function storedPageSize() {
92
+ return pageSize;
93
+ }
94
+
95
+ /** The Pages tool's zoom, kept with the rest of the pane's furniture so one key has one writer. */
96
+ export function rememberPageSize(size) {
97
+ pageSize = size;
98
+ remember();
99
+ }
100
+
101
+ // --- The tools ---------------------------------------------------------------------------------------------------------------------------
102
+
103
+ /**
104
+ * A tool says how to draw its panel. Called as the tool's module loads, which is why this module imports
105
+ * nothing that could still be half-built at that point.
106
+ */
107
+ export function registerTool(id, render) {
108
+ renderers.set(id, render);
109
+ }
110
+
111
+ /** Whether the pane is open on that tool right now. */
112
+ export function isToolOpen(id) {
113
+ return openTool === id;
114
+ }
115
+
116
+ /**
117
+ * What a tool has to say on its tab: `unread` is what is waiting for the user in it, and `empty` is whether
118
+ * it has anything to show at all - which is what an untouched pane reads to decide whether to stay out of
119
+ * the way.
120
+ */
121
+ export function noteTool(id, { unread = 0, empty = false } = {}) {
122
+ marks.set(id, { unread, empty });
123
+ renderShell();
124
+ }
125
+
126
+ /** Open a tool, drawing its panel. A tool the user chose is the tool this window opens on next time. */
127
+ export function showTool(id) {
128
+ if (!TOOLS.some((tool) => tool.id === id)) return;
129
+ openTool = id;
130
+ remember();
131
+ renderShell();
132
+ renderers.get(id)?.();
133
+ }
134
+
135
+ function drawTabs() {
136
+ const tabs = TOOLS.map((tool) => {
137
+ const button = document.createElement("button");
138
+ button.type = "button";
139
+ button.className = tool.id === openTool ? "tool-tab on" : "tool-tab";
140
+ button.dataset.tool = tool.id;
141
+ button.append(tool.label);
142
+ // What is waiting in a tool nobody is looking at. The one thing a tab says beyond its name, so a tool
143
+ // never has to take the screen to be noticed.
144
+ const waiting = (marks.get(tool.id) ?? EMPTY_MARK).unread;
145
+ if (waiting > 0) {
146
+ const badge = document.createElement("span");
147
+ badge.className = "tool-mark";
148
+ badge.textContent = String(waiting);
149
+ button.append(badge);
150
+ }
151
+ button.addEventListener("click", () => showTool(tool.id));
152
+ return button;
153
+ });
154
+ toolTabs.replaceChildren(...tabs);
155
+ for (const tool of TOOLS) tool.panel.hidden = tool.id !== openTool;
156
+ }
157
+
158
+ // --- How wide ----------------------------------------------------------------------------------------------------------------------------
159
+
160
+ // How wide, put away, or the whole row. Its own function because a drag is the one thing that changes the
161
+ // pane without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
162
+ function applyGeometry(collapsedNow = shut) {
163
+ pane.classList.toggle("collapsed", collapsedNow);
164
+ // Only one of the two can be true: a pane that is put away has no width to be full of.
165
+ pane.classList.toggle("full", full && !collapsedNow);
166
+ // The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
167
+ // last word: the collapsed spine, the full row and the narrow-screen layout are rules, and an inline
168
+ // width would beat all three.
169
+ pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
170
+ paneFull.classList.toggle("on", full && !collapsedNow);
171
+ const back = full && !collapsedNow;
172
+ paneFull.title = back ? "Give the terminal its room back" : "Full width";
173
+ paneFull.setAttribute("aria-label", paneFull.title);
174
+ }
175
+
176
+ /** Draw the pane's own chrome: the tabs, the width, and what the spine says while the pane is away. */
177
+ export function renderShell() {
178
+ const mark = marks.get(openTool) ?? EMPTY_MARK;
179
+ shut = collapsed === null ? mark.empty : collapsed;
180
+ pane.hidden = false;
181
+ // There is no width worth choosing for a tool with nothing in it.
182
+ grip.hidden = mark.empty && shut;
183
+
184
+ applyGeometry(shut);
185
+ drawTabs();
186
+
187
+ // The spine stands for the whole pane, so it carries what every tool is waiting on, not just the open one.
188
+ let waiting = 0;
189
+ for (const entry of marks.values()) waiting += entry.unread;
190
+ spineBadge.textContent = waiting > 0 ? String(waiting) : "";
191
+ spineBadge.hidden = waiting === 0;
192
+ }
193
+
194
+ /**
195
+ * Draw the pane for the first time, on the tool this window was left on. Called from the entry rather than
196
+ * here, because the tools register as they load and there is no pane to draw until they all have.
197
+ */
198
+ export function mountPane() {
199
+ renderShell();
200
+ renderers.get(openTool)?.();
201
+ }
202
+
203
+ /** Whether the pane is drawn as the spine rather than as a pane. */
204
+ export function isPaneShut() {
205
+ return shut;
206
+ }
207
+
208
+ /** Whether the user put the pane away, as opposed to never having said. */
209
+ export function isPaneCollapsed() {
210
+ return collapsed === true;
211
+ }
212
+
213
+ // Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
214
+ // resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
215
+ export function setPaneCollapsed(next) {
216
+ collapsed = next;
217
+ shut = next;
218
+ if (collapsed) {
219
+ // Coming back to a pane that fills the window, with the terminal nowhere, is not what putting one
220
+ // away asks for. The width it had is remembered; the far end is not.
221
+ full = false;
222
+ } else {
223
+ // Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one
224
+ // from a window remembered narrower than this one, has to open into something a page fits in.
225
+ const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
226
+ if (width !== null && width < floor) width = floor;
227
+ }
228
+ remember();
229
+ renderShell();
230
+ renderers.get(openTool)?.();
231
+ }
232
+
233
+ // The far end, and back. A press either way, and a drag of the grip is the other way back.
234
+ function setFull(next) {
235
+ full = next;
236
+ if (full) collapsed = false;
237
+ remember();
238
+ renderShell();
239
+ renderers.get(openTool)?.();
240
+ }
241
+
242
+ paneFull.addEventListener("click", () => setFull(!full));
243
+ paneCollapse.addEventListener("click", () => setPaneCollapsed(true));
244
+ spine.addEventListener("click", () => setPaneCollapsed(false));
245
+
246
+ // --- Saying that something arrived -------------------------------------------------------------------------------------------------------
247
+
248
+ // One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
249
+ const ARRIVAL_MS = 1_000;
250
+ let arrivalTimer = null;
251
+
252
+ // The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
253
+ // is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
254
+ // the two last the same moment.
255
+ export function flashPane() {
256
+ clearTimeout(arrivalTimer);
257
+ // A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
258
+ // notice, on again. Reading a layout property is what makes the restart real.
259
+ pane.classList.remove("arriving");
260
+ void pane.offsetWidth;
261
+ pane.classList.add("arriving");
262
+ arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
263
+ }
264
+
265
+ // --- The divider -------------------------------------------------------------------------------------------------------------------------
266
+
267
+ // Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
268
+ // spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
269
+ // a page is a document with its own event loop, and without capture the pointer would be lost in it.
270
+ let dragging = false;
271
+ // The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
272
+ // for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
273
+ // pointer passed through on the way there.
274
+ let widthBeforeDrag = null;
275
+
276
+ grip.addEventListener("pointerdown", (event) => {
277
+ dragging = true;
278
+ widthBeforeDrag = width;
279
+ document.body.classList.add("dragging");
280
+ grip.setPointerCapture(event.pointerId);
281
+ });
282
+
283
+ grip.addEventListener("pointermove", (event) => {
284
+ if (!dragging) return;
285
+ // A drag is a width, so it is the way out of the far end as well as the way into any other width.
286
+ full = false;
287
+ const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
288
+ if (wanted < COLLAPSE_AT) {
289
+ collapsed = true;
290
+ width = widthBeforeDrag;
291
+ } else {
292
+ collapsed = false;
293
+ width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
294
+ }
295
+ // A drag is an answer too, so what it decides is what the next render starts from.
296
+ shut = collapsed;
297
+ applyGeometry(shut);
298
+ });
299
+
300
+ function endDrag() {
301
+ if (!dragging) return;
302
+ dragging = false;
303
+ document.body.classList.remove("dragging");
304
+ remember();
305
+ // A drag that crossed the threshold changed which of the three states the pane is in, and the tool that
306
+ // is open in it has a bar and a document to lay out again at the new width.
307
+ renderShell();
308
+ renderers.get(openTool)?.();
309
+ }
310
+
311
+ grip.addEventListener("pointerup", endDrag);
312
+ grip.addEventListener("pointercancel", endDrag);
313
+
314
+ // The bar is chrome: a click on it must not leave the terminal without focus for no reason.
315
+ panebar.addEventListener("mousedown", (event) => event.preventDefault());
@@ -1,58 +1,47 @@
1
- // pane.js - the web pane: the pages an agent published, beside the terminal it published them from.
1
+ // pane.js - the Pages tool: the pages an agent published, beside the terminal it published them from.
2
2
  //
3
- // The pane is chrome this interface draws - the chips bar, the counter, the spine - around a page it does
4
- // not trust (pane-frame.js renders that part). Keeping the two apart is the point: a published page can
5
- // never fake which session and which page the user is looking at, because everything that says so is drawn
6
- // outside its frame.
3
+ // One of the tools the pane holds (pane-shell.js holds them), and the whole of what this module draws is its
4
+ // own panel - the chips bar, the counter, and the page. The pane's own furniture, the tab bar and the width,
5
+ // is the shell's, so nothing here can move the pane or say which tool the user is looking at.
7
6
  //
8
- // What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
9
- // the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, whether it
10
- // is collapsed, and how big a page is drawn in it - and that survives the switch, and a reload.
7
+ // The chips bar is chrome this interface draws around a page it does not trust (pane-frame.js renders that
8
+ // part). Keeping the two apart is the point: a published page can never fake which session and which page the
9
+ // user is looking at, because everything that says so is drawn outside its frame.
10
+ //
11
+ // What this tool shows belongs to the attached session, and it follows the tabs: switching sessions switches
12
+ // the list, the selection, and the page.
11
13
  //
12
14
  // A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
13
15
  // keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
14
16
  // "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
15
17
  // reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
16
18
  // it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
17
- // (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
19
+ // (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits. A page that lands
20
+ // while the pane is open on another tool marks the Pages tab and waits there: an arrival is worth saying, and
21
+ // never worth taking the screen for.
18
22
  //
19
- // The pane exists before any of that. A session that has published nothing still has its spine, and opening it
23
+ // The tool exists before any of that. A session that has published nothing still has its tab, and opening it
20
24
  // says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
21
25
  // one is asked for in words rather than found in a menu.
22
26
 
23
27
  import { sendFrame } from "./connection.js";
24
- import {
25
- chips,
26
- grip,
27
- pageBigger,
28
- pagebar,
29
- pageCount,
30
- pageDoc,
31
- pageSizeVal,
32
- pageSmaller,
33
- pane,
34
- paneCollapse,
35
- paneNext,
36
- panePrev,
37
- spine,
38
- spineBadge,
39
- } from "./dom.js";
28
+ import { chips, pageBigger, pagebar, pageCount, pageDoc, pageSizeVal, pageSmaller, paneNext, panePrev, toolPages } from "./dom.js";
40
29
  import { arrivingPage } from "./pane-arrival.js";
41
30
  import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
31
+ import {
32
+ flashPane,
33
+ isPaneCollapsed,
34
+ isPaneShut,
35
+ isToolOpen,
36
+ noteTool,
37
+ PAGES,
38
+ registerTool,
39
+ rememberPageSize,
40
+ setPaneCollapsed,
41
+ storedPageSize,
42
+ } from "./pane-shell.js";
42
43
  import { attachedSid } from "./state.js";
43
44
 
44
- // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
45
- // threshold, and the width the spine itself takes.
46
- const COLLAPSE_AT = 150;
47
- // What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
48
- const DEFAULT_FRACTION = 0.44;
49
- const MAX_FRACTION = 0.7;
50
- // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
51
- // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
52
- const MIN_OPEN_FRACTION = 1 / 3;
53
- // The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
54
- // Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
55
- const GEOMETRY_KEY = "webterm-pane";
56
45
  // The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
57
46
  // deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
58
47
  // wants to get there in two presses.
@@ -66,39 +55,13 @@ const AGE_TICK_MS = 30_000;
66
55
  // heard about, so switching back to a tab lands on the page it was left on.
67
56
  const panes = new Map();
68
57
 
69
- let width = null;
70
- // null means nobody has said. An untouched pane keeps out of the way while its session has nothing in it, and
71
- // is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
72
- // the spine or on the collapse button is an answer, and from then on it is the only one that counts.
73
- let collapsed = null;
74
- // Which of TEXT_SIZES the shown page is drawn at.
75
- let sizeStep = DEFAULT_SIZE_STEP;
76
- // What the last render decided, for the two things that only need to know whether the pane is showing.
77
- let shut = true;
78
-
79
- // --- What the window remembers -----------------------------------------------------------------------------------------------------------
80
-
81
- try {
82
- const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
83
- if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
84
- if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
85
- // The multiplier is stored rather than its position, so a window that was left at a size this version no
86
- // longer offers falls back to the default instead of landing between two steps.
87
- const step = TEXT_SIZES.indexOf(stored.size);
88
- if (step !== -1) sizeStep = step;
89
- } catch {
90
- // A disabled or full store only means the pane opens at its default width.
91
- }
92
-
93
- function rememberGeometry() {
94
- try {
95
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
96
- } catch {
97
- // The pane still works for this page's lifetime.
98
- }
99
- }
58
+ // Which of TEXT_SIZES the shown page is drawn at. The multiplier is what the pane stores, so a window that
59
+ // was left at a size this version no longer offers falls back to the default instead of landing between two
60
+ // steps.
61
+ const storedStep = TEXT_SIZES.indexOf(storedPageSize());
62
+ let sizeStep = storedStep === -1 ? DEFAULT_SIZE_STEP : storedStep;
100
63
 
101
- // --- The pane's own state ----------------------------------------------------------------------------------------------------------------
64
+ // --- The tool's own state ----------------------------------------------------------------------------------------------------------------
102
65
 
103
66
  function paneFor(sid) {
104
67
  let entry = panes.get(sid);
@@ -122,14 +85,16 @@ export function applyPages(msg) {
122
85
  // The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
123
86
  // and so is what a burst of publishes does under it.
124
87
  const arrived = arrivingPage(entry.pages);
88
+ // It is selected either way, so the tab the user comes back to is on the page that arrived. Only the
89
+ // saying-so is held back while another tool has the pane.
125
90
  if (arrived !== null) {
126
91
  select(msg.sid, arrived.id, { user: false });
127
- flashArrival();
92
+ if (isToolOpen(PAGES)) flashPane();
128
93
  }
129
94
  // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
130
95
  // has said, already opens the pane when its session stops being empty, and settling it here would open
131
96
  // the pane in every other empty session in this window too.
132
- if (arrived !== null && collapsed === true) setCollapsed(false);
97
+ if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
133
98
  else render();
134
99
  }
135
100
 
@@ -141,7 +106,7 @@ export function prunePanes(live) {
141
106
  }
142
107
 
143
108
  /**
144
- * Draw the pane for whatever session this window is now on, asking for its pages when this window has never
109
+ * Draw the tool for whatever session this window is now on, asking for its pages when this window has never
145
110
  * been told them. The server pushes the list when a window lands on a session, so this covers the frame that
146
111
  * did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
147
112
  */
@@ -257,25 +222,16 @@ function chipFor(sid, page, selected) {
257
222
  return chip;
258
223
  }
259
224
 
260
- // How wide, and collapsed or not. Its own function because a drag is the one thing that changes the pane
261
- // without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
262
- function applyGeometry(collapsedNow = shut) {
263
- pane.classList.toggle("collapsed", collapsedNow);
264
- // The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
265
- // last word: the collapsed spine and the narrow-screen layout are rules, and an inline width would beat
266
- // both of them.
267
- pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
268
- }
269
-
270
225
  // --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
271
226
 
272
- // The size is a property on the pane, and the stylesheet zooms the frame from it. Two reasons, both structural:
273
- // the frame's document has an opaque origin and the shell cannot reach into it, and renderPage() throws the
274
- // iframe away and builds a fresh one on every chip click, so anything set on the frame itself would have to be
275
- // put back on every one of those paths. Setting it out here costs nothing and cannot be missed.
227
+ // The size is a property on this tool's panel, and the stylesheet zooms the frame from it. Two reasons, both
228
+ // structural: the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
229
+ // throws the iframe away and builds a fresh one on every chip click, so anything set on the frame itself
230
+ // would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
231
+ // missed.
276
232
  function applyTextSize() {
277
233
  const size = TEXT_SIZES[sizeStep];
278
- pane.style.setProperty("--page-zoom", String(size));
234
+ toolPages.style.setProperty("--page-zoom", String(size));
279
235
  pageSizeVal.textContent = `${Math.round(size * 100)}%`;
280
236
  // A control that can do nothing says so, rather than swallowing the press.
281
237
  pageSmaller.disabled = sizeStep === 0;
@@ -287,49 +243,25 @@ function stepTextSize(by) {
287
243
  if (next === sizeStep) return;
288
244
  sizeStep = next;
289
245
  applyTextSize();
290
- rememberGeometry();
246
+ rememberPageSize(TEXT_SIZES[sizeStep]);
291
247
  }
292
248
 
293
249
  pageSmaller.addEventListener("click", () => stepTextSize(-1));
294
250
  pageBigger.addEventListener("click", () => stepTextSize(1));
295
251
  applyTextSize();
296
252
 
297
- // --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
298
-
299
- // One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
300
- const ARRIVAL_MS = 1_000;
301
- let arrivalTimer = null;
302
-
303
- // The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
304
- // is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
305
- // the two last the same moment.
306
- function flashArrival() {
307
- clearTimeout(arrivalTimer);
308
- // A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
309
- // notice, on again. Reading a layout property is what makes the restart real.
310
- pane.classList.remove("arriving");
311
- void pane.offsetWidth;
312
- pane.classList.add("arriving");
313
- arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
314
- }
315
-
316
253
  function render() {
317
254
  const sid = attachedSid;
318
255
  const entry = sid === null ? null : (panes.get(sid) ?? null);
319
256
  const pages = entry?.pages ?? [];
320
257
  const unread = pages.filter((page) => page.unread).length;
321
258
 
322
- // Nothing published in this session, and nothing to say about it. The pane stays - as the spine, until
323
- // someone opens it - because a pane that only appears once an agent has used it is one nobody knows to
324
- // ask for. The grip is the part that goes: there is no width worth choosing for a page that does not exist.
259
+ // Nothing published in this session, and nothing to say about it. The tab stays - and the pane stays as
260
+ // the spine, until someone opens it - because a pane that only appears once an agent has used it is one
261
+ // nobody knows to ask for. Saying so is the shell's business: the tab's mark, and whether an untouched
262
+ // pane keeps out of the way, are the pane's decisions to take from what its open tool has.
325
263
  const bare = pages.length === 0 && !entry?.notice;
326
- shut = collapsed === null ? bare : collapsed;
327
- pane.hidden = false;
328
- grip.hidden = bare && shut;
329
-
330
- applyGeometry(shut);
331
- spineBadge.textContent = unread > 0 ? String(unread) : "";
332
- spineBadge.hidden = unread === 0;
264
+ noteTool(PAGES, { unread, empty: bare });
333
265
 
334
266
  chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
335
267
  const at = pages.findIndex((page) => page.id === entry?.selected);
@@ -364,75 +296,18 @@ function render() {
364
296
  chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
365
297
  }
366
298
 
299
+ // The tool draws itself whenever the pane asks - a tab click, a width that changed, a session switch.
300
+ registerTool(PAGES, render);
301
+
367
302
  setInterval(() => {
368
- if (!shut) render();
303
+ if (!isPaneShut() && isToolOpen(PAGES)) render();
369
304
  }, AGE_TICK_MS);
370
305
 
371
- // --- The divider, and the spine ----------------------------------------------------------------------------------------------------------
372
-
373
- // Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
374
- // spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
375
- // a page is a document with its own event loop, and without capture the pointer would be lost in it.
376
- let dragging = false;
377
- // The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
378
- // for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
379
- // pointer passed through on the way there.
380
- let widthBeforeDrag = null;
381
-
382
- grip.addEventListener("pointerdown", (event) => {
383
- dragging = true;
384
- widthBeforeDrag = width;
385
- document.body.classList.add("dragging");
386
- grip.setPointerCapture(event.pointerId);
387
- });
388
-
389
- grip.addEventListener("pointermove", (event) => {
390
- if (!dragging) return;
391
- const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
392
- if (wanted < COLLAPSE_AT) {
393
- collapsed = true;
394
- width = widthBeforeDrag;
395
- } else {
396
- collapsed = false;
397
- width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
398
- }
399
- // A drag is an answer too, so what it decides is what the next render starts from.
400
- shut = collapsed;
401
- applyGeometry(shut);
402
- });
403
-
404
- function endDrag() {
405
- if (!dragging) return;
406
- dragging = false;
407
- document.body.classList.remove("dragging");
408
- rememberGeometry();
409
- }
410
-
411
- grip.addEventListener("pointerup", endDrag);
412
- grip.addEventListener("pointercancel", endDrag);
413
-
414
- // Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
415
- // resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
416
- function setCollapsed(next) {
417
- collapsed = next;
418
- shut = next;
419
- // Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one from
420
- // a window remembered narrower than this one, has to open into something a page fits in.
421
- if (!collapsed) {
422
- const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
423
- if (width !== null && width < floor) width = floor;
424
- }
425
- rememberGeometry();
426
- render();
427
- }
428
-
429
- paneCollapse.addEventListener("click", () => setCollapsed(true));
430
- spine.addEventListener("click", () => setCollapsed(false));
431
-
432
- // The chips bar scrolls by roughly a bar's worth, which is what the arrows in the mock do.
433
- panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
434
- paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
435
306
  // A bar that fits at one width may not at another, and the arrows only exist when it does not.
436
307
  window.addEventListener("resize", render);
308
+ // The bar hides its scrollbar, so these are the only way to reach a chip that scrolled out of it. A little
309
+ // under a full bar each time, so the chip at the edge stays on the screen and says where the bar now is.
310
+ panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
311
+ paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
437
312
  // The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
438
313
  pagebar.addEventListener("mousedown", (event) => event.preventDefault());