anentrypoint-design 0.0.201 → 0.0.203
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 +47 -12
- package/chat.css +91 -0
- package/dist/247420.css +138 -12
- package/dist/247420.js +13 -13
- package/package.json +1 -1
- package/src/components/agent-chat.js +64 -6
- package/src/components/chat.js +129 -24
- package/src/components/content.js +2 -1
- package/src/components/context-pane.js +3 -1
- package/src/components/files-modals.js +83 -24
- package/src/components/files.js +23 -3
- package/src/components/sessions.js +59 -16
- package/src/components/shell.js +12 -8
- package/src/components.js +3 -3
- package/src/kits/os/theme.css +2 -2
- package/src/kits/os/wm.css +15 -0
- package/src/kits/os/wm.js +15 -6
- package/src/markdown-cache.js +15 -11
- package/src/markdown.js +15 -0
package/src/components/files.js
CHANGED
|
@@ -31,8 +31,11 @@ export function fileGlyph(type) {
|
|
|
31
31
|
return TYPE_ICON[type] || TYPE_ICON.other;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
|
|
35
|
+
// format everywhere: '0 B' for zero; the em-dash means unknown/null ONLY.
|
|
34
36
|
export function fmtFileSize(bytes) {
|
|
35
|
-
if (bytes == null
|
|
37
|
+
if (bytes == null) return '—';
|
|
38
|
+
if (bytes === 0) return '0 B';
|
|
36
39
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
37
40
|
let i = 0, n = bytes;
|
|
38
41
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
@@ -278,11 +281,27 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
278
281
|
);
|
|
279
282
|
}
|
|
280
283
|
|
|
281
|
-
|
|
284
|
+
// UploadProgress — per-file upload rows. Error rows are recoverable, not dead
|
|
285
|
+
// ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
|
|
286
|
+
// a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
|
|
287
|
+
// can be cleared without waiting for the next successful batch.
|
|
288
|
+
export function UploadProgress({ items = [], onDismiss } = {}) {
|
|
282
289
|
if (!items.length) return null;
|
|
283
290
|
return h('div', { class: 'ds-upload-progress' },
|
|
284
291
|
...items.map((it, i) => {
|
|
285
292
|
const status = it.error ? 'error' : (it.done ? 'complete' : `uploading ${it.pct || 0}%`);
|
|
293
|
+
const rowActions = [
|
|
294
|
+
...((it.actions || []).map((a, ai) => h('button', {
|
|
295
|
+
key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
|
|
296
|
+
'aria-label': `${a.label} ${it.name}`,
|
|
297
|
+
onclick: () => a.onClick && a.onClick(it, i),
|
|
298
|
+
}, a.label))),
|
|
299
|
+
(it.error && onDismiss) ? h('button', {
|
|
300
|
+
key: 'ud', type: 'button', class: 'ds-upload-act',
|
|
301
|
+
'aria-label': `dismiss ${it.name}`,
|
|
302
|
+
onclick: () => onDismiss(it, i),
|
|
303
|
+
}, 'dismiss') : null,
|
|
304
|
+
].filter(Boolean);
|
|
286
305
|
return h('div', {
|
|
287
306
|
key: it.name + i,
|
|
288
307
|
class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
|
|
@@ -297,7 +316,8 @@ export function UploadProgress({ items = [] } = {}) {
|
|
|
297
316
|
h('span', { class: 'ds-upload-bar' },
|
|
298
317
|
h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
|
|
299
318
|
),
|
|
300
|
-
h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%')))
|
|
319
|
+
h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%'))),
|
|
320
|
+
rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
|
|
301
321
|
);
|
|
302
322
|
})
|
|
303
323
|
);
|
|
@@ -8,6 +8,19 @@ import { Btn, Icon } from './shell.js';
|
|
|
8
8
|
import { Select, SearchInput } from './content.js';
|
|
9
9
|
const h = webjsx.createElement;
|
|
10
10
|
|
|
11
|
+
// ONE duration format for every surface (live cards, running panel, session
|
|
12
|
+
// meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
|
|
13
|
+
// roll s -> m -> h instead of an hour-long run reading '3712s'.
|
|
14
|
+
export function fmtDuration(ms) {
|
|
15
|
+
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
16
|
+
const s = Math.round(ms / 1000);
|
|
17
|
+
if (s < 60) return s + 's';
|
|
18
|
+
const m = Math.floor(s / 60);
|
|
19
|
+
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
20
|
+
const hrs = Math.floor(m / 60);
|
|
21
|
+
return hrs + 'h ' + (m % 60) + 'm';
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
// ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
|
|
12
25
|
// caller-supplied group label, each row showing title/project, relative time,
|
|
13
26
|
// agent badge, and a running/new-event indicator. Selecting a row switches the
|
|
@@ -36,8 +49,10 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
36
49
|
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
37
50
|
// each h() call gets a clean, consistent child list.
|
|
38
51
|
h('span', { class: 'ds-session-main' }, [
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
// Two-sided truncation: the CSS ellipsis is paired with a title= carrying
|
|
53
|
+
// the full string, so a long title/project is recoverable on hover.
|
|
54
|
+
h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
|
|
55
|
+
(s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
|
|
41
56
|
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
42
57
|
].filter(Boolean)),
|
|
43
58
|
h('span', { class: 'ds-session-meta' }, [
|
|
@@ -125,36 +140,50 @@ export function SessionMeta({ items = [] } = {}) {
|
|
|
125
140
|
// no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
|
|
126
141
|
// is visually distinct from a busy one (a frozen elapsed alone reads identically
|
|
127
142
|
// for both, which is the high-severity oversight gap this closes).
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
// `session.stopping` is the in-flight cancel state: the stop button disables
|
|
144
|
+
// with label 'stopping…' and the status word flips to 'stopping', so the click
|
|
145
|
+
// visibly took and cannot re-fire while the host waits for the active poll.
|
|
146
|
+
// `session.external` marks a session we observe (ccsniff stream) but do not own
|
|
147
|
+
// (no process to kill): the stop button is suppressed, an 'external' tag renders
|
|
148
|
+
// in the head, and the host wires onView to open it in history instead.
|
|
149
|
+
// `session.title` is the SAME string the conversation rails use, rendered as
|
|
150
|
+
// the card heading so the rail row and its dashboard card share one identity.
|
|
151
|
+
// `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
|
|
152
|
+
// pre-formatted `elapsed` string remains as a legacy fallback.
|
|
153
|
+
const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
|
|
154
|
+
const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
|
|
130
155
|
|
|
131
156
|
export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
|
|
132
157
|
selectable = false, selected = false, onToggleSelect } = {}) {
|
|
133
158
|
const s = session;
|
|
134
|
-
const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
|
|
159
|
+
const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
|
|
135
160
|
// The stat line composes elapsed + live counter; the activity line carries the
|
|
136
161
|
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
137
162
|
// start offset. Both are middot-joined (kept product separator).
|
|
138
|
-
const
|
|
163
|
+
const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
|
|
164
|
+
const statBits = [elapsedText, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
|
|
139
165
|
const activityBits = [
|
|
140
166
|
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
141
167
|
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
142
168
|
].filter(Boolean);
|
|
143
|
-
const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
|
|
144
|
-
return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
|
|
169
|
+
const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '');
|
|
170
|
+
return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
|
|
171
|
+
// Shared session identity: the same title the conversation rails show.
|
|
172
|
+
s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
|
|
145
173
|
h('div', { class: 'ds-dash-card-head' },
|
|
146
174
|
selectable ? h('button', {
|
|
147
175
|
type: 'button', class: 'ds-dash-select', role: 'checkbox',
|
|
148
176
|
'aria-checked': selected ? 'true' : 'false',
|
|
149
|
-
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
|
|
177
|
+
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
|
|
150
178
|
onclick: () => onToggleSelect && onToggleSelect(s),
|
|
151
179
|
}, selected ? '[x]' : '[ ]') : null,
|
|
152
180
|
h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
|
|
153
181
|
// Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
|
|
154
182
|
// aria-hidden, so the visible/AT status word carries the state.
|
|
155
183
|
h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
|
|
156
|
-
h('span', { class: 'ds-dash-
|
|
157
|
-
|
|
184
|
+
s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
|
|
185
|
+
h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
|
|
186
|
+
s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null),
|
|
158
187
|
h('div', { class: 'ds-dash-meta' },
|
|
159
188
|
s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
|
|
160
189
|
statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
|
|
@@ -163,8 +192,10 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
|
|
|
163
192
|
// open and resume collapsed into one 'open' action (they both just reopen
|
|
164
193
|
// the session in chat); 'events' kept for the read-only event view.
|
|
165
194
|
onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
|
|
166
|
-
onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
|
|
167
|
-
|
|
195
|
+
onView ? Btn({ key: 'view', onClick: () => onView(s), children: s.external ? 'open in history' : 'events' }) : null,
|
|
196
|
+
// External sessions get no stop control: we own no process to kill.
|
|
197
|
+
(onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping,
|
|
198
|
+
onClick: () => !s.stopping && onStop(s), children: s.stopping ? 'stopping…' : 'stop' }) : null));
|
|
168
199
|
}
|
|
169
200
|
|
|
170
201
|
// SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
|
|
@@ -177,7 +208,14 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
|
|
|
177
208
|
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
178
209
|
// Streamstate words: the live-stream health signal so "connected, zero running"
|
|
179
210
|
// still tells the user the dashboard is listening (vs a dropped stream).
|
|
180
|
-
|
|
211
|
+
// One connection vocabulary across the crumb, settings chip, and the dashboard
|
|
212
|
+
// stream line: connected / connecting / offline ('lost' kept as a legacy alias).
|
|
213
|
+
const STREAM_WORD = {
|
|
214
|
+
connected: 'listening for activity',
|
|
215
|
+
connecting: 'connecting to live stream…',
|
|
216
|
+
offline: 'live stream offline — retrying…',
|
|
217
|
+
lost: 'live stream offline — retrying…',
|
|
218
|
+
};
|
|
181
219
|
|
|
182
220
|
// The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
|
|
183
221
|
// is stateless): the first click fires onArmStop* so the host flips confirming*
|
|
@@ -196,6 +234,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
|
|
|
196
234
|
}
|
|
197
235
|
const selSet = selected instanceof Set ? selected : new Set(selected || []);
|
|
198
236
|
const selCount = selSet.size;
|
|
237
|
+
// While any session is mid-cancel the bulk control reads disabled
|
|
238
|
+
// 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
|
|
239
|
+
const stoppingCount = sessions.filter((s) => s.stopping).length;
|
|
199
240
|
// The stream-state line always renders (even with zero sessions) so a
|
|
200
241
|
// connected-but-idle dashboard reads differently from an offline one.
|
|
201
242
|
const streamLine = streamState
|
|
@@ -225,7 +266,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
|
|
|
225
266
|
selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
|
|
226
267
|
streamLine,
|
|
227
268
|
h('span', { class: 'spread' }),
|
|
228
|
-
|
|
269
|
+
stoppingCount > 0 && (onStopSelected || onStopAll)
|
|
270
|
+
? Btn({ key: 'stopbusy', danger: true, disabled: true, children: 'stopping ' + stoppingCount + '…' })
|
|
271
|
+
: (selectable && selCount && onStopSelected
|
|
229
272
|
? (onArmStopSelected && !confirmingStopSelected
|
|
230
273
|
? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
|
|
231
274
|
: Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
|
|
@@ -235,7 +278,7 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
|
|
|
235
278
|
? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
|
|
236
279
|
: Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
|
|
237
280
|
children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
|
|
238
|
-
: null),
|
|
281
|
+
: null)),
|
|
239
282
|
toolbar);
|
|
240
283
|
const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
|
|
241
284
|
...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
|
package/src/components/shell.js
CHANGED
|
@@ -240,14 +240,18 @@ export function Status({ left = [], right = [] } = {}) {
|
|
|
240
240
|
);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
// Toggle the
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
// Toggle the sidebar drawer. Pure-DOM because AppShell is stateless chrome; the
|
|
244
|
+
// class lives on .app-body and is read by the @container(max-width:900px) query.
|
|
245
|
+
// `fromEl` scopes the toggle to the shell that owns the clicked control — without
|
|
246
|
+
// it, document.querySelector grabs the FIRST .app-body on the page, so a second
|
|
247
|
+
// dashboard instance (multiple thebird WM windows) would toggle the wrong drawer.
|
|
248
|
+
function toggleSide(open, fromEl) {
|
|
249
|
+
const shell = (fromEl && fromEl.closest && fromEl.closest('.app')) || document;
|
|
250
|
+
const body = shell.querySelector('.app-body');
|
|
247
251
|
if (!body) return;
|
|
248
252
|
const next = open != null ? open : !body.classList.contains('side-open');
|
|
249
253
|
body.classList.toggle('side-open', next);
|
|
250
|
-
const btn =
|
|
254
|
+
const btn = shell.querySelector('.app-side-toggle');
|
|
251
255
|
if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
|
|
252
256
|
}
|
|
253
257
|
|
|
@@ -267,12 +271,12 @@ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
|
|
|
267
271
|
hasSide ? h('button', {
|
|
268
272
|
class: 'app-side-toggle', type: 'button',
|
|
269
273
|
'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-main',
|
|
270
|
-
onclick: () => toggleSide(),
|
|
274
|
+
onclick: (e) => toggleSide(null, e.currentTarget),
|
|
271
275
|
}, Icon('menu')) : null,
|
|
272
276
|
chrome,
|
|
273
277
|
h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
|
|
274
|
-
h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: () => toggleSide(false) }),
|
|
275
|
-
h('div', { class: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false); } }, sideNode),
|
|
278
|
+
h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: (e) => toggleSide(false, e.currentTarget) }),
|
|
279
|
+
h('div', { class: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
|
|
276
280
|
// tabindex=-1 so the skip-link (href="#app-main") actually moves
|
|
277
281
|
// keyboard focus into the main region, not just scroll to it.
|
|
278
282
|
h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '-1' }, ...(Array.isArray(main) ? main : [main]))
|
package/src/components.js
CHANGED
|
@@ -20,15 +20,15 @@ export {
|
|
|
20
20
|
} from './components/content.js';
|
|
21
21
|
|
|
22
22
|
export {
|
|
23
|
-
fmtBytes, renderInline,
|
|
23
|
+
fmtBytes, renderInline, hasSelectionInside,
|
|
24
24
|
ChatMessage, ChatComposer, Chat,
|
|
25
25
|
AICAT_FACE, AICatPortrait, AICat
|
|
26
26
|
} from './components/chat.js';
|
|
27
27
|
|
|
28
|
-
export { AgentChat } from './components/agent-chat.js';
|
|
28
|
+
export { AgentChat, MESSAGE_CAP } from './components/agent-chat.js';
|
|
29
29
|
|
|
30
30
|
export {
|
|
31
|
-
ConversationList, SessionCard, SessionDashboard, SessionMeta
|
|
31
|
+
ConversationList, SessionCard, SessionDashboard, SessionMeta, fmtDuration
|
|
32
32
|
} from './components/sessions.js';
|
|
33
33
|
|
|
34
34
|
export { ContextPane } from './components/context-pane.js';
|
package/src/kits/os/theme.css
CHANGED
|
@@ -453,7 +453,7 @@ html, body {
|
|
|
453
453
|
.wm-bar .wm-btns { pointer-events: auto; }
|
|
454
454
|
.wm-btns .wm-btn:nth-child(1), .wm-btns .wm-btn:nth-child(2) { display: none !important; }
|
|
455
455
|
.wm-btns .wm-btn:nth-child(3) { width: 44px !important; height: 44px !important; border-radius: var(--r-1, 6px) !important; background: var(--os-bg-3) !important; color: var(--os-fg) !important; }
|
|
456
|
-
.wm-resize { display: none !important; }
|
|
456
|
+
.wm-resize, .wm-edge { display: none !important; }
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
/* ---- data-attr theme/accent: deferred to the canonical theme ----
|
|
@@ -1405,7 +1405,7 @@ html.ds-247420 { touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-t
|
|
|
1405
1405
|
border-radius: var(--r-1, 6px) !important;
|
|
1406
1406
|
background: var(--os-bg-3) !important; color: var(--os-fg) !important;
|
|
1407
1407
|
}
|
|
1408
|
-
.ds-247420 .wm-resize { display: none !important; }
|
|
1408
|
+
.ds-247420 .wm-resize, .ds-247420 .wm-edge { display: none !important; }
|
|
1409
1409
|
/* collapse the menubar workspace cluster on mobile (the unconditional
|
|
1410
1410
|
* .ds-247420 .os-instances{display:flex} otherwise leaves it visible) */
|
|
1411
1411
|
.ds-247420 .os-menubar .os-instances,
|
package/src/kits/os/wm.css
CHANGED
|
@@ -109,6 +109,21 @@
|
|
|
109
109
|
linear-gradient(135deg, transparent 0 70%, currentColor 70% 80%, transparent 80% 100%);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/* Edge + corner resize hit-zones. Invisible (no glyph) — they only widen the
|
|
113
|
+
* grab area beyond the SE .wm-resize grip. `width`/`height` are the grab
|
|
114
|
+
* thickness; corners overlap edges and win via later source order + size. */
|
|
115
|
+
.wm-edge { position: absolute; touch-action: none; z-index: 1; }
|
|
116
|
+
.wm-edge[data-dir='n'] { top: -3px; left: 8px; right: 8px; height: 8px; cursor: ns-resize; }
|
|
117
|
+
.wm-edge[data-dir='s'] { bottom: -3px; left: 8px; right: 8px; height: 8px; cursor: ns-resize; }
|
|
118
|
+
.wm-edge[data-dir='e'] { right: -3px; top: 8px; bottom: 8px; width: 8px; cursor: ew-resize; }
|
|
119
|
+
.wm-edge[data-dir='w'] { left: -3px; top: 8px; bottom: 8px; width: 8px; cursor: ew-resize; }
|
|
120
|
+
.wm-edge[data-dir='ne'] { top: -3px; right: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
|
|
121
|
+
.wm-edge[data-dir='nw'] { top: -3px; left: -3px; width: 14px; height: 14px; cursor: nwse-resize; }
|
|
122
|
+
.wm-edge[data-dir='sw'] { bottom: -3px; left: -3px; width: 14px; height: 14px; cursor: nesw-resize; }
|
|
123
|
+
|
|
124
|
+
.wm-win.wm-min .wm-edge,
|
|
125
|
+
.wm-win.wm-max .wm-edge { display: none; }
|
|
126
|
+
|
|
112
127
|
.wm-win.wm-min .wm-body,
|
|
113
128
|
.wm-win.wm-min .wm-resize { display: none; }
|
|
114
129
|
|
package/src/kits/os/wm.js
CHANGED
|
@@ -46,10 +46,19 @@ export function renderWindow(opts = {}) {
|
|
|
46
46
|
bodyEl.className = 'wm-body';
|
|
47
47
|
setBodyContent(bodyEl, body);
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
resize.
|
|
49
|
+
// Resize affordances: one grip per edge + corner. `data-dir` carries the
|
|
50
|
+
// direction (n/s/e/w/ne/nw/se/sw) to the consumer's resize math. The SE
|
|
51
|
+
// corner keeps the visible diagonal grip glyph (.wm-resize); the other
|
|
52
|
+
// seven are invisible hit-zones (.wm-edge) styled in wm.css.
|
|
53
|
+
const DIRS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
|
|
54
|
+
const grips = DIRS.map(dir => {
|
|
55
|
+
const g = document.createElement('div');
|
|
56
|
+
g.className = dir === 'se' ? 'wm-resize' : 'wm-edge';
|
|
57
|
+
g.dataset.dir = dir;
|
|
58
|
+
return g;
|
|
59
|
+
});
|
|
51
60
|
|
|
52
|
-
el.append(bar, bodyEl,
|
|
61
|
+
el.append(bar, bodyEl, ...grips);
|
|
53
62
|
|
|
54
63
|
minBtn.addEventListener('click', e => { e.stopPropagation(); callbacks.onMinimize && callbacks.onMinimize(); });
|
|
55
64
|
maxBtn.addEventListener('click', e => { e.stopPropagation(); callbacks.onMaximize && callbacks.onMaximize(); });
|
|
@@ -66,11 +75,11 @@ export function renderWindow(opts = {}) {
|
|
|
66
75
|
if (callbacks.onDragStart) callbacks.onDragStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
|
|
67
76
|
});
|
|
68
77
|
|
|
69
|
-
|
|
78
|
+
grips.forEach(g => g.addEventListener('pointerdown', e => {
|
|
70
79
|
e.stopPropagation();
|
|
71
80
|
focus();
|
|
72
|
-
if (callbacks.onResizeStart) callbacks.onResizeStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
|
|
73
|
-
});
|
|
81
|
+
if (callbacks.onResizeStart) callbacks.onResizeStart(e, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, dir: g.dataset.dir });
|
|
82
|
+
}));
|
|
74
83
|
|
|
75
84
|
applyFocused(el, focused);
|
|
76
85
|
applyMaximized(el, maximized);
|
package/src/markdown-cache.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Ensures libraries are loaded once globally, reused for all subsequent renders.
|
|
3
3
|
// Tracks initialization status and render timings.
|
|
4
4
|
|
|
5
|
-
import { renderMarkdown, ensureReady as ensureMarkdownReady } from './markdown.js';
|
|
5
|
+
import { renderMarkdown, ensureReady as ensureMarkdownReady, isDegraded as isMarkdownDegraded } from './markdown.js';
|
|
6
6
|
import { highlightAllUnder, ensurePrism } from './highlight.js';
|
|
7
7
|
import { register } from './debug.js';
|
|
8
8
|
|
|
@@ -84,19 +84,23 @@ export async function renderMarkdownCached(text) {
|
|
|
84
84
|
return _renderCache.get(hash);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// Ensure markdown is ready
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
// Ensure markdown is ready. NOT latched behind _markdownInitialized: a
|
|
88
|
+
// failed loader must be retried on a later render (markdown.js owns the
|
|
89
|
+
// retry backoff), otherwise an offline boot is sticky-degraded forever.
|
|
90
|
+
await ensureMarkdownReady();
|
|
91
|
+
_markdownInitialized = !isMarkdownDegraded();
|
|
92
92
|
|
|
93
93
|
const html = await renderMarkdown(text);
|
|
94
94
|
|
|
95
|
-
// Store in content cache (limit to 500 entries to prevent unbounded growth)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
_renderCache.
|
|
95
|
+
// Store in content cache (limit to 500 entries to prevent unbounded growth).
|
|
96
|
+
// Never cache degraded (escaped-fallback) output: when the loader recovers,
|
|
97
|
+
// the same content must re-render as real markdown, not replay the fallback.
|
|
98
|
+
if (!isMarkdownDegraded()) {
|
|
99
|
+
_renderCache.set(hash, html);
|
|
100
|
+
if (_renderCache.size > 500) {
|
|
101
|
+
const first = _renderCache.keys().next().value;
|
|
102
|
+
_renderCache.delete(first);
|
|
103
|
+
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
const renderMs = performance.now() - t0;
|
package/src/markdown.js
CHANGED
|
@@ -5,20 +5,35 @@
|
|
|
5
5
|
let _ready = null;
|
|
6
6
|
let _marked = null;
|
|
7
7
|
let _purify = null;
|
|
8
|
+
// A failed load is NOT cached forever: we drop _ready so a later render retries,
|
|
9
|
+
// guarded by a small backoff so an offline session doesn't hammer the CDN.
|
|
10
|
+
let _failedAt = 0;
|
|
11
|
+
const RETRY_BACKOFF_MS = 30000;
|
|
8
12
|
|
|
9
13
|
const MARKED_URL = 'https://cdn.jsdelivr.net/npm/marked@15/+esm';
|
|
10
14
|
const PURIFY_URL = 'https://cdn.jsdelivr.net/npm/dompurify@3/+esm';
|
|
11
15
|
|
|
16
|
+
// True while the markdown stack is unavailable (escaped-fallback rendering).
|
|
17
|
+
// Consumers (markdown-cache) use this to avoid caching degraded output.
|
|
18
|
+
export function isDegraded() {
|
|
19
|
+
return !_marked || !_purify;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export async function ensureReady() {
|
|
13
23
|
if (_ready) return _ready;
|
|
24
|
+
if (_failedAt && Date.now() - _failedAt < RETRY_BACKOFF_MS) return false;
|
|
14
25
|
_ready = (async () => {
|
|
15
26
|
try {
|
|
16
27
|
const [{ marked }, DOMPurifyMod] = await Promise.all([import(MARKED_URL), import(PURIFY_URL)]);
|
|
17
28
|
_marked = marked;
|
|
18
29
|
_purify = DOMPurifyMod.default || DOMPurifyMod;
|
|
30
|
+
_failedAt = 0;
|
|
19
31
|
return true;
|
|
20
32
|
} catch (err) {
|
|
21
33
|
console.warn('[247420] markdown loader failed:', err);
|
|
34
|
+
// Reset the cached promise so a later render retries (after backoff).
|
|
35
|
+
_ready = null;
|
|
36
|
+
_failedAt = Date.now();
|
|
22
37
|
return false;
|
|
23
38
|
}
|
|
24
39
|
})();
|