@zq-silk/yui 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/config/configCatalog.js +1 -1
  2. package/dist/controlPlaneCli.js +1 -1
  3. package/dist/job/projectSync.js +3 -3
  4. package/dist/storage/sqliteSchema.js +12 -0
  5. package/dist/storage/storageVersions.js +1 -1
  6. package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
  7. package/dist/web/assets/assetManifest.js +23 -1
  8. package/dist/web/assets/client/app/catalog.js +40 -14
  9. package/dist/web/assets/client/app/login.js +156 -0
  10. package/dist/web/assets/client/app/main.js +33 -9
  11. package/dist/web/assets/client/app/manage.js +18 -0
  12. package/dist/web/assets/client/app/selection.js +4 -1
  13. package/dist/web/assets/client/app/taskView.js +22 -5
  14. package/dist/web/assets/client/i18n/en.js +28 -4
  15. package/dist/web/assets/client/i18n/zh-CN.js +28 -4
  16. package/dist/web/assets/client/index.js +8 -0
  17. package/dist/web/assets/client/layout/manageShell.js +30 -8
  18. package/dist/web/assets/client/layout/shortcuts.js +7 -3
  19. package/dist/web/assets/client/layout/sidebarNav.js +147 -0
  20. package/dist/web/assets/client/layout/sizing.js +5 -2
  21. package/dist/web/assets/client/layout/workspace.js +120 -21
  22. package/dist/web/assets/client/lib/api.js +5 -0
  23. package/dist/web/assets/client/lib/motion.js +59 -0
  24. package/dist/web/assets/client/lib/prefs.js +29 -2
  25. package/dist/web/assets/client/lib/session.js +21 -0
  26. package/dist/web/assets/client/lib/theme.js +11 -4
  27. package/dist/web/assets/client/ui/primitives.js +27 -0
  28. package/dist/web/assets/client/views/dock/terminal.js +4 -1
  29. package/dist/web/assets/client/views/overview.js +8 -5
  30. package/dist/web/assets/client/views/task/page.js +10 -10
  31. package/dist/web/assets/shared/brand.js +22 -0
  32. package/dist/web/assets/shared/geometry.js +7 -1
  33. package/dist/web/assets/shared/icons.js +5 -0
  34. package/dist/web/assets/shared/motion.js +74 -0
  35. package/dist/web/assets/shell/appRail.js +14 -0
  36. package/dist/web/assets/shell/dialogs.js +1 -0
  37. package/dist/web/assets/shell/dock.js +19 -6
  38. package/dist/web/assets/shell/index.js +6 -2
  39. package/dist/web/assets/shell/login.js +57 -0
  40. package/dist/web/assets/shell/manage.js +32 -20
  41. package/dist/web/assets/shell/settingsContent.js +1 -1
  42. package/dist/web/assets/shell/sidebar.js +16 -23
  43. package/dist/web/assets/styles/base.js +1 -1
  44. package/dist/web/assets/styles/index.js +4 -0
  45. package/dist/web/assets/styles/layout/responsive.js +63 -19
  46. package/dist/web/assets/styles/layout/workspace.js +53 -16
  47. package/dist/web/assets/styles/motion.js +51 -0
  48. package/dist/web/assets/styles/tokens.js +5 -3
  49. package/dist/web/assets/styles/ui/controls.js +1 -1
  50. package/dist/web/assets/styles/ui/forms.js +1 -1
  51. package/dist/web/assets/styles/ui/surfaces.js +1 -2
  52. package/dist/web/assets/styles/views/conversation.js +1 -1
  53. package/dist/web/assets/styles/views/dock.js +22 -2
  54. package/dist/web/assets/styles/views/login.js +45 -0
  55. package/dist/web/assets/styles/views/manage.js +25 -42
  56. package/dist/web/assets/styles/views/page.js +2 -3
  57. package/dist/web/assets/styles/views/records.js +1 -1
  58. package/dist/web/assets/styles/views/settings.js +1 -1
  59. package/dist/web/assets/styles/views/sidebar.js +25 -20
  60. package/dist/web/assets/styles/views/task.js +9 -15
  61. package/dist/web/assets/styles/views/terminal.js +1 -1
  62. package/dist/web/webAccess.js +129 -9
  63. package/dist/web/webServer.js +170 -14
  64. package/docs/release-workflow.md +67 -0
  65. package/docs/release-workflow.zh-CN.md +49 -0
  66. package/docs/storage-baseline.md +7 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +77 -11
  69. package/docs/testing/verification-levels.zh-CN.md +63 -9
  70. package/package.json +1 -1
@@ -5,15 +5,17 @@ export const WORKSPACE_SCRIPT = String.raw `
5
5
  // in the center), below the narrow breakpoint a full-screen sheet opened
6
6
  // explicitly per Task. Everything here is view state and browser preference.
7
7
  import { readPreference, writePreference, readSessionAccessMode } from "/assets/js/lib/prefs.js";
8
- import { markSelected } from "/assets/js/lib/dom.js";
8
+ import { markSelected, h, icon, clear } from "/assets/js/lib/dom.js";
9
9
  import { createSizing } from "/assets/js/layout/sizing.js";
10
+ import { layoutTransition, leave } from "/assets/js/lib/motion.js";
10
11
 
11
12
  const NARROW_QUERY = "(max-width: ${BREAKPOINTS.narrow}px)";
12
13
  const DOCK_INITIAL = ${DOCK.initial};
13
14
 
14
- // deps: { el, state, terminal, sessionOpened }
15
- // el the shell elements (dock, dividers, sidebar, operator button,
16
- // and the Task page's [data-dock-toggle] buttons inside el.detail)
15
+ // deps: { el, state, t, terminal, conversation, preview, sessionOpened }
16
+ // el the shell elements (dock, dividers, sidebar, the dock's target
17
+ // menu, and the page's [data-dock-toggle] / [data-operator-toggle]
18
+ // buttons inside el.detail)
17
19
  // state read-only here: state.selected
18
20
  // terminal the dock's native Session (views/dock/terminal)
19
21
  // sessionOpened() a Session was just opened in the dock; the caller
@@ -36,8 +38,8 @@ export function createWorkspace(deps) {
36
38
  dockVisible: function () { return dockVisible(ws); },
37
39
  updateLayout: function () { updateLayout(ws); },
38
40
  syncDockButtons: function () { syncDockButtons(ws); },
39
- setDockOpen: function (open) { setDockOpen(ws, open); },
40
- toggleDock: function (mode) { toggleDock(ws, mode); },
41
+ setDockOpen: function (open) { glide(function () { setDockOpen(ws, open); }); },
42
+ toggleDock: function (mode) { glide(function () { toggleDock(ws, mode); }); },
41
43
  openSession: function (target) { openSession(ws, target); },
42
44
  openConversation: function (target, materials) {
43
45
  ws.deps.terminal.close();
@@ -47,12 +49,8 @@ export function createWorkspace(deps) {
47
49
  ws.deps.conversation.open(target, { current: true, materials: materials });
48
50
  syncDockButtons(ws);
49
51
  },
50
- openOperator: function () { openOperator(ws); },
51
- restoreDefaultMode: function () {
52
- if (!ws.deps.state.selected && ws.dock.open && !ws.narrow.matches && preferredMode() === "session") {
53
- openSession(ws, { scope: "global", roleName: "operator" });
54
- }
55
- },
52
+ openOperator: function () { glide(function () { openOperator(ws); }); },
53
+ toggleOperator: function () { glide(function () { toggleOperator(ws); }); },
56
54
  // A newly selected Task starts with the narrow-screen sheet closed.
57
55
  enterTask: function () {
58
56
  ws.deps.terminal.close();
@@ -60,17 +58,27 @@ export function createWorkspace(deps) {
60
58
  ws.dock.sheet = false;
61
59
  ws.dock.mode = preferredMode();
62
60
  ws.deps.conversation.open({ scope: "task", taskId: ws.deps.state.selected, roleName: "leader" });
61
+ pauseHiddenConversation(ws);
63
62
  if (ws.dock.mode === "session" && !ws.narrow.matches && ws.dock.open) {
64
63
  ws.deps.terminal.open({ scope: "task", taskId: ws.deps.state.selected, roleName: "leader" });
65
64
  }
65
+ syncDockButtons(ws);
66
66
  },
67
67
  leaveTask: function () { leaveTask(ws); }
68
68
  };
69
69
  }
70
70
 
71
+ // User-initiated dock changes (open, close, swap sides) glide the columns to
72
+ // their new widths; internal re-layouts stay immediate.
73
+ function glide(update) {
74
+ if (typeof layoutTransition === "function") layoutTransition(update);
75
+ else update();
76
+ }
77
+
71
78
  function readDockState() {
72
79
  return {
73
- open: readPreference("yui.dock.open", "true") !== "false",
80
+ // The panel starts closed on every load and opens only on request.
81
+ open: false,
74
82
  mode: "conversation",
75
83
  center: readPreference("yui.dock.side", "end") === "center",
76
84
  width: Number(readPreference("yui.dock.width", String(DOCK_INITIAL))) || DOCK_INITIAL,
@@ -100,7 +108,7 @@ function updateLayout(ws) {
100
108
  document.body.classList.toggle("task-open", selected);
101
109
  document.body.classList.toggle("dock-open", visible);
102
110
  document.body.classList.toggle("dock-center", dock.center);
103
- el.dock.hidden = !visible;
111
+ revealDock(ws, visible);
104
112
  el.divider.hidden = !visible;
105
113
  el.discussion.hidden = dock.mode !== "discussion";
106
114
  el.session.hidden = dock.mode !== "session";
@@ -119,13 +127,88 @@ function updateLayout(ws) {
119
127
  syncDockButtons(ws);
120
128
  }
121
129
 
130
+ // On narrow screens the dock is a full-screen sheet: CSS slides it up on
131
+ // open, and closing slides it back down before it hides (an immediate close
132
+ // would cut the motion off). Wider screens change the grid; user-initiated
133
+ // changes there glide through glide().
134
+ function revealDock(ws, visible) {
135
+ const dockEl = ws.deps.el.dock;
136
+ if (ws.closingDock) { ws.closingDock.cancel(); ws.closingDock = null; }
137
+ if (visible || dockEl.hidden || !ws.narrow.matches || typeof leave !== "function") { dockEl.hidden = !visible; return; }
138
+ const closing = leave(dockEl, "sheetDown");
139
+ if (!closing.animation) { dockEl.hidden = true; return; }
140
+ ws.closingDock = closing;
141
+ closing.finished.then(function () {
142
+ if (ws.closingDock !== closing) return;
143
+ ws.closingDock = null;
144
+ dockEl.hidden = true;
145
+ closing.cancel();
146
+ });
147
+ }
148
+
122
149
  function syncDockButtons(ws) {
123
150
  const el = ws.deps.el;
124
151
  const visible = dockVisible(ws);
125
152
  markSelected(el.detail.querySelectorAll("[data-dock-toggle]"), "aria-pressed", function (button) {
126
153
  return visible && ws.dock.mode === button.dataset.dockToggle;
127
154
  });
128
- el.operator.setAttribute("aria-pressed", String(operatorShown(ws)));
155
+ markSelected(el.detail.querySelectorAll("[data-operator-toggle]"), "aria-pressed", function () { return operatorShown(ws); });
156
+ drawTarget(ws);
157
+ }
158
+
159
+ // --- Conversation target -----------------------------------------------------
160
+ // The dock header names who the panel talks to. Operator is always offered;
161
+ // a selected Task adds its Leader. Other Roles use the global message dialog.
162
+ function currentTarget(ws) {
163
+ const mode = ws.dock.mode;
164
+ const selected = ws.deps.state.selected;
165
+ if (mode === "session") return ws.deps.terminal.current() || ws.terminalTarget || null;
166
+ if (mode === "conversation") return ws.deps.conversation.current();
167
+ return selected ? { scope: "task", taskId: selected, roleName: "leader" } : null;
168
+ }
169
+
170
+ function targetLabel(ws, target) {
171
+ if (!target) return ws.deps.t("actions.operator");
172
+ if (target.scope === "global") return target.roleName === "operator" ? ws.deps.t("actions.operator") : target.roleName;
173
+ return target.roleName + " · " + target.taskId;
174
+ }
175
+
176
+ function sameTarget(a, b) {
177
+ return !!a && !!b && a.scope === b.scope && a.roleName === b.roleName && (a.scope !== "task" || a.taskId === b.taskId);
178
+ }
179
+
180
+ function drawTarget(ws) {
181
+ const el = ws.deps.el;
182
+ const current = currentTarget(ws);
183
+ el.dockTargetLabel.textContent = targetLabel(ws, current);
184
+ el.dockTargetIcon.replaceChildren(icon(current && current.scope === "task" ? "target" : "broadcast"));
185
+ const options = [{ scope: "global", roleName: "operator" }];
186
+ if (ws.deps.state.selected) options.push({ scope: "task", taskId: ws.deps.state.selected, roleName: "leader" });
187
+ if (current && !options.some(function (option) { return sameTarget(option, current); })) options.push(current);
188
+ const signature = JSON.stringify([options, current, ws.deps.t("actions.operator")]);
189
+ if (el.dockTargetOptions.dataset.signature === signature) return;
190
+ el.dockTargetOptions.dataset.signature = signature;
191
+ clear(el.dockTargetOptions);
192
+ options.forEach(function (target) {
193
+ el.dockTargetOptions.append(h("button.dock-target-item", {
194
+ type: "button", "aria-pressed": String(sameTarget(target, current)),
195
+ onclick: function () { chooseTarget(ws, target); }
196
+ }, icon(target.scope === "task" ? "target" : "broadcast"), h("span", null, targetLabel(ws, target)),
197
+ sameTarget(target, current) ? icon("check", "icon-sm dock-target-check") : null));
198
+ });
199
+ }
200
+
201
+ // Keep the current view (live terminal or structured conversation) and switch
202
+ // only its owner; Task-only views fall back to the conversation.
203
+ function chooseTarget(ws, target) {
204
+ ws.deps.el.dockTarget.open = false;
205
+ if (ws.dock.mode === "session") { openSession(ws, target); return; }
206
+ ws.deps.terminal.close();
207
+ showDock(ws, true);
208
+ ws.dock.mode = "conversation";
209
+ updateLayout(ws);
210
+ ws.deps.conversation.open(target);
211
+ syncDockButtons(ws);
129
212
  }
130
213
 
131
214
  // The dock currently shows the global operator Session.
@@ -139,7 +222,6 @@ function showDock(ws, open) {
139
222
  if (ws.narrow.matches) ws.dock.sheet = open;
140
223
  else {
141
224
  ws.dock.open = open;
142
- writePreference("yui.dock.open", String(open));
143
225
  }
144
226
  }
145
227
 
@@ -176,6 +258,7 @@ function setDockMode(ws, mode) {
176
258
  ws.deps.conversation.open(target ? { scope: target.scope, ...(target.scope === "task" ? { taskId: target.taskId } : {}), roleName: target.roleName } : ws.deps.state.selected
177
259
  ? { scope: "task", taskId: ws.deps.state.selected, roleName: "leader" }
178
260
  : { scope: "global", roleName: "operator" });
261
+ syncDockButtons(ws);
179
262
  return;
180
263
  }
181
264
  if (mode === "session") {
@@ -209,6 +292,11 @@ function openSession(ws, target) {
209
292
  ws.deps.sessionOpened();
210
293
  }
211
294
 
295
+ function toggleOperator(ws) {
296
+ if (operatorShown(ws)) setDockOpen(ws, false);
297
+ else openOperator(ws);
298
+ }
299
+
212
300
  function openOperator(ws) {
213
301
  ws.deps.terminal.close();
214
302
  showDock(ws, true);
@@ -224,6 +312,14 @@ function leaveTask(ws) {
224
312
  ws.deps.terminal.close();
225
313
  ws.dock.mode = "conversation";
226
314
  ws.deps.conversation.open({ scope: "global", roleName: "operator" });
315
+ pauseHiddenConversation(ws);
316
+ syncDockButtons(ws);
317
+ }
318
+
319
+ // The conversation keeps its owner while the panel is hidden but stops
320
+ // polling; opening the panel reconnects it.
321
+ function pauseHiddenConversation(ws) {
322
+ if (!dockVisible(ws) || ws.dock.mode !== "conversation") ws.deps.conversation.close();
227
323
  }
228
324
 
229
325
  function bindDockControls(ws) {
@@ -233,15 +329,18 @@ function bindDockControls(ws) {
233
329
  el.dockTabConversation.addEventListener("click", function () { setDockMode(ws, "conversation"); });
234
330
  el.dockTabSession.addEventListener("click", function () { setDockMode(ws, "session"); });
235
331
  el.dockTabPreview.addEventListener("click", function () { setDockMode(ws, "preview"); });
236
- el.dockClose.addEventListener("click", function () { setDockOpen(ws, false); });
332
+ el.dockClose.addEventListener("click", function () { glide(function () { setDockOpen(ws, false); }); });
237
333
  el.dockSwap.addEventListener("click", function () {
238
334
  dock.center = !dock.center;
239
335
  writePreference("yui.dock.side", dock.center ? "center" : "end");
240
- updateLayout(ws);
336
+ glide(function () { updateLayout(ws); });
337
+ });
338
+ el.globalInputOpen.addEventListener("click", function () { el.dockTarget.open = false; });
339
+ document.addEventListener("click", function (event) {
340
+ if (el.dockTarget.open && !el.dockTarget.contains(event.target)) el.dockTarget.open = false;
241
341
  });
242
- el.operator.addEventListener("click", function () {
243
- if (operatorShown(ws)) setDockOpen(ws, false);
244
- else openOperator(ws);
342
+ el.dockTarget.addEventListener("keydown", function (event) {
343
+ if (event.key === "Escape" && el.dockTarget.open) { event.stopPropagation(); el.dockTarget.open = false; el.dockTarget.querySelector("summary").focus(); }
245
344
  });
246
345
  }
247
346
  `;
@@ -1,6 +1,8 @@
1
1
  export const API_SCRIPT = String.raw `
2
2
  // Loopback API client. Every request carries the page token; a write is keyed
3
3
  // so an unresolved submission can never be silently replayed from this page.
4
+ import { sessionExpired } from "/assets/js/lib/session.js";
5
+
4
6
  const token = document.querySelector('meta[name="yui-web-token"]').content;
5
7
  const unresolved = new Set();
6
8
 
@@ -22,6 +24,9 @@ export async function requestJson(path, options) {
22
24
  const body = await response.json();
23
25
  if (body && body.error) message = body.error;
24
26
  if (body && body.disposition === "not-submitted") disposition = "not-submitted";
27
+ // The server rejected the request before reading it: the browser session
28
+ // is missing or expired. Return to the sign-in page for this view.
29
+ if (response.status === 401 && body && body.code === "authentication-required") sessionExpired();
25
30
  } catch {}
26
31
  throw Object.assign(new Error(message), { disposition, status: response.status });
27
32
  }
@@ -0,0 +1,59 @@
1
+ import { BREAKPOINTS } from "../../shared/geometry.js";
2
+ import { MOTION } from "../../shared/motion.js";
3
+ export const MOTION_SCRIPT = String.raw `
4
+ // Script-side motion, on the same durations, curves and presets as the
5
+ // stylesheets (shared/motion.ts). Every helper degrades to an immediate,
6
+ // synchronous change when the browser lacks the API, the user prefers
7
+ // reduced motion, or (for layout transitions) the screen is narrow, where
8
+ // drawers and sheets carry their own CSS motion.
9
+ const M = ${JSON.stringify(MOTION)};
10
+ const NARROW = ${BREAKPOINTS.narrow};
11
+
12
+ export const motion = M;
13
+
14
+ export function reducedMotion() {
15
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
16
+ }
17
+
18
+ function options(preset, extra) {
19
+ return Object.assign({ duration: M.durations[preset.duration], easing: M.easings[preset.easing] }, extra || {});
20
+ }
21
+
22
+ function canAnimate(el) { return Boolean(el) && typeof el.animate === "function" && !reducedMotion(); }
23
+
24
+ // Plays a preset on an element that has just appeared. Returns the
25
+ // Animation, or null when nothing was played.
26
+ export function enter(el, name) {
27
+ const preset = M.presets[name || "rise"];
28
+ if (!preset || !canAnimate(el)) return null;
29
+ return el.animate(preset.frames, options(preset));
30
+ }
31
+
32
+ // Plays an exit preset and resolves once it has finished (or at once when
33
+ // motion is unavailable). The final frame is held until the caller hides
34
+ // the element; call cancel() on the returned animation to abort.
35
+ export function leave(el, name) {
36
+ const preset = M.presets[name || "fade"];
37
+ if (!preset || !canAnimate(el)) return { finished: Promise.resolve(), cancel() {} , animation: null };
38
+ const animation = el.animate(preset.frames, options(preset, { fill: "forwards" }));
39
+ return { finished: animation.finished.then(function () {}, function () {}), cancel() { animation.cancel(); }, animation };
40
+ }
41
+
42
+ // Runs a DOM update that moves the workspace columns (sidebar collapse,
43
+ // dock open or close) inside a View Transition, so the columns glide to
44
+ // their new widths. Without support the update runs synchronously.
45
+ let layoutRunning = 0;
46
+ export function layoutTransition(update) {
47
+ const supported = typeof document !== "undefined" && typeof document.startViewTransition === "function" &&
48
+ typeof window !== "undefined" && window.innerWidth > NARROW && !reducedMotion() && !document.hidden;
49
+ if (!supported) { update(); return; }
50
+ // The column names exist only during the transition (styles/motion).
51
+ const root = document.documentElement;
52
+ const done = function () { if (--layoutRunning === 0) root.classList.remove("vt-layout"); };
53
+ layoutRunning += 1;
54
+ root.classList.add("vt-layout");
55
+ let transition;
56
+ try { transition = document.startViewTransition(update); } catch { done(); update(); return; }
57
+ transition.finished.finally(done);
58
+ }
59
+ `;
@@ -14,11 +14,38 @@ export function clearPreference(key) {
14
14
  try { localStorage.removeItem(key); return true; } catch { return false; }
15
15
  }
16
16
 
17
+ // Per-tab return points between the workbench and the management areas, so
18
+ // switching back restores the filters, Task or section the user left. Only
19
+ // same-origin paths of the expected area are accepted; anything else falls back.
20
+ const PLACES = { workbench: /^\/(?:\?|$)/, models: /^\/models(?:[/?]|$)/, settings: /^\/settings(?:[/?]|$)/,
21
+ manage: /^\/(?:models|settings)(?:[/?]|$)/ };
22
+
23
+ export function rememberPlace(area, href) {
24
+ if (!PLACES[area] || !PLACES[area].test(href)) return;
25
+ try { sessionStorage.setItem("yui.nav." + area, href); } catch { /* storage unavailable: links keep defaults */ }
26
+ }
27
+
28
+ export function recallPlace(area, fallback) {
29
+ try {
30
+ const value = sessionStorage.getItem("yui.nav." + area);
31
+ return value && PLACES[area] && PLACES[area].test(value) ? value : fallback;
32
+ } catch { return fallback; }
33
+ }
34
+
35
+ // Point every a[data-nav-area] inside root at its area's return point.
36
+ export function linkPlaces(root) {
37
+ root.querySelectorAll("a[data-nav-area]").forEach(function (link) {
38
+ if (!link.dataset.navDefault) link.dataset.navDefault = link.getAttribute("href");
39
+ link.setAttribute("href", recallPlace(link.dataset.navArea, link.dataset.navDefault));
40
+ });
41
+ }
42
+
17
43
  // Shared with session access consumers: no server configuration or live
18
44
  // Session is changed by this browser-local preference.
19
45
  export function readSessionAccessMode() {
20
- const value = readPreference("yui.session.accessMode", "native");
21
- return value === "structured" ? "structured" : "native";
46
+ // The structured conversation is the default; the native terminal is opt-in.
47
+ const value = readPreference("yui.session.accessMode", "structured");
48
+ return value === "native" ? "native" : "structured";
22
49
  }
23
50
 
24
51
  export function writeSessionAccessMode(value) {
@@ -0,0 +1,21 @@
1
+ export const SESSION_SCRIPT = String.raw `
2
+ // Browser session helper for password-protected listeners. The session
3
+ // itself is an HttpOnly cookie owned by the server; this module never reads
4
+ // or stores a credential. It only routes an expired page back to the
5
+ // sign-in page with a same-origin return path.
6
+ let redirecting = false;
7
+
8
+ export function returnTarget() {
9
+ return window.location.pathname + window.location.search;
10
+ }
11
+
12
+ // Called when any API read/write answers 401. Navigates once; the fragment
13
+ // survives on the sign-in URL and is restored after a successful sign-in.
14
+ export function sessionExpired() {
15
+ if (redirecting) return;
16
+ redirecting = true;
17
+ const query = new URLSearchParams({ reason: "expired" });
18
+ if (returnTarget() !== "/") query.set("next", returnTarget());
19
+ window.location.assign("/login?" + query.toString() + window.location.hash);
20
+ }
21
+ `;
@@ -1,3 +1,4 @@
1
+ import { BRAND_ICONS } from "../../shared/brand.js";
1
2
  import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, THEME_OPTIONS } from "../../shared/themes.js";
2
3
  // Shared by the parser-blocking head script and the later settings controller.
3
4
  // Both resolve the same browser preference without publishing global state.
@@ -16,15 +17,21 @@ function storedPreference() {
16
17
  function resolveTheme(preference, media) {
17
18
  return preference === "system" ? (media.matches ? DEFAULT_LIGHT : DEFAULT_DARK) : preference;
18
19
  }
20
+
21
+ // Stamp the resolved theme on <html> and swap in its pre-coloured favicon.
22
+ const BRAND_ICONS = ${JSON.stringify(BRAND_ICONS)};
23
+ function stampTheme(id) {
24
+ document.documentElement.dataset.theme = id;
25
+ const icon = document.querySelector('link[rel="icon"]');
26
+ if (icon && BRAND_ICONS[id] && icon.getAttribute("href") !== BRAND_ICONS[id]) icon.setAttribute("href", BRAND_ICONS[id]);
27
+ }
19
28
  `;
20
29
  // A same-origin classic script runs before styles/body, unlike the deferred
21
30
  // application module graph. No inline script or weaker CSP is needed.
22
31
  export const THEME_BOOTSTRAP_SCRIPT = String.raw `
23
32
  (function () {
24
33
  ${THEME_PREFERENCE_SCRIPT}
25
- document.documentElement.dataset.theme = resolveTheme(
26
- storedPreference(), window.matchMedia("(prefers-color-scheme: light)")
27
- );
34
+ stampTheme(resolveTheme(storedPreference(), window.matchMedia("(prefers-color-scheme: light)")));
28
35
  })();
29
36
  `;
30
37
  export const THEME_SCRIPT = String.raw `
@@ -60,7 +67,7 @@ export function createThemeController(container, t) {
60
67
  return resolveTheme(preference, media);
61
68
  }
62
69
  function apply() {
63
- document.documentElement.dataset.theme = resolved();
70
+ stampTheme(resolved());
64
71
  if (container) markSelected(container.querySelectorAll("[data-theme-option]"), "aria-pressed", function (button) {
65
72
  return button.dataset.themeOption === preference;
66
73
  });
@@ -42,6 +42,33 @@ export function button(text, options) {
42
42
  return element;
43
43
  }
44
44
 
45
+ // The page header's Operator switch: the same place on the overview and on
46
+ // every Task page; layout/workspace marks it pressed while the dock shows it.
47
+ export function operatorToggle(t, onClick) {
48
+ const element = h("button.btn.btn-ghost.dock-toggle.operator-toggle", {
49
+ type: "button", title: t("actions.operator") + " · O", "aria-pressed": "false", onclick: onClick
50
+ }, icon("broadcast"), h("span", null, t("actions.operator")));
51
+ element.dataset.operatorToggle = "true";
52
+ return element;
53
+ }
54
+
55
+ // The task-list switch at the start of the page header. It shows only while
56
+ // the list is out of view (collapsed, or a drawer on smaller screens);
57
+ // layout/sidebarNav keeps aria-expanded current.
58
+ export function sidebarToggle(t, onClick) {
59
+ const element = h("button.icon-btn.side-expand", {
60
+ type: "button", "aria-label": t("sidebar.show"), title: t("sidebar.show") + " · [", "aria-expanded": "false", onclick: onClick
61
+ }, h("span.i-sidebar", null, icon("sidebar")), h("span.i-menu", null, icon("menu")));
62
+ element.dataset.sidebarToggle = "true";
63
+ return element;
64
+ }
65
+
66
+ // The top bar every page starts with (the management shell renders the same
67
+ // markup statically): the list switch, the location, then page actions.
68
+ export function topBar(t, revealSidebar, location, actions) {
69
+ return h("header.top-bar", null, sidebarToggle(t, revealSidebar), location, h("span.spacer"), actions || []);
70
+ }
71
+
45
72
  export function note(text, toneName) {
46
73
  return h("p.note" + (toneName ? ".tone-" + toneName : ""), null, text);
47
74
  }
@@ -8,6 +8,7 @@ import { h, icon, clear } from "/assets/js/lib/dom.js";
8
8
  import { fill } from "/assets/js/lib/format.js";
9
9
  import { pageToken, requestJson } from "/assets/js/lib/api.js";
10
10
  import { dot } from "/assets/js/ui/primitives.js";
11
+ import { sessionExpired } from "/assets/js/lib/session.js";
11
12
 
12
13
  function cssVar(name, fallback) {
13
14
  const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -172,9 +173,11 @@ function bindSocket(ctl, socket, terminal, link, generation) {
172
173
  terminal.writeln("\r\n" + message.message);
173
174
  }
174
175
  });
175
- socket.addEventListener("close", function () {
176
+ socket.addEventListener("close", function (event) {
176
177
  if (generation !== ctl.generation) return;
177
178
  link.writable = false;
179
+ // 1008: the server closed this terminal because its browser session ended.
180
+ if (event && event.code === 1008) { sessionExpired(); return; }
178
181
  if (ctl.stateKey !== "terminal.error") setState(ctl, "terminal.closed", "closed");
179
182
  });
180
183
  socket.addEventListener("error", function () {
@@ -4,7 +4,7 @@ export const OVERVIEW_SCRIPT = String.raw `
4
4
  // then catalog-wide signals and the explicit Session read.
5
5
  import { h, icon, clear } from "/assets/js/lib/dom.js";
6
6
  import { byNewest, fill, formatDateTime, relativeTime } from "/assets/js/lib/format.js";
7
- import { badge, button, dot, emptyState, note } from "/assets/js/ui/primitives.js";
7
+ import { badge, button, dot, emptyState, note, operatorToggle, topBar } from "/assets/js/ui/primitives.js";
8
8
  import { card } from "/assets/js/ui/containers.js";
9
9
  import { metricTile } from "/assets/js/ui/metrics.js";
10
10
  import { label, sessionBadges, tone } from "/assets/js/domain/vocab.js";
@@ -16,6 +16,12 @@ export function renderOverview(container, state, t, locale, actions) {
16
16
  const active = tasks.filter(function (task) { return task.status === "active"; });
17
17
  const recent = tasks.filter(function (task) { return task.status !== "archived"; }).slice().sort(byNewest).slice(0, 8);
18
18
  const page = h("div.page.overview");
19
+ container.append(topBar(t, actions.revealSidebar,
20
+ h("nav.crumbs", { "aria-label": t("crumbs.label") },
21
+ h("span.crumb", null, t("manage.workbench")),
22
+ h("span.crumb-sep", { "aria-hidden": "true" }, "/"),
23
+ h("span.crumb-current", null, t("overview.title"))),
24
+ [operatorToggle(t, actions.toggleOperator)]));
19
25
  page.append(overviewHead(state.counts, t, actions), metricStrip(state.counts, t));
20
26
  page.append(h("div.card-stack", null,
21
27
  inboxCard(state, t, actions),
@@ -30,13 +36,10 @@ export function renderOverview(container, state, t, locale, actions) {
30
36
  function overviewHead(counts, t, actions) {
31
37
  return h("header.page-head", null,
32
38
  h("div.page-head-main", null,
33
- h("p.kicker", null, t("overview.kicker")),
34
39
  h("h1.page-title", null, t("overview.title")),
35
40
  h("p.page-sub", null, counts
36
41
  ? fill(t("overview.sub"), { total: counts.total, active: counts.active || 0 })
37
- : t("loading.dashboard"))),
38
- h("div.page-head-actions", null,
39
- button(t("actions.operator"), { icon: "terminal", variant: "ghost", onClick: actions.openOperator })));
42
+ : t("loading.dashboard"))));
40
43
  }
41
44
 
42
45
  function metricStrip(counts, t) {
@@ -4,7 +4,7 @@ export const TASK_PAGE_SCRIPT = String.raw `
4
4
  // observation arrives separately from the Context snapshot, so every fact it
5
5
  // feeds lives in a [data-slot] that updateObservation() redraws in place.
6
6
  import { h, icon, clear } from "/assets/js/lib/dom.js";
7
- import { badge, chip, timeTag } from "/assets/js/ui/primitives.js";
7
+ import { badge, chip, operatorToggle, timeTag, topBar } from "/assets/js/ui/primitives.js";
8
8
  import { label, statusBadge } from "/assets/js/domain/vocab.js";
9
9
  import { totalOf } from "/assets/js/domain/context.js";
10
10
  import { renderOverviewPanel } from "/assets/js/views/task/overview.js";
@@ -29,8 +29,8 @@ export function renderTaskDetail(container, data, t, locale, ctx) {
29
29
  container.dataset.jobsLocale = locale;
30
30
  data.viewState.coreKey = data.core.coreCursor;
31
31
  const page = h("div.page.task-page");
32
- page.append(h("header.task-header", null, taskBar(task, t, ctx)),
33
- h("div.task-hero", null, h("h1.task-title", null, task.title), taskMeta(task, t, locale)),
32
+ container.append(taskBar(task, t, ctx));
33
+ page.append(h("div.task-hero", null, h("h1.task-title", null, task.title), taskMeta(task, t, locale)),
34
34
  h("div.tabs-wrap", null, taskTabs(data.core, t, ctx)));
35
35
  const panels = {};
36
36
  TABS.forEach(function (tab) {
@@ -74,15 +74,15 @@ export function selectTab(container, tab) {
74
74
  }
75
75
 
76
76
  function taskBar(task, t, ctx) {
77
- return h("div.task-bar", null,
78
- h("button.icon-btn.back-btn", { type: "button", "aria-label": t("actions.back"), title: t("actions.back"), onclick: ctx.onBack }, icon("back")),
77
+ return topBar(t, ctx.revealSidebar,
79
78
  h("nav.crumbs", { "aria-label": t("crumbs.label") },
80
- h("button.crumb", { type: "button", onclick: ctx.onBack }, t("crumbs.tasks")),
79
+ h("button.crumb", { type: "button", title: t("overview.title"), onclick: ctx.onBack }, t("manage.workbench")),
81
80
  h("span.crumb-sep", { "aria-hidden": "true" }, "/"),
82
- h("code.crumb-current", null, task.id)),
83
- h("span.spacer"),
84
- dockToggle("discussion", "chat", t("dock.discussion") + " · D", t, ctx),
85
- dockToggle("session", "terminal", t("dock.session"), t, ctx));
81
+ h("code.crumb-current", { title: task.title }, task.id)),
82
+ [dockToggle("discussion", "chat", t("dock.discussion") + " · D", t, ctx),
83
+ dockToggle("session", "terminal", t("dock.session"), t, ctx),
84
+ h("span.task-bar-sep", { "aria-hidden": "true" }),
85
+ operatorToggle(t, ctx.toggleOperator)]);
86
86
  }
87
87
 
88
88
  function dockToggle(mode, iconName, title, t, ctx) {
@@ -0,0 +1,22 @@
1
+ import { THEMES } from "./themes.js";
2
+ // The Yui mark: a capital Y with an accent dot on a tile (64×64 grid). One
3
+ // geometry source for the in-page mark and the favicon. The in-page mark is
4
+ // drawn with theme tokens via CSS (tile = ink, Y = canvas, dot = accent), so it
5
+ // follows a theme switch; the favicon cannot read page CSS, so each theme gets
6
+ // a pre-coloured copy that the theme script swaps in.
7
+ const Y_PATH = "M17 17L30 33L43 17M30 33V49";
8
+ const DOT = { cx: 49.5, cy: 46, r: 4.6 };
9
+ // Inline glyph for `.brand-mark`; the tile itself is the element's background.
10
+ export function brandGlyphSvg() {
11
+ return `<svg class="brand-glyph" viewBox="0 0 64 64" aria-hidden="true" focusable="false">`
12
+ + `<path d="${Y_PATH}" fill="none" stroke="currentColor" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`
13
+ + `<circle class="brand-dot" cx="${DOT.cx}" cy="${DOT.cy}" r="${DOT.r}"/></svg>`;
14
+ }
15
+ function brandIconSvg(tile, glyph, dot) {
16
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">`
17
+ + `<rect width="64" height="64" rx="16" fill="${tile}"/>`
18
+ + `<path d="${Y_PATH}" fill="none" stroke="${glyph}" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`
19
+ + `<circle cx="${DOT.cx}" cy="${DOT.cy}" r="${DOT.r}" fill="${dot}"/></svg>`;
20
+ }
21
+ // Favicon data URIs keyed by theme id (img-src allows data:).
22
+ export const BRAND_ICONS = Object.freeze(Object.fromEntries(THEMES.map(theme => [theme.id, "data:image/svg+xml," + encodeURIComponent(brandIconSvg(theme.tokens.ink, theme.tokens.canvas, theme.tokens.accent))])));
@@ -9,11 +9,17 @@ export const BREAKPOINTS = Object.freeze({
9
9
  // ≤ small: phone layout for page content.
10
10
  small: 620
11
11
  });
12
+ // The global navigation rail (Workbench / Models / Settings) on every page.
13
+ export const RAIL_WIDTH = 60;
12
14
  export const SIDEBAR = Object.freeze({ min: 240, max: 560, compact: 264, step: 16 });
13
15
  export const DOCK = Object.freeze({ min: 320, max: 760, initial: 440, step: 24 });
14
16
  // The task column never shrinks below this while a divider is dragged.
15
17
  export const CENTER_MIN = 420;
16
18
  export const DIVIDER_WIDTH = 9;
19
+ // Between narrow and this width an open dock and the default task list cannot
20
+ // both fit beside a minimum task column, so the task list steps aside while
21
+ // the dock is open (closing the dock brings it back).
22
+ export const DOCK_FOCUS_MAX = RAIL_WIDTH + SIDEBAR.compact + DIVIDER_WIDTH + DOCK.initial + CENTER_MIN - 1;
17
23
  export const LAYOUT_GEOMETRY = Object.freeze({
18
- narrow: BREAKPOINTS.narrow, sidebar: SIDEBAR, dock: DOCK, centerMin: CENTER_MIN, divider: DIVIDER_WIDTH
24
+ narrow: BREAKPOINTS.narrow, focusMax: DOCK_FOCUS_MAX, rail: RAIL_WIDTH, sidebar: SIDEBAR, dock: DOCK, centerMin: CENTER_MIN, divider: DIVIDER_WIDTH
19
25
  });
@@ -4,10 +4,14 @@
4
4
  export const ICON_PATHS = Object.freeze({
5
5
  refresh: ["M20 11a8 8 0 0 0-14.3-4.9L4 8", "M4 4v4h4", "M4 13a8 8 0 0 0 14.3 4.9L20 16", "M20 20v-4h-4"],
6
6
  search: ["M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z", "M20 20l-4-4"],
7
+ textSearch: ["M4 6h14", "M4 11h6", "M4 16h4", "M15.5 19a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z", "M21 21l-3-3"],
7
8
  terminal: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M7 10l3 2-3 2", "M12 15h5"],
8
9
  send: ["M4 12l16-8-6 16-2.5-6.5z", "M11.5 13.5L20 4"],
9
10
  swap: ["M7 7h13", "M16 3l4 4-4 4", "M17 17H4", "M8 13l-4 4 4 4"],
10
11
  close: ["M6 6l12 12", "M18 6L6 18"],
12
+ workbench: ["M4 4h7v7H4z", "M13 4h7v4h-7z", "M13 10h7v10h-7z", "M4 13h7v7H4z"],
13
+ sidebar: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M9 5v14", "M5.5 9h1", "M5.5 12h1"],
14
+ menu: ["M4 7h16", "M4 12h16", "M4 17h16"],
11
15
  back: ["M19 12H5", "M11 6l-6 6 6 6"],
12
16
  dock: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M14 5v14", "M17 9h1", "M17 12h1"],
13
17
  settings: ["M4 7h10", "M18 7h2", "M16 5v4", "M4 17h4", "M12 17h8", "M10 15v4"],
@@ -32,6 +36,7 @@ export const ICON_PATHS = Object.freeze({
32
36
  dots: ["M5 12h.01", "M12 12h.01", "M19 12h.01"],
33
37
  inbox: ["M4 13l2.5-8h11L20 13", "M4 13v6h16v-6", "M4 13h5l1 2h4l1-2h5"],
34
38
  user: ["M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M4 20a8 8 0 0 1 16 0"],
39
+ eyeOff: ["M3 3l18 18", "M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3 3.9", "M6.6 6.6C3.9 8.3 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6", "M9.9 9.9a3 3 0 0 0 4.2 4.2"],
35
40
  eye: ["M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
36
41
  plus: ["M12 5v14", "M5 12h14"],
37
42
  play: ["M8 5v14l11-7z"],