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
@@ -1,222 +1,16 @@
1
- // specs-host.js - the Specs tool: the spec workspace, in a frame of its own.
1
+ // specs-host.js - the Specs tool on the page.
2
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.
3
+ // The host around a tool's frame is `tool-host.js`, where every tool's is. What is here is which tool this
4
+ // is: its name, which is what its routes and its messages are named after, the panel the pane gave it, and
5
+ // the sentence its tab shows while it is opening.
13
6
 
14
- import { sendFrame } from "./connection.js";
15
7
  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 });
8
+ import { SPECS } from "./pane-shell.js";
9
+ import { createToolHost } from "./tool-host.js";
10
+
11
+ createToolHost({
12
+ id: SPECS,
13
+ label: "Specs",
14
+ panel: toolSpecs,
15
+ opening: "Opening the specs in this repository.",
189
16
  });
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();
@@ -13,6 +13,10 @@ export let maxSessions = 8;
13
13
  export let defaultAgent = "the agent";
14
14
  export let agents = [];
15
15
  export let workspaceName = "";
16
+ // The accent colour id the user picked for this workspace, or "" for none. What is stored, never what is on
17
+ // the screen: the panel marks this, so "nothing picked" stays tellable from "picked the colour the port
18
+ // would have given anyway".
19
+ export let workspaceColorId = "";
16
20
  // Where "+ New session" starts an agent, relative to the workspace root ("" is the root itself). The server
17
21
  // owns it - it follows the directory chamba was last run in - and the picker opens on it.
18
22
  export let defaultCwd = "";
@@ -26,6 +30,7 @@ export function adoptSessionsFrame(msg) {
26
30
  defaultAgent = msg.agent || defaultAgent;
27
31
  if (Array.isArray(msg.agents)) agents = msg.agents;
28
32
  workspaceName = msg.workspace || "";
33
+ workspaceColorId = typeof msg.color === "string" ? msg.color : "";
29
34
  defaultCwd = msg.defaultCwd ?? "";
30
35
  }
31
36
 
@@ -18,6 +18,7 @@ import { repositionNewPop, toggleNewPop } from "./new-session.js";
18
18
  import { agents, attachedSid, defaultAgent, defaultCwd, maxSessions, sessions, workspaceName } from "./state.js";
19
19
  import { focusTerminal, term } from "./terminal.js";
20
20
  import { colorFor } from "./theme.js";
21
+ import { repositionColorPop, workspaceColorButton } from "./workspace-color.js";
21
22
 
22
23
  // The session tab being renamed right now (its id), and the text in its editor. Kept in module state so a
23
24
  // bar re-render (an incoming server frame, or the 60s age tick) rebuilds the editor without losing what
@@ -513,11 +514,14 @@ export function renderBar() {
513
514
  ws.textContent = workspaceName;
514
515
  right.append(ws);
515
516
  }
516
- right.append(bellButton(), stopButton());
517
+ // The colour first: it is about this window's identity rather than about what the window does, so it sits
518
+ // next to the name it belongs with, on the far side of the bell from the button that ends everything.
519
+ right.append(workspaceColorButton(), bellButton(), stopButton());
517
520
  tabbar.append(right);
518
521
 
519
- // The panel hangs off the body, so it survives this rebuild - but the button under it just moved.
522
+ // Both panels hang off the body, so they survive this rebuild - but the buttons under them just moved.
520
523
  repositionNewPop();
524
+ repositionColorPop();
521
525
 
522
526
  // Keep focus in the rename editor across re-renders. The first render after a double-click selects the
523
527
  // whole label so it can be typed over; a later render (an incoming frame) only restores the caret.
@@ -1,25 +1,23 @@
1
- // theme.js - one palette, used for two different jobs.
1
+ // theme.js - what colour this workspace is wearing right now, and what colour its sessions get.
2
2
  //
3
- // The workspace takes one slot of it and keeps it: the composer border, the active tab's edge, the buttons
4
- // and the frame around the tab icon are all that one colour, so a window is recognisable across a screen full
5
- // of them before a single word is read. The slot comes from the published port, which chamba assigns per workspace,
6
- // so two containers open side by side are almost never the same colour and nothing has to be configured.
3
+ // The workspace wears one colour: the composer border, the active tab's edge, the buttons and the frame around
4
+ // the tab icon are all that one colour, so a window is recognisable across a screen full of them before a
5
+ // single word is read. The user picks it from the bar, and until someone does it comes from the published
6
+ // port, which chamba assigns per workspace, so two containers open side by side are almost never alike.
7
7
  //
8
8
  // The sessions inside that window then rotate through the slots the workspace did not take. That is what
9
9
  // makes a tab's own dot mean "this session" rather than "this workspace", and it is why a dot can never come
10
10
  // out the same colour as the window it lives in.
11
+ //
12
+ // The colour moves while the page is open, so nothing here is a constant for anyone else to import: it is read
13
+ // through workspaceColor() and set through applyWorkspaceColor(). A module that took a copy at load would keep
14
+ // drawing the colour the workspace had when it started. The palette and the rules over it are in palette.js,
15
+ // which touches no globals and is where the tests check them.
16
+ //
17
+ // The stored colour lives in the container and arrives over the socket, which connects after the first paint.
18
+ // So this module also keeps the browser's own copy of the last colour it was told, and paints that first.
11
19
 
12
- // Cyberpunk neons on a near-black bar, spaced around the wheel and deliberately free of yellow and orange -
13
- // those read as a warning here, and the bar has enough to say without one. Keep the length one longer than
14
- // PALETTE_SIZE in sessions.js (a test pins it): the workspace eats one slot, sessions rotate the rest.
15
- const PALETTE = [
16
- "#2fe58a", // spring green
17
- "#1fe0cf", // turquoise
18
- "#f24bff", // magenta
19
- "#b45cff", // neon violet
20
- "#29b6ff", // azure
21
- "#ff2e88", // rose
22
- ];
20
+ import { automaticId, hexFor, sessionColors } from "./palette.js";
23
21
 
24
22
  // A stable number for this window when there is no port to read (a proxy, or the default 80/443). Not a
25
23
  // hash worth defending - it only has to be the same on every reload of the same address.
@@ -29,18 +27,68 @@ function hostSeed() {
29
27
  return seed;
30
28
  }
31
29
 
32
- // The workspace's slot in the palette. Ports are handed out lowest-free-first per workspace, so neighbours
33
- // land on different colours.
34
- const WORKSPACE_SLOT = (Number(location.port) || hostSeed()) % PALETTE.length;
35
- export const WORKSPACE_COLOR = PALETTE[WORKSPACE_SLOT];
36
- // Every slot except the workspace's own, in palette order. A session index maps into this, so "never the
37
- // workspace colour" is a property of the list rather than a rule someone has to remember.
38
- const SESSION_COLORS = PALETTE.filter((_, slot) => slot !== WORKSPACE_SLOT);
30
+ // What this workspace wears when nothing is stored for it.
31
+ const AUTOMATIC_ID = automaticId(Number(location.port) || hostSeed());
32
+
33
+ // The last colour this browser was told, kept so the next load paints it before the socket answers. One
34
+ // localStorage entry, named the way the interface names its own (webterm-sound, webterm-pane).
35
+ //
36
+ // A cache and never the truth. The file in the workspace's state directory decides, and the first sessions
37
+ // frame replaces whatever is held here. It exists for one thing: without it every load would open on the
38
+ // colour the port gives and then jump to the stored one, which is the flicker this work was asked to settle.
39
+ // localStorage is held per origin and the origin names the port, so a workspace whose published port moves
40
+ // starts again with nothing cached - it paints the new automatic colour once, then settles on the stored one.
41
+ const COLOR_KEY = "webterm-color";
42
+
43
+ function cachedColorId() {
44
+ try {
45
+ return localStorage.getItem(COLOR_KEY) || "";
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ // Written whenever a frame carries a colour, and removed when one carries "". A browser that refuses storage
52
+ // still works: it just opens on the automatic colour every time and corrects itself when the socket answers.
53
+ //
54
+ // Only on a change. A sessions frame goes out on every working-or-idle transition the registry notices, which
55
+ // is many a minute under a busy agent, and this is a synchronous write of a value that almost never moves.
56
+ function rememberColorId(id) {
57
+ try {
58
+ if (id === cachedColorId()) return;
59
+ if (id === "") localStorage.removeItem(COLOR_KEY);
60
+ else localStorage.setItem(COLOR_KEY, id);
61
+ } catch {
62
+ // Nothing to do about it, and nothing worth saying to the user.
63
+ }
64
+ }
39
65
 
40
- // Handed to the stylesheet once, before anything is drawn: everything that carries the window's identity
41
- // reads it from there, so there is one line in the page that decides what colour this workspace is.
42
- document.documentElement.style.setProperty("--ws", WORKSPACE_COLOR);
66
+ let workspaceHex = "";
67
+ let sessionHexes = [];
68
+
69
+ // Handed to the stylesheet: everything that carries the window's identity reads --ws from there, so one line
70
+ // in the page decides what colour this workspace is. Called once below, before anything is drawn, and again
71
+ // whenever the stored colour changes.
72
+ export function applyWorkspaceColor(id) {
73
+ // Cached here rather than in the frame router, because the two callers are the frame that carries a
74
+ // colour and the load below that reads the cache back - so caching what is applied is caching what the
75
+ // server last said, and the load's own call rewrites the value it just read.
76
+ rememberColorId(id);
77
+ // The empty string asks for automatic, and an id this version does not know means the same thing.
78
+ const chosen = hexFor(id) ? id : AUTOMATIC_ID;
79
+ workspaceHex = hexFor(chosen);
80
+ sessionHexes = sessionColors(chosen, AUTOMATIC_ID);
81
+ document.documentElement.style.setProperty("--ws", workspaceHex);
82
+ }
83
+
84
+ /** The colour the workspace is wearing, read fresh by everything that draws it. */
85
+ export function workspaceColor() {
86
+ return workspaceHex;
87
+ }
43
88
 
44
89
  export function colorFor(entry) {
45
- return SESSION_COLORS[(entry.colorIndex ?? 0) % SESSION_COLORS.length];
90
+ return sessionHexes[(entry.colorIndex ?? 0) % sessionHexes.length];
46
91
  }
92
+
93
+ // Before anything is drawn, and before the socket has answered: what this browser was told last time.
94
+ applyWorkspaceColor(cachedColorId());