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,222 @@
1
+ // specs-host.js - the Specs tool: the spec workspace, in a frame of its own.
2
+ //
3
+ // The tool's client is not part of this interface. It is built elsewhere and rendered here in a frame with an
4
+ // opaque origin, the same way a published page is, so the shell's key never enters it and one rendering bug
5
+ // can leak spec reading at worst. This module is the host around that frame: it owns the panel, the frame,
6
+ // and the one message that gives the frame what it needs to ask the server anything.
7
+ //
8
+ // The handover is the whole point of this file. The frame gets a Specs-scoped key that opens the tool's data
9
+ // routes and a path prefix that opens workspace files, and it gets them once, unprompted, on the load of the
10
+ // document this shell put there. It is never handed out on request: a window object survives a navigation, so
11
+ // a document that navigated itself into this frame would ask with the same identity as the one that was
12
+ // loaded. A second load is therefore read as a navigation, and the frame is thrown away and built again.
13
+
14
+ import { sendFrame } from "./connection.js";
15
+ import { toolSpecs } from "./dom.js";
16
+ import { noteTool, registerTool, SPECS } from "./pane-shell.js";
17
+ import { KEY_HEADER, WEB_KEY } from "./state.js";
18
+
19
+ // Where the built client is served from, and where the shell asks for the frame's credentials. The second one
20
+ // is behind the master key in a header, which is the shell's own gate and never the frame's.
21
+ const FRAME_SRC = "/pane-apps/specs/index.html";
22
+ const KEYS_URL = "/specs/keys";
23
+
24
+ // What the shell and the frame call each other's messages. Namespaced because a frame may be talking to
25
+ // something else of its own, and every message is checked against the frame it must come from.
26
+ const HANDOVER = "specs:credentials";
27
+ const CHANGE = "specs:change";
28
+ const OPEN = "specs:open";
29
+ const DELIVER = "specs:deliver";
30
+ const WAITING = "specs:waiting";
31
+ const DELIVERED = "specs:delivered";
32
+
33
+ // How many times a frame that navigates itself is rebuilt before the tab says so instead. A client that keeps
34
+ // leaving the document it was given is broken or is being driven, and either way rebuilding it for ever is a
35
+ // spinning window rather than a recovery.
36
+ const MAX_REBUILDS = 3;
37
+
38
+ let frame = null;
39
+ let handed = false;
40
+ let rebuilds = 0;
41
+ let credentials = null;
42
+ let asking = false;
43
+ let failure = null;
44
+ // How many things are waiting for the user inside the tool. Kept here rather than in the frame, because the
45
+ // mark has to hold while another tool is open and the frame is not being drawn.
46
+ let waiting = 0;
47
+
48
+ /** Fetch the frame's credentials. The shell holds the master key; this is the only route it opens for Specs. */
49
+ async function fetchCredentials() {
50
+ if (asking) return;
51
+ asking = true;
52
+ try {
53
+ const response = await fetch(KEYS_URL, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
54
+ if (!response.ok) throw new Error(String(response.status));
55
+ const answer = await response.json();
56
+ if (typeof answer?.key !== "string" || typeof answer?.raw !== "string") throw new Error("no credentials");
57
+ credentials = answer;
58
+ failure = null;
59
+ } catch {
60
+ failure = "The Specs tool could not be reached. The next time this tab is opened it tries again.";
61
+ } finally {
62
+ asking = false;
63
+ }
64
+ // Drawn whether the tab is open or not. The panel is off the screen when it is not, so nothing is shown
65
+ // either way, but this is the step that puts the frame up - and the tool has to be running to say what
66
+ // is waiting for the user, wherever in the pane that user happens to be.
67
+ render();
68
+ }
69
+
70
+ /** Put a fresh frame in the panel, replacing whatever was there. */
71
+ function build() {
72
+ const next = document.createElement("iframe");
73
+ next.className = "tool-frame";
74
+ // The whole boundary in one attribute: scripts and forms, and no same-origin, so the document has an
75
+ // opaque origin and nothing of this window is reachable from inside it - not the DOM, not storage, and
76
+ // not the URL that carries the master key. Set before the src, since the sandbox of a frame that already
77
+ // has a document is not what a later attribute says it is.
78
+ next.setAttribute("sandbox", "allow-scripts allow-forms");
79
+ // A frame inherits its embedder's referrer, and this window's URL carries the master key - both as
80
+ // `document.referrer` inside the frame and as the `Referer` header on everything it loads.
81
+ next.setAttribute("referrerpolicy", "no-referrer");
82
+ next.setAttribute("title", "Specs");
83
+ next.addEventListener("load", () => onLoad(next));
84
+ next.src = FRAME_SRC;
85
+ frame = next;
86
+ handed = false;
87
+ toolSpecs.replaceChildren(next);
88
+ }
89
+
90
+ /** The frame finished loading a document. The first one is the client; a second one is a navigation. */
91
+ function onLoad(loaded) {
92
+ if (loaded !== frame) return;
93
+ if (!handed) {
94
+ handed = true;
95
+ loaded.contentWindow?.postMessage({ t: HANDOVER, key: credentials.key, raw: credentials.raw }, "*");
96
+ return;
97
+ }
98
+ if (rebuilds >= MAX_REBUILDS) {
99
+ frame = null;
100
+ failure = "The Specs tool kept leaving the page it was given, so it was stopped.";
101
+ render();
102
+ return;
103
+ }
104
+ rebuilds += 1;
105
+ build();
106
+ }
107
+
108
+ /** One sentence in the middle of the panel: what the tab is doing, or why it is doing nothing. */
109
+ function say(line) {
110
+ const wrap = document.createElement("div");
111
+ wrap.className = "pane-intro";
112
+ const title = document.createElement("h2");
113
+ title.textContent = "Specs";
114
+ const lede = document.createElement("p");
115
+ lede.textContent = line;
116
+ wrap.append(title, lede);
117
+ toolSpecs.replaceChildren(wrap);
118
+ }
119
+
120
+ function render() {
121
+ // The tab always has something to show, so it never asks the pane to keep out of the way. What is on it
122
+ // is what the tool last said is waiting for the user, which holds while another tool is open.
123
+ noteTool(SPECS, { unread: waiting, empty: false });
124
+ if (failure !== null) {
125
+ // Said once and then forgotten, so coming back to the tab is what tries again.
126
+ const line = failure;
127
+ failure = null;
128
+ frame = null;
129
+ say(line);
130
+ return;
131
+ }
132
+ // A frame that is up is left alone: it holds the tool's whole state, and rebuilding it would be a reload
133
+ // every time the user came back to the tab.
134
+ if (frame !== null) return;
135
+ if (credentials === null) {
136
+ say("Opening the specs in this repository.");
137
+ fetchCredentials();
138
+ return;
139
+ }
140
+ build();
141
+ }
142
+
143
+ /** The disk moved under a spec. Passed straight in: the frame decides what is worth re-reading. */
144
+ export function applySpecs(msg) {
145
+ if (frame === null || !handed) return;
146
+ frame.contentWindow?.postMessage({ t: CHANGE, change: msg.change }, "*");
147
+ }
148
+
149
+ /**
150
+ * Whether a message is the tool asking for something, rather than anything else on this window.
151
+ *
152
+ * Two checks, and each one holds without the other. The sender must be the frame's own window, which refuses
153
+ * a workspace document nested inside the tool - it is a frame of its own and can post here. And the message
154
+ * must carry the Specs key, which only the document this shell loaded ever received, so a document that
155
+ * navigated the frame passes the window check and fails this one.
156
+ */
157
+ function fromTool(event, message) {
158
+ if (frame === null || !handed || credentials === null) return false;
159
+ if (event.source !== frame.contentWindow) return false;
160
+ return typeof message.key === "string" && message.key === credentials.key;
161
+ }
162
+
163
+ // A link the tool will not follow itself. The frame has to stay the document this shell put in it, since a
164
+ // navigation would keep the window identity every check here rests on, so an outside link is opened as a
165
+ // window of this interface instead - the web alone, and with nothing of this window carried into it.
166
+ window.addEventListener("message", (event) => {
167
+ const message = event.data;
168
+ if (!message || typeof message !== "object" || message.t !== OPEN) return;
169
+ if (!fromTool(event, message)) return;
170
+ let url;
171
+ try {
172
+ url = new URL(String(message.url));
173
+ } catch {
174
+ return;
175
+ }
176
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
177
+ window.open(url.href, "_blank", "noopener,noreferrer");
178
+ });
179
+
180
+ // The tool saying what it is waiting on. The count is the tool's own, and the mark it puts on the tab is the
181
+ // shell's - which is what makes something awaiting an answer visible from any other tool in the pane.
182
+ window.addEventListener("message", (event) => {
183
+ const message = event.data;
184
+ if (!message || typeof message !== "object" || message.t !== WAITING) return;
185
+ if (!fromTool(event, message)) return;
186
+ const count = Number(message.count);
187
+ waiting = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 99) : 0;
188
+ noteTool(SPECS, { unread: waiting, empty: false });
189
+ });
190
+
191
+ // A delivery: the tool telling the agent beside it that something arrived. It rides this window's own socket,
192
+ // so the server resolves the session from the socket the way every other pane frame does, and nothing here
193
+ // or in the frame names one. What travels is the kind of event, a line of detail, the file the tool wrote,
194
+ // and whether the user agreed to start a session for it.
195
+ window.addEventListener("message", (event) => {
196
+ const message = event.data;
197
+ if (!message || typeof message !== "object" || message.t !== DELIVER) return;
198
+ if (!fromTool(event, message)) return;
199
+ sendFrame({
200
+ t: DELIVER,
201
+ event: String(message.event ?? ""),
202
+ detail: String(message.detail ?? ""),
203
+ path: String(message.path ?? ""),
204
+ create: message.create === true,
205
+ });
206
+ });
207
+
208
+ /** What the server made of the last delivery, passed back to the frame that asked for it. */
209
+ export function applySpecsDelivery(msg) {
210
+ if (frame === null || !handed) return;
211
+ frame.contentWindow?.postMessage({ t: DELIVERED, ok: msg.ok === true, error: msg.error ?? "" }, "*");
212
+ }
213
+
214
+ registerTool(SPECS, render);
215
+
216
+ // The frame is built at startup rather than the first time the tab is opened, because the mark on the tab is
217
+ // the tool's to put there and the tool cannot say anything until it is running. Waiting for the first open
218
+ // would make "something is waiting for you" visible only to a user who already went and looked, which is the
219
+ // one reader who did not need telling - and a window reopened on another tool would show nothing at all.
220
+ // Nothing is drawn by this: the panel is off the screen until its tab is picked, and the frame holds the
221
+ // tool's state from here on, so opening the tab shows what is already there instead of loading it then.
222
+ render();
@@ -87,10 +87,18 @@ term.onData((data) => {
87
87
  // Keep the PTY's window size in step with the rendered terminal. Debounced: a resize storm
88
88
  // (layout settling, pane drag) collapses to one fit + one resize, which cuts down the redraw
89
89
  // artifacts the TUI shows when width changes mid-render.
90
+ //
91
+ // A terminal with no width on the screen is not a narrow terminal, and the PTY is not told about one.
92
+ // The pane at its full width leaves the row nothing, and the fit would answer with its own floor of two
93
+ // columns: the agent's TUI would take that as a real window, reflow its frame and its scrollback into
94
+ // two columns, and keep the mangled scrollback after the pane came back. Holding the last real size
95
+ // instead is what makes "squeezed out rather than hidden" true for the process as well as the layout -
96
+ // nothing is sent while it is out of the row, and the next pass sends the width it returns at.
90
97
  let resizeTimer = null;
91
98
  const resizeObserver = new ResizeObserver(() => {
92
99
  if (resizeTimer) clearTimeout(resizeTimer);
93
100
  resizeTimer = setTimeout(() => {
101
+ if (termEl.clientWidth === 0 || termEl.clientHeight === 0) return;
94
102
  try {
95
103
  fit.fit();
96
104
  sendResize();
@@ -27,34 +27,58 @@
27
27
  <span id="note"></span>
28
28
  </div>
29
29
 
30
- <!-- The web pane, rendered by app/pane.js: the pages this session's agent published, with the chips
31
- bar and the counter drawn out here by the interface, and the page itself inside a sandboxed
32
- frame. Both it and its drag handle are hidden until the session has a page. -->
30
+ <!-- The web pane, rendered by app/pane-shell.js: the tool tabs and the two width controls are the
31
+ pane's own chrome, and one panel per tool sits under them - Pages (app/pane.js) and Specs
32
+ (app/specs-host.js). Both the pane and its drag handle are hidden until there is something in it. -->
33
33
  <div id="grip" hidden></div>
34
34
  <aside id="pane" hidden>
35
- <div id="pagebar">
36
- <button type="button" id="pane-prev" class="pane-nav" title="Older pages" aria-label="Older pages" hidden>&#8249;</button>
37
- <div id="chips"></div>
38
- <button type="button" id="pane-next" class="pane-nav" title="Newer pages" aria-label="Newer pages" hidden>&#8250;</button>
39
- <span id="page-count"></span>
40
- <!-- How big the shown page is drawn, for this browser. Interface furniture: no agent knows it
41
- exists, and a page written before it scales like any other. -->
42
- <span id="page-size">
43
- <button type="button" id="page-smaller" title="Smaller text" aria-label="Smaller text">&#8722;</button>
44
- <span id="page-size-val"></span>
45
- <button type="button" id="page-bigger" title="Larger text" aria-label="Larger text">+</button>
35
+ <!-- The pane's own bar: which tool is open, and how wide the pane is. Nothing in it belongs to a
36
+ tool, so a tool can never move the pane or say which tool the user is looking at. -->
37
+ <div id="panebar">
38
+ <div id="tool-tabs"></div>
39
+ <span id="pane-ctl">
40
+ <!-- The pane's edge to the far wall, and back again. -->
41
+ <button type="button" id="pane-full" class="pane-nav" title="Full width" aria-label="Full width">
42
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
43
+ <path d="M4 5v14" />
44
+ <path d="M20 12H9" />
45
+ <path d="M13 8l-4 4 4 4" />
46
+ </svg>
47
+ </button>
48
+ <!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is
49
+ static chrome, unlike the session bar's icons, which are drawn per frame. -->
50
+ <button type="button" id="pane-collapse" class="pane-nav" title="Collapse the pane" aria-label="Collapse the pane">
51
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
52
+ <path d="M4 12h11" />
53
+ <path d="M11 8l4 4-4 4" />
54
+ <path d="M20 5v14" />
55
+ </svg>
56
+ </button>
46
57
  </span>
47
- <!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is static
48
- chrome, unlike the session bar's icons, which are drawn per frame. -->
49
- <button type="button" id="pane-collapse" class="pane-nav" title="Collapse the web pane" aria-label="Collapse the web pane">
50
- <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51
- <path d="M4 12h11" />
52
- <path d="M11 8l4 4-4 4" />
53
- <path d="M20 5v14" />
54
- </svg>
55
- </button>
56
58
  </div>
57
- <div id="page-doc"></div>
59
+
60
+ <!-- Pages: the pages this session's agent published, with the chips bar and the counter drawn out
61
+ here by the interface, and the page itself inside a sandboxed frame. -->
62
+ <div id="tool-pages" class="tool-panel">
63
+ <div id="pagebar">
64
+ <button type="button" id="pane-prev" class="pane-nav" title="Older pages" aria-label="Older pages" hidden>&#8249;</button>
65
+ <div id="chips"></div>
66
+ <button type="button" id="pane-next" class="pane-nav" title="Newer pages" aria-label="Newer pages" hidden>&#8250;</button>
67
+ <span id="page-count"></span>
68
+ <!-- How big the shown page is drawn, for this browser. Interface furniture: no agent knows
69
+ it exists, and a page written before it scales like any other. -->
70
+ <span id="page-size">
71
+ <button type="button" id="page-smaller" title="Smaller text" aria-label="Smaller text">&#8722;</button>
72
+ <span id="page-size-val"></span>
73
+ <button type="button" id="page-bigger" title="Larger text" aria-label="Larger text">+</button>
74
+ </span>
75
+ </div>
76
+ <div id="page-doc"></div>
77
+ </div>
78
+
79
+ <!-- Specs: the spec workspace, in a frame of its own. -->
80
+ <div id="tool-specs" class="tool-panel" hidden></div>
81
+
58
82
  <button type="button" id="spine" title="Open the web pane" aria-label="Open the web pane">
59
83
  <span class="label"><span id="spine-badge" hidden></span> Web pane</span>
60
84
  </button>
@@ -80,10 +104,10 @@
80
104
  <!-- The one button that sends without being written: it types a fixed sentence into the
81
105
  session, through the same paste path as anything else, and leaves the box alone. Its
82
106
  place in the markup is what puts it above Send: the block fills row by row. -->
83
- <button type="button" id="ask-page" class="icon-btn" title="Put the last answer in the web pane"
84
- aria-label="Put the last answer in the web pane">
107
+ <button type="button" id="ask-page" class="icon-btn" title="Put the last answer on a page"
108
+ aria-label="Put the last answer on a page">
85
109
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
86
- <title>Put the last answer in the web pane</title>
110
+ <title>Put the last answer on a page</title>
87
111
  <rect x="3" y="4" width="18" height="16" rx="2" />
88
112
  <path d="M14 4v16" />
89
113
  <path d="M17 9h1" />
@@ -527,6 +527,13 @@ body {
527
527
  display: flex;
528
528
  flex: 1;
529
529
  min-height: 0;
530
+ /* Stated once because two rules need it: the grip is this wide, and a full-width pane is the row less
531
+ the grip - which is what keeps the grip on the screen with the terminal squeezed out behind it. */
532
+ --grip-width: 12px;
533
+ /* The terminal's own inset, on both sides. A margin does not shrink with the box it is on, so a
534
+ full-width pane has to leave it as well or the row is wider than the window and the pane's own
535
+ controls, which sit at its far end, go off the screen. */
536
+ --term-gutter: 24px;
530
537
  }
531
538
 
532
539
  #termwrap {
@@ -537,7 +544,7 @@ body {
537
544
  /* Spacing via margin, not padding: FitAddon measures the wrapper height but does not subtract
538
545
  its padding, so wrapper padding makes it propose one row too many and the last line clips.
539
546
  Margin gives the same visual inset (page and terminal share a background) without breaking fit. */
540
- margin: 8px 12px;
547
+ margin: 8px calc(var(--term-gutter) / 2);
541
548
  }
542
549
 
543
550
  #term {
@@ -595,7 +602,7 @@ body {
595
602
  #grip {
596
603
  position: relative;
597
604
  flex: none;
598
- width: 12px;
605
+ width: var(--grip-width);
599
606
  background: var(--panel);
600
607
  border-left: 1px solid var(--border);
601
608
  border-right: 1px solid var(--border);
@@ -626,8 +633,9 @@ body.dragging {
626
633
  user-select: none;
627
634
  }
628
635
 
629
- /* The width is app/pane.js's, in a custom property so the two rules that must win - the collapsed spine and
630
- the narrow-screen layout - still do. The value here is what shows before the first drag. */
636
+ /* The width is app/pane-shell.js's, in a custom property so the three rules that must win - the collapsed
637
+ spine, the full row and the narrow-screen layout - still do. The value here is what shows before the first
638
+ drag. */
631
639
  #pane {
632
640
  display: flex;
633
641
  flex: none;
@@ -637,6 +645,16 @@ body.dragging {
637
645
  background: var(--bg);
638
646
  }
639
647
 
648
+ /* The far end of the range: the pane takes the row and the terminal is squeezed out of it rather than hidden,
649
+ so the terminal keeps its place in the layout and comes back at whatever the next width is. The grip is
650
+ what the row leaves over, which is what keeps the way back on the screen, and the terminal's gutter is left
651
+ as well - it is a margin, so it stays whatever the box it is on shrinks to, and taking the row without it
652
+ would push the pane's own controls past the right edge of the window. Both are the same background, so what
653
+ the terminal leaves behind reads as part of the pane. */
654
+ #pane.full {
655
+ width: calc(100% - var(--grip-width) - var(--term-gutter));
656
+ }
657
+
640
658
  /* A page arrived and opened itself: one pulse of the workspace colour along the pane's own edge, and nothing
641
659
  moves. Inset, so it is drawn over the pane rather than pushing the layout about, and over the frame's edge
642
660
  rather than under it. ARRIVAL_MS in app/pane.js must match the duration - it is what takes the class off
@@ -664,20 +682,8 @@ body.dragging {
664
682
  }
665
683
  }
666
684
 
667
- /* The pagination bar: this session's pages as chips, the arrows for a bar too narrow to hold them, and where
668
- in the history the open page sits. Drawn by the interface, outside the page's frame, so it always says the
669
- truth about which page is open. */
670
- #pagebar {
671
- display: flex;
672
- flex: none;
673
- align-items: center;
674
- gap: 6px;
675
- padding: 7px 10px;
676
- background: var(--panel);
677
- border-bottom: 1px solid var(--border);
678
- overflow: hidden;
679
- }
680
-
685
+ /* The small square buttons of the pane's two bars: the width controls up in the pane's own chrome, and the
686
+ chip arrows down in the Pages bar. */
681
687
  .pane-nav {
682
688
  flex: none;
683
689
  padding: 1px 6px;
@@ -695,6 +701,112 @@ body.dragging {
695
701
  color: var(--text);
696
702
  }
697
703
 
704
+ /* The pane's own bar: which tool is open, and how wide the pane is. Nothing in it belongs to a tool, which is
705
+ what stops a tool from moving the pane or from saying which tool the user is looking at. */
706
+ #panebar {
707
+ display: flex;
708
+ flex: none;
709
+ align-items: flex-end;
710
+ gap: 2px;
711
+ padding: 7px 10px 0;
712
+ background: var(--bg);
713
+ border-bottom: 1px solid var(--border);
714
+ }
715
+
716
+ #tool-tabs {
717
+ display: flex;
718
+ gap: 2px;
719
+ min-width: 0;
720
+ overflow: hidden;
721
+ }
722
+
723
+ /* A tab is a folder mouth: the open one carries the panel's own background up into the bar, so the two read
724
+ as one surface and the others read as behind it. */
725
+ .tool-tab {
726
+ display: flex;
727
+ flex: none;
728
+ align-items: center;
729
+ gap: 6px;
730
+ padding: 4px 14px;
731
+ border: 1px solid transparent;
732
+ border-bottom: none;
733
+ border-radius: 7px 7px 0 0;
734
+ background: none;
735
+ color: var(--muted);
736
+ font: inherit;
737
+ font-size: 12.5px;
738
+ cursor: pointer;
739
+ }
740
+
741
+ .tool-tab:hover {
742
+ color: var(--text);
743
+ }
744
+
745
+ .tool-tab.on {
746
+ background: var(--panel);
747
+ border-color: var(--border);
748
+ color: var(--text);
749
+ }
750
+
751
+ /* What is waiting in a tool nobody is looking at. The same green as everything else that means "something
752
+ happened here". */
753
+ .tool-mark {
754
+ padding: 0 5px;
755
+ border-radius: 8px;
756
+ background: var(--accent);
757
+ color: #04140b;
758
+ font-size: 9.5px;
759
+ font-weight: 700;
760
+ }
761
+
762
+ #pane-ctl {
763
+ display: flex;
764
+ flex: none;
765
+ margin-left: auto;
766
+ gap: 4px;
767
+ padding-bottom: 5px;
768
+ }
769
+
770
+ #pane-ctl .pane-nav {
771
+ display: flex;
772
+ align-items: center;
773
+ justify-content: center;
774
+ padding: 2px 5px;
775
+ }
776
+
777
+ /* The pane is already at the far end, and the button is the way back from it. */
778
+ #pane-ctl .pane-nav.on {
779
+ background: #10151c;
780
+ color: var(--accent);
781
+ }
782
+
783
+ /* One tool's panel. Only the open one is on the screen, and each fills whatever the pane's bar leaves. */
784
+ .tool-panel {
785
+ display: flex;
786
+ flex: 1;
787
+ flex-direction: column;
788
+ min-width: 0;
789
+ min-height: 0;
790
+ }
791
+
792
+ .tool-panel[hidden] {
793
+ display: none;
794
+ }
795
+
796
+ /* The pagination bar: this session's pages as chips, the arrows for a bar too narrow to hold them, and where
797
+ in the history the open page sits. Drawn by the interface, outside the page's frame, so it always says the
798
+ truth about which page is open. */
799
+ #pagebar {
800
+ display: flex;
801
+ flex: none;
802
+ align-items: center;
803
+ gap: 6px;
804
+ padding: 7px 10px;
805
+ background: var(--panel);
806
+ border-bottom: 1px solid var(--border);
807
+ overflow: hidden;
808
+ }
809
+
698
810
  #chips {
699
811
  display: flex;
700
812
  gap: 6px;
@@ -810,15 +922,6 @@ body.dragging {
810
922
  letter-spacing: 0.02em;
811
923
  }
812
924
 
813
- /* Out of the way, at the end of the bar: the pane is put away far more often than the chips are paged. */
814
- #pane-collapse {
815
- display: flex;
816
- align-items: center;
817
- justify-content: center;
818
- margin-left: 4px;
819
- padding: 2px 5px;
820
- }
821
-
822
925
  #page-doc {
823
926
  flex: 1;
824
927
  min-width: 0;
@@ -841,10 +944,21 @@ body.dragging {
841
944
  zoom: var(--page-zoom, 1);
842
945
  }
843
946
 
947
+ /* A tool's own client, in a frame of its own. It fills the panel and this side styles nothing inside it: the
948
+ tool ships its own document, its own stylesheet and its own theme, and the shell cannot reach in anyway. */
949
+ .tool-frame {
950
+ display: block;
951
+ width: 100%;
952
+ height: 100%;
953
+ border: none;
954
+ background: var(--bg);
955
+ }
956
+
844
957
  /* A page is a document with an event loop of its own, and a pointer that crosses into it during a drag is a
845
958
  pointer the grip has to keep. Capture does that in every engine that has it; taking the frame out of the
846
959
  hit test for the length of the drag is what makes it true everywhere. */
847
- body.dragging .page-frame {
960
+ body.dragging .page-frame,
961
+ body.dragging .tool-frame {
848
962
  pointer-events: none;
849
963
  }
850
964
 
@@ -893,8 +1007,8 @@ body.dragging .page-frame {
893
1007
  width: 30px;
894
1008
  }
895
1009
 
896
- #pane.collapsed #pagebar,
897
- #pane.collapsed #page-doc {
1010
+ #pane.collapsed #panebar,
1011
+ #pane.collapsed .tool-panel {
898
1012
  display: none;
899
1013
  }
900
1014