anentrypoint-design 0.0.324 → 0.0.326
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/app-shell.css +3 -3
- package/dist/247420.css +55 -46
- package/dist/247420.js +82 -19
- package/package.json +7 -1
- package/scripts/lint-glyphs.mjs +105 -0
- package/scripts/lint-null-children.mjs +129 -0
- package/src/community-app.js +3 -3
- package/src/components/chat-message-parts.js +4 -3
- package/src/components/chat.js +5 -4
- package/src/components/content.js +9 -5
- package/src/components/editor-primitives.js +2 -2
- package/src/components/files-modals.js +5 -4
- package/src/components/freddie/helpers.js +1 -1
- package/src/components/freddie.js +1 -9
- package/src/components/interaction-primitives.js +6 -6
- package/src/components/sessions.js +15 -0
- package/src/components/shell.js +8 -8
- package/src/components/theme-toggle.js +2 -2
- package/src/components.js +1 -1
- package/src/css/app-shell/catalog-theme.css +4 -8
- package/src/css/app-shell/data-density.css +4 -7
- package/src/css/app-shell/hero-content.css +4 -6
- package/src/css/app-shell/states-interactions.css +25 -0
- package/src/i18n.js +58 -0
- package/src/index.js +7 -2
- package/src/kits/os/freddie/pages-chat.js +4 -4
- package/src/kits/os/freddie/pages-tools.js +1 -1
- package/src/kits/os/terminal-app.js +1 -1
- package/src/kits/slides/deck-stage-state.js +3 -3
- package/src/lint.js +10 -0
- package/src/page-html.js +121 -4
- package/src/theme.js +7 -7
- package/src/uid.js +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.326",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -57,11 +57,17 @@
|
|
|
57
57
|
"./components/files-modals.js": "./src/components/files-modals.js",
|
|
58
58
|
"./web-components/ds-chat.js": "./src/web-components/ds-chat.js",
|
|
59
59
|
"./web-components/freddie-chat.js": "./src/web-components/freddie-chat.js",
|
|
60
|
+
"./lint": {
|
|
61
|
+
"import": "./src/lint.js",
|
|
62
|
+
"default": "./src/lint.js"
|
|
63
|
+
},
|
|
60
64
|
"./package.json": "./package.json"
|
|
61
65
|
},
|
|
62
66
|
"files": [
|
|
63
67
|
"dist",
|
|
64
68
|
"src",
|
|
69
|
+
"scripts/lint-glyphs.mjs",
|
|
70
|
+
"scripts/lint-null-children.mjs",
|
|
65
71
|
"vendor/webjsx",
|
|
66
72
|
"colors_and_type.css",
|
|
67
73
|
"app-shell.css",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Decorative-glyph guard: fail the build if any source file hard-codes a
|
|
3
|
+
// decorative unicode glyph (arrows, bullets, checks, stars, status dots, etc.).
|
|
4
|
+
// The design system bans these — chrome must read as one coherent line-icon set
|
|
5
|
+
// (the Icon() SVG component) or industry-standard ASCII, never machine-shaped
|
|
6
|
+
// ornament. This is the durable companion to lint-tokens.mjs: without it, the
|
|
7
|
+
// next hand-edit silently re-introduces a glyph the sweep removed.
|
|
8
|
+
//
|
|
9
|
+
// Run standalone (`node scripts/lint-glyphs.mjs`) or as part of build.mjs.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const root = path.resolve(__dirname, '..');
|
|
16
|
+
|
|
17
|
+
// Directories scanned recursively for source files. dist/ is generated,
|
|
18
|
+
// node_modules/ is vendored, vendor/ is third-party (webjsx), .gm/ is the
|
|
19
|
+
// plugkit spool — none are hand-authored design surfaces.
|
|
20
|
+
const SCAN_DIRS = ['src', 'ui_kits', 'slides', 'site'];
|
|
21
|
+
const SCAN_EXT = new Set(['.js', '.mjs', '.css', '.html']);
|
|
22
|
+
// The bundled root CSS files (app-shell.css, chat.css, etc.) are the design
|
|
23
|
+
// system's shipped styling but live at the repo root, outside SCAN_DIRS — they
|
|
24
|
+
// previously escaped the glyph lint entirely (box-drawing comment dividers
|
|
25
|
+
// slipped through). Scan them explicitly so a decorative glyph in shipped CSS
|
|
26
|
+
// fails the build.
|
|
27
|
+
const SCAN_ROOT_FILES = [
|
|
28
|
+
'app-shell.css', 'chat.css', 'colors_and_type.css', 'community.css',
|
|
29
|
+
'community-app.css', 'editor-primitives.css', 'app-surfaces.css',
|
|
30
|
+
'marketing.css',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Files that are DELIBERATE glyph showcases or documentation of the unicode
|
|
34
|
+
// surface itself — exempt wholesale.
|
|
35
|
+
const EXEMPT_FILES = new Set([
|
|
36
|
+
'preview/icons-unicode.html', // a deliberate catalog of unicode icons
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// The banned decorative-glyph class. NOT included (intentionally allowed):
|
|
40
|
+
// ⌘ ⇧ ⌥ ⌃ ⇪ ⏎ ⌫ (Mac/keyboard key symbols — industry-standard, not decorative)
|
|
41
|
+
// · (middle-dot separator), — (em-dash), … (ellipsis), › ‹ » « (text seps left to authors)
|
|
42
|
+
// functional JS operators (=>, ??, ?., >=, <=) — those are ASCII, never matched here
|
|
43
|
+
const GLYPH_RE = /[●○◆◉◈▸▾▴◀▶★☆✓✗✕✖✔⟶⇒•◦‣◔↓↑→←⏸⏭ℹ⚠⚒◈▷▭▰◎◐▢↗◌▤▦♪§◫⊞❖✷✢⟳↻↺⥁⟲⌛⏳♻]/;
|
|
44
|
+
|
|
45
|
+
// Per-file allowlist: a line is exempt if it contains the substring AND the
|
|
46
|
+
// file matches. Keep tiny and audited.
|
|
47
|
+
const ALLOW = {
|
|
48
|
+
// (currently none — the sweep left zero intentional decorative glyphs)
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function isAllowed(rel, line) {
|
|
52
|
+
const list = ALLOW[rel] || [];
|
|
53
|
+
return list.some((s) => line.includes(s));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walk(dir, acc) {
|
|
57
|
+
let entries;
|
|
58
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
59
|
+
catch { return acc; }
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
const full = path.join(dir, e.name);
|
|
62
|
+
if (e.isDirectory()) walk(full, acc);
|
|
63
|
+
else if (SCAN_EXT.has(path.extname(e.name))) acc.push(full);
|
|
64
|
+
}
|
|
65
|
+
return acc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Returns the array of violation strings (empty == clean). Pure; no exit/log.
|
|
69
|
+
export function findGlyphViolations() {
|
|
70
|
+
const violations = [];
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const d of SCAN_DIRS) walk(path.join(root, d), files);
|
|
73
|
+
for (const f of SCAN_ROOT_FILES) { const p = path.join(root, f); if (fs.existsSync(p)) files.push(p); }
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
76
|
+
if (EXEMPT_FILES.has(rel)) continue;
|
|
77
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
78
|
+
src.split(/\r?\n/).forEach((line, i) => {
|
|
79
|
+
if (GLYPH_RE.test(line) && !isAllowed(rel, line)) {
|
|
80
|
+
violations.push(`${rel}:${i + 1}: ${line.trim().slice(0, 100)}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return violations;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Throws on violation; build.mjs calls this so a regression fails the build
|
|
88
|
+
// even under runners (flatspace) that skip npm lifecycle hooks.
|
|
89
|
+
export function lintGlyphsOrThrow() {
|
|
90
|
+
const violations = findGlyphViolations();
|
|
91
|
+
if (violations.length) {
|
|
92
|
+
const msg = '[lint-glyphs] FAIL — decorative unicode glyphs in source '
|
|
93
|
+
+ '(use the Icon() SVG component or industry-standard ASCII like -> [x] *):\n '
|
|
94
|
+
+ violations.join('\n ')
|
|
95
|
+
+ `\n[lint-glyphs] ${violations.length} violation(s). If a glyph is a genuine product-design icon, route it through Icon() or add the file to EXEMPT_FILES / the audited ALLOW list in scripts/lint-glyphs.mjs.`;
|
|
96
|
+
throw new Error(msg);
|
|
97
|
+
}
|
|
98
|
+
console.log('[lint-glyphs] OK — no decorative glyphs in ' + SCAN_DIRS.join('/') + ' (all icons via Icon()/ASCII).');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// CLI entry: `node scripts/lint-glyphs.mjs`.
|
|
102
|
+
if (process.argv[1] && process.argv[1].endsWith('lint-glyphs.mjs')) {
|
|
103
|
+
try { lintGlyphsOrThrow(); }
|
|
104
|
+
catch (e) { console.error(e.message); process.exit(1); }
|
|
105
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Null-children guard: webjsx applyDiff crashes ("Cannot read properties of
|
|
3
|
+
// undefined (reading 'key')") when a bare null sits among VElement siblings in
|
|
4
|
+
// a children array. The kit discipline is to .filter(Boolean) every children
|
|
5
|
+
// array that contains a conditional vnode — this lint makes the discipline
|
|
6
|
+
// durable instead of comment-maintained (two live crashes are on record: the
|
|
7
|
+
// agentgui 4th and 10th runs).
|
|
8
|
+
//
|
|
9
|
+
// Detection (kept narrow to avoid prop-object false positives): every
|
|
10
|
+
// top-level array literal is bracket-matched; it is flagged iff
|
|
11
|
+
// (a) an ELEMENT-level member starts a vnode call (`h(` or `Component(`), AND
|
|
12
|
+
// (b) an ELEMENT-level member token is `? null` / `: null` (conditional child), AND
|
|
13
|
+
// (c) the closing `]` is not followed (whitespace-tolerant) by `.filter(`.
|
|
14
|
+
// Element-level means at the bracket depth of the array itself, so
|
|
15
|
+
// `{ tabindex: disabled ? '-1' : null }` props never match.
|
|
16
|
+
//
|
|
17
|
+
// Run standalone (`node scripts/lint-null-children.mjs`) or via build.mjs.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const root = path.resolve(__dirname, '..');
|
|
24
|
+
const SCAN_DIRS = ['src'];
|
|
25
|
+
const SCAN_EXT = new Set(['.js', '.mjs']);
|
|
26
|
+
|
|
27
|
+
// Per-file allowlist of line numbers or substrings audited as safe.
|
|
28
|
+
const ALLOW = {};
|
|
29
|
+
|
|
30
|
+
function* walk(dir) {
|
|
31
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
const p = path.join(dir, e.name);
|
|
33
|
+
if (e.isDirectory()) {
|
|
34
|
+
if (e.name === 'node_modules' || e.name === 'vendor') continue;
|
|
35
|
+
yield* walk(p);
|
|
36
|
+
} else if (SCAN_EXT.has(path.extname(e.name))) yield p;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Blanks out `//` line-comment content (replacing with spaces, keeping
|
|
41
|
+
// newlines and length so reported line numbers stay accurate) before the
|
|
42
|
+
// bracket-matching pass below. Without this, an apostrophe inside ANY
|
|
43
|
+
// comment anywhere in the file (e.g. "the kit's own") is read as opening a
|
|
44
|
+
// string literal by the naive scanner, silently corrupting bracket-depth
|
|
45
|
+
// tracking for the rest of the file - a false positive here, or worse, a
|
|
46
|
+
// false NEGATIVE that hides a real missing .filter(Boolean) elsewhere.
|
|
47
|
+
// Comments living inside actual string/template literals (rare, but a `//`
|
|
48
|
+
// can appear in a URL string) are left alone via the same inStr tracking.
|
|
49
|
+
function stripLineComments(src) {
|
|
50
|
+
const out = src.split('');
|
|
51
|
+
let inStr = null;
|
|
52
|
+
for (let i = 0; i < out.length; i++) {
|
|
53
|
+
const c = out[i];
|
|
54
|
+
if (inStr) {
|
|
55
|
+
if (c === '\\') { i++; continue; }
|
|
56
|
+
if (c === inStr) inStr = null;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (c === "'" || c === '"' || c === '`') { inStr = c; continue; }
|
|
60
|
+
if (c === '/' && out[i + 1] === '/') {
|
|
61
|
+
let j = i;
|
|
62
|
+
while (j < out.length && out[j] !== '\n') out[j++] = ' ';
|
|
63
|
+
i = j - 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out.join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findViolations(src) {
|
|
70
|
+
const scan = stripLineComments(src);
|
|
71
|
+
const out = [];
|
|
72
|
+
for (let i = 0; i < scan.length; i++) {
|
|
73
|
+
if (scan[i] !== '[') continue;
|
|
74
|
+
// bracket-match this array literal, tracking element-level segments
|
|
75
|
+
let depth = 0, j = i, inStr = null, elemStart = i + 1;
|
|
76
|
+
const elems = [];
|
|
77
|
+
for (; j < scan.length; j++) {
|
|
78
|
+
const c = scan[j];
|
|
79
|
+
if (inStr) {
|
|
80
|
+
if (c === '\\') j++;
|
|
81
|
+
else if (c === inStr) inStr = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === "'" || c === '"' || c === '`') { inStr = c; continue; }
|
|
85
|
+
if (c === '[' || c === '(' || c === '{') depth++;
|
|
86
|
+
else if (c === ']' || c === ')' || c === '}') {
|
|
87
|
+
depth--;
|
|
88
|
+
if (depth === 0 && c === ']') { elems.push(scan.slice(elemStart, j)); break; }
|
|
89
|
+
} else if (c === ',' && depth === 1) { elems.push(scan.slice(elemStart, j)); elemStart = j + 1; }
|
|
90
|
+
}
|
|
91
|
+
if (j >= scan.length) continue;
|
|
92
|
+
const after = scan.slice(j + 1, j + 24);
|
|
93
|
+
if (/^\s*\.filter\(/.test(after)) continue;
|
|
94
|
+
const hasVnode = elems.some((e) => /(^|\s|\(|,)(h\(|[A-Z][A-Za-z0-9]*\()/.test(e.trim()));
|
|
95
|
+
// conditional-child tail: the ELEMENT itself ends in `: null` or `? ... : null`
|
|
96
|
+
const hasCondNull = elems.some((e) => /[?:]\s*null\s*$/.test(e.trim()));
|
|
97
|
+
if (hasVnode && hasCondNull) {
|
|
98
|
+
const line = src.slice(0, i).split('\n').length;
|
|
99
|
+
out.push(line);
|
|
100
|
+
i = j; // skip past this array
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function lintNullChildrenOrThrow() {
|
|
107
|
+
const failures = [];
|
|
108
|
+
for (const dir of SCAN_DIRS) {
|
|
109
|
+
const abs = path.join(root, dir);
|
|
110
|
+
if (!fs.existsSync(abs)) continue;
|
|
111
|
+
for (const file of walk(abs)) {
|
|
112
|
+
const rel = path.relative(root, file);
|
|
113
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
114
|
+
const allow = ALLOW[rel] || [];
|
|
115
|
+
for (const line of findViolations(src)) {
|
|
116
|
+
if (allow.includes(line)) continue;
|
|
117
|
+
failures.push(rel + ':' + line);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (failures.length) {
|
|
122
|
+
throw new Error('[lint-null-children] conditional null among vnode siblings without .filter(Boolean):\n ' + failures.join('\n '));
|
|
123
|
+
}
|
|
124
|
+
console.log('[lint-null-children] OK — every conditional-children array is filter(Boolean)\'d.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (import.meta.url === 'file://' + process.argv[1] || process.argv[1]?.endsWith('lint-null-children.mjs')) {
|
|
128
|
+
lintNullChildrenOrThrow();
|
|
129
|
+
}
|
package/src/community-app.js
CHANGED
|
@@ -214,8 +214,8 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
214
214
|
),
|
|
215
215
|
// overlays
|
|
216
216
|
ctx.open ? ContextMenu({ items: ctx.items, anchor: { x: ctx.x, y: ctx.y }, onClose: () => { ctx = { ...ctx, open: false }; render(); } }) : null,
|
|
217
|
-
emoji.open ? EmojiPicker({ open: true, anchorX: emoji.x, anchorY: emoji.y, onSelect: (em) => { try { emoji.onSelect && emoji.onSelect(em); } catch (_) {} emoji = { ...emoji, open: false }; render(); }, onClose: () => { emoji = { ...emoji, open: false }; render(); } }) : null,
|
|
218
|
-
palette.open ? CommandPalette({ open: true, items: palette.items, onSelect: (it) => { try { palette.onSelect && palette.onSelect(it); } catch (_) {} palette = { ...palette, open: false }; render(); }, onClose: () => { palette = { ...palette, open: false }; render(); } }) : null,
|
|
217
|
+
emoji.open ? EmojiPicker({ open: true, anchorX: emoji.x, anchorY: emoji.y, onSelect: (em) => { try { emoji.onSelect && emoji.onSelect(em); } catch (_) { /* swallow: consumer onSelect callback must not break overlay close/re-render */ } emoji = { ...emoji, open: false }; render(); }, onClose: () => { emoji = { ...emoji, open: false }; render(); } }) : null,
|
|
218
|
+
palette.open ? CommandPalette({ open: true, items: palette.items, onSelect: (it) => { try { palette.onSelect && palette.onSelect(it); } catch (_) { /* swallow: consumer onSelect callback must not break overlay close/re-render */ } palette = { ...palette, open: false }; render(); }, onClose: () => { palette = { ...palette, open: false }; render(); } }) : null,
|
|
219
219
|
// global overlays (visibility driven by adapter snapshot)
|
|
220
220
|
s.showAuthModal ? AuthModal({ open: true, mode: s.authMode || 'extension', error: s.authError || '', busy: !!s.authBusy, onModeChange: (m) => A.setAuthMode && A.setAuthMode(m), onConnectExtension: () => A.authExtension && A.authExtension(), onGenerate: () => A.authGenerate && A.authGenerate(), onImport: (k) => A.authImport && A.authImport(k), onClose: () => A.closeAuth && A.closeAuth() }) : null,
|
|
221
221
|
BootOverlay({ progress: s.bootProgress || 0, phase: s.bootPhase || '', errored: !!s.bootErrored, visible: !!s.bootVisible }),
|
|
@@ -255,5 +255,5 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
255
255
|
});
|
|
256
256
|
|
|
257
257
|
render();
|
|
258
|
-
return { render, api, destroy: () => { if (unsub) try { unsub(); } catch (_) {} } };
|
|
258
|
+
return { render, api, destroy: () => { if (unsub) try { unsub(); } catch (_) { /* swallow: unsubscribe failure during teardown is non-fatal */ } } };
|
|
259
259
|
}
|
|
@@ -20,6 +20,7 @@ import { renderMarkdownCached, highlightCodeBlockCached } from '../markdown-cach
|
|
|
20
20
|
import { isDegraded as isMarkdownDegraded } from '../markdown.js';
|
|
21
21
|
import { Icon } from './shell.js';
|
|
22
22
|
import { fmtFileSize } from './files.js';
|
|
23
|
+
import { t } from '../i18n.js';
|
|
23
24
|
|
|
24
25
|
const h = webjsx.createElement;
|
|
25
26
|
|
|
@@ -84,7 +85,7 @@ export function copyToClipboardWithFeedback(text, btn) {
|
|
|
84
85
|
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600);
|
|
85
86
|
};
|
|
86
87
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(() => {});
|
|
87
|
-
else { try { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
88
|
+
else { try { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch { /* swallow: legacy execCommand copy fallback unsupported, nothing more to try */ } }
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Inject a per-block copy button into every <pre> inside a rendered-markdown
|
|
@@ -270,13 +271,13 @@ function ToolCallNode(p) {
|
|
|
270
271
|
function ThinkingNode(p) {
|
|
271
272
|
if (p.settled) {
|
|
272
273
|
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
273
|
-
h('summary', {}, 'View thinking'),
|
|
274
|
+
h('summary', {}, t('chat.viewThinking', 'View thinking')),
|
|
274
275
|
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
275
276
|
);
|
|
276
277
|
}
|
|
277
278
|
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
278
279
|
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
279
|
-
h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
|
|
280
|
+
h('span', { class: 'chat-thinking-text' }, p.text || t('chat.thinking', 'thinking…'))
|
|
280
281
|
);
|
|
281
282
|
}
|
|
282
283
|
|
package/src/components/chat.js
CHANGED
|
@@ -8,6 +8,7 @@ import { register } from '../debug.js';
|
|
|
8
8
|
import { Icon } from './shell.js';
|
|
9
9
|
import { fmtFileSize } from './files.js';
|
|
10
10
|
import { EmojiPicker } from './overlay-primitives.js';
|
|
11
|
+
import { t } from '../i18n.js';
|
|
11
12
|
import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
|
|
12
13
|
|
|
13
14
|
// Matches a trailing `:keyword` at the end of the composer draft (optionally
|
|
@@ -191,7 +192,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
191
192
|
// Flat layout leads the turn with a small role label (You / agent name)
|
|
192
193
|
// above the content, the way claude.ai/code titles each turn.
|
|
193
194
|
const roleLabel = isFlat
|
|
194
|
-
? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? 'You' : (name || 'Assistant'))
|
|
195
|
+
? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? t('chat.roleYou', 'You') : (name || t('chat.roleAssistant', 'Assistant')))
|
|
195
196
|
: null;
|
|
196
197
|
const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
|
|
197
198
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
@@ -263,7 +264,7 @@ export function ChatComposer({ value, onInput, onSend, onEmoji, onCancel, busy,
|
|
|
263
264
|
sessionStorage.setItem('ds.composer.undoNoteShown', '1');
|
|
264
265
|
flashComposerNote(taEl.closest('.chat-composer'), 'inserted — undo history does not include this insert');
|
|
265
266
|
}
|
|
266
|
-
} catch { /* sessionStorage unavailable (private mode etc) — skip the note */ }
|
|
267
|
+
} catch { /* swallow: sessionStorage unavailable (private mode etc) — skip the note */ }
|
|
267
268
|
taEl.value = next;
|
|
268
269
|
taEl.focus();
|
|
269
270
|
taEl.selectionStart = taEl.selectionEnd = next.length;
|
|
@@ -443,8 +444,8 @@ export function Chat({ title = 'chat', sub, messages = [], composer, header, sug
|
|
|
443
444
|
h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages' },
|
|
444
445
|
messages.length === 0
|
|
445
446
|
? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
|
|
446
|
-
h('p', { class: 'chat-empty-title' }, 'start a conversation'),
|
|
447
|
-
h('p', { class: 'chat-empty-sub' }, sub || 'Send a message to start the conversation'),
|
|
447
|
+
h('p', { class: 'chat-empty-title' }, t('chat.startConversation', 'start a conversation')),
|
|
448
|
+
h('p', { class: 'chat-empty-sub' }, sub || t('chat.emptySub', 'Send a message to start the conversation')),
|
|
448
449
|
(suggestions && suggestions.length)
|
|
449
450
|
? h('div', { class: 'chat-empty-suggestions' },
|
|
450
451
|
...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
|
|
@@ -267,13 +267,17 @@ export function Install({ cmd, copied, onCopy }) {
|
|
|
267
267
|
// every portfolio consumer theme.mjs (zellous/wireweave/247420) had hand-rolled
|
|
268
268
|
// identically: lines.map((l,i) => a div per line holding a prompt span ('$' or
|
|
269
269
|
// '#' for a comment line) and a cmd span, all wrapped in a Panel. This factory
|
|
270
|
-
// targets the multi-line `.cli` contract
|
|
271
|
-
//
|
|
272
|
-
//
|
|
270
|
+
// targets the multi-line `.ds-cli-block` contract defined in gm-prose.css
|
|
271
|
+
// (`.ds-cli-block` holding `.ds-cli-row` rows — each a prompt+cmd pair — and
|
|
272
|
+
// `.ds-cli-comment` comment rows). `lines` is [{kind, text}] where kind: 'cmt' renders a
|
|
273
273
|
// comment-only row (no prompt glyph); any other kind (or omitted) renders a
|
|
274
274
|
// command row prefixed '$'. `heading` titles the wrapping Panel ('quick start'
|
|
275
275
|
// default, matching every hand-rolled instance); pass `heading: null` to
|
|
276
|
-
// render the bare `.cli` block with no Panel chrome.
|
|
276
|
+
// render the bare `.ds-cli-block` block with no Panel chrome.
|
|
277
|
+
// Note: this is a different component than the bare `.cli` single prompt+cmd
|
|
278
|
+
// row primitive (app-shell.css; see Install() above and the per-line usage
|
|
279
|
+
// in terminal/site quickstart renderers) — the two used to collide on the
|
|
280
|
+
// same `.cli` class name with incompatible display models.
|
|
277
281
|
export function CliBlock({ lines = [], heading = 'quick start', className = '' } = {}) {
|
|
278
282
|
if (!lines || !lines.length) return null;
|
|
279
283
|
const rows = lines.map((l, i) => {
|
|
@@ -285,7 +289,7 @@ export function CliBlock({ lines = [], heading = 'quick start', className = '' }
|
|
|
285
289
|
h('span', { class: 'prompt' }, '$'),
|
|
286
290
|
h('span', { class: 'cmd' }, text));
|
|
287
291
|
});
|
|
288
|
-
const body = h('div', { class: 'cli' + (className ? ' ' + className : '') }, ...rows);
|
|
292
|
+
const body = h('div', { class: 'ds-cli-block' + (className ? ' ' + className : '') }, ...rows);
|
|
289
293
|
return heading == null ? body : Panel({ title: heading, children: body });
|
|
290
294
|
}
|
|
291
295
|
|
|
@@ -345,7 +345,7 @@ export function ResizeHandle({ axis = 'horizontal', onResize, ariaLabel } = {})
|
|
|
345
345
|
};
|
|
346
346
|
const onPointerUp = (e) => {
|
|
347
347
|
dragOrigin = null;
|
|
348
|
-
try { e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
|
348
|
+
try { e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
|
|
349
349
|
};
|
|
350
350
|
const onKeyDown = (e) => {
|
|
351
351
|
const k = e.key;
|
|
@@ -829,7 +829,7 @@ export function JsonViewer({ value, emptyText = 'no data', maxHeight, mode = 'pl
|
|
|
829
829
|
if (!text) return h('div', { class: 'ds-ep-json ds-ep-json-empty' }, emptyText);
|
|
830
830
|
const style = maxHeight ? ('max-height:' + maxHeight) : null;
|
|
831
831
|
if (!knownJson && (mode === 'highlight' || mode === 'tree')) {
|
|
832
|
-
try { parsed = JSON.parse(text); knownJson = true; } catch { /* not JSON — render plain */ }
|
|
832
|
+
try { parsed = JSON.parse(text); knownJson = true; } catch { /* swallow: not JSON — render plain */ }
|
|
833
833
|
}
|
|
834
834
|
let body;
|
|
835
835
|
if (mode === 'tree' && knownJson && parsed !== null && typeof parsed === 'object') {
|
|
@@ -4,6 +4,7 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
4
4
|
import { Btn, Icon } from './shell.js';
|
|
5
5
|
import { fileGlyph, fmtFileSize } from './files.js';
|
|
6
6
|
import { highlightAllUnder } from '../highlight.js';
|
|
7
|
+
import { shortUid } from '../uid.js';
|
|
7
8
|
const h = webjsx.createElement;
|
|
8
9
|
|
|
9
10
|
// Full focusable set for the modal Tab trap — omitting textarea/select/a[href]
|
|
@@ -81,7 +82,7 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
81
82
|
// Only restore focus when the modal is genuinely going away (not a
|
|
82
83
|
// re-render remount) and focus is not already somewhere useful.
|
|
83
84
|
if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
|
|
84
|
-
try { Backdrop._invoker.focus(); } catch {}
|
|
85
|
+
try { Backdrop._invoker.focus(); } catch { /* swallow: restoring focus on close is best-effort, teardown still completes */ }
|
|
85
86
|
}
|
|
86
87
|
if (removed) Backdrop._invoker = null;
|
|
87
88
|
};
|
|
@@ -140,7 +141,7 @@ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body,
|
|
|
140
141
|
// stays constant across re-renders of the same dialog instance — an
|
|
141
142
|
// incrementing counter advances on every render, leaving the previous
|
|
142
143
|
// aria-labelledby reference pointing at a now-absent element.
|
|
143
|
-
const headId = head != null ? ('ds-modal-head-' +
|
|
144
|
+
const headId = head != null ? ('ds-modal-head-' + shortUid(6)) : null;
|
|
144
145
|
return Backdrop({
|
|
145
146
|
onClose,
|
|
146
147
|
kind,
|
|
@@ -270,7 +271,7 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
|
|
|
270
271
|
const btn = e.currentTarget;
|
|
271
272
|
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
272
273
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
|
|
273
|
-
else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
274
|
+
else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch { /* swallow: legacy execCommand copy fallback unsupported, nothing more to try */ } }
|
|
274
275
|
};
|
|
275
276
|
return h('div', { class: 'ds-preview-code-wrap' },
|
|
276
277
|
h('div', { class: 'chat-code-head ds-preview-code-head' },
|
|
@@ -295,7 +296,7 @@ function codeBody({ content = '', lang } = {}) {
|
|
|
295
296
|
: null;
|
|
296
297
|
const highlightRef = (el) => {
|
|
297
298
|
if (!el) return;
|
|
298
|
-
try { highlightAllUnder(el); } catch {}
|
|
299
|
+
try { highlightAllUnder(el); } catch { /* swallow: syntax highlighting is a progressive enhancement, plain code still renders */ }
|
|
299
300
|
};
|
|
300
301
|
return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
|
|
301
302
|
gutter,
|
|
@@ -75,7 +75,7 @@ export function saveRecentPath(path) {
|
|
|
75
75
|
if (typeof localStorage === 'undefined' || !path) return;
|
|
76
76
|
const list = getRecentPaths().filter(p => p !== path);
|
|
77
77
|
list.unshift(path);
|
|
78
|
-
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_CAP))); } catch {}
|
|
78
|
+
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_CAP))); } catch { /* swallow: persistence is best-effort, recent-path history is non-critical */ }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// Helper used by consumers that want to render chat-message arrays with
|
|
@@ -10,20 +10,12 @@ import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from '
|
|
|
10
10
|
import { Panel, Row, Table, Kpi, PageHeader, SearchInput, TextField, Select } from './content.js';
|
|
11
11
|
import { Chip, Btn, Icon } from './shell.js';
|
|
12
12
|
import { ChatMessage, ChatComposer } from './chat.js';
|
|
13
|
+
import { fmtTime, fmtAgo } from './sessions.js';
|
|
13
14
|
|
|
14
15
|
const h = webjsx.createElement;
|
|
15
16
|
|
|
16
17
|
// ---- shared bits -----------------------------------------------------------
|
|
17
18
|
|
|
18
|
-
const fmtTime = (t) => { try { return new Date(t).toLocaleString(); } catch { return String(t || ''); } };
|
|
19
|
-
const fmtAgo = (t) => {
|
|
20
|
-
if (!t) return '';
|
|
21
|
-
const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
|
22
|
-
if (s < 60) return s + 's ago';
|
|
23
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
24
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
25
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
26
|
-
};
|
|
27
19
|
const section = (title, ...children) => Panel({ title, children: children.flat().filter(Boolean) });
|
|
28
20
|
const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note.kind, role: 'alert' },
|
|
29
21
|
h('span', { class: 'ds-alert-icon' }, '!'),
|
|
@@ -32,7 +32,7 @@ export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
|
|
|
32
32
|
if (!active) return;
|
|
33
33
|
const wasStarted = started;
|
|
34
34
|
active = false; started = false;
|
|
35
|
-
try { if (pid != null) el.releasePointerCapture(pid); } catch {}
|
|
35
|
+
try { if (pid != null) el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
|
|
36
36
|
pid = null;
|
|
37
37
|
el.removeAttribute('data-dragging');
|
|
38
38
|
const hit = document.elementFromPoint(e.clientX, e.clientY);
|
|
@@ -47,7 +47,7 @@ export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
|
|
|
47
47
|
if (e.button != null && e.button !== 0) return;
|
|
48
48
|
active = true; started = false;
|
|
49
49
|
startX = e.clientX; startY = e.clientY; pid = e.pointerId;
|
|
50
|
-
try { el.setPointerCapture(e.pointerId); } catch {}
|
|
50
|
+
try { el.setPointerCapture(e.pointerId); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
|
|
51
51
|
window.addEventListener('pointermove', onMove);
|
|
52
52
|
window.addEventListener('pointerup', onUp);
|
|
53
53
|
window.addEventListener('pointercancel', onUp);
|
|
@@ -111,7 +111,7 @@ export function useNumberScrub(el, { getValue, onChange, step = 0.01, threshold
|
|
|
111
111
|
};
|
|
112
112
|
const onUp = () => {
|
|
113
113
|
if (pid == null) return;
|
|
114
|
-
try { el.releasePointerCapture(pid); } catch {}
|
|
114
|
+
try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
|
|
115
115
|
pid = null;
|
|
116
116
|
el.removeAttribute('data-scrubbing');
|
|
117
117
|
window.removeEventListener('pointermove', onMove);
|
|
@@ -125,7 +125,7 @@ export function useNumberScrub(el, { getValue, onChange, step = 0.01, threshold
|
|
|
125
125
|
pid = e.pointerId; startX = e.clientX; moved = false;
|
|
126
126
|
const cur = getValue ? getValue() : parseFloat(el.value);
|
|
127
127
|
startV = Number.isFinite(cur) ? cur : 0;
|
|
128
|
-
try { el.setPointerCapture(pid); } catch {}
|
|
128
|
+
try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
|
|
129
129
|
window.addEventListener('pointermove', onMove);
|
|
130
130
|
window.addEventListener('pointerup', onUp);
|
|
131
131
|
window.addEventListener('pointercancel', onUp);
|
|
@@ -157,7 +157,7 @@ export function usePointerDrag(el, { onStart, onMove, onEnd, button = 0 } = {})
|
|
|
157
157
|
};
|
|
158
158
|
const finish = (e, cancelled) => {
|
|
159
159
|
if (pid == null) return;
|
|
160
|
-
try { el.releasePointerCapture(pid); } catch {}
|
|
160
|
+
try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
|
|
161
161
|
pid = null;
|
|
162
162
|
el.removeAttribute('data-pointer-dragging');
|
|
163
163
|
window.removeEventListener('pointermove', onMoveEv);
|
|
@@ -173,7 +173,7 @@ export function usePointerDrag(el, { onStart, onMove, onEnd, button = 0 } = {})
|
|
|
173
173
|
if (onStart && onStart(e) === false) return; // caller declined (e.g. raycast miss)
|
|
174
174
|
pid = e.pointerId;
|
|
175
175
|
el.setAttribute('data-pointer-dragging', 'true');
|
|
176
|
-
try { el.setPointerCapture(pid); } catch {}
|
|
176
|
+
try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
|
|
177
177
|
window.addEventListener('pointermove', onMoveEv);
|
|
178
178
|
window.addEventListener('pointerup', onUpEv);
|
|
179
179
|
window.addEventListener('pointercancel', onCancelEv);
|
|
@@ -11,6 +11,21 @@ const h = webjsx.createElement;
|
|
|
11
11
|
// ONE duration format for every surface (live cards, running panel, session
|
|
12
12
|
// meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
|
|
13
13
|
// roll s -> m -> h instead of an hour-long run reading '3712s'.
|
|
14
|
+
// ONE absolute-time / relative-time formatter for every surface that shows a
|
|
15
|
+
// timestamp (freddie pages, chat transcripts). fmtTime -> localized
|
|
16
|
+
// date+time string; fmtAgo -> coarse relative ('Ns/Nm/Nh/Nd ago').
|
|
17
|
+
export function fmtTime(t) {
|
|
18
|
+
try { return new Date(t).toLocaleString(); } catch { return String(t || ''); }
|
|
19
|
+
}
|
|
20
|
+
export function fmtAgo(t) {
|
|
21
|
+
if (!t) return '';
|
|
22
|
+
const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
|
23
|
+
if (s < 60) return s + 's ago';
|
|
24
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
25
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
26
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export function fmtDuration(ms) {
|
|
15
30
|
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
16
31
|
const s = Math.round(ms / 1000);
|
package/src/components/shell.js
CHANGED
|
@@ -345,9 +345,9 @@ function toggleSide(open, fromEl) {
|
|
|
345
345
|
if (next) {
|
|
346
346
|
const drawer = shell.querySelector('.app-side-shell');
|
|
347
347
|
const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
|
|
348
|
-
if (focusable) try { focusable.focus(); } catch (_) {}
|
|
348
|
+
if (focusable) try { focusable.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element, drawer still opens */ }
|
|
349
349
|
const onKey = (e) => {
|
|
350
|
-
if (e.key === 'Escape') { toggleSide(false, btn || body); if (btn) try { btn.focus(); } catch (_) {} return; }
|
|
350
|
+
if (e.key === 'Escape') { toggleSide(false, btn || body); if (btn) try { btn.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element */ } return; }
|
|
351
351
|
if (drawer) trapTab(drawer, e);
|
|
352
352
|
};
|
|
353
353
|
body._dsSideKey = onKey;
|
|
@@ -428,7 +428,7 @@ function toggleWs(which, fromEl) {
|
|
|
428
428
|
});
|
|
429
429
|
try {
|
|
430
430
|
localStorage.setItem('ds.ws.' + which, nowCollapsed ? 'collapsed' : 'open');
|
|
431
|
-
} catch (_) {}
|
|
431
|
+
} catch (_) { /* swallow: persistence is best-effort, collapse state still applies in-memory */ }
|
|
432
432
|
// Expanding restores the persisted width (seed skips collapsed columns, so
|
|
433
433
|
// it must run after the open flag is written).
|
|
434
434
|
if (!nowCollapsed) seedWsWidths(shell);
|
|
@@ -459,7 +459,7 @@ function wsResize(col, dx, persist = true, fromEl) {
|
|
|
459
459
|
if (handle) { handle.setAttribute('aria-valuenow', String(next)); handle.setAttribute('aria-valuetext', next + ' pixels'); }
|
|
460
460
|
// Commit to storage only on a settled move (pointerup / keyboard), not on
|
|
461
461
|
// every pointermove frame (that fired dozens of synchronous writes per drag).
|
|
462
|
-
if (persist) { try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) {} }
|
|
462
|
+
if (persist) { try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) { /* swallow: persistence is best-effort, resize still applies in-memory */ } }
|
|
463
463
|
}
|
|
464
464
|
// Per-column viewport caps for persisted widths: a width dragged on a wide
|
|
465
465
|
// monitor must not crush the content column when the page reloads on a
|
|
@@ -474,7 +474,7 @@ function seedWsWidths(el) {
|
|
|
474
474
|
if (wsCollapsed(col, false)) return;
|
|
475
475
|
const v = localStorage.getItem('ds.ws.w.' + col);
|
|
476
476
|
if (v && /^\d+$/.test(v)) el.style.setProperty('--ws-' + col + '-w', `min(${v}px, ${WS_VW_CAP[col]})`);
|
|
477
|
-
} catch (_) {}
|
|
477
|
+
} catch (_) { /* swallow: localStorage unavailable, seeding is best-effort */ }
|
|
478
478
|
});
|
|
479
479
|
}
|
|
480
480
|
function WsResizer(col) {
|
|
@@ -534,10 +534,10 @@ function toggleWsDrawer(which, open, fromEl) {
|
|
|
534
534
|
// into the scrim/background content behind it).
|
|
535
535
|
const drawer = shell.querySelector(which === 'pane' ? '.ws-pane' : '.ws-sessions');
|
|
536
536
|
const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
|
|
537
|
-
if (focusable) try { focusable.focus(); } catch (_) {}
|
|
537
|
+
if (focusable) try { focusable.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element, drawer still opens */ }
|
|
538
538
|
removeWsDrawerHandlers(shell); // replace, never stack (opening one drawer over the other)
|
|
539
539
|
const onKey = (e) => {
|
|
540
|
-
if (e.key === 'Escape') { toggleWsDrawer(which, false, shell); if (btn) try { btn.focus(); } catch (_) {} return; }
|
|
540
|
+
if (e.key === 'Escape') { toggleWsDrawer(which, false, shell); if (btn) try { btn.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element */ } return; }
|
|
541
541
|
if (drawer) trapTab(drawer, e);
|
|
542
542
|
};
|
|
543
543
|
shell._wsEscHandler = onKey;
|
|
@@ -571,7 +571,7 @@ function wsCollapsed(which, fallback) {
|
|
|
571
571
|
const v = localStorage.getItem('ds.ws.' + which);
|
|
572
572
|
if (v === 'collapsed') return true;
|
|
573
573
|
if (v === 'open') return false;
|
|
574
|
-
} catch (_) {}
|
|
574
|
+
} catch (_) { /* swallow: localStorage unavailable, fall back to caller-supplied default */ }
|
|
575
575
|
return !!fallback;
|
|
576
576
|
}
|
|
577
577
|
|
|
@@ -47,7 +47,7 @@ export function ThemeToggle({ compact = false, onChange } = {}) {
|
|
|
47
47
|
onclick: () => {
|
|
48
48
|
const next = current === 'auto' ? 'paper' : (current === 'paper' ? 'ink' : 'auto');
|
|
49
49
|
applyTheme(next);
|
|
50
|
-
if (onChange) try { onChange(next); } catch {}
|
|
50
|
+
if (onChange) try { onChange(next); } catch { /* swallow: consumer onChange callback must not break the toggle */ }
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
// CSS-drawn half-disc so the control still reads as the theme switch
|
|
@@ -69,7 +69,7 @@ export function ThemeToggle({ compact = false, onChange } = {}) {
|
|
|
69
69
|
class: 'ds-seg-btn' + (current === mode ? ' is-on' : ''),
|
|
70
70
|
onclick: () => {
|
|
71
71
|
applyTheme(mode);
|
|
72
|
-
if (onChange) try { onChange(mode); } catch {}
|
|
72
|
+
if (onChange) try { onChange(mode); } catch { /* swallow: consumer onChange callback must not break the toggle */ }
|
|
73
73
|
}
|
|
74
74
|
}, label)
|
|
75
75
|
));
|