@oharness/server 0.10.0 → 0.11.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 (70) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +152 -8
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/model-order.js +1 -0
  9. package/dist/model-order.js.map +1 -1
  10. package/dist/page/client/account.js +140 -4
  11. package/dist/page/client/account.js.map +1 -1
  12. package/dist/page/client/base.js +40 -16
  13. package/dist/page/client/base.js.map +1 -1
  14. package/dist/page/client/browser.js +1 -1
  15. package/dist/page/client/composer.js +111 -7
  16. package/dist/page/client/composer.js.map +1 -1
  17. package/dist/page/client/dialogs.js +75 -0
  18. package/dist/page/client/dialogs.js.map +1 -1
  19. package/dist/page/client/events.js +25 -2
  20. package/dist/page/client/events.js.map +1 -1
  21. package/dist/page/client/index.js +4 -0
  22. package/dist/page/client/index.js.map +1 -1
  23. package/dist/page/client/panels.js +4 -4
  24. package/dist/page/client/ripple.d.ts +13 -0
  25. package/dist/page/client/ripple.js +247 -0
  26. package/dist/page/client/ripple.js.map +1 -0
  27. package/dist/page/client/routines.js +173 -41
  28. package/dist/page/client/routines.js.map +1 -1
  29. package/dist/page/client/shell.js +200 -54
  30. package/dist/page/client/shell.js.map +1 -1
  31. package/dist/page/client/state.js +7 -2
  32. package/dist/page/client/state.js.map +1 -1
  33. package/dist/page/client/terminal.js +34 -7
  34. package/dist/page/client/terminal.js.map +1 -1
  35. package/dist/page/client/theme.d.ts +17 -0
  36. package/dist/page/client/theme.js +365 -0
  37. package/dist/page/client/theme.js.map +1 -0
  38. package/dist/page/client/transcript.js +63 -7
  39. package/dist/page/client/transcript.js.map +1 -1
  40. package/dist/page/style.js +183 -22
  41. package/dist/page/style.js.map +1 -1
  42. package/dist/page.js +4 -5
  43. package/dist/page.js.map +1 -1
  44. package/dist/prefs.d.ts +17 -0
  45. package/dist/prefs.js +5 -0
  46. package/dist/prefs.js.map +1 -1
  47. package/dist/server.js +465 -42
  48. package/dist/server.js.map +1 -1
  49. package/dist/suggest.d.ts +42 -0
  50. package/dist/suggest.js +215 -0
  51. package/dist/suggest.js.map +1 -0
  52. package/dist/terminal.d.ts +28 -2
  53. package/dist/terminal.js +45 -3
  54. package/dist/terminal.js.map +1 -1
  55. package/dist/theme-assets.d.ts +39 -0
  56. package/dist/theme-assets.js +109 -0
  57. package/dist/theme-assets.js.map +1 -0
  58. package/dist/theme-store.d.ts +50 -0
  59. package/dist/theme-store.js +124 -0
  60. package/dist/theme-store.js.map +1 -0
  61. package/dist/theme-tools.d.ts +25 -0
  62. package/dist/theme-tools.js +243 -0
  63. package/dist/theme-tools.js.map +1 -0
  64. package/dist/turn-shots.d.ts +18 -2
  65. package/dist/turn-shots.js +10 -1
  66. package/dist/turn-shots.js.map +1 -1
  67. package/dist/user-theme.d.ts +143 -0
  68. package/dist/user-theme.js +510 -0
  69. package/dist/user-theme.js.map +1 -0
  70. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/page/client/state.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAy/C9B,CAAC"}
1
+ {"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/page/client/state.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8/C9B,CAAC"}
@@ -84,7 +84,7 @@ function terminalActive() {
84
84
  * Asked again for the project already on show it hides, which is what the
85
85
  * tab's button wants; force keeps it open, for the menus.
86
86
  */
87
- async function openTerminalIn(cwd, force) {
87
+ async function openTerminalIn(cwd, force, opts) {
88
88
  if (!cwd) return;
89
89
  if (terminalShownFor(cwd) && !force) return hideTerminal();
90
90
  track("panel_opened", { panel: "terminal" });
@@ -115,12 +115,28 @@ async function openTerminalIn(cwd, force) {
115
115
  const entry = terminalCreate(lib, cwd);
116
116
  void terminalAttach(entry, found, true);
117
117
  }
118
- if (!group.tabs.length) await terminalNew(cwd);
119
- else group.active = group.tabs[group.tabs.length - 1];
120
- } else if (!group.tabs.length) {
118
+ if (!group.tabs.length && !(opts && opts.bare)) await terminalNew(cwd);
119
+ else if (group.tabs.length) group.active = group.tabs[group.tabs.length - 1];
120
+ } else if (!group.tabs.length && !(opts && opts.bare)) {
121
121
  await terminalNew(cwd);
122
122
  }
123
- if (terminalCwd === cwd) terminalSelect(group.active);
123
+ if (terminalCwd === cwd && group.active) terminalSelect(group.active);
124
+ }
125
+
126
+ /**
127
+ * Hand the project to Claude Code: a new terminal tab running the user's own
128
+ * claude, signed in however they signed it in. OHarness starts it and stays
129
+ * out of the way — no login of ours, no token read — which is what keeps a
130
+ * Claude plan usable from here at all.
131
+ */
132
+ async function openClaudeCode(cwd, resume) {
133
+ if (!cwd) return;
134
+ track("panel_opened", { panel: "claude-code" });
135
+ // bare: the panel without the plain shell it would otherwise start, since
136
+ // the tab being opened is the one that was asked for.
137
+ await openTerminalIn(cwd, true, { bare: true });
138
+ // resume: a session an @claude reply ran in, opened in Claude Code itself.
139
+ if (terminalCwd === cwd) await terminalNew(cwd, "claude", resume);
124
140
  }
125
141
 
126
142
  function hideTerminal() {
@@ -135,9 +151,13 @@ function terminalFollow(cwd) {
135
151
  }
136
152
 
137
153
  /** Another shell in the project on show, as a new tab in front. */
138
- async function terminalNew(cwd) {
154
+ async function terminalNew(cwd, run, resume) {
139
155
  const lib = await loadTerminalLib();
140
156
  const entry = terminalCreate(lib, cwd);
157
+ // Only the first shell runs it: a tab restarted by typing after an exit is
158
+ // an ordinary shell, not a second Claude Code the user did not ask for.
159
+ if (run) entry.run = run;
160
+ if (run && resume) entry.resume = resume;
141
161
  terminalGroup(cwd).active = entry;
142
162
  terminalSelect(entry);
143
163
  await terminalStart(entry);
@@ -266,8 +286,15 @@ async function terminalStart(entry) {
266
286
  entry.id = null;
267
287
  if (replace) entry.term.reset();
268
288
  terminalFit(entry);
289
+ const run = entry.run;
290
+ const resume = entry.resume;
291
+ entry.run = undefined;
292
+ entry.resume = undefined;
269
293
  const body = await api("/term/open", {
270
- cwd: entry.cwd, cols: entry.term.cols, rows: entry.term.rows, ...(replace ? { replace } : {}),
294
+ cwd: entry.cwd, cols: entry.term.cols, rows: entry.term.rows,
295
+ ...(replace ? { replace } : {}),
296
+ ...(run ? { run } : {}),
297
+ ...(resume ? { resume } : {}),
271
298
  });
272
299
  entry.starting = false;
273
300
  if (!body || body.error) {
@@ -1 +1 @@
1
- {"version":3,"file":"terminal.js","sourceRoot":"","sources":["../../../src/page/client/terminal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmVjC,CAAC"}
1
+ {"version":3,"file":"terminal.js","sourceRoot":"","sources":["../../../src/page/client/terminal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8WjC,CAAC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Themes the agent proposes, and the one in use.
3
+ *
4
+ * A proposal arrives as a card in the conversation that asked for it. Nothing
5
+ * changes until the person acts on the card: Preview tries it on this page
6
+ * only, Apply makes it the theme everywhere, Discard drops it. The applied
7
+ * theme is printed into the page by the server and replaced here when another
8
+ * window changes it.
9
+ *
10
+ * Safe mode is the way out of a theme that made the page unreadable, so it is
11
+ * the one thing here that needs no theme, no card and no conversation: the
12
+ * page loaded with ?theme=default, or the shortcut below, and custom colours
13
+ * are off in this window until it is pressed again.
14
+ *
15
+ * One part of the client script; see ./index.ts for how the parts join.
16
+ */
17
+ export declare const THEME: string;
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Themes the agent proposes, and the one in use.
3
+ *
4
+ * A proposal arrives as a card in the conversation that asked for it. Nothing
5
+ * changes until the person acts on the card: Preview tries it on this page
6
+ * only, Apply makes it the theme everywhere, Discard drops it. The applied
7
+ * theme is printed into the page by the server and replaced here when another
8
+ * window changes it.
9
+ *
10
+ * Safe mode is the way out of a theme that made the page unreadable, so it is
11
+ * the one thing here that needs no theme, no card and no conversation: the
12
+ * page loaded with ?theme=default, or the shortcut below, and custom colours
13
+ * are off in this window until it is pressed again.
14
+ *
15
+ * One part of the client script; see ./index.ts for how the parts join.
16
+ */
17
+ export const THEME = String.raw `
18
+ // --- theme ----------------------------------------------------------------
19
+
20
+ const themeCards = new Map();
21
+ let themeState = null;
22
+ let themePreviewing = null;
23
+ const THEME_SAFE_KEY = "oharness-theme-safe";
24
+ const themeSafeKey = IS_MAC ? "⌘⇧0" : "Ctrl+Shift+0";
25
+
26
+ function themeSafe() {
27
+ if (new URLSearchParams(location.search).get("theme") === "default") return true;
28
+ try { return sessionStorage.getItem(THEME_SAFE_KEY) === "1"; } catch { return false; }
29
+ }
30
+
31
+ function themeTag(id) {
32
+ let tag = document.getElementById(id);
33
+ if (!tag) {
34
+ tag = document.createElement("style");
35
+ tag.id = id;
36
+ document.head.appendChild(tag);
37
+ }
38
+ return tag;
39
+ }
40
+
41
+ /** Put the applied theme and any preview on the page, unless safe mode is on. */
42
+ function paintTheme() {
43
+ const safe = themeSafe();
44
+ themeTag("oh-user-theme").textContent = safe || !themeState ? "" : themeState.css;
45
+ const preview = themePreviewing ? themeCards.get(themePreviewing) : null;
46
+ // After the applied theme in the document, so a preview wins on order.
47
+ themeTag("oh-theme-preview").textContent = safe || !preview ? "" : preview.preview.css;
48
+ void measureWall();
49
+ }
50
+
51
+ /**
52
+ * How much of the page colour has to cover the conversation background for
53
+ * its text to stay readable, measured from the image itself.
54
+ *
55
+ * The image is drawn small, so each pixel is the average of a region, and its
56
+ * darkest and lightest are taken as the worst cases text could land on. The
57
+ * scrim is then raised until body text and secondary text both clear 4.5:1
58
+ * over both, with the scrim mixed in the way the stylesheet mixes it. The
59
+ * result is a floor: the stylesheet uses it or the theme's own scrim,
60
+ * whichever is more, so a theme can be calmer than this but never busier.
61
+ */
62
+ let wallMeasured = "";
63
+ async function measureWall() {
64
+ const logEl = document.getElementById("log");
65
+ const root = getComputedStyle(document.documentElement);
66
+ const image = getComputedStyle(logEl).getPropertyValue("--wall-image").trim();
67
+ const match = /url\(["']?(\/theme-asset\/[0-9a-f]{64}\.(?:png|jpg|webp))["']?\)/.exec(image);
68
+ // Read from the root: #log may carry a lifted --dim of its own from last time.
69
+ const colours = ["--bg", "--fg", "--dim"].map((name) => root.getPropertyValue(name).trim());
70
+ const key = (match ? match[1] : "") + "|" + colours.join(",");
71
+ if (key === wallMeasured) return;
72
+ wallMeasured = key;
73
+ if (!match) {
74
+ logEl.style.removeProperty("--wall-scrim-min");
75
+ logEl.style.removeProperty("--dim");
76
+ return;
77
+ }
78
+
79
+ const img = new Image();
80
+ img.src = match[1];
81
+ try { await img.decode(); } catch { return; }
82
+ if (wallMeasured !== key) return;
83
+ const canvas = document.createElement("canvas");
84
+ canvas.width = 48; canvas.height = 32;
85
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
86
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
87
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
88
+
89
+ const rgb = (hex) => {
90
+ const m = /^#([0-9a-f]{6})/i.exec(hex);
91
+ if (!m) return null;
92
+ const n = parseInt(m[1], 16);
93
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
94
+ };
95
+ const [bg, fg, dim] = colours.map(rgb);
96
+ if (!bg || !fg || !dim) return;
97
+ const lin = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
98
+ const lum = (c) => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
99
+ const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
100
+
101
+ let darkest = null, lightest = null;
102
+ for (let i = 0; i < data.length; i += 4) {
103
+ const px = [data[i], data[i + 1], data[i + 2]];
104
+ if (!darkest || lum(px) < lum(darkest)) darkest = px;
105
+ if (!lightest || lum(px) > lum(lightest)) lightest = px;
106
+ }
107
+ // Two levers, used in this order: how far secondary text may be lifted
108
+ // toward body text (up to 60%, so it still reads as secondary), and how much
109
+ // of the page colour covers the image. Without the first, a theme whose dim
110
+ // only just passes on its own background would need the image covered
111
+ // completely, and the background would be there and invisible.
112
+ const lift = (t) => dim.map((v, i) => v * (1 - t) + fg[i] * t);
113
+ let cover = 100, lifted = 0;
114
+ search: for (let pct = 0; pct <= 100; pct += 2) {
115
+ const a = pct / 100;
116
+ const unders = [darkest, lightest].map((px) => px.map((v, i) => bg[i] * a + v * (1 - a)));
117
+ if (!unders.every((under) => ratio(fg, under) >= 4.5)) continue;
118
+ for (let step = 0; step <= 12; step++) {
119
+ const t = step * 0.05;
120
+ if (unders.every((under) => ratio(lift(t), under) >= 4.5)) { cover = pct; lifted = t; break search; }
121
+ }
122
+ }
123
+ logEl.style.setProperty("--wall-scrim-min", cover + "%");
124
+ // The theme's own scrim may be heavier than the floor; secondary text then
125
+ // needs less lift, or none, and keeps more of its own colour.
126
+ const themed = parseFloat(getComputedStyle(logEl).getPropertyValue("--wall-scrim")) || 0;
127
+ if (themed > cover) {
128
+ const a = themed / 100;
129
+ const unders = [darkest, lightest].map((px) => px.map((v, i) => bg[i] * a + v * (1 - a)));
130
+ for (let step = 0; step <= 12; step++) {
131
+ if (unders.every((under) => ratio(lift(step * 0.05), under) >= 4.5)) { lifted = step * 0.05; break; }
132
+ }
133
+ }
134
+ if (lifted > 0) {
135
+ const hex = "#" + lift(lifted).map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
136
+ logEl.style.setProperty("--dim", hex);
137
+ } else {
138
+ logEl.style.removeProperty("--dim");
139
+ }
140
+ }
141
+
142
+ // The colours the floor was measured against change with the system theme
143
+ // and with the light/dark switch; measure again when they do.
144
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => void measureWall());
145
+ new MutationObserver(() => void measureWall())
146
+ .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
147
+
148
+ function themeSwatchRow(label, colours) {
149
+ const row = el("div", "theme-swatch-row");
150
+ row.appendChild(el("span", "theme-swatch-label", label));
151
+ for (const colour of colours) {
152
+ const swatch = el("span", "theme-swatch");
153
+ // Validated hex from the server; set as a property, never as markup.
154
+ swatch.style.background = colour;
155
+ swatch.title = colour;
156
+ row.appendChild(swatch);
157
+ }
158
+ return row;
159
+ }
160
+
161
+ function themeReportList(title, lines) {
162
+ if (!lines || !lines.length) return null;
163
+ const box = el("div", "theme-report");
164
+ box.appendChild(el("div", "theme-report-title", title));
165
+ for (const line of lines) box.appendChild(el("div", "theme-report-line", line));
166
+ return box;
167
+ }
168
+
169
+ async function themeAction(path, body, box) {
170
+ const result = await api(path, body);
171
+ if (result.error) {
172
+ const note = el("p", "err", t("theme.failed", { error: result.error }));
173
+ box.appendChild(note);
174
+ return null;
175
+ }
176
+ if (result.state) setThemeState(result.state);
177
+ return result;
178
+ }
179
+
180
+ function renderThemeCard(entry) {
181
+ const { box, preview } = entry;
182
+ box.replaceChildren();
183
+ const head = el("div", "theme-card-head");
184
+ head.appendChild(el("span", "theme-card-name", t("theme.previewTitle", { name: preview.name })));
185
+ const statusKey = themePreviewing === preview.id ? "theme.status.previewing" : "theme.status." + entry.status;
186
+ head.appendChild(el("span", "theme-card-status " + entry.status, t(statusKey)));
187
+ box.appendChild(head);
188
+
189
+ const swatches = el("div", "theme-swatches");
190
+ if (preview.swatches.light) swatches.appendChild(themeSwatchRow(t("theme.light"), preview.swatches.light));
191
+ if (preview.swatches.dark) swatches.appendChild(themeSwatchRow(t("theme.dark"), preview.swatches.dark));
192
+ box.appendChild(swatches);
193
+
194
+ if (preview.images && preview.images.length) {
195
+ const images = el("div", "theme-images");
196
+ for (const image of preview.images) {
197
+ const figure = el("div", "theme-image");
198
+ const thumb = el("img");
199
+ // Only our own asset route, as the server printed it.
200
+ if (/^\/theme-asset\/[0-9a-f]{64}\.(png|jpg|webp)$/.test(image.url)) thumb.src = image.url;
201
+ thumb.alt = "";
202
+ figure.appendChild(thumb);
203
+ figure.appendChild(el("span", "", t("theme.slot." + image.slot)));
204
+ images.appendChild(figure);
205
+ }
206
+ box.appendChild(images);
207
+ }
208
+
209
+ for (const part of [
210
+ themeReportList(t("theme.fixed"), preview.report.fixed),
211
+ themeReportList(t("theme.dropped"), preview.report.dropped),
212
+ themeReportList(t("theme.notes"), preview.report.notes),
213
+ ]) if (part) box.appendChild(part);
214
+
215
+ const actions = el("div", "connection-actions theme-actions");
216
+ if (entry.status === "pending") {
217
+ const previewing = themePreviewing === preview.id;
218
+ const tryOn = el("button", "ghost", t(previewing ? "theme.stopPreview" : "theme.preview"));
219
+ tryOn.type = "button";
220
+ tryOn.setAttribute("aria-pressed", previewing ? "true" : "false");
221
+ tryOn.onclick = () => {
222
+ themePreviewing = previewing ? null : preview.id;
223
+ paintTheme();
224
+ for (const other of themeCards.values()) renderThemeCard(other);
225
+ };
226
+ const discard = el("button", "ghost", t("theme.discard"));
227
+ discard.type = "button";
228
+ discard.onclick = async () => {
229
+ discard.disabled = true;
230
+ await themeAction("/theme/discard", { preview: preview.id }, box);
231
+ closeThemePreview(preview.id, "discarded");
232
+ };
233
+ const apply = el("button", "ghost theme-apply", t("theme.apply"));
234
+ apply.type = "button";
235
+ apply.onclick = async () => {
236
+ apply.disabled = true;
237
+ const done = await themeAction("/theme/apply", { preview: preview.id }, box);
238
+ if (done) closeThemePreview(preview.id, "applied");
239
+ else apply.disabled = false;
240
+ };
241
+ actions.appendChild(tryOn);
242
+ actions.appendChild(discard);
243
+ actions.appendChild(apply);
244
+ } else if (entry.status === "applied") {
245
+ const undo = el("button", "ghost", t("theme.undo"));
246
+ undo.type = "button";
247
+ undo.disabled = !(themeState && themeState.canUndo);
248
+ undo.onclick = async () => { undo.disabled = true; await themeAction("/theme/undo", {}, box); };
249
+ const reset = el("button", "ghost", t("theme.reset"));
250
+ reset.type = "button";
251
+ reset.disabled = !(themeState && themeState.active);
252
+ reset.onclick = async () => { reset.disabled = true; await themeAction("/theme/reset", {}, box); };
253
+ actions.appendChild(undo);
254
+ actions.appendChild(reset);
255
+ }
256
+ if (actions.childNodes.length) box.appendChild(actions);
257
+ box.appendChild(el("p", "theme-safe-hint", t("theme.safeHint", { key: themeSafeKey })));
258
+ }
259
+
260
+ /**
261
+ * The way back that does not depend on a card.
262
+ *
263
+ * A card lives in the conversation that proposed the theme and in this
264
+ * server's memory, so after a restart or in another session there is no card
265
+ * to press Reset on. This row sits under the light/dark switch in the account
266
+ * menu, and is there only while a custom theme is in use.
267
+ */
268
+ function customThemeRows() {
269
+ if (!themeState || !themeState.active) return [];
270
+ const row = el("div", "menu-theme menu-custom-theme");
271
+ // The name alone: the row sits under "Theme", so what it is goes without
272
+ // saying, and every character spent saying it was one the name lost.
273
+ const label = el("span", "menu-label", themeState.active.name);
274
+ label.title = t("theme.custom", { name: themeState.active.name });
275
+ row.appendChild(label);
276
+ const group = el("div", "custom-theme-actions");
277
+ const act = (key, path, enabled, longKey) => {
278
+ const button = el("button", "custom-theme-btn", t(key));
279
+ button.type = "button";
280
+ button.title = t(longKey || key);
281
+ button.setAttribute("aria-label", t(longKey || key));
282
+ button.disabled = !enabled;
283
+ button.addEventListener("click", async (e) => {
284
+ e.stopPropagation();
285
+ button.disabled = true;
286
+ const result = await api(path, {});
287
+ if (result.state) setThemeState(result.state);
288
+ closeRowMenu();
289
+ if (result.error) addNotice(t("theme.failed", { error: result.error }), "err");
290
+ });
291
+ group.appendChild(button);
292
+ };
293
+ act("theme.undo", "/theme/undo", themeState.canUndo);
294
+ act("theme.resetShort", "/theme/reset", true, "theme.reset");
295
+ row.appendChild(group);
296
+ return [row];
297
+ }
298
+
299
+ /** A proposal from set_theme, drawn in the conversation that asked for it. */
300
+ function themePreviewCard(preview) {
301
+ let entry = themeCards.get(preview.id);
302
+ if (!entry) {
303
+ const target = pane(preview.session);
304
+ const box = el("section", "row-card column connection-card theme-card");
305
+ box.dataset.themePreview = preview.id;
306
+ box.setAttribute("aria-live", "polite");
307
+ target.node.appendChild(box);
308
+ entry = { box, preview, status: "pending" };
309
+ themeCards.set(preview.id, entry);
310
+ } else {
311
+ entry.preview = preview;
312
+ }
313
+ renderThemeCard(entry);
314
+ if (preview.session === activeId) follow();
315
+ }
316
+
317
+ function closeThemePreview(id, outcome) {
318
+ const entry = themeCards.get(id);
319
+ if (themePreviewing === id) themePreviewing = null;
320
+ if (entry && entry.status === "pending") entry.status = outcome;
321
+ paintTheme();
322
+ for (const other of themeCards.values()) renderThemeCard(other);
323
+ }
324
+
325
+ function setThemeState(state) {
326
+ themeState = state;
327
+ paintTheme();
328
+ for (const entry of themeCards.values()) renderThemeCard(entry);
329
+ }
330
+
331
+ async function loadThemes() {
332
+ try {
333
+ const result = await fetch(q("/theme")).then((r) => r.json());
334
+ setThemeState(result.state);
335
+ for (const preview of result.previews || []) themePreviewCard(preview);
336
+ } catch {
337
+ // The theme the server printed into the page is already showing; a card
338
+ // that could not be fetched again is not worth an error on screen.
339
+ }
340
+ }
341
+
342
+ document.addEventListener("keydown", (e) => {
343
+ if (e.code !== "Digit0") return;
344
+ if (!e.shiftKey || e.altKey) return;
345
+ if (IS_MAC ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
346
+ e.preventDefault();
347
+ const on = !themeSafe();
348
+ try {
349
+ if (on) sessionStorage.setItem(THEME_SAFE_KEY, "1");
350
+ else sessionStorage.removeItem(THEME_SAFE_KEY);
351
+ } catch { /* storage refused: the URL form of safe mode still works */ }
352
+ if (!on && new URLSearchParams(location.search).get("theme") === "default") {
353
+ // Loaded in safe mode by URL: the way back on is a load without it.
354
+ const url = new URL(location.href);
355
+ url.searchParams.delete("theme");
356
+ location.replace(url.href);
357
+ return;
358
+ }
359
+ paintTheme();
360
+ addNotice(t(on ? "theme.safeOn" : "theme.safeOff", { key: themeSafeKey }));
361
+ });
362
+
363
+ setTimeout(() => { void loadThemes(); }, 0);
364
+ `;
365
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../../src/page/client/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2V9B,CAAC"}
@@ -190,8 +190,10 @@ function actButton(icon, label) {
190
190
  }
191
191
 
192
192
  function turnActions(wrap, keepMessages) {
193
- if (!keepMessages || wrap.querySelector(":scope > .actions")) return;
194
- const bar = el("div", "actions");
193
+ if (!keepMessages || wrap.querySelector(":scope > .actions[data-turn]")) return;
194
+ // One bar per reply: an @claude reply already has one, holding its button.
195
+ const bar = wrap.querySelector(":scope > .actions") || el("div", "actions");
196
+ bar.dataset.turn = "1";
195
197
 
196
198
  const fork = actButton("fork", t("turn.fork"));
197
199
  fork.addEventListener("click", () => {
@@ -212,7 +214,26 @@ function turnActions(wrap, keepMessages) {
212
214
  });
213
215
  bar.appendChild(copy);
214
216
 
215
- wrap.appendChild(bar);
217
+ if (!bar.parentNode) wrap.appendChild(bar);
218
+ }
219
+
220
+ /**
221
+ * A reply an external agent wrote (@claude): named for it rather than
222
+ * "assistant", and, when the agent's session is known, a button that opens
223
+ * that session in Claude Code itself — where the whole of it can be read,
224
+ * every tool call and result, and carried on.
225
+ */
226
+ function agentReply(wrap, agent) {
227
+ if (!wrap || !agent || !agent.name) return;
228
+ const who = wrap.querySelector(":scope > .who");
229
+ if (who) who.textContent = agent.name;
230
+ if (!agent.session || wrap.querySelector(":scope > .actions [data-agent-open]")) return;
231
+ const bar = wrap.querySelector(":scope > .actions") || el("div", "actions");
232
+ const open = actButton("command", t("projects.claudeCode"));
233
+ open.dataset.agentOpen = "1";
234
+ open.addEventListener("click", () => void openClaudeCode(agent.cwd || currentCwd, agent.session));
235
+ bar.prepend(open);
236
+ if (!bar.parentNode) wrap.appendChild(bar);
216
237
  }
217
238
 
218
239
  /**
@@ -646,14 +667,20 @@ function toolShots(body, result) {
646
667
  if (strip.childElementCount) body.appendChild(strip);
647
668
  }
648
669
 
670
+ /** Pictures a turn shows in its row; the rest open from the last of them. */
671
+ const TURN_SHOTS_SHOWN = 5;
672
+
649
673
  /**
650
- * The windows a Computer use turn ended on, under its reply.
674
+ * The windows and browser tabs a turn ended on, under its reply.
651
675
  *
652
676
  * The agent reads the accessibility tree rather than looking, so a turn in a
653
677
  * native app often has no picture of its own; the host takes one of each
654
678
  * window when the turn ends (see turn-shots.ts). Out in the turn rather than
655
679
  * folded into a step, because it is the result — what the work left behind —
656
680
  * and one per window at most. Above the fork and copy bar, which belongs last.
681
+ *
682
+ * One row however many there are: a turn through six windows and six tabs
683
+ * would otherwise stack four rows of pictures under a two-line reply.
657
684
  */
658
685
  function turnShots(wrap, shots) {
659
686
  if (!wrap || !shots || !shots.length) return;
@@ -666,21 +693,49 @@ function turnShots(wrap, shots) {
666
693
  }
667
694
  for (const shot of shots) {
668
695
  const figure = el("figure", "turn-shot");
696
+ const frame = el("div", "turn-shot-frame");
669
697
  const img = document.createElement("img");
670
698
  img.src = q(shot.src);
671
699
  img.alt = shot.app || "";
672
700
  img.loading = "lazy";
673
701
  img.addEventListener("click", () => previewImage(img));
674
- img.addEventListener("error", () => figure.remove());
675
- figure.appendChild(img);
676
- const caption = el("figcaption", "", shot.title ? shot.app + " — " + shot.title : shot.app);
702
+ img.addEventListener("error", () => { figure.remove(); fitTurnShots(strip); });
703
+ frame.appendChild(img);
704
+ figure.appendChild(frame);
705
+ // A browser tab is captioned by the browser and the page; its title says
706
+ // more than its host, which is the fallback.
707
+ const label = shot.kind === "browser"
708
+ ? t("turnShots.browser") + " — " + (shot.title || shot.app)
709
+ : shot.title ? shot.app + " — " + shot.title : shot.app;
710
+ const caption = el("figcaption", "", label);
677
711
  caption.title = caption.textContent;
678
712
  figure.appendChild(caption);
679
713
  strip.appendChild(figure);
680
714
  }
715
+ fitTurnShots(strip);
681
716
  follow();
682
717
  }
683
718
 
719
+ /**
720
+ * Show the first pictures of a row and fold the rest behind a "+N" on the last
721
+ * one shown. Run again whenever the row changes: Computer use and the browser
722
+ * each add theirs, and a picture that fails to load takes itself out.
723
+ */
724
+ function fitTurnShots(strip) {
725
+ const figures = [...strip.querySelectorAll(":scope > .turn-shot")];
726
+ const more = figures.length - TURN_SHOTS_SHOWN;
727
+ strip.querySelectorAll(".turn-shot-more").forEach((badge) => badge.remove());
728
+ figures.forEach((figure, index) => { figure.hidden = index >= TURN_SHOTS_SHOWN; });
729
+ if (more <= 0) return;
730
+ const last = figures[TURN_SHOTS_SHOWN - 1];
731
+ const img = last.querySelector("img");
732
+ const badge = el("button", "turn-shot-more", "+" + more);
733
+ badge.type = "button";
734
+ badge.setAttribute("aria-label", t("turnShots.more", { n: more }));
735
+ badge.addEventListener("click", () => previewImage(img));
736
+ last.querySelector(".turn-shot-frame").appendChild(badge);
737
+ }
738
+
684
739
  /**
685
740
  * An image at full size, over the page.
686
741
  *
@@ -996,6 +1051,7 @@ async function focusTab(id) {
996
1051
  setNav(null);
997
1052
  paintEmpty(panes.get(id));
998
1053
  paintTabs();
1054
+ paintSuggestion();
999
1055
  browserActivateSession();
1000
1056
  void computerActivateSession();
1001
1057
 
@@ -1 +1 @@
1
- {"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAigCnC,CAAC"}
1
+ {"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyjCnC,CAAC"}