vanspod 0.0.0-stage → 0.1.2

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 (79) hide show
  1. package/README.md +74 -2
  2. package/bin/register.cjs +11 -0
  3. package/bin/vanspod.cjs +2 -0
  4. package/dist/adapterSettings.js +37 -0
  5. package/dist/adapters/chat-output.js +69 -0
  6. package/dist/adapters/chat-stream.js +44 -0
  7. package/dist/adapters/chat.js +63 -0
  8. package/dist/adapters/claude-reasoning.js +118 -0
  9. package/dist/adapters/claude-request.js +190 -0
  10. package/dist/adapters/claude-response.js +79 -0
  11. package/dist/adapters/index.js +19 -0
  12. package/dist/adapters/messages-stream.js +126 -0
  13. package/dist/adapters/messages.js +21 -0
  14. package/dist/adapters/responses-stream.js +70 -0
  15. package/dist/adapters/responses.js +28 -0
  16. package/dist/adapters/stream.js +99 -0
  17. package/dist/adapters/transport.js +26 -0
  18. package/dist/adapters/types.js +35 -0
  19. package/dist/appearance.js +10 -0
  20. package/dist/classroomApp.js +1814 -0
  21. package/dist/classroomUpdate.js +155 -0
  22. package/dist/claudeWebSearch.js +248 -0
  23. package/dist/clientDetection.js +35 -0
  24. package/dist/clientIcons.js +33 -0
  25. package/dist/clientRouteRuntime.js +149 -0
  26. package/dist/clientRoutes.js +118 -0
  27. package/dist/clientSettingsBackup.js +110 -0
  28. package/dist/clientTraffic.js +26 -0
  29. package/dist/clipboard.js +32 -0
  30. package/dist/codexWebSearch.js +80 -0
  31. package/dist/codexWire/apply-patch-envelope.js +139 -0
  32. package/dist/codexWire/chat-options.js +51 -0
  33. package/dist/codexWire/chat-tools.js +80 -0
  34. package/dist/codexWire/freeform-wrapper-scan.js +326 -0
  35. package/dist/codexWire/progressive-freeform-input.js +133 -0
  36. package/dist/codexWire/request.js +73 -0
  37. package/dist/codexWire/responses-tools.js +88 -0
  38. package/dist/codexWire/tool-arguments.js +39 -0
  39. package/dist/codexWire/tool-guidance.js +77 -0
  40. package/dist/commandRunner.js +100 -0
  41. package/dist/connectionMascot.js +235 -0
  42. package/dist/courseCatalog.js +131 -0
  43. package/dist/desktop.js +25 -0
  44. package/dist/entry.js +17 -0
  45. package/dist/environmentIcons.js +11 -0
  46. package/dist/exitGuard.js +17 -0
  47. package/dist/folderPicker.js +35 -0
  48. package/dist/installProgress.js +171 -0
  49. package/dist/installations.js +72 -0
  50. package/dist/instance.js +160 -0
  51. package/dist/launcher.js +155 -0
  52. package/dist/legacyMigration.js +87 -0
  53. package/dist/lifecyclePage.js +41 -0
  54. package/dist/modelProxy.js +105 -0
  55. package/dist/modelStatus.js +42 -0
  56. package/dist/page.js +1016 -0
  57. package/dist/pagePrototype.js +528 -0
  58. package/dist/platformStorage.js +91 -0
  59. package/dist/removal.js +41 -0
  60. package/dist/responsesChat.js +835 -0
  61. package/dist/routeFiles.js +646 -0
  62. package/dist/routerSessionModels.js +32 -0
  63. package/dist/runtimeOptions.js +14 -0
  64. package/dist/secureStorage.js +63 -0
  65. package/dist/settingsActions.js +100 -0
  66. package/dist/start.js +822 -0
  67. package/dist/storageErrors.js +17 -0
  68. package/dist/storageLocation.js +58 -0
  69. package/dist/version.js +4 -0
  70. package/dist/vsCodeRouteSettings.js +208 -0
  71. package/dist/webSearch.js +56 -0
  72. package/dist/windowsSecureStorage.js +163 -0
  73. package/media/brand-icon.ico +0 -0
  74. package/media/brand-logo.png +0 -0
  75. package/media/fadou-superhero-blink.png +0 -0
  76. package/media/fadou-superhero.png +0 -0
  77. package/package.json +27 -4
  78. package/scripts/tray.ps1 +40 -0
  79. package/scripts/uninstall.ps1 +133 -0
package/dist/page.js ADDED
@@ -0,0 +1,1016 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.modelStatus = modelStatus;
4
+ exports.classroomBlocks = classroomBlocks;
5
+ exports.renderPage = renderPage;
6
+ const version_1 = require("./version");
7
+ const appearance_1 = require("./appearance");
8
+ const installProgress_1 = require("./installProgress");
9
+ const settingsActions_1 = require("./settingsActions");
10
+ const modelStatus_1 = require("./modelStatus");
11
+ const classroomUpdate_1 = require("./classroomUpdate");
12
+ const connectionMascot_1 = require("./connectionMascot");
13
+ const clientIcons_1 = require("./clientIcons");
14
+ const environmentIcons_1 = require("./environmentIcons");
15
+ const courseCatalog_1 = require("./courseCatalog");
16
+ const PRODUCT = "VansPod";
17
+ const VERSION = `v${version_1.VERSION}`;
18
+ const COPY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
19
+ const MOON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"/></svg>`;
20
+ const SUN = `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" fill="currentColor"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5 5l1.4 1.4M17.6 17.6 19 19M19 5l-1.4 1.4M6.4 17.6 5 19"/></svg>`;
21
+ function escapeHtml(value) {
22
+ return value
23
+ .replaceAll("&", "&amp;")
24
+ .replaceAll("<", "&lt;")
25
+ .replaceAll(">", "&gt;")
26
+ .replaceAll('"', "&quot;")
27
+ .replaceAll("'", "&#39;");
28
+ }
29
+ function locked(view) {
30
+ return view.commandRunning ? " disabled" : "";
31
+ }
32
+ function clientLabel(client) {
33
+ if (client === "codex")
34
+ return "Codex";
35
+ if (client === "claude")
36
+ return "Claude Code CLI";
37
+ return "VS Code";
38
+ }
39
+ function restartHtml(view) {
40
+ const clients = view.modelRestart ?? [];
41
+ const routes = clients.filter((client) => client !== "vscode");
42
+ const parts = [];
43
+ if (routes.length > 0) {
44
+ const names = routes.map((client) => clientLabel(client));
45
+ const joined = names.length === 1
46
+ ? names[0]
47
+ : `${names.slice(0, -1).join("、")} 與 ${names[names.length - 1]}`;
48
+ parts.push(`<p>設定幫你更新好了,${escapeHtml(joined)} 還在裝睡。重新開啟 ${escapeHtml(joined)} 後生效,披風可不包叫醒服務。</p>`);
49
+ }
50
+ if (clients.includes("vscode")) {
51
+ parts.push(`<p>設定幫你更新好了,VS Code 還在裝睡。請完全退出 VS Code 再打開,不要只重載視窗。別敷衍牠,牠真的會繼續睡。</p>`);
52
+ }
53
+ return parts.length ? `<div class="model-update" role="status" data-restart-clients="${clients.join(",")}"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v2"/></svg><div>${parts.join("")}</div></div>` : "";
54
+ }
55
+ function statusWord(view) {
56
+ if (!view.connected)
57
+ return "未連線";
58
+ return view.mode === "classroom" ? "Classroom" : "Native";
59
+ }
60
+ function notices(view) {
61
+ const notice = view.notice && view.notice !== view.connectionNotice ? `<p class="notice">${escapeHtml(view.notice)}</p>` : "";
62
+ const running = view.commandRunning ? `<p class="running">執行中</p>` : "";
63
+ const klass = view.classLabel
64
+ ? `<p class="class-label">${escapeHtml(view.classLabel)}</p>`
65
+ : "";
66
+ return `${klass}${notice}${running}`;
67
+ }
68
+ const COPY_KEY_NOTICE = "已複製 Classroom API Key。請勿分享給不信任的人。";
69
+ function connectionFeedback(view) {
70
+ const original = view.connectionError || view.connectionNotice || (view.detail !== "Classroom API Key 已設定。" ? view.detail : "");
71
+ const message = original === COPY_KEY_NOTICE
72
+ ? "複製好了!API Key 不是群組伴手禮,別發給不信任的人喔。" : original;
73
+ const feedback = `<div class="connection-feedback">${message ? `<div class="${view.connectionError ? "connection-error" : "connection-message"}" role="${view.connectionError ? "alert" : "status"}"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 15v2"/></svg><p>${escapeHtml(message)}</p></div>` : ""}</div>`;
74
+ return feedback;
75
+ }
76
+ function connectInner(view) {
77
+ const feedback = connectionFeedback(view);
78
+ if (view.canCopyKey) {
79
+ const off = locked(view);
80
+ return `<form method="post" action="/copy">
81
+ <button type="submit"${off}>複製 Classroom API Key</button>
82
+ </form>
83
+ <form method="post" action="/clear">
84
+ <button type="submit" class="quiet"${off}>清除連線</button>
85
+ </form>${feedback}`;
86
+ }
87
+ return `<form class="stack" method="post" action="/redeem">
88
+ <label>邀請碼 <input name="invite_code" autocomplete="off" value="${escapeHtml(view.connectionDraft?.inviteCode ?? "")}"${locked(view)}></label>
89
+ <label>課堂暱稱 <input name="nickname" autocomplete="off" value="${escapeHtml(view.connectionDraft?.nickname ?? "")}"${locked(view)}></label>
90
+ ${feedback}
91
+ <button type="submit" class="go"${locked(view)}>連線</button>
92
+ </form>`;
93
+ }
94
+ function folderInner(view) {
95
+ const value = view.projectFolder ? ` value="${escapeHtml(view.projectFolder)}"` : "";
96
+ const off = locked(view);
97
+ const note = view.installNotice
98
+ ? `<p class="hint">${escapeHtml(view.installNotice)}</p>`
99
+ : "";
100
+ return `<form class="stack" method="post" action="/project-folder">
101
+ <label>專案資料夾 <input name="project_folder" autocomplete="off"${value}${off}></label>
102
+ <button type="submit" class="go"${off}>設定專案資料夾</button>
103
+ ${note}
104
+ </form>`;
105
+ }
106
+ function modelInner(view) {
107
+ const off = locked(view);
108
+ const models = view.allowedModels ?? (view.modelId ? [view.modelId] : []);
109
+ const list = models.length
110
+ ? `<ul class="allowed-model-list">${models.map(id => `<li>${escapeHtml(id)}</li>`).join("")}</ul>`
111
+ : `<p class="hint">${view.connected ? "這堂課尚無可用模型。" : "連線課堂後可查看。"}</p>`;
112
+ return `
113
+ ${restartHtml(view)}
114
+ ${adapterForms(view)}
115
+ <details class="allowed-models"><summary>老師允許的模型 <span>${models.length}</span></summary>${list}</details>
116
+ <form method="post" action="/reload">
117
+ <button type="submit" class="quiet"${off}>重新載入</button>
118
+ </form>`;
119
+ }
120
+ function clientRoute(view, client) {
121
+ return client === "codex" ? view.clientRoutes?.codex : client === "claude" ? view.clientRoutes?.claudeCode : view.clientRoutes?.vsCodeChat;
122
+ }
123
+ function clientLight(view, client) {
124
+ const route = clientRoute(view, client);
125
+ const detected = client === "vscode" ? route?.health !== "gray" : view.detectedClients?.[client] ?? false;
126
+ const health = route && route.health !== "gray" ? route.health : detected ? "green" : "gray";
127
+ const healthText = health === "red" ? "入口失敗" : health === "yellow" ? "等待重開" : health === "green" ? "可用" : "未偵測";
128
+ const status = `${clientLabel(client)} ${route && route.health !== "gray" ? healthText : detected ? "已偵測" : "未偵測"}`;
129
+ const lightClass = health === "green" ? " detected" : health === "red" || health === "yellow" ? ` health-${health}` : "";
130
+ return { health, className: `client-light${lightClass}`, status };
131
+ }
132
+ function modelStatus(view) {
133
+ return {
134
+ lights: { codex: clientLight(view, "codex"), claude: clientLight(view, "claude"), vscode: clientLight(view, "vscode") },
135
+ restart: restartHtml(view),
136
+ };
137
+ }
138
+ function adapterForms(view) {
139
+ const selected = view.adapters ?? { codex: "responses", claude: "responses" };
140
+ const forms = ["codex", "claude", "vscode"].map(client => {
141
+ const choices = client === "claude" ? ["responses", "chat", "messages"] : ["responses", "chat"];
142
+ const selectedAdapter = client === "vscode" ? view.clientRoutes?.vsCodeChat.adapter ?? "responses" : selected[client];
143
+ const options = choices.map(id => `<option value="${id}"${selectedAdapter === id ? " selected" : ""}>${id === "responses" ? "Responses" : id === "chat" ? "Chat" : "Messages"}</option>`).join("");
144
+ const route = clientRoute(view, client);
145
+ const mode = client === "vscode" ? route?.mode ?? "native" : view.clientModes?.[client] ?? view.mode;
146
+ const { health, className, status } = clientLight(view, client);
147
+ const routeHealthy = health === "green" || health === "yellow";
148
+ const available = view.connected && (view.allowedModels?.length ?? (view.modelId ? 1 : 0)) > 0;
149
+ const adapterDisabled = view.commandRunning || !routeHealthy;
150
+ return `<div class="model-client"><div class="client-head"><span class="adapter-label"><span class="${className}" tabindex="0" title="${status}" aria-label="${status}" data-client-light="${client}"></span>${clientIcons_1.clientIcons[client]}<span>${clientLabel(client)}</span></span><form class="modes client-modes" method="post" action="/switch"><input type="hidden" name="client" value="${client}"><button type="submit" name="mode" value="native" aria-pressed="${mode === "native"}" class="${mode === "native" ? "on" : ""}"${locked(view)}>個人</button><button type="submit" name="mode" value="classroom" aria-pressed="${mode === "classroom"}" class="${mode === "classroom" ? "on" : ""}"${!available || !routeHealthy || view.commandRunning ? " disabled" : ""}>課堂</button></form></div><form class="adapter-row" method="post" action="/adapter"><input type="hidden" name="client" value="${client}"><span class="adapter-select"><select name="adapter" aria-label="${clientLabel(client)} API 協定"${adapterDisabled ? " disabled" : ""}>${options}</select><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m7 10 5 5 5-5"/></svg></span></form><span class="client-flow" data-client-flow="${client}" aria-hidden="true"></span></div>`;
151
+ }).join("");
152
+ return forms +
153
+ (view.adapterNotice ? `<p class="hint">${escapeHtml(view.adapterNotice)}</p>` : "");
154
+ }
155
+ function environmentInner(view) {
156
+ const off = locked(view);
157
+ const confirmOff = view.installAvailable && !view.commandRunning ? "" : " disabled";
158
+ const tools = view.tools
159
+ .map((tool) => {
160
+ const checked = tool.selected ? " checked" : "";
161
+ const mark = tool.installed ? "已安裝" : "未安裝";
162
+ return `<label class="tool"><input type="checkbox" name="tool" value="${escapeHtml(tool.id)}"${checked}${off}> ${environmentIcons_1.environmentIcons[tool.id]}<span>${escapeHtml(tool.label)}</span> <em>${mark}</em></label>`;
163
+ })
164
+ .join("");
165
+ const note = view.environmentNotice
166
+ ? `<p class="hint">${escapeHtml(view.environmentNotice)}</p>`
167
+ : "";
168
+ return `<form method="post" action="/environment-check">
169
+ <button type="submit" class="quiet"${off}>重新檢查</button>
170
+ </form>
171
+ <form class="stack" method="post" action="/environment">
172
+ <div class="tools">${tools}</div>
173
+ <button type="submit" class="go"${confirmOff}${view.commandRunning ? " hidden" : ""}>確認安裝</button>
174
+ </form>
175
+ ${note}${abortInstallForm(view)}`;
176
+ }
177
+ /** Abort Install 入口;僅在安裝執行中顯示,送出前由 installProgressScript 確認。 */
178
+ function abortInstallForm(view) {
179
+ return `<form method="post" action="/abort-install" data-abort-install${view.commandRunning ? "" : " hidden"}><button type="submit" class="quiet">停止安裝</button></form>`;
180
+ }
181
+ function catalogInner(view) {
182
+ if (!view.catalog && !view.catalogError) {
183
+ return `<p class="empty">這台電腦還沒有課程清單。</p>`;
184
+ }
185
+ const error = view.catalogError
186
+ ? `<p class="error">${escapeHtml(view.catalogError)}</p>`
187
+ : "";
188
+ const note = view.catalog?.localNote
189
+ ? `<p class="hint">${escapeHtml(view.catalog.localNote)}</p>`
190
+ : "";
191
+ return `${note}${error}`;
192
+ }
193
+ function commandInner(view) {
194
+ const hasOutput = view.commandOutput !== undefined;
195
+ if (!view.pendingCommand && !hasOutput) {
196
+ return `<p class="empty">沒有待確認的指令。</p>`;
197
+ }
198
+ const command = view.pendingCommand
199
+ ? `<pre class="pending">${escapeHtml(view.pendingCommand)}</pre>`
200
+ : "";
201
+ // 執行中保留「確認執行」的空間,讓「停止安裝」落在「取消」的位置,避免連點誤觸。
202
+ const running = view.commandRunning;
203
+ const choices = view.pendingCommand
204
+ ? `<div class="choices">
205
+ <form method="post" action="/confirm"${running ? " data-reserved" : ""}><button type="submit" class="go"${running ? " disabled" : ""}>確認執行</button></form>
206
+ <form method="post" action="/cancel"${running ? " hidden" : ""}><button type="submit" class="quiet">取消</button></form>
207
+ ${abortInstallForm(view)}
208
+ </div>`
209
+ : "";
210
+ const output = hasOutput
211
+ ? `<h3>指令輸出</h3><pre>${escapeHtml(view.commandOutput ?? "")}</pre>`
212
+ : "";
213
+ return `${command}${choices}${output}`;
214
+ }
215
+ function snippetInner(snippet) {
216
+ const hint = snippet.pasteHint
217
+ ? `<p class="hint">${escapeHtml(snippet.pasteHint)}</p>`
218
+ : "";
219
+ return `<h3>${escapeHtml(snippet.title)}</h3>
220
+ ${hint}
221
+ <pre>${escapeHtml(snippet.body)}</pre>`;
222
+ }
223
+ function catalogTable(view) {
224
+ const head = catalogInner(view);
225
+ const rows = (view.catalog?.actions ?? [])
226
+ .map((action) => {
227
+ const off = view.commandRunning ? " disabled" : "";
228
+ const description = action.description
229
+ ? `<p class="hint">${escapeHtml(action.description)}</p>`
230
+ : "";
231
+ return `<tr>
232
+ <td>${escapeHtml((0, courseCatalog_1.actionKindLabel)(action.kind))}</td>
233
+ <td><strong>${escapeHtml(action.title)}</strong>${description}<code>${escapeHtml(action.command)}</code></td>
234
+ <td><form method="post" action="/prepare">
235
+ <input type="hidden" name="action_id" value="${escapeHtml(action.id)}">
236
+ <button type="submit"${off}>查看完整指令</button>
237
+ </form></td>
238
+ </tr>`;
239
+ })
240
+ .join("");
241
+ const table = rows
242
+ ? `<table><thead><tr><th>種類</th><th>項目</th><th></th></tr></thead><tbody>${rows}</tbody></table>`
243
+ : "";
244
+ const snippets = (view.catalog?.snippets ?? [])
245
+ .map((snippet) => `<article class="line">${snippetInner(snippet)}</article>`)
246
+ .join("");
247
+ const snippetBlock = snippets ? `<h3>本課片段</h3>${snippets}` : "";
248
+ return `${head}${table}${snippetBlock}`;
249
+ }
250
+ function stopForm() {
251
+ return `<form method="post" action="/stop"><button type="submit" class="stop">停止</button></form>`;
252
+ }
253
+ function tile(title, body, wide = false) {
254
+ return `<section class="tile${wide ? " wide" : ""}"><h2>${title}</h2>${body}</section>`;
255
+ }
256
+ const CSS = `
257
+ :root {
258
+ color-scheme: light;
259
+ --bg: #f2f2f7;
260
+ --tile: #fff;
261
+ --text: #1d1d1f;
262
+ --muted: #6e6e73;
263
+ --accent: #009999;
264
+ --on-accent: #fff;
265
+ --quiet: #009999;
266
+ --stop: #ff3b30;
267
+ --input: #f2f2f7;
268
+ --modes: rgba(120, 120, 128, 0.16);
269
+ --modes-on: #fff;
270
+ --modes-on-text: #1d1d1f;
271
+ --bar: rgba(242, 242, 247, 0.72);
272
+ --line: rgba(60, 60, 67, 0.12);
273
+ --title-a: #007070;
274
+ --title-b: #009999;
275
+ --title-c: #f8c000;
276
+ }
277
+ [data-appearance="light"] {
278
+ color-scheme: light;
279
+ --bg: #f2f2f7;
280
+ --tile: #fff;
281
+ --text: #1d1d1f;
282
+ --muted: #6e6e73;
283
+ --accent: #009999;
284
+ --on-accent: #fff;
285
+ --quiet: #009999;
286
+ --stop: #ff3b30;
287
+ --input: #f2f2f7;
288
+ --modes: rgba(120, 120, 128, 0.16);
289
+ --modes-on: #fff;
290
+ --modes-on-text: #1d1d1f;
291
+ --bar: rgba(242, 242, 247, 0.72);
292
+ --line: rgba(60, 60, 67, 0.12);
293
+ --title-a: #007070;
294
+ --title-b: #009999;
295
+ --title-c: #f8c000;
296
+ }
297
+ [data-appearance="dark"] {
298
+ color-scheme: dark;
299
+ --bg: #0c0c0e;
300
+ --tile: #1c1c1e;
301
+ --text: #f5f5f7;
302
+ --muted: #98989d;
303
+ --accent: #f8c000;
304
+ --on-accent: #1c1c1e;
305
+ --quiet: #f8c000;
306
+ --stop: #ff453a;
307
+ --input: #2c2c2e;
308
+ --modes: #2c2c2e;
309
+ --modes-on: #3a3a3c;
310
+ --modes-on-text: #f8c000;
311
+ --bar: rgba(28, 28, 30, 0.72);
312
+ --line: rgba(255, 255, 255, 0.08);
313
+ --title-a: #9cf6ee;
314
+ --title-b: #2ec4b6;
315
+ --title-c: #f8c000;
316
+ }
317
+ @media (prefers-color-scheme: dark) {
318
+ :root:not([data-appearance="light"]) {
319
+ color-scheme: dark;
320
+ --bg: #0c0c0e;
321
+ --tile: #1c1c1e;
322
+ --text: #f5f5f7;
323
+ --muted: #98989d;
324
+ --accent: #f8c000;
325
+ --on-accent: #1c1c1e;
326
+ --quiet: #f8c000;
327
+ --stop: #ff453a;
328
+ --input: #2c2c2e;
329
+ --modes: #2c2c2e;
330
+ --modes-on: #3a3a3c;
331
+ --modes-on-text: #f8c000;
332
+ --bar: rgba(28, 28, 30, 0.72);
333
+ --line: rgba(255, 255, 255, 0.08);
334
+ --title-a: #9cf6ee;
335
+ --title-b: #2ec4b6;
336
+ --title-c: #f8c000;
337
+ }
338
+ }
339
+ * { box-sizing: border-box; }
340
+ html, body { margin: 0; min-height: 100%; }
341
+ body {
342
+ background: var(--bg);
343
+ color: var(--text);
344
+ font-family: system-ui, "Segoe UI", "Microsoft JhengHei", sans-serif;
345
+ line-height: 1.45;
346
+ }
347
+ button, input { font: inherit; color: inherit; }
348
+ button { cursor: pointer; }
349
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
350
+ button:active { transform: scale(0.97); transition: transform 100ms ease-out; }
351
+ pre, code { white-space: pre-wrap; overflow-wrap: anywhere; }
352
+ h1, h2, h3, p { margin: 0; }
353
+ .bar {
354
+ position: sticky;
355
+ top: 0;
356
+ z-index: 2;
357
+ display: flex;
358
+ align-items: center;
359
+ justify-content: space-between;
360
+ gap: 0.75rem;
361
+ padding: 0.75rem 1.1rem;
362
+ background: var(--bar);
363
+ backdrop-filter: blur(24px) saturate(160%);
364
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
365
+ border-bottom: 1px solid var(--line);
366
+ }
367
+ .brand { display: flex; align-items: center; gap: 0.7rem; min-width: 0; }
368
+ .logo-badge {
369
+ display: flex;
370
+ align-items: center;
371
+ justify-content: center;
372
+ width: 2.75rem;
373
+ height: 2.75rem;
374
+ padding: 3px;
375
+ border-radius: 8px;
376
+ background: #eef5f5;
377
+ box-shadow: 0 0 0 1px rgba(0, 112, 112, 0.18);
378
+ overflow: hidden;
379
+ flex-shrink: 0;
380
+ }
381
+ .logo-badge img { display: block; width: 100%; height: 100%; object-fit: contain; }
382
+ .gradient-text {
383
+ font-size: 1.35rem;
384
+ font-weight: 700;
385
+ letter-spacing: -0.02em;
386
+ line-height: 1.15;
387
+ background: linear-gradient(135deg, var(--title-a) 0%, var(--title-b) 46%, var(--title-c) 100%);
388
+ -webkit-background-clip: text;
389
+ background-clip: text;
390
+ -webkit-text-fill-color: transparent;
391
+ color: transparent;
392
+ }
393
+ [data-appearance-toggle] {
394
+ width: 2.75rem;
395
+ height: 2.75rem;
396
+ flex: 0 0 auto;
397
+ display: grid;
398
+ place-items: center;
399
+ padding: 0;
400
+ border: 0;
401
+ border-radius: 999px;
402
+ background: var(--tile);
403
+ color: var(--text);
404
+ }
405
+ [data-appearance-toggle] svg { width: 1.25rem; height: 1.25rem; }
406
+ main { width: min(52rem, 100%); margin: 0 auto; padding: 0.4rem 1.1rem 2rem; }
407
+ .display {
408
+ font-size: clamp(2.4rem, 6vw, 3.4rem);
409
+ font-weight: 700;
410
+ line-height: 1.02;
411
+ letter-spacing: -0.03em;
412
+ margin: 1rem 0 0.35rem;
413
+ }
414
+ .detail, .notice, .class-label { color: var(--text); }
415
+ .hint, .empty, .kind, .mode-line { color: var(--muted); }
416
+ .error, .restart { color: var(--stop); }
417
+ .running { color: var(--accent); font-weight: 700; }
418
+ .modules {
419
+ display: grid;
420
+ grid-template-columns: 1fr 1fr;
421
+ gap: 0.75rem;
422
+ margin-top: 1.2rem;
423
+ }
424
+ .tile {
425
+ min-width: 0;
426
+ display: flex;
427
+ flex-direction: column;
428
+ gap: 0.65rem;
429
+ padding: 0.95rem 1rem 1rem;
430
+ border-radius: 16px;
431
+ background: var(--tile);
432
+ }
433
+ .tile.wide { grid-column: 1 / -1; }
434
+ h2 {
435
+ font-size: 0.78rem;
436
+ font-weight: 600;
437
+ letter-spacing: 0.04em;
438
+ line-height: 1.3;
439
+ color: var(--muted);
440
+ }
441
+ form.stack, .tools { display: flex; flex-direction: column; gap: 0.65rem; align-items: stretch; width: 100%; }
442
+ label { display: flex; flex-direction: column; gap: 0.3rem; }
443
+ .tool { flex-direction: row; align-items: center; gap: 0.55rem; width: 100%; }
444
+ .tool span { min-width: 0; }
445
+ .tool svg { width: 20px; height: 20px; flex-shrink: 0; color: var(--text); }
446
+ .tool em { margin-left: auto; font-style: normal; color: var(--muted); font-size: 0.85rem; white-space: nowrap; }
447
+ .tool input[type="checkbox"] {
448
+ width: 1.15rem;
449
+ min-width: 1.15rem;
450
+ height: 1.15rem;
451
+ min-height: 1.15rem;
452
+ flex: 0 0 auto;
453
+ margin: 0;
454
+ }
455
+ input:not([type="checkbox"]) {
456
+ width: 100%;
457
+ min-height: 2.75rem;
458
+ border: 0;
459
+ border-radius: 10px;
460
+ background: var(--input);
461
+ color: var(--text);
462
+ padding: 0 0.8rem;
463
+ }
464
+ button {
465
+ min-height: 2.75rem;
466
+ border: 0;
467
+ border-radius: 10px;
468
+ background: var(--accent);
469
+ color: var(--on-accent);
470
+ font-weight: 700;
471
+ padding: 0 1rem;
472
+ align-self: flex-start;
473
+ }
474
+ button.quiet { background: transparent; color: var(--quiet); padding: 0; }
475
+ button.stop { background: transparent; color: var(--stop); }
476
+ .modes {
477
+ display: flex;
478
+ gap: 2px;
479
+ padding: 2px;
480
+ border-radius: 9px;
481
+ background: var(--modes);
482
+ }
483
+ .modes button { flex: 1; background: transparent; color: var(--text); }
484
+ .modes button.on { background: var(--modes-on); color: var(--modes-on-text); }
485
+ table { width: 100%; border-collapse: collapse; }
486
+ th, td { text-align: left; vertical-align: top; padding: 0.5rem 0.4rem 0.5rem 0; }
487
+ th { font-size: 0.75rem; font-weight: 600; color: var(--muted); }
488
+ code, pre { color: var(--text); font-family: Consolas, "Cascadia Mono", monospace; font-size: 0.86rem; }
489
+ .line { display: flex; flex-direction: column; gap: 0.35rem; padding-top: 0.65rem; }
490
+ .choices { display: flex; gap: 0.75rem; align-items: center; }
491
+ :focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
492
+ @media (max-width: 40rem) {
493
+ .modules { grid-template-columns: 1fr; }
494
+ .tile.wide { grid-column: auto; }
495
+ }
496
+ @media (prefers-reduced-motion: reduce) {
497
+ button:active { transform: none; transition: none; }
498
+ }
499
+ @media (prefers-reduced-transparency: reduce) {
500
+ .bar { background: var(--bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
501
+ }
502
+ `;
503
+ function appearanceScript() {
504
+ return `
505
+ ${appearance_1.resolveAppearance.toString()}
506
+ var appearanceKey = "classroom-appearance";
507
+ var moon = ${JSON.stringify(MOON)};
508
+ var sun = ${JSON.stringify(SUN)};
509
+ function readAppearance() {
510
+ try { return localStorage.getItem(appearanceKey); } catch (error) { return null; }
511
+ }
512
+ function systemDark() {
513
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
514
+ }
515
+ function paintAppearance() {
516
+ var appearance = resolveAppearance(readAppearance(), systemDark());
517
+ document.documentElement.dataset.appearance = appearance;
518
+ var button = document.querySelector("[data-appearance-toggle]");
519
+ if (!button) return;
520
+ var next = appearance === "dark" ? "light" : "dark";
521
+ button.setAttribute("aria-label", next === "dark" ? "切換到深色" : "切換到淺色");
522
+ button.innerHTML = next === "dark" ? moon : sun;
523
+ }
524
+ paintAppearance();
525
+ document.addEventListener("DOMContentLoaded", paintAppearance);
526
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function () {
527
+ var stored = readAppearance();
528
+ if (stored === "light" || stored === "dark") return;
529
+ paintAppearance();
530
+ });
531
+ document.addEventListener("click", function (event) {
532
+ var target = event.target;
533
+ if (!target || !target.closest) return;
534
+ var button = target.closest("[data-appearance-toggle]");
535
+ if (!button) return;
536
+ var current = document.documentElement.dataset.appearance === "dark" ? "dark" : "light";
537
+ var next = current === "dark" ? "light" : "dark";
538
+ try { localStorage.setItem(appearanceKey, next); } catch (error) {}
539
+ paintAppearance();
540
+ });
541
+ window.addEventListener("pagehide", function () {
542
+ navigator.sendBeacon("/close");
543
+ });
544
+ `;
545
+ }
546
+ function classroomBlocks(view) {
547
+ return {
548
+ status: statusWord(view),
549
+ notices: notices(view),
550
+ connect: connectInner(view),
551
+ folder: folderInner(view),
552
+ model: modelInner(view),
553
+ environment: environmentInner(view),
554
+ command: commandInner(view),
555
+ catalog: catalogTable(view),
556
+ stop: stopForm(),
557
+ };
558
+ }
559
+ function basePage(view) {
560
+ return `<!DOCTYPE html>
561
+ <html lang="zh-Hant">
562
+ <head>
563
+ <meta charset="utf-8">
564
+ <meta name="viewport" content="width=device-width, initial-scale=1">
565
+ <title>${PRODUCT}</title>
566
+ <link rel="icon" href="/favicon.ico" sizes="any">
567
+ <style>${CSS}</style>
568
+ <script>${appearanceScript()}</script>
569
+ </head>
570
+ <body>
571
+ <header class="bar">
572
+ <div class="brand">
573
+ <div class="logo-badge"><img src="/brand-logo.png" alt=""></div>
574
+ <h1 class="gradient-text">${PRODUCT}</h1>
575
+ </div>
576
+ <button type="button" data-appearance-toggle aria-label="切換外觀"></button>
577
+ </header>
578
+ <main>
579
+ <p class="display">${escapeHtml(statusWord(view))}</p>
580
+ ${notices(view)}
581
+ <div class="modules">
582
+ ${tile("連線", connectInner(view))}
583
+ ${tile("資料夾", folderInner(view))}
584
+ ${tile("模型", modelInner(view))}
585
+ ${tile("環境", environmentInner(view))}
586
+ ${tile("指令", commandInner(view), true)}
587
+ ${tile("課程", catalogTable(view), true)}
588
+ ${tile("離開", stopForm(), true)}
589
+ </div>
590
+ </main>
591
+ </body>
592
+ </html>`;
593
+ }
594
+ function installActionCards(view) {
595
+ const off = view.commandRunning || !view.projectFolder ? " disabled" : "";
596
+ return (view.catalog?.actions ?? [])
597
+ .map((action, index) => {
598
+ const number = index + 1;
599
+ const description = action.description
600
+ ? `<p class="hint">${escapeHtml(action.description)}</p>`
601
+ : "";
602
+ const installed = view.installedActionIds?.includes(action.id) ?? false;
603
+ return `<article class="install-card${installed ? " installed" : ""}"><span class="install-number" aria-hidden="true">${number}</span><div class="install-main"><div class="install-line"><strong><span class="visually-hidden">安裝 ${number}:</span>${escapeHtml(action.title)}</strong><span data-kind="${escapeHtml(action.kind)}" class="kind-tag">${escapeHtml((0, courseCatalog_1.actionKindLabel)(action.kind))}</span></div>${description}</div><form method="post" action="/prepare"><input type="hidden" name="action_id" value="${escapeHtml(action.id)}"><button type="submit"${installed ? ' class="quiet"' : ""}${off}>${installed ? "重新安裝" : "安裝"}</button></form></article>`;
604
+ })
605
+ .join("");
606
+ }
607
+ function presentCommand(body, view) {
608
+ let inline = body;
609
+ const pending = inline.match(/<pre class="pending">[\s\S]*?<\/pre>/);
610
+ const choices = inline.match(/<div class="choices">[\s\S]*?<\/div>/);
611
+ let dialog = "";
612
+ if (pending) {
613
+ dialog = `<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-title"><div class="confirm-panel"><h2 id="confirm-title">確認安裝</h2>${pending[0]}<p data-install-status role="status" aria-live="polite"></p><pre data-install-output aria-label="安裝輸出" hidden></pre>${choices?.[0] ?? ""}<button type="button" data-install-dismiss hidden>確認</button></div></div>`;
614
+ inline = inline.replace(pending[0], "");
615
+ if (choices)
616
+ inline = inline.replace(choices[0], "");
617
+ }
618
+ inline = inline.replace(/<h3>指令輸出<\/h3>(<pre>[\s\S]*?<\/pre>)/, "");
619
+ return { inline, dialog };
620
+ }
621
+ function projectFolderRow(view) {
622
+ return `<div class="project-folder-row"><div class="project-folder-info"><span class="hint">專案資料夾</span><p class="folder-path"${view.projectFolder ? ` title="${escapeHtml(view.projectFolder)}"` : ""}>${escapeHtml(view.projectFolder || "尚未選擇安裝位置")}</p></div><form method="post" action="/pick-folder"><button type="submit" class="quiet"${locked(view)}>${view.projectFolder ? "變更" : "選擇專案資料夾"}</button></form>${view.folderError ? `<p class="error" role="alert">${escapeHtml(view.folderError)}</p>` : ""}${view.installNotice ? `<p class="hint" role="status">${escapeHtml(view.installNotice)}</p>` : ""}</div>`;
623
+ }
624
+ function splitCourseCards(html, view) {
625
+ const commandMatch = html.match(/<section class="tile wide"><h2>指令<\/h2>([\s\S]*?)<\/section>/);
626
+ const courseMatch = html.match(/<section class="tile wide"><h2>課程<\/h2>([\s\S]*?)<\/section>/);
627
+ if (!commandMatch || !courseMatch)
628
+ return html;
629
+ const commandBody = commandMatch[1].includes("沒有待確認的指令。")
630
+ ? ""
631
+ : commandMatch[1];
632
+ const presented = presentCommand(commandBody, view);
633
+ const snippetHeading = "<h3>本課片段</h3>";
634
+ const snippetAt = courseMatch[1].indexOf(snippetHeading);
635
+ const courseBody = snippetAt === -1 ? courseMatch[1] : courseMatch[1].slice(0, snippetAt);
636
+ const copied = new Set(view.copiedSnippetIds ?? []);
637
+ const snippetBody = (view.catalog?.snippets ?? []).map((snippet, index) => {
638
+ const number = index + 1;
639
+ const title = escapeHtml(snippet.title);
640
+ const done = copied.has(snippet.id);
641
+ const hint = snippet.pasteHint
642
+ ? `<span class="snippet-hint">${escapeHtml(snippet.pasteHint)}</span>`
643
+ : "";
644
+ return `<article class="snippet-card${done ? " copied" : ""}"><span class="snippet-number" aria-hidden="true">${number}</span><div class="snippet-main"><div class="snippet-line"><strong><span class="visually-hidden">片段 ${number}:</span>${title}</strong>${hint}</div><details class="snippet-peek"><summary>查看內容</summary><pre class="snippet-code">${escapeHtml(snippet.body)}</pre></details></div><button type="button"${done ? ' class="quiet"' : ""} data-copy-snippet="${escapeHtml(snippet.id)}" aria-label="${done ? "再複製一次" : "複製"}「${title}」">${COPY_ICON}<span>${done ? "✓ 已複製" : "複製"}</span></button><span class="visually-hidden" data-snippet-copy-status role="status" aria-live="polite"></span></article>`;
645
+ }).join("") || `<p class="empty">這堂課沒有片段。</p>`;
646
+ const chevron = `<svg class="course-chevron" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6-6 6"/></svg>`;
647
+ const install = `<section class="tile wide"><details class="course-section" data-course-section="install" open><summary class="tile-head"><h2>課程安裝</h2>${chevron}</summary><div class="course-content">${courseBody}${presented.inline}</div></details></section>${presented.dialog}`;
648
+ const snippets = `<section class="tile wide"><details class="course-section" data-course-section="snippets" open><summary class="tile-head"><h2>課程片段</h2>${chevron}</summary><div class="course-content">${snippetBody}</div></details></section>`;
649
+ return html.replace(commandMatch[0], install).replace(courseMatch[0], snippets);
650
+ }
651
+ function drawerScript() {
652
+ return `
653
+ document.addEventListener("click", function (event) {
654
+ var target = event.target;
655
+ if (!target || !target.closest) return;
656
+ var bar = document.getElementById("side-menu");
657
+ var scrim = document.querySelector(".drawer-scrim");
658
+ var toggle = document.querySelector(".menu-toggle");
659
+ if (!bar || !scrim || !toggle) return;
660
+ function setOpen(open) {
661
+ bar.classList.toggle("open", open);
662
+ scrim.classList.toggle("show", open);
663
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
664
+ toggle.setAttribute("aria-label", open ? "關閉選單" : "開啟選單");
665
+ }
666
+ if (target.closest(".menu-toggle")) setOpen(!bar.classList.contains("open"));
667
+ if (target.closest(".drawer-scrim") || target.closest(".side-item")) setOpen(false);
668
+ });
669
+ `;
670
+ }
671
+ function snippetCopyScript() {
672
+ return `
673
+ document.addEventListener("click", async function (event) {
674
+ var target = event.target;
675
+ var button = target && target.closest ? target.closest("[data-copy-snippet]") : null;
676
+ if (!button || button.disabled || button.snippetCopying) return;
677
+ var card = button.closest(".snippet-card");
678
+ var status = card.querySelector("[data-snippet-copy-status]");
679
+ var label = button.querySelector("span");
680
+ var title = button.getAttribute("aria-label").replace(/^(再複製一次|複製)/, "");
681
+ button.snippetCopying = true;
682
+ status.textContent = "";
683
+ try {
684
+ var response = await fetch("/copy-snippet", {
685
+ method: "POST",
686
+ body: new URLSearchParams({ snippet_id: button.getAttribute("data-copy-snippet") }),
687
+ });
688
+ if (!response.ok || !(await response.json()).ok) throw new Error("copy failed");
689
+ card.classList.add("copied");
690
+ button.classList.add("quiet");
691
+ button.setAttribute("aria-label", "再複製一次" + title);
692
+ label.textContent = "已複製!";
693
+ status.textContent = "已複製" + title + "。";
694
+ } catch (error) {
695
+ label.textContent = "複製失敗";
696
+ status.textContent = "複製失敗,請再試一次。";
697
+ } finally {
698
+ button.snippetCopying = false;
699
+ clearTimeout(button.snippetCopyTimer);
700
+ button.snippetCopyTimer = setTimeout(function () {
701
+ label.textContent = card.classList.contains("copied") ? "✓ 已複製" : "複製";
702
+ }, 1500);
703
+ }
704
+ });
705
+ `;
706
+ }
707
+ function courseSectionScript() {
708
+ return `
709
+ function initializeCourseSections() {
710
+ document.querySelectorAll("[data-course-section]").forEach(function (section) {
711
+ var key = "vpod.course." + section.getAttribute("data-course-section") + ".collapsed";
712
+ try { section.open = sessionStorage.getItem(key) !== "true"; } catch (error) {}
713
+ section.addEventListener("toggle", function () {
714
+ try { sessionStorage.setItem(key, String(!section.open)); } catch (error) {}
715
+ });
716
+ });
717
+ }
718
+ initializeCourseSections();
719
+ `;
720
+ }
721
+ function organizeSettings(html, view) {
722
+ const cards = [];
723
+ const updated = html.replace(/<section class="tile"><div class="tile-head"><h2>(連線|模型|環境)<\/h2>([\s\S]*?)<\/section>/g, (_card, title, rest) => {
724
+ const headEnd = rest.indexOf("</div>") + 6;
725
+ const head = `<div class="tile-head"><h2>${title}</h2>${rest.slice(0, headEnd)}`;
726
+ let body = rest.slice(headEnd);
727
+ let actions = "";
728
+ let reminder = "";
729
+ const moveForm = (action) => {
730
+ body = body.replace(new RegExp(`<form[^>]*action="/${action}">[\\s\\S]*?</form>`), (form) => {
731
+ actions += form;
732
+ return "";
733
+ });
734
+ };
735
+ if (title === "連線") {
736
+ moveForm("clear");
737
+ if (actions && !view.connectionError && view.connectionNotice === COPY_KEY_NOTICE) {
738
+ const feedback = connectionFeedback(view);
739
+ body = body.replace(feedback, "");
740
+ const speech = `<div class="mascot-speech">${feedback}<button type="button" class="mascot-speech-close" aria-label="收起法鬥台詞">知道啦</button></div>`;
741
+ body = body.replace('class="connection-mascot"', 'class="connection-mascot mascot-waiting mascot-speaking"')
742
+ .replace('<div class="mascot-traveler">', `${speech}<div class="mascot-traveler">`);
743
+ }
744
+ if (!actions) {
745
+ body = body.replace('action="/redeem"', 'action="/redeem" id="settings-redeem"');
746
+ body = body.replace(/<button[\s\S]*?<\/button>/, (button) => {
747
+ actions = button.replace('<button ', '<button form="settings-redeem" ');
748
+ return "";
749
+ });
750
+ const feedback = body.match(/<div class="connection-feedback">[\s\S]*?<\/div>(?=\s*<\/form>)/)?.[0] ?? '<div class="connection-feedback"></div>';
751
+ body = body.replace(feedback, "");
752
+ const quote = view.connectionError === "請先輸入邀請碼與課堂暱稱,再按「連線」。"
753
+ ? "我披風都穿好了,你的邀請碼跟暱稱呢?"
754
+ : "連線這關還沒過,披風倒是先上班了。照下面的提示再試一次!";
755
+ const speech = `<div class="mascot-speech"><p class="mascot-quote">${escapeHtml(quote)}</p>${feedback}</div>`;
756
+ body += connectionMascot_1.connectionMascot.replace('class="connection-mascot"', 'class="connection-mascot mascot-waiting"').replace('<div class="mascot-traveler">', `${speech}<div class="mascot-traveler">`);
757
+ }
758
+ }
759
+ else if (title === "模型") {
760
+ body = body.replace(/<div class="model-update"[\s\S]*?<\/div><\/div>/, (block) => {
761
+ reminder = block;
762
+ return "";
763
+ });
764
+ moveForm("reload");
765
+ if (view.connected && view.canCopyKey) {
766
+ actions = "";
767
+ reminder = "";
768
+ }
769
+ }
770
+ else {
771
+ moveForm("environment-check");
772
+ body = body.replace('action="/environment"', 'action="/environment" id="settings-environment"');
773
+ body = body.replace(/<button[\s\S]*?<\/button>/, (button) => {
774
+ actions += button.replace('<button ', '<button form="settings-environment" ');
775
+ return "";
776
+ });
777
+ body = body.replace(/<form[^>]*data-abort-install[\s\S]*?<\/form>/, (abort) => {
778
+ actions += abort;
779
+ return "";
780
+ });
781
+ const missing = view.tools.filter((tool) => !tool.installed).length;
782
+ const summary = !view.tools.length ? "尚未檢查工具" : missing ? `尚缺 ${missing} 項工具` : "所有工具已安裝";
783
+ body = body.replace(/<div class="tools">([\s\S]*?)<\/div>/, `<p class="environment-summary">${summary}</p><details class="environment-tools"><summary>查看與選擇工具</summary><div class="tools">$1</div></details>`);
784
+ }
785
+ cards.push(`<section class="tile settings-card${title === "環境" ? " environment-card" : ""}" data-setting="${title}">${head}<div class="settings-content">${body}</div>${reminder}<div class="settings-actions">${actions}</div></section>`);
786
+ return "";
787
+ });
788
+ if (cards.length !== 3)
789
+ return html;
790
+ return updated.replace('<div class="modules">', `<div class="modules"><div class="settings-grid">${cards.slice(0, 2).join("")}</div>${projectFolderRow(view)}${cards[2]}`);
791
+ }
792
+ function renderPage(view) {
793
+ const tag = view.connected
794
+ ? `<span class="badge">已連線</span>`
795
+ : `<span class="badge off">未連線</span>`;
796
+ let html = basePage(view).replace(`<section class="tile"><h2>連線</h2>`, `<section class="tile"><div class="tile-head"><h2>連線</h2>${tag}</div>`);
797
+ html = html.replace(`<header class="bar">`, `<div class="mobile-top"><button type="button" class="menu-toggle" aria-label="開啟選單" aria-expanded="false" aria-controls="side-menu"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></button><div class="brand"><div class="logo-badge"><img src="/brand-logo.png" alt=""></div><h1 class="gradient-text">VansPod</h1></div></div><button type="button" class="drawer-scrim" aria-label="關閉選單"></button><header class="bar" id="side-menu">`);
798
+ html = html.replace(`<button type="button" data-appearance-toggle aria-label="切換外觀"></button>`, `<nav aria-label="選單"><button type="button" class="side-item" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 3-.6 2.3-2 .9-2.1-.6-2.5 4.3 1.5 1.7v2.3l-1.5 1.7 2.5 4.3 2.1-.6 2 .9.6 2.3h5l.6-2.3 2-.9 2.1.6 2.5-4.3-1.5-1.7v-2.3l1.5-1.7-2.5-4.3-2.1.6-2-.9L14.5 3z"/><circle cx="12" cy="12" r="3"/></svg><span>教室設定</span></button></nav><button type="button" data-appearance-toggle aria-label="切換外觀"></button>`);
799
+ html = html.replace(/<section class="tile"><h2>資料夾<\/h2>[\s\S]*?<\/section>/, "");
800
+ const modelCount = view.allowedModels?.length ?? (view.modelId ? 1 : 0);
801
+ const modelTag = modelCount
802
+ ? `<span class="badge">課堂可用 ${modelCount} 個</span>`
803
+ : `<span class="badge off">${view.connected ? "無課堂模型" : "未連線"}</span>`;
804
+ html = html.replace(`<section class="tile"><h2>模型</h2>`, `<section class="tile"><div class="tile-head"><h2>模型</h2>${modelTag}</div>`);
805
+ html = html.replace(/<p class="mode-line">[\s\S]*?<\/p>/, "");
806
+ html = html.replaceAll(">Classroom</button>", ">課堂</button>");
807
+ html = html.replaceAll(">Native</button>", ">個人</button>");
808
+ const envReady = view.tools.length > 0 && view.tools.every((tool) => tool.installed);
809
+ const envTag = envReady
810
+ ? `<span class="badge">已就緒</span>`
811
+ : `<span class="badge off">未完成</span>`;
812
+ html = html.replace(`<section class="tile"><h2>環境</h2>`, `<section class="tile"><div class="tile-head"><h2>環境</h2>${envTag}</div>`);
813
+ html = html.replaceAll(`<em>未安裝</em>`, `<em class="missing">未安裝</em>`);
814
+ if (!view.tools.some((tool) => tool.selected)) {
815
+ html = html.replace(`<button type="submit" class="go">確認安裝</button>`, `<button type="submit" class="go" disabled>確認安裝</button>`);
816
+ }
817
+ if (view.connected && view.canCopyKey) {
818
+ const off = view.commandRunning ? " disabled" : "";
819
+ const course = view.courseTitle
820
+ ? `<p class="course-title">${escapeHtml(view.courseTitle)}</p>`
821
+ : "";
822
+ const meta = [view.sessionTitle, view.nickname]
823
+ .filter((value) => Boolean(value))
824
+ .map((value) => `<span>${escapeHtml(value)}</span>`)
825
+ .join('<span class="connection-separator" aria-hidden="true">·</span>');
826
+ const identity = course || meta
827
+ ? `<div class="connection-identity">${course}${meta ? `<p class="connection-meta">${meta}</p>` : ""}</div>`
828
+ : "";
829
+ const mascot = `<div class="classroom-update-home"><div class="classroom-update">${connectionMascot_1.connectionMascot}${(0, classroomUpdate_1.classroomUpdateControl)(view, restartHtml(view))}</div></div>`;
830
+ const block = `${identity}<div class="key-row"><p>API Key 已設定</p><form method="post" action="/copy"><button type="submit" class="connection-copy" aria-label="複製 Classroom API Key"${off}>${COPY_ICON}<span>複製</span></button></form></div>${mascot}<form method="post" action="/clear"><button type="submit" class="connection-clear"${off}>清除連線</button></form>`;
831
+ html = html.replace(/<form method="post" action="\/copy">[\s\S]*?<\/form>\s*<form method="post" action="\/clear">[\s\S]*?<\/form>/, block);
832
+ html = html.replace(/<p class="class-label">[\s\S]*?<\/p>/, "");
833
+ html = html.replace(`<p class="detail">Classroom API Key 已設定。</p>`, "");
834
+ }
835
+ html = html.replaceAll(`<h1 class="gradient-text">${PRODUCT}</h1>`, `<h1 class="brand-title">VansPod</h1><span class="ver">${VERSION}</span>`);
836
+ html = html.replace(`<div class="modules">`, `<header class="page-head"><h2>教室設定</h2><p>連線、模型與開發環境。</p></header><div class="modules">`);
837
+ if (view.catalog?.actions.length) {
838
+ html = html.replace(/<table>[\s\S]*?<\/table>/, installActionCards(view));
839
+ }
840
+ html = organizeSettings(html, view);
841
+ html = splitCourseCards(html, view);
842
+ const extra = `<style>
843
+ .tile { border: 1px solid light-dark(#e6e6e6, #3d3d3d); }
844
+ .display { display: none; }
845
+ body { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100dvh; }
846
+ .bar { position: sticky; top: 0; align-self: start; height: 100dvh; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 4px; padding: 18px 14px; border-bottom: 0; border-right: 1px solid var(--line); }
847
+ .bar .brand { padding: 6px 8px 14px; }
848
+ .bar .logo-badge { width: 28px; height: 28px; }
849
+ .bar .brand-title, .mobile-top .brand-title { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: 0; line-height: 26px; color: light-dark(#0d0d0d, #ececec); background: none; -webkit-text-fill-color: currentColor; white-space: nowrap; }
850
+ .brand .ver { align-self: center; flex: 0 0 auto; font-family: Consolas, "Cascadia Mono", monospace; font-size: 10px; line-height: 1.2; color: var(--muted); background: light-dark(#f4f4f4, #303030); border: 1px solid light-dark(#e6e6e6, #3d3d3d); padding: 2px 6px; border-radius: 999px; white-space: nowrap; }
851
+ .bar nav { display: flex; flex-direction: column; }
852
+ .side-item { display: flex; align-items: center; gap: 8px; align-self: stretch; justify-content: flex-start; min-height: 36px; padding: 8px 10px; border-radius: 8px; background: light-dark(rgba(13, 13, 13, 0.06), rgba(255, 255, 255, 0.09)); color: var(--text); font-size: 13px; font-weight: 600; text-align: left; }
853
+ .side-item svg { width: 18px; height: 18px; flex-shrink: 0; }
854
+ .bar [data-appearance-toggle] { margin-top: auto; align-self: flex-start; }
855
+ .mobile-top, .drawer-scrim, .menu-toggle { display: none; }
856
+ main { justify-self: center; width: min(52rem, 100%); }
857
+ @media (max-width: 760px) {
858
+ body { display: block; }
859
+ .mobile-top { display: flex; position: sticky; top: 0; z-index: 20; align-items: center; gap: 4px; padding: 4px 10px; border-bottom: 1px solid var(--line); background: var(--bar); backdrop-filter: blur(24px) saturate(160%); }
860
+ .mobile-top .brand { flex: 1; min-width: 0; padding: 4px; }
861
+ .mobile-top .logo-badge { width: 32px; height: 32px; }
862
+ .mobile-top .brand-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
863
+ .menu-toggle { display: grid; place-items: center; width: 44px; height: 44px; min-height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text); }
864
+ .menu-toggle svg { width: 20px; height: 20px; }
865
+ .bar { position: fixed; top: 0; left: 0; bottom: 0; z-index: 40; width: min(280px, 84vw); height: 100dvh; transform: translateX(-100%); visibility: hidden; border-right: 1px solid var(--line); background: light-dark(rgba(242, 242, 247, 0.97), rgba(12, 12, 14, 0.96)); }
866
+ .bar.open { transform: translateX(0); visibility: visible; }
867
+ .bar .brand { display: none; }
868
+ .drawer-scrim.show { display: block; position: fixed; inset: 0; z-index: 30; border: 0; padding: 0; background: light-dark(rgba(20, 20, 20, 0.32), rgba(0, 0, 0, 0.52)); }
869
+ main { width: min(52rem, 100%); margin: 0 auto; }
870
+ }
871
+ .tile-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
872
+ .course-section > summary { cursor: pointer; list-style: none; border-radius: 6px; }
873
+ .course-section > summary::-webkit-details-marker { display: none; }
874
+ .course-section > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
875
+ .course-chevron { width: 20px; height: 20px; flex-shrink: 0; color: var(--muted); }
876
+ .course-section[open] > summary .course-chevron { transform: rotate(90deg); }
877
+ .course-content { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; }
878
+ .project-folder-row { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px 18px; border: 1px solid var(--line); border-radius: 10px; background: var(--input); font-size: 13px; }
879
+ .project-folder-info { flex: 1 1 200px; min-width: 0; }
880
+ .project-folder-info .hint { font-size: 12px; }
881
+ .folder-path { margin-top: 4px; overflow-wrap: anywhere; }
882
+ .project-folder-row > .error, .project-folder-row > .hint { flex-basis: 100%; }
883
+ .badge { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: light-dark(rgba(16, 163, 127, 0.10), rgba(78, 203, 157, 0.13)); color: light-dark(#0a7d5c, #4ecb9d); font-size: 12px; font-weight: 600; }
884
+ .badge.off { background: light-dark(#f4f4f4, #303030); color: light-dark(#6e6e6e, #a6a6a6); }
885
+ .connection-identity { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
886
+ .course-title { font-size: 1.15rem; line-height: 1.5; font-weight: 600; overflow-wrap: anywhere; }
887
+ .connection-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; color: var(--muted); font-size: 13px; line-height: 1.6; }
888
+ .connection-meta > span { min-width: 0; overflow-wrap: anywhere; }
889
+ .connection-separator { opacity: 0.5; }
890
+ .tool em { color: var(--text); }
891
+ .tool em.missing { color: var(--muted); }
892
+ .install-card { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; width: 100%; padding: 12px; border: 1px solid light-dark(#dfe3e8, #404044); border-radius: 12px; background: light-dark(#f8fafc, #252528); }
893
+ .install-card.installed { border-color: light-dark(#6ee7b7, #1f5a45); }
894
+ .install-number { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: light-dark(#0d0d0d, #ececec); color: light-dark(#ffffff, #0d0d0d); font: 700 16px Consolas, "Cascadia Mono", monospace; }
895
+ .install-card.installed .install-number { background: light-dark(#047857, #6ee7b7); }
896
+ .install-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
897
+ .install-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 32px; }
898
+ .install-line strong { min-width: 0; overflow-wrap: anywhere; }
899
+ .install-main .hint { font-size: 13px; }
900
+ .install-card > form { flex-shrink: 0; }
901
+ .install-card > .install-open-error { flex-basis: 100%; margin-left: 44px; }
902
+ .kind-tag { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: light-dark(rgba(13, 13, 13, 0.06), rgba(255, 255, 255, 0.09)); color: var(--muted); font-size: 12px; font-weight: 600; }
903
+ .kind-tag[data-kind="package"] { background: light-dark(#dbeafe, #172f50); color: light-dark(#1e40af, #93c5fd); }
904
+ .kind-tag[data-kind="skill"] { background: light-dark(#ede9fe, #35254e); color: light-dark(#6d28d9, #c4b5fd); }
905
+ .kind-tag[data-kind="mcp"] { background: light-dark(#d1fae5, #163c30); color: light-dark(#065f46, #6ee7b7); }
906
+ .snippet-card { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 12px; border: 1px solid light-dark(#dfe3e8, #404044); border-radius: 12px; background: light-dark(#f8fafc, #252528); }
907
+ .snippet-card.copied { border-color: light-dark(#6ee7b7, #1f5a45); }
908
+ .snippet-number { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: light-dark(#0d0d0d, #ececec); color: light-dark(#ffffff, #0d0d0d); font: 700 16px Consolas, "Cascadia Mono", monospace; }
909
+ .snippet-card.copied .snippet-number { background: light-dark(#047857, #6ee7b7); }
910
+ .snippet-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
911
+ .snippet-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 32px; }
912
+ .snippet-line strong { min-width: 0; overflow-wrap: anywhere; }
913
+ .snippet-hint { display: inline-flex; align-items: center; min-height: 22px; max-width: 100%; padding: 0 8px; border-radius: 999px; background: light-dark(rgba(13, 13, 13, 0.06), rgba(255, 255, 255, 0.09)); color: var(--muted); font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
914
+ .snippet-peek summary { width: fit-content; margin-top: 4px; color: var(--muted); font-size: 12px; cursor: pointer; }
915
+ .snippet-code { width: 100%; max-height: 12rem; margin-top: 6px; overflow: auto; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--input); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
916
+ main .snippet-card > button { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
917
+ .snippet-card > button svg { width: 14px; height: 14px; }
918
+ .snippet-card.copied > button svg { display: none; }
919
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
920
+ .confirm-dialog { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: light-dark(rgba(20, 20, 20, 0.32), rgba(0, 0, 0, 0.52)); }
921
+ .confirm-panel { width: min(36rem, 100%); max-height: min(70dvh, 32rem); overflow: hidden; display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 16px; background: var(--tile); border: 1px solid var(--line); }
922
+ .confirm-panel > :not(pre) { flex-shrink: 0; }
923
+ .confirm-panel h2 { font-size: 20px; font-weight: 600; color: var(--text); }
924
+ .confirm-panel pre { max-height: 40vh; overflow: auto; }
925
+ .confirm-panel .pending { max-height: min(12dvh, 5rem); min-height: 0; flex-shrink: 1; }
926
+ .confirm-panel [data-install-output] { height: min(32dvh, 16rem); max-height: min(32dvh, 16rem); min-height: 0; flex: 0 1 16rem; padding: 14px 16px; border-radius: 10px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; color-scheme: dark; }
927
+ .confirm-panel [hidden] { display: none; }
928
+ .confirm-panel [data-reserved] { visibility: hidden; }
929
+ .confirm-panel button { min-height: 34px; padding: 8px 16px; border-radius: 999px; border: 1px solid transparent; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 500; }
930
+ .confirm-panel button.quiet { background: light-dark(#ffffff, #212121); color: light-dark(#0d0d0d, #ececec); border-color: light-dark(#e6e6e6, #3d3d3d); }
931
+ .restart { color: light-dark(#9a4a08, #fbbf24); }
932
+ [data-appearance="dark"] { --bg: #212121; }
933
+ @media (prefers-color-scheme: dark) {
934
+ :root:not([data-appearance="light"]) { --bg: #212121; }
935
+ }
936
+ .settings-grid { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
937
+ .settings-card { gap: 16px; padding: 18px; }
938
+ .settings-card .tile-head { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
939
+ .settings-content { display: flex; flex-direction: column; gap: 10px; flex: 1; min-width: 0; container-type: inline-size; }
940
+ .settings-content label { font-size: 13px; color: var(--muted); }
941
+ .settings-content input { color: var(--text); }
942
+ .model-client { display: grid; grid-template-columns: minmax(0, 1fr) auto 104px; align-items: center; gap: 6px; min-width: 0; }
943
+ .model-client + .model-client { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 4px; }
944
+ .client-head { display: contents; }
945
+ .adapter-row { min-width: 0; }
946
+ .adapter-label { display: flex; align-items: center; gap: 8px; line-height: 1.4; font-size: 13px; color: var(--text); }
947
+ .adapter-label svg { width: 20px; height: 20px; flex-shrink: 0; }
948
+ .client-light { position: relative; display: inline-block; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: light-dark(#b58a39, #c5a15b); box-shadow: 0 0 0 2px light-dark(rgba(181, 138, 57, 0.08), rgba(197, 161, 91, 0.09)); }
949
+ .client-light:focus-visible::after { content: attr(title); position: absolute; top: 14px; left: -6px; z-index: 10; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--tile); color: var(--text); font-size: 11px; white-space: nowrap; }
950
+ .client-light.detected { background: light-dark(#4b9274, #70b697); box-shadow: 0 0 0 2px light-dark(rgba(75, 146, 116, 0.08), rgba(112, 182, 151, 0.09)); }
951
+ .client-light.health-yellow { background: #c9932b; }
952
+ .client-light.health-red { background: #c95757; }
953
+ .client-flow { grid-column: 1 / -1; position: relative; height: 2px; border-radius: 1px; overflow: hidden; background: light-dark(#ececec, #333333); transition: background-color 400ms ease-out; }
954
+ .client-flow::after { content: ""; position: absolute; inset: 0 auto 0 0; width: 35%; background: linear-gradient(90deg, transparent, var(--accent), transparent); opacity: 0; transform: translateX(-100%); transition: opacity 400ms ease-out; animation: client-flow 1.6s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
955
+ .client-flow.flowing { background: color-mix(in srgb, var(--accent) 22%, light-dark(#ececec, #333333)); }
956
+ .client-flow.flowing::after { opacity: 1; }
957
+ /* The band is 35% of the line, so 286% of its own width carries it off the far end. */
958
+ @keyframes client-flow { to { transform: translateX(286%); } }
959
+ @media (prefers-reduced-motion: reduce) { .client-flow::after { animation: none; } }
960
+ main .client-modes button { min-height: 30px; padding: 5px 10px; font-size: 12px; }
961
+ @container (max-width: 330px) {
962
+ .model-client { grid-template-columns: minmax(0, 1fr) auto; }
963
+ .adapter-row { grid-column: 2; width: 104px; justify-self: end; }
964
+ }
965
+ .allowed-models { padding-top: 12px; border-top: 1px solid var(--line); font-size: 13px; }
966
+ .allowed-models summary { cursor: pointer; color: var(--muted); }
967
+ .allowed-models summary span { margin-left: 6px; }
968
+ .allowed-model-list { list-style: none; display: flex; flex-direction: column; gap: 8px; margin: 12px 0 0; padding: 0; }
969
+ .allowed-model-list li { padding: 8px 10px; border-radius: 8px; background: var(--input); overflow-wrap: anywhere; font-size: 12px; }
970
+ .allowed-models > .hint { margin-top: 10px; }
971
+ .adapter-select { display: block; position: relative; min-width: 0; }
972
+ .adapter-select select { appearance: none; width: 100%; height: 34px; border: 1px solid var(--line); border-radius: 8px; background: var(--input); color: var(--text); font: inherit; font-size: 12px; padding: 5px 24px 5px 8px; cursor: pointer; }
973
+ .adapter-select select:disabled { cursor: default; opacity: 0.5; }
974
+ .adapter-select svg { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; color: var(--muted); pointer-events: none; }
975
+ .settings-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 53px; padding-top: 12px; border-top: 1px solid var(--line); }
976
+ .settings-actions .modes { width: auto; }
977
+ .environment-card { grid-column: 1 / -1; }
978
+ .environment-card .settings-actions { justify-content: flex-end; }
979
+ .connection-error { display: flex; align-items: flex-start; gap: 8px; width: 100%; min-width: 0; padding: 10px 12px; border-radius: 10px; background: light-dark(#fef2f2, #40272b); color: light-dark(#b91c1c, #fca5a5); font-size: 13px; line-height: 1.6; }
980
+ .connection-error svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
981
+ .connection-error p { min-width: 0; overflow-wrap: anywhere; }
982
+ .connection-feedback:empty { display: none; }
983
+ .connection-feedback { min-width: 0; width: 100%; }
984
+ .connection-message { display: flex; align-items: flex-start; gap: 8px; width: 100%; min-width: 0; padding: 10px 12px; border-radius: 10px; background: light-dark(#fff7ed, #3b3024); color: light-dark(#9a3412, #fed7aa); font-size: 13px; line-height: 1.6; }
985
+ .connection-message svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
986
+ .connection-message p { min-width: 0; overflow-wrap: anywhere; }
987
+ .model-update { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; border-radius: 10px; background: light-dark(#f1f5f9, #2d333b); color: var(--muted); font-size: 12px; line-height: 1.6; }
988
+ .model-update svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
989
+ .environment-summary { font-weight: 600; }
990
+ .environment-tools > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
991
+ .environment-tools .tools { margin-top: 12px; }
992
+ @media (max-width: 40rem) { .settings-grid { grid-template-columns: 1fr; } }
993
+ .key-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
994
+ .key-row p { color: var(--muted); font-size: 13px; }
995
+ .key-row form { flex-shrink: 0; }
996
+ ${connectionMascot_1.connectionMascotStyles}
997
+ .page-head { margin: 0.75rem 0 0.15rem; }
998
+ .page-head h2 { font-size: 20px; font-weight: 600; color: var(--text); letter-spacing: 0; }
999
+ .page-head p { margin-top: 4px; color: var(--muted); font-size: 14px; font-weight: 400; }
1000
+ main button { min-height: 34px; padding: 8px 16px; border-radius: 999px; border: 1px solid transparent; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 500; }
1001
+ main button.quiet { background: light-dark(#ffffff, #212121); color: light-dark(#0d0d0d, #ececec); border-color: light-dark(#e6e6e6, #3d3d3d); padding: 8px 16px; }
1002
+ main button.stop { background: transparent; color: light-dark(#b91c1c, #f87171); border-color: light-dark(rgba(185, 28, 28, 0.35), rgba(248, 113, 113, 0.35)); }
1003
+ main .modes { border-radius: 999px; padding: 2px; background: light-dark(#f4f4f4, #303030); border: 1px solid light-dark(#e6e6e6, #3d3d3d); }
1004
+ main .modes button { background: transparent; color: var(--text); border-color: transparent; }
1005
+ main .modes button.on { background: var(--accent); color: var(--on-accent); }
1006
+ main button.connection-copy { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: transparent; color: var(--muted); border-radius: 8px; }
1007
+ .connection-copy svg { width: 16px; height: 16px; }
1008
+ main button.connection-clear { padding: 6px 0; background: transparent; color: var(--muted); border-radius: 6px; font-size: 12px; font-weight: 400; }
1009
+ main button.connection-copy:hover:not(:disabled) { background: var(--input); color: var(--text); }
1010
+ main button.connection-clear:hover:not(:disabled) { color: light-dark(#b91c1c, #f87171); }
1011
+ ${classroomUpdate_1.classroomUpdateStyles}
1012
+ </style>
1013
+ <script>${drawerScript()}${installProgress_1.installProgressScript}${snippetCopyScript()}${courseSectionScript()}${connectionMascot_1.connectionMascotScript}${classroomUpdate_1.classroomUpdateScript}${settingsActions_1.settingsActionsScript}${modelStatus_1.modelStatusScript}
1014
+ </script>`;
1015
+ return html.replace("</body>", `${extra}</body>`);
1016
+ }