@zq-silk/yui 2.15.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 (53) hide show
  1. package/dist/web/assets/client/app/catalog.js +40 -14
  2. package/dist/web/assets/client/app/main.js +33 -9
  3. package/dist/web/assets/client/app/manage.js +18 -0
  4. package/dist/web/assets/client/app/selection.js +4 -1
  5. package/dist/web/assets/client/app/taskView.js +22 -5
  6. package/dist/web/assets/client/i18n/en.js +10 -3
  7. package/dist/web/assets/client/i18n/zh-CN.js +10 -3
  8. package/dist/web/assets/client/index.js +4 -0
  9. package/dist/web/assets/client/layout/manageShell.js +30 -8
  10. package/dist/web/assets/client/layout/shortcuts.js +7 -3
  11. package/dist/web/assets/client/layout/sidebarNav.js +147 -0
  12. package/dist/web/assets/client/layout/sizing.js +5 -2
  13. package/dist/web/assets/client/layout/workspace.js +120 -21
  14. package/dist/web/assets/client/lib/motion.js +59 -0
  15. package/dist/web/assets/client/lib/prefs.js +29 -2
  16. package/dist/web/assets/client/lib/theme.js +11 -4
  17. package/dist/web/assets/client/ui/primitives.js +27 -0
  18. package/dist/web/assets/client/views/overview.js +8 -5
  19. package/dist/web/assets/client/views/task/page.js +10 -10
  20. package/dist/web/assets/shared/brand.js +22 -0
  21. package/dist/web/assets/shared/geometry.js +7 -1
  22. package/dist/web/assets/shared/icons.js +4 -0
  23. package/dist/web/assets/shared/motion.js +74 -0
  24. package/dist/web/assets/shell/appRail.js +14 -0
  25. package/dist/web/assets/shell/dialogs.js +1 -0
  26. package/dist/web/assets/shell/dock.js +19 -6
  27. package/dist/web/assets/shell/index.js +6 -2
  28. package/dist/web/assets/shell/login.js +9 -8
  29. package/dist/web/assets/shell/manage.js +32 -20
  30. package/dist/web/assets/shell/settingsContent.js +1 -1
  31. package/dist/web/assets/shell/sidebar.js +16 -23
  32. package/dist/web/assets/styles/base.js +1 -1
  33. package/dist/web/assets/styles/index.js +2 -0
  34. package/dist/web/assets/styles/layout/responsive.js +63 -19
  35. package/dist/web/assets/styles/layout/workspace.js +53 -16
  36. package/dist/web/assets/styles/motion.js +51 -0
  37. package/dist/web/assets/styles/tokens.js +5 -3
  38. package/dist/web/assets/styles/ui/controls.js +1 -1
  39. package/dist/web/assets/styles/ui/forms.js +1 -1
  40. package/dist/web/assets/styles/ui/surfaces.js +1 -2
  41. package/dist/web/assets/styles/views/conversation.js +1 -1
  42. package/dist/web/assets/styles/views/dock.js +22 -2
  43. package/dist/web/assets/styles/views/login.js +16 -13
  44. package/dist/web/assets/styles/views/manage.js +25 -42
  45. package/dist/web/assets/styles/views/page.js +2 -3
  46. package/dist/web/assets/styles/views/records.js +1 -1
  47. package/dist/web/assets/styles/views/settings.js +1 -1
  48. package/dist/web/assets/styles/views/sidebar.js +25 -20
  49. package/dist/web/assets/styles/views/task.js +9 -15
  50. package/dist/web/assets/styles/views/terminal.js +1 -1
  51. package/docs/release-workflow.md +22 -0
  52. package/docs/release-workflow.zh-CN.md +15 -0
  53. package/package.json +1 -1
@@ -0,0 +1,147 @@
1
+ import { LAYOUT_GEOMETRY } from "../../shared/geometry.js";
2
+ export const SIDEBAR_NAV_SCRIPT = String.raw `
3
+ // Task list visibility. On wide screens the list is a column the user can
4
+ // collapse (remembered in this browser). Where it cannot sit beside the
5
+ // workspace — narrow screens, or a medium screen with the dock open — it
6
+ // opens as a drawer over the workspace instead, and closes on a choice,
7
+ // on the scrim, on Escape or by dragging it away. Every [data-sidebar-toggle]
8
+ // reports the state. Drawer motion lives in CSS (layout/responsive); a drag
9
+ // only moves the panels with the finger and hands the release back to CSS.
10
+ // Collapsing the inline column glides through lib/motion.
11
+ import { readPreference, writePreference } from "/assets/js/lib/prefs.js";
12
+ import { layoutTransition } from "/assets/js/lib/motion.js";
13
+
14
+ const G = ${JSON.stringify(LAYOUT_GEOMETRY)};
15
+
16
+ // deps: { el: { sidebar, rail?, scrim, collapse }, relayout }
17
+ // rail the global rail, which travels with the list on narrow screens
18
+ // relayout() re-applies column widths after the list appears or hides
19
+ export function createSidebarNav(deps) {
20
+ const body = document.body;
21
+ const state = { collapsed: readPreference("yui.sidebar.collapsed", "0") === "1" };
22
+
23
+ // Whether the list can sit in the grid at the current width and dock state.
24
+ function inlineAvailable() {
25
+ const width = window.innerWidth;
26
+ if (width <= G.narrow) return false;
27
+ return !(width <= G.focusMax && body.classList.contains("dock-open"));
28
+ }
29
+ function drawerOpen() { return body.classList.contains("sidebar-drawer"); }
30
+ function visible() { return drawerOpen() || (inlineAvailable() && !state.collapsed); }
31
+ function sync() {
32
+ const expanded = String(visible());
33
+ document.querySelectorAll("[data-sidebar-toggle]").forEach(function (button) {
34
+ if (button.getAttribute("aria-expanded") !== expanded) button.setAttribute("aria-expanded", expanded);
35
+ });
36
+ }
37
+ function setCollapsed(collapsed) {
38
+ state.collapsed = collapsed;
39
+ writePreference("yui.sidebar.collapsed", collapsed ? "1" : "0");
40
+ // The columns glide to their new widths (View Transition, desktop only).
41
+ const update = function () {
42
+ body.classList.toggle("sidebar-collapsed", collapsed);
43
+ deps.relayout();
44
+ sync();
45
+ };
46
+ if (typeof layoutTransition === "function") layoutTransition(update);
47
+ else update();
48
+ }
49
+ function setDrawer(open) {
50
+ if (open === drawerOpen()) return;
51
+ body.classList.toggle("sidebar-drawer", open);
52
+ if (open) deps.el.collapse.focus({ preventScroll: true });
53
+ sync();
54
+ }
55
+ function reveal() {
56
+ if (!inlineAvailable()) setDrawer(true);
57
+ else if (state.collapsed) setCollapsed(false);
58
+ }
59
+ function hide() {
60
+ if (drawerOpen()) setDrawer(false);
61
+ else if (inlineAvailable()) setCollapsed(true);
62
+ }
63
+
64
+ body.classList.toggle("sidebar-collapsed", state.collapsed);
65
+ deps.el.collapse.addEventListener("click", hide);
66
+ deps.el.scrim.addEventListener("click", function () { setDrawer(false); });
67
+ // A choice made in the drawer hands the screen back to the workspace.
68
+ deps.el.sidebar.addEventListener("click", function (event) {
69
+ if (drawerOpen() && event.target.closest(".task-row, .manage-link, #create-task, #body-search-open")) setDrawer(false);
70
+ });
71
+ bindDrag(deps.el, drawerOpen, function () { setDrawer(false); });
72
+ window.addEventListener("resize", function () { if (drawerOpen() && inlineAvailable()) setDrawer(false); sync(); });
73
+ // The dock opening or closing changes whether the list fits.
74
+ new MutationObserver(sync).observe(body, { attributes: true, attributeFilter: ["class"] });
75
+ sync();
76
+
77
+ return {
78
+ toggle: function () { if (visible()) hide(); else reveal(); },
79
+ reveal: reveal,
80
+ hide: hide,
81
+ closeDrawer: function () { setDrawer(false); },
82
+ drawerOpen: drawerOpen,
83
+ sync: sync
84
+ };
85
+ }
86
+
87
+ // Drag to dismiss, as native drawers do: once a touch moves mostly sideways,
88
+ // the panels follow the finger (leftward only) and the scrim thins with them.
89
+ // Releasing past a third of the width, or with a quick flick, closes; any
90
+ // other release springs back. Either way CSS animates from the current spot.
91
+ function bindDrag(el, isOpen, close) {
92
+ const panels = [el.sidebar, el.rail].filter(Boolean);
93
+ let drag = null;
94
+ function place(dx) {
95
+ const offset = "translate3d(" + dx + "px,0,0)";
96
+ panels.forEach(function (panel) { panel.style.transform = offset; });
97
+ el.scrim.style.opacity = String(Math.max(0, 1 + dx / drag.width));
98
+ }
99
+ function release() {
100
+ panels.forEach(function (panel) { panel.style.transform = ""; });
101
+ el.scrim.style.opacity = "";
102
+ document.body.classList.remove("drawer-dragging");
103
+ }
104
+ function start(event) {
105
+ if (!isOpen() || event.touches.length !== 1) return;
106
+ const touch = event.touches[0];
107
+ const width = panels.reduce(function (sum, panel) { return sum + panel.getBoundingClientRect().width; }, 0);
108
+ drag = { x: touch.clientX, y: touch.clientY, width: width || 1, active: false, dx: 0, at: event.timeStamp, v: 0 };
109
+ }
110
+ function move(event) {
111
+ if (!drag) return;
112
+ const touch = event.touches[0];
113
+ const dx = touch.clientX - drag.x;
114
+ const dy = touch.clientY - drag.y;
115
+ if (!drag.active) {
116
+ if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
117
+ // A vertical start is a scroll; leave it alone.
118
+ if (Math.abs(dy) >= Math.abs(dx) || dx > 0) { drag = null; return; }
119
+ drag.active = true;
120
+ document.body.classList.add("drawer-dragging");
121
+ }
122
+ const next = Math.min(0, dx);
123
+ const elapsed = event.timeStamp - drag.at;
124
+ if (elapsed > 0) drag.v = (next - drag.dx) / elapsed;
125
+ drag.dx = next;
126
+ drag.at = event.timeStamp;
127
+ place(next);
128
+ }
129
+ function end() {
130
+ if (!drag) return;
131
+ const finished = drag;
132
+ drag = null;
133
+ if (!finished.active) return;
134
+ // Removing the inline offset in the same frame as the class change lets
135
+ // the CSS transition continue from where the finger let go.
136
+ const dismiss = finished.dx < -finished.width / 3 || finished.v < -0.45;
137
+ release();
138
+ if (dismiss) close();
139
+ }
140
+ [el.sidebar, el.rail, el.scrim].filter(Boolean).forEach(function (node) {
141
+ node.addEventListener("touchstart", start, { passive: true });
142
+ node.addEventListener("touchmove", move, { passive: true });
143
+ node.addEventListener("touchend", end, { passive: true });
144
+ node.addEventListener("touchcancel", end, { passive: true });
145
+ });
146
+ }
147
+ `;
@@ -18,7 +18,10 @@ export function createSizing(el, dock, sidebar, dockVisible) {
18
18
  const root = document.documentElement.style;
19
19
 
20
20
  function maxDockWidth() {
21
- const aside = window.innerWidth > G.narrow ? el.sidebar.offsetWidth : 0;
21
+ // The global rail and an inline task list both take room from the
22
+ // workspace; a drawer floats over it and takes none.
23
+ const inline = !document.body.classList.contains("sidebar-drawer");
24
+ const aside = window.innerWidth > G.narrow ? G.rail + (inline ? el.sidebar.offsetWidth : 0) : 0;
22
25
  return Math.max(G.dock.min, Math.min(G.dock.max, window.innerWidth - aside - G.centerMin));
23
26
  }
24
27
  function setDockWidth(width, persist) {
@@ -30,7 +33,7 @@ export function createSizing(el, dock, sidebar, dockVisible) {
30
33
  }
31
34
  function maxSidebarWidth() {
32
35
  // An open dock can shrink to its minimum, so it reserves only that much.
33
- const reserve = dockVisible() ? G.dock.min + G.divider : 0;
36
+ const reserve = G.rail + (dockVisible() ? G.dock.min + G.divider : 0);
34
37
  return Math.max(G.sidebar.min, Math.min(G.sidebar.max, window.innerWidth - reserve - G.centerMin));
35
38
  }
36
39
  function applySidebarWidth() {
@@ -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
  `;
@@ -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) {
@@ -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
  }
@@ -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) {