chamba 0.7.0 → 0.8.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 (120) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +28 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +12 -19
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +3 -2
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -1588
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +223 -27
  74. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  75. package/templates/skills/dx-spec/references/review-guide.md +4 -50
  76. package/templates/skills/dx-spec/references/spec-guide.md +24 -4
  77. package/templates/skills/dx-spec/references/stages.md +143 -58
  78. package/templates/skills/dx-spec-execute/SKILL.md +148 -35
  79. package/templates/startup.mjs +10 -4
  80. package/templates/tool-helper.sh +166 -0
  81. package/templates/webterm/README.md +60 -26
  82. package/templates/webterm/color.js +61 -0
  83. package/templates/webterm/config.js +35 -4
  84. package/templates/webterm/context/claude.md +0 -1
  85. package/templates/webterm/public/app/alerts.js +4 -4
  86. package/templates/webterm/public/app/anchored.js +81 -0
  87. package/templates/webterm/public/app/connection.js +9 -0
  88. package/templates/webterm/public/app/dom.js +2 -0
  89. package/templates/webterm/public/app/frames.js +51 -17
  90. package/templates/webterm/public/app/main.js +22 -8
  91. package/templates/webterm/public/app/new-session.js +13 -41
  92. package/templates/webterm/public/app/palette.js +58 -0
  93. package/templates/webterm/public/app/pane-shape.js +167 -0
  94. package/templates/webterm/public/app/pane-shell.js +199 -134
  95. package/templates/webterm/public/app/pane.js +33 -24
  96. package/templates/webterm/public/app/reviews-host.js +15 -0
  97. package/templates/webterm/public/app/specs-host.js +12 -218
  98. package/templates/webterm/public/app/state.js +5 -0
  99. package/templates/webterm/public/app/tabs.js +6 -2
  100. package/templates/webterm/public/app/theme.js +75 -27
  101. package/templates/webterm/public/app/tool-host.js +302 -0
  102. package/templates/webterm/public/app/workspace-color.js +192 -0
  103. package/templates/webterm/public/index.html +17 -3
  104. package/templates/webterm/public/styles.css +143 -21
  105. package/templates/webterm/server.js +198 -150
  106. package/templates/webterm/tools/commands.js +61 -0
  107. package/templates/webterm/tools/index.js +25 -0
  108. package/templates/webterm/tools/mount.js +337 -0
  109. package/templates/webterm/tools/paths.js +34 -0
  110. package/templates/webterm/tools/reviews.js +133 -0
  111. package/templates/webterm/tools/specs.js +154 -0
  112. package/templates/webterm/typed-line.js +70 -7
  113. package/templates/context/context-usage.md +0 -1
  114. package/templates/context-usage.sh +0 -266
  115. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  116. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  117. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  118. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  119. package/templates/specs.sh +0 -106
  120. package/templates/webterm/specs.js +0 -358
@@ -0,0 +1,302 @@
1
+ // tool-host.js - a pane tool's client, in a frame of its own.
2
+ //
3
+ // A 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 that tool's own reading at worst. This module is the host around such a frame: it owns the panel,
6
+ // the frame, 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 it. The frame gets a tool-scoped key that opens that tool's data routes
9
+ // and a path prefix that opens the files it serves, 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
+ // One host per tool, made by that tool's own module beside this file, which names the tool and nothing more.
15
+ // Everything here is the same for every tool, so a third one is a panel and three lines.
16
+
17
+ import { sendFrame } from "./connection.js";
18
+ import { noteTool, registerTool, rememberPaneTheme, storedPaneTheme } from "./pane-shell.js";
19
+ import { attachedSid, KEY_HEADER, WEB_KEY } from "./state.js";
20
+
21
+ // How many times a frame that navigates itself is rebuilt before the tab says so instead. A client that keeps
22
+ // leaving the document it was given is broken or is being driven, and either way rebuilding it for ever is a
23
+ // spinning window rather than a recovery.
24
+ const MAX_REBUILDS = 3;
25
+
26
+ // What the shell and a frame call each other's messages. Namespaced by the tool, because a frame may be
27
+ // talking to something else of its own, and every message is checked against the frame it must come from.
28
+ const HANDOVER = "credentials";
29
+ const CHANGE = "change";
30
+ const OPEN = "open";
31
+ const DELIVER = "deliver";
32
+ const WAITING = "waiting";
33
+ const DELIVERED = "delivered";
34
+ const SESSION = "session";
35
+ const THEME = "theme";
36
+
37
+ // id -> the host of that tool, so a frame from the server reaches the one it is about.
38
+ const hosts = new Map();
39
+
40
+ /** The host of one tool, or undefined. */
41
+ export function toolHost(id) {
42
+ return hosts.get(id);
43
+ }
44
+
45
+ /** Tell every tool which session this window is now driving. */
46
+ export function toolsMovedToSession() {
47
+ for (const host of hosts.values()) host.session();
48
+ }
49
+
50
+ /**
51
+ * Put one tool on the page.
52
+ *
53
+ * `id` is the tool's own name, which is both the prefix of its routes and the name of its messages - the
54
+ * server derives the same paths from the same word (see `tools/paths.js`). `panel` is the element the pane
55
+ * gave this tool, and `opening` is the one sentence shown while its credentials are being fetched.
56
+ */
57
+ export function createToolHost({ id, label, panel, opening }) {
58
+ // Where the built client is served from, and where the shell asks for the frame's credentials. The second
59
+ // one is behind the master key in a header, which is the shell's own gate and never the frame's.
60
+ const FRAME_SRC = `/pane-apps/${id}/index.html`;
61
+ const KEYS_URL = `/${id}/keys`;
62
+ const named = (what) => `${id}:${what}`;
63
+
64
+ let frame = null;
65
+ let handed = false;
66
+ let rebuilds = 0;
67
+ let credentials = null;
68
+ let asking = false;
69
+ let failure = null;
70
+ // How many things are waiting for the user inside the tool, and whether the tool has anything to show at
71
+ // all. Kept here rather than in the frame, because both have to hold while another tool is open and the
72
+ // frame is not being drawn. A tool that has said nothing yet has something to show: a panel drawn once
73
+ // and then declared empty is a pane that moves under a reader who did nothing.
74
+ let waiting = 0;
75
+ let anything = true;
76
+
77
+ /** Fetch the frame's credentials. The shell holds the master key; this is the only route it opens for this tool. */
78
+ async function fetchCredentials() {
79
+ if (asking) return;
80
+ asking = true;
81
+ try {
82
+ const response = await fetch(KEYS_URL, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
83
+ if (!response.ok) throw new Error(String(response.status));
84
+ const answer = await response.json();
85
+ if (typeof answer?.key !== "string" || typeof answer?.raw !== "string") throw new Error("no credentials");
86
+ credentials = answer;
87
+ failure = null;
88
+ } catch {
89
+ failure = `The ${label} tool could not be reached. The next time this tab is opened it tries again.`;
90
+ } finally {
91
+ asking = false;
92
+ }
93
+ // Drawn whether the tab is open or not. The panel is off the screen when it is not, so nothing is
94
+ // shown either way, but this is the step that puts the frame up - and the tool has to be running to
95
+ // say what is waiting for the user, wherever in the pane that user happens to be.
96
+ render();
97
+ }
98
+
99
+ /** Put a fresh frame in the panel, replacing whatever was there. */
100
+ function build() {
101
+ const next = document.createElement("iframe");
102
+ next.className = "tool-frame";
103
+ // The whole boundary in one attribute: scripts and forms, and no same-origin, so the document has an
104
+ // opaque origin and nothing of this window is reachable from inside it - not the DOM, not storage,
105
+ // and not the URL that carries the master key. Set before the src, since the sandbox of a frame that
106
+ // already has a document is not what a later attribute says it is.
107
+ next.setAttribute("sandbox", "allow-scripts allow-forms");
108
+ // A frame inherits its embedder's referrer, and this window's URL carries the master key - both as
109
+ // `document.referrer` inside the frame and as the `Referer` header on everything it loads.
110
+ next.setAttribute("referrerpolicy", "no-referrer");
111
+ next.setAttribute("title", label);
112
+ next.addEventListener("load", () => onLoad(next));
113
+ next.src = FRAME_SRC;
114
+ frame = next;
115
+ handed = false;
116
+ panel.replaceChildren(next);
117
+ }
118
+
119
+ /** The frame finished loading a document. The first one is the client; a second one is a navigation. */
120
+ function onLoad(loaded) {
121
+ if (loaded !== frame) return;
122
+ if (!handed) {
123
+ handed = true;
124
+ loaded.contentWindow?.postMessage({ t: named(HANDOVER), key: credentials.key, raw: credentials.raw }, "*");
125
+ // The session this window is on, right behind the handover. The frame usually finishes loading
126
+ // after the first session has attached, so without this a window that never switches would never
127
+ // hear which session it is beside - and a tool keeps its view per session.
128
+ sendSession(loaded);
129
+ return;
130
+ }
131
+ if (rebuilds >= MAX_REBUILDS) {
132
+ frame = null;
133
+ failure = `The ${label} tool kept leaving the page it was given, so it was stopped.`;
134
+ render();
135
+ return;
136
+ }
137
+ rebuilds += 1;
138
+ build();
139
+ }
140
+
141
+ /** One sentence in the middle of the panel: what the tab is doing, or why it is doing nothing. */
142
+ function say(line) {
143
+ const wrap = document.createElement("div");
144
+ wrap.className = "pane-intro";
145
+ const title = document.createElement("h2");
146
+ title.textContent = label;
147
+ const lede = document.createElement("p");
148
+ lede.textContent = line;
149
+ wrap.append(title, lede);
150
+ panel.replaceChildren(wrap);
151
+ }
152
+
153
+ function render() {
154
+ // What the tool last said about itself, which holds while another tool is open: what is waiting for
155
+ // the user in it, and whether it has anything to show.
156
+ noteTool(id, { unread: waiting, empty: !anything });
157
+ if (failure !== null) {
158
+ // Said once and then forgotten, so coming back to the tab is what tries again.
159
+ const line = failure;
160
+ failure = null;
161
+ frame = null;
162
+ say(line);
163
+ return;
164
+ }
165
+ // A frame that is up is left alone: it holds the tool's whole state, and rebuilding it would be a
166
+ // reload every time the user came back to the tab.
167
+ if (frame !== null) return;
168
+ if (credentials === null) {
169
+ say(opening);
170
+ fetchCredentials();
171
+ return;
172
+ }
173
+ build();
174
+ }
175
+
176
+ /**
177
+ * Tell one frame which session this window is driving, and how that session reads. `null` says there is none.
178
+ *
179
+ * The theme rides with the session because it belongs to it: the frame has an opaque origin and no storage
180
+ * of its own, so what it was set to last is the pane's to keep and the pane's to hand back.
181
+ */
182
+ function sendSession(target) {
183
+ const sid = attachedSid ?? null;
184
+ // Both fields say the same session or neither does: the pane is drawn in the session it opened with
185
+ // before the first frame names one, and handing the frame that session's palette beside "there is no
186
+ // session" would have it read in a theme it was never told the reason for.
187
+ target?.contentWindow?.postMessage({ t: named(SESSION), sid, theme: sid === null ? null : storedPaneTheme() }, "*");
188
+ }
189
+
190
+ /**
191
+ * Whether a message is this tool asking for something, rather than anything else on this window.
192
+ *
193
+ * Two checks, and each one holds without the other. The sender must be the frame's own window, which
194
+ * refuses a workspace document nested inside the tool - it is a frame of its own and can post here. And
195
+ * the message must carry the tool's key, which only the document this shell loaded ever received, so a
196
+ * document that navigated the frame passes the window check and fails this one.
197
+ */
198
+ function fromTool(event, message) {
199
+ if (frame === null || !handed || credentials === null) return false;
200
+ if (event.source !== frame.contentWindow) return false;
201
+ return typeof message.key === "string" && message.key === credentials.key;
202
+ }
203
+
204
+ // A link the tool will not follow itself. The frame has to stay the document this shell put in it, since a
205
+ // navigation would keep the window identity every check here rests on, so an outside link is opened as a
206
+ // window of this interface instead - the web alone, and with nothing of this window carried into it.
207
+ window.addEventListener("message", (event) => {
208
+ const message = event.data;
209
+ if (!message || typeof message !== "object" || message.t !== named(OPEN)) return;
210
+ if (!fromTool(event, message)) return;
211
+ let url;
212
+ try {
213
+ url = new URL(String(message.url));
214
+ } catch {
215
+ return;
216
+ }
217
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
218
+ window.open(url.href, "_blank", "noopener,noreferrer");
219
+ });
220
+
221
+ // The tool saying what it is waiting on, and whether it holds anything. Both are the tool's own; the mark
222
+ // they put on the tab is the shell's - which is what makes something awaiting an answer visible from any
223
+ // other tool in the pane. Only the word "no" makes a tool empty, so a message from an older client that
224
+ // says nothing about it leaves the tab as it was.
225
+ window.addEventListener("message", (event) => {
226
+ const message = event.data;
227
+ if (!message || typeof message !== "object" || message.t !== named(WAITING)) return;
228
+ if (!fromTool(event, message)) return;
229
+ const count = Number(message.count);
230
+ waiting = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 99) : 0;
231
+ anything = message.anything !== false;
232
+ noteTool(id, { unread: waiting, empty: !anything });
233
+ });
234
+
235
+ // The tool asking for the other palette. It has nowhere to keep the answer, so the pane keeps it with the
236
+ // rest of that session's shape and hands it straight back down the session path - which is the one way the
237
+ // frame ever learns its theme, so the store is what the tool is reading rather than a copy of it.
238
+ window.addEventListener("message", (event) => {
239
+ const message = event.data;
240
+ if (!message || typeof message !== "object" || message.t !== named(THEME)) return;
241
+ if (!fromTool(event, message)) return;
242
+ rememberPaneTheme(message.theme);
243
+ sendSession(frame);
244
+ });
245
+
246
+ // A delivery: the tool telling the agent beside it that something arrived. It rides this window's own
247
+ // socket, so the server resolves the session from the socket the way every other pane frame does, and
248
+ // nothing here or in the frame names one. What travels is which tool it is from, the kind of event, a line
249
+ // of detail, the file the tool wrote, and whether the user agreed to start a session for it.
250
+ window.addEventListener("message", (event) => {
251
+ const message = event.data;
252
+ if (!message || typeof message !== "object" || message.t !== named(DELIVER)) return;
253
+ if (!fromTool(event, message)) return;
254
+ sendFrame({
255
+ t: "tool:deliver",
256
+ tool: id,
257
+ event: String(message.event ?? ""),
258
+ detail: String(message.detail ?? ""),
259
+ path: String(message.path ?? ""),
260
+ create: message.create === true,
261
+ });
262
+ });
263
+
264
+ const host = {
265
+ /** The disk moved under this tool's work. Passed straight in: the frame decides what is worth re-reading. */
266
+ change(msg) {
267
+ if (frame === null || !handed) return;
268
+ frame.contentWindow?.postMessage({ t: named(CHANGE), change: msg.change }, "*");
269
+ },
270
+
271
+ /**
272
+ * The window moved to another session, or off the one it had.
273
+ *
274
+ * A tool keeps one view per session - what is open, where the reader is, and what they are holding -
275
+ * so a switch has to reach it. It rides the same path as every other message to the frame, and it
276
+ * names the session this window is on rather than one the frame may choose.
277
+ */
278
+ session() {
279
+ if (frame === null || !handed) return;
280
+ sendSession(frame);
281
+ },
282
+
283
+ /** What the server made of the last delivery, passed back to the frame that asked for it. */
284
+ delivered(msg) {
285
+ if (frame === null || !handed) return;
286
+ frame.contentWindow?.postMessage({ t: named(DELIVERED), ok: msg.ok === true, error: msg.error ?? "" }, "*");
287
+ },
288
+ };
289
+
290
+ hosts.set(id, host);
291
+ registerTool(id, render);
292
+
293
+ // The frame is built at startup rather than the first time the tab is opened, because the mark on the tab
294
+ // is the tool's to put there and the tool cannot say anything until it is running. Waiting for the first
295
+ // open would make "something is waiting for you" visible only to a user who already went and looked,
296
+ // which is the one reader who did not need telling - and a window reopened on another tool would show
297
+ // nothing at all. Nothing is drawn by this: the panel is off the screen until its tab is picked, and the
298
+ // frame holds the tool's state from here on, so opening the tab shows what is already there instead of
299
+ // loading it then.
300
+ render();
301
+ return host;
302
+ }
@@ -0,0 +1,192 @@
1
+ // workspace-color.js - the control in the bar that says what colour this workspace wears, and changes it.
2
+ //
3
+ // The colour is the one thing a window says about itself from across a screen full of windows, and until now
4
+ // it was worked out from the published port with nothing the user could do about it. The control is a palette
5
+ // glyph beside the bell, drawn and coloured exactly as the bell is, so the row stays a row.
6
+ //
7
+ // Everything is one click. Picking a colour applies it at once, with nothing to confirm, because there is
8
+ // nothing to get wrong - the panel offers eleven colours and a way back to automatic, and a colour that turns
9
+ // out to be the wrong one is another click. The mark stays on what is *stored*, not on what is on the screen,
10
+ // so "nothing picked" is still tellable from "picked the colour the port would have given anyway".
11
+ //
12
+ // The panel hangs off the body through anchored.js. It is not a nicety: renderBar() empties the bar on every
13
+ // frame, and picking a colour broadcasts a frame back to this very window, so a panel inside the bar would
14
+ // close itself the moment it was used.
15
+
16
+ import { createAnchoredPanel } from "./anchored.js";
17
+ import { connected, sendFrame } from "./connection.js";
18
+ import { SVG_NS } from "./dom.js";
19
+ import { hexFor, PALETTE } from "./palette.js";
20
+ import { workspaceColorId } from "./state.js";
21
+
22
+ const BUTTON_ID = "wscolorbtn";
23
+
24
+ // markButton runs on every open and every close, so the button says what the panel is doing however the
25
+ // panel got there - a choice made in it, a click outside, Escape, or the socket dropping.
26
+ const panel = createAnchoredPanel(BUTTON_ID, () => markButton());
27
+
28
+ // Put the open panel back under its button after the bar was rebuilt, and take it away with a button that has
29
+ // gone. Called from renderBar(), beside the new-session panel's own.
30
+ export function repositionColorPop() {
31
+ panel.reposition();
32
+ }
33
+
34
+ // The tick on the chosen cell. Drawn rather than styled so it sits on any of the eleven: the stroke is the
35
+ // interface's own near-black, which every colour in the palette carries at 3:1 or better.
36
+ function tick() {
37
+ const svg = document.createElementNS(SVG_NS, "svg");
38
+ svg.setAttribute("viewBox", "0 0 10 8");
39
+ svg.setAttribute("width", "10");
40
+ svg.setAttribute("height", "8");
41
+ svg.setAttribute("aria-hidden", "true");
42
+ const path = document.createElementNS(SVG_NS, "path");
43
+ path.setAttribute("d", "M1 4.2 3.6 6.8 9 1.2");
44
+ path.setAttribute("fill", "none");
45
+ path.setAttribute("stroke", "#0d1117");
46
+ path.setAttribute("stroke-width", "2");
47
+ path.setAttribute("stroke-linecap", "round");
48
+ path.setAttribute("stroke-linejoin", "round");
49
+ svg.append(path);
50
+ return svg;
51
+ }
52
+
53
+ // One cell of the grid. `id` is "" for the automatic cell, which is one of the choices rather than a button
54
+ // under them: it is what the workspace goes back to, so it belongs in the same grid as what it replaces.
55
+ function cell(id, hex, label, stored) {
56
+ const button = document.createElement("button");
57
+ button.type = "button";
58
+ button.className = id === "" ? "wscell auto" : "wscell";
59
+ button.title = label;
60
+ // Plain buttons that say whether they are the one in force. Not role="radio": that role promises arrow-key
61
+ // navigation with a roving tabindex, and describing behaviour the code does not have is worse than saying
62
+ // less. Every cell stays in the tab order, which is what actually happens.
63
+ const chosen = id === stored;
64
+ button.setAttribute("aria-pressed", String(chosen));
65
+ button.setAttribute("aria-label", label);
66
+ if (id === "") button.textContent = "A";
67
+ else button.style.background = hex;
68
+ if (chosen) {
69
+ button.classList.add("chosen");
70
+ // The ring carries the mark on every cell. The tick goes on the colours alone: the automatic cell
71
+ // already holds its "A", and a tick beside it would sit off centre and be drawn in a near-black that
72
+ // is all but invisible on that cell's own dark stripes.
73
+ if (id !== "") button.append(tick());
74
+ }
75
+ button.addEventListener("click", () => {
76
+ panel.close();
77
+ // Sent, never held: the server decides, and every window is told on the frame that comes back. A pick
78
+ // kept to send later would be undone by the next sessions frame anyway (FR-17).
79
+ sendFrame({ t: "color", color: id });
80
+ });
81
+ return button;
82
+ }
83
+
84
+ function popLabel(text) {
85
+ const label = document.createElement("span");
86
+ label.className = "poplabel";
87
+ label.textContent = text;
88
+ return label;
89
+ }
90
+
91
+ function openPanel() {
92
+ // What is *stored*, and an id this version does not know counts as nothing stored - the same reading
93
+ // theme.js gives it when it paints. Without that, a colour dropped from the palette in a later version
94
+ // would leave the panel with no mark at all: no cell matches the id, and the automatic cell would not
95
+ // either, though automatic is exactly what the window is showing.
96
+ const stored = hexFor(workspaceColorId) ? workspaceColorId : "";
97
+ const pop = document.createElement("div");
98
+ pop.id = "wscolorpop";
99
+ pop.setAttribute("role", "dialog");
100
+ pop.setAttribute("aria-label", "Workspace colour");
101
+
102
+ const grid = document.createElement("div");
103
+ grid.className = "wsgrid";
104
+ grid.setAttribute("role", "group");
105
+ grid.setAttribute("aria-label", "Workspace colour");
106
+ for (const colour of PALETTE) grid.append(cell(colour.id, colour.hex, colour.id, stored));
107
+ // Automatic last, so the eleven read as one set and the way out sits at the end of it.
108
+ grid.append(cell("", "", "Automatic", stored));
109
+
110
+ const foot = document.createElement("p");
111
+ foot.className = "wscolorfoot";
112
+ foot.textContent = "Automatic goes back to the colour this workspace works out for itself.";
113
+
114
+ pop.append(popLabel("Workspace colour"), grid, foot);
115
+ panel.open(pop);
116
+ }
117
+
118
+ function togglePanel() {
119
+ if (panel.isOpen()) panel.close();
120
+ else openPanel();
121
+ }
122
+
123
+ // The open state, said on the button as it happens rather than left to the next bar rebuild, which may be a
124
+ // frame away or the whole sixty-second age tick away: a screen reader would otherwise be told "collapsed" over
125
+ // an open panel. The bar is rebuilt from scratch on every frame, so this looks the button up rather than
126
+ // holding one, and does nothing when the bar has not drawn yet.
127
+ function markButton() {
128
+ const button = document.getElementById(BUTTON_ID);
129
+ if (!button) return;
130
+ button.setAttribute("aria-expanded", String(panel.isOpen()));
131
+ button.classList.toggle("open", panel.isOpen());
132
+ }
133
+
134
+ // Called when the socket drops, so the panel goes with it. renderBar() rebuilds the button disabled, but a
135
+ // panel that is already open hangs off the body, where the bar's own offline rules cannot reach it.
136
+ export function closeColorPop() {
137
+ panel.close();
138
+ }
139
+
140
+ // A palette: a circle with a bite out of it for the thumb hole, and three wells of paint. Drawn in the idiom
141
+ // the bell and the power button use - a 24-unit grid at 14px, no fill, currentColor at stroke-width 2 - so
142
+ // the three read as one row of controls. The wells are filled rather than stroked, because a ring of one
143
+ // unit closes up at this size.
144
+ function paletteGlyph() {
145
+ const svg = document.createElementNS(SVG_NS, "svg");
146
+ svg.setAttribute("viewBox", "0 0 24 24");
147
+ svg.setAttribute("width", "14");
148
+ svg.setAttribute("height", "14");
149
+ svg.setAttribute("fill", "none");
150
+ svg.setAttribute("stroke", "currentColor");
151
+ svg.setAttribute("stroke-width", "2");
152
+ svg.setAttribute("stroke-linecap", "round");
153
+ svg.setAttribute("stroke-linejoin", "round");
154
+ const body = document.createElementNS(SVG_NS, "path");
155
+ body.setAttribute("d", "M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-1.6a2.4 2.4 0 0 0-1.7 4.1A2 2 0 0 1 12 22z");
156
+ svg.append(body);
157
+ for (const [cx, cy] of [
158
+ [7.5, 12.5],
159
+ [9.5, 8],
160
+ [14.5, 7.5],
161
+ ]) {
162
+ const well = document.createElementNS(SVG_NS, "circle");
163
+ well.setAttribute("cx", String(cx));
164
+ well.setAttribute("cy", String(cy));
165
+ well.setAttribute("r", "1");
166
+ well.setAttribute("fill", "currentColor");
167
+ well.setAttribute("stroke", "none");
168
+ svg.append(well);
169
+ }
170
+ return svg;
171
+ }
172
+
173
+ // The control that opens the palette. It carries no colour of its own: the bar already says which colour is
174
+ // in force, on the edge of the active tab, and a filled swatch in a row of glyphs reads as a thing that
175
+ // wandered in rather than as one of the row.
176
+ export function workspaceColorButton() {
177
+ const button = document.createElement("button");
178
+ button.id = BUTTON_ID;
179
+ button.type = "button";
180
+ // Nothing to open, and nothing to send, while this window is not talking to the server: a pick that could
181
+ // not be sent would be undone by the next frame, which is worse than not offering it. The socket dropping
182
+ // is what closes an open panel, in connection.js - by the time this runs the panel is already gone.
183
+ button.disabled = !connected;
184
+ button.title = "The colour this workspace wears";
185
+ button.setAttribute("aria-label", "Change the workspace colour");
186
+ button.setAttribute("aria-haspopup", "dialog");
187
+ button.setAttribute("aria-expanded", String(panel.isOpen()));
188
+ button.classList.toggle("open", panel.isOpen());
189
+ button.append(paletteGlyph());
190
+ button.addEventListener("click", togglePanel);
191
+ return button;
192
+ }
@@ -21,7 +21,7 @@
21
21
  <div id="middle">
22
22
  <div id="termwrap">
23
23
  <main id="term"></main>
24
- <!-- Take over / end session / limit reached, scoped to the terminal area. -->
24
+ <!-- Take over / end session / limit reached, fixed to the viewport so the card is in the middle of the screen. -->
25
25
  <div id="overlay"><div id="card" class="card"></div></div>
26
26
  <!-- Transient status ("copied 3 lines", upload and dictation errors), floating so it shifts nothing. -->
27
27
  <span id="note"></span>
@@ -45,6 +45,14 @@
45
45
  <path d="M13 8l-4 4 4 4" />
46
46
  </svg>
47
47
  </button>
48
+ <!-- Two equal panels, split down the middle: the pane at half the window, which is the
49
+ width it opens at. -->
50
+ <button type="button" id="pane-half" class="pane-nav" title="Half the window" aria-label="Half the window">
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
+ <rect x="3.5" y="5" width="17" height="14" rx="2" />
53
+ <path d="M12 5v14" />
54
+ </svg>
55
+ </button>
48
56
  <!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is
49
57
  static chrome, unlike the session bar's icons, which are drawn per frame. -->
50
58
  <button type="button" id="pane-collapse" class="pane-nav" title="Collapse the pane" aria-label="Collapse the pane">
@@ -79,8 +87,14 @@
79
87
  <!-- Specs: the spec workspace, in a frame of its own. -->
80
88
  <div id="tool-specs" class="tool-panel" hidden></div>
81
89
 
82
- <button type="button" id="spine" title="Open the web pane" aria-label="Open the web pane">
83
- <span class="label"><span id="spine-badge" hidden></span> Web pane</span>
90
+ <!-- Code Reviews: the reviews of this repository, in a frame of its own. -->
91
+ <div id="tool-reviews" class="tool-panel" hidden></div>
92
+
93
+ <!-- The label names what the pane holds now that it holds more than pages: one tab per app.
94
+ The `webpane` helper, the framed line a page's feedback arrives on, and the skill are all
95
+ named for the pane itself and are unchanged - they are an agent's interface, not a label. -->
96
+ <button type="button" id="spine" title="Open the apps pane" aria-label="Open the apps pane">
97
+ <span class="label"><span id="spine-badge" hidden></span> Apps pane</span>
84
98
  </button>
85
99
  </aside>
86
100
  </div>