@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.
- package/dist/config/configCatalog.js +1 -1
- package/dist/controlPlaneCli.js +1 -1
- package/dist/job/projectSync.js +3 -3
- package/dist/storage/sqliteSchema.js +12 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/projectSyncClockMigration.js +30 -0
- package/dist/web/assets/assetManifest.js +23 -1
- package/dist/web/assets/client/app/catalog.js +40 -14
- package/dist/web/assets/client/app/login.js +156 -0
- package/dist/web/assets/client/app/main.js +33 -9
- package/dist/web/assets/client/app/manage.js +18 -0
- package/dist/web/assets/client/app/selection.js +4 -1
- package/dist/web/assets/client/app/taskView.js +22 -5
- package/dist/web/assets/client/i18n/en.js +28 -4
- package/dist/web/assets/client/i18n/zh-CN.js +28 -4
- package/dist/web/assets/client/index.js +8 -0
- package/dist/web/assets/client/layout/manageShell.js +30 -8
- package/dist/web/assets/client/layout/shortcuts.js +7 -3
- package/dist/web/assets/client/layout/sidebarNav.js +147 -0
- package/dist/web/assets/client/layout/sizing.js +5 -2
- package/dist/web/assets/client/layout/workspace.js +120 -21
- package/dist/web/assets/client/lib/api.js +5 -0
- package/dist/web/assets/client/lib/motion.js +59 -0
- package/dist/web/assets/client/lib/prefs.js +29 -2
- package/dist/web/assets/client/lib/session.js +21 -0
- package/dist/web/assets/client/lib/theme.js +11 -4
- package/dist/web/assets/client/ui/primitives.js +27 -0
- package/dist/web/assets/client/views/dock/terminal.js +4 -1
- package/dist/web/assets/client/views/overview.js +8 -5
- package/dist/web/assets/client/views/task/page.js +10 -10
- package/dist/web/assets/shared/brand.js +22 -0
- package/dist/web/assets/shared/geometry.js +7 -1
- package/dist/web/assets/shared/icons.js +5 -0
- package/dist/web/assets/shared/motion.js +74 -0
- package/dist/web/assets/shell/appRail.js +14 -0
- package/dist/web/assets/shell/dialogs.js +1 -0
- package/dist/web/assets/shell/dock.js +19 -6
- package/dist/web/assets/shell/index.js +6 -2
- package/dist/web/assets/shell/login.js +57 -0
- package/dist/web/assets/shell/manage.js +32 -20
- package/dist/web/assets/shell/settingsContent.js +1 -1
- package/dist/web/assets/shell/sidebar.js +16 -23
- package/dist/web/assets/styles/base.js +1 -1
- package/dist/web/assets/styles/index.js +4 -0
- package/dist/web/assets/styles/layout/responsive.js +63 -19
- package/dist/web/assets/styles/layout/workspace.js +53 -16
- package/dist/web/assets/styles/motion.js +51 -0
- package/dist/web/assets/styles/tokens.js +5 -3
- package/dist/web/assets/styles/ui/controls.js +1 -1
- package/dist/web/assets/styles/ui/forms.js +1 -1
- package/dist/web/assets/styles/ui/surfaces.js +1 -2
- package/dist/web/assets/styles/views/conversation.js +1 -1
- package/dist/web/assets/styles/views/dock.js +22 -2
- package/dist/web/assets/styles/views/login.js +45 -0
- package/dist/web/assets/styles/views/manage.js +25 -42
- package/dist/web/assets/styles/views/page.js +2 -3
- package/dist/web/assets/styles/views/records.js +1 -1
- package/dist/web/assets/styles/views/settings.js +1 -1
- package/dist/web/assets/styles/views/sidebar.js +25 -20
- package/dist/web/assets/styles/views/task.js +9 -15
- package/dist/web/assets/styles/views/terminal.js +1 -1
- package/dist/web/webAccess.js +129 -9
- package/dist/web/webServer.js +170 -14
- package/docs/release-workflow.md +67 -0
- package/docs/release-workflow.zh-CN.md +49 -0
- package/docs/storage-baseline.md +7 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +77 -11
- package/docs/testing/verification-levels.zh-CN.md +63 -9
- 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,
|
|
16
|
-
// and the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
243
|
-
if (
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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("
|
|
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",
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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"],
|