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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.324",
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
+ }
@@ -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
 
@@ -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 already defined in app-shell.css and
271
- // gm-prose.css (`.cli` holding `.ds-cli-row` rows — each a prompt+cmd pair —
272
- // and `.ds-cli-comment` comment rows). `lines` is [{kind, text}] where kind: 'cmt' renders a
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-' + Math.random().toString(36).slice(2, 8)) : null;
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);
@@ -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
  ));