@oharness/server 0.10.0 → 0.12.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.
- package/dist/analytics.d.ts +32 -29
- package/dist/analytics.js +225 -278
- package/dist/analytics.js.map +1 -1
- package/dist/i18n.js +242 -12
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +3 -0
- package/dist/icons.js +69 -3
- package/dist/icons.js.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/page/client/account.js +140 -4
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +42 -16
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +641 -47
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/dialogs.js +75 -0
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +25 -2
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +4 -4
- package/dist/page/client/ripple.d.ts +13 -0
- package/dist/page/client/ripple.js +247 -0
- package/dist/page/client/ripple.js.map +1 -0
- package/dist/page/client/routines.js +173 -41
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +200 -54
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +7 -2
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/terminal.js +34 -7
- package/dist/page/client/terminal.js.map +1 -1
- package/dist/page/client/theme.d.ts +17 -0
- package/dist/page/client/theme.js +365 -0
- package/dist/page/client/theme.js.map +1 -0
- package/dist/page/client/transcript.js +63 -7
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/style.js +273 -22
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +5 -5
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +17 -0
- package/dist/prefs.js +5 -0
- package/dist/prefs.js.map +1 -1
- package/dist/server.js +710 -45
- package/dist/server.js.map +1 -1
- package/dist/suggest.d.ts +42 -0
- package/dist/suggest.js +215 -0
- package/dist/suggest.js.map +1 -0
- package/dist/terminal.d.ts +35 -2
- package/dist/terminal.js +60 -4
- package/dist/terminal.js.map +1 -1
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +109 -0
- package/dist/theme-assets.js.map +1 -0
- package/dist/theme-store.d.ts +50 -0
- package/dist/theme-store.js +124 -0
- package/dist/theme-store.js.map +1 -0
- package/dist/theme-tools.d.ts +25 -0
- package/dist/theme-tools.js +243 -0
- package/dist/theme-tools.js.map +1 -0
- package/dist/tool-icons.d.ts +12 -0
- package/dist/tool-icons.js +16 -0
- package/dist/tool-icons.js.map +1 -0
- package/dist/turn-shots.d.ts +18 -2
- package/dist/turn-shots.js +10 -1
- package/dist/turn-shots.js.map +1 -1
- package/dist/user-theme.d.ts +143 -0
- package/dist/user-theme.js +510 -0
- package/dist/user-theme.js.map +1 -0
- 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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
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"}
|