@ikenga/pkg-tasks 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ // Tasks main view — ported from views/TasksView.tsx. List + filter bar +
2
+ // grouped rows + master/detail split + in-body view switcher (Tasks/Agenda/
3
+ // Triage) with localStorage persistence.
4
+
5
+ import { html, cn, Icon, Button, useState, useMemo, useEffect, useQuery } from '../../lib/ui.js';
6
+ import { hostDbQuery, hostSendToActiveSession, isStandalone } from '../../lib/bridge.js';
7
+ import { queryKeys } from '../../lib/query-keys.js';
8
+ import { TASKS_LIST_COLUMNS, triageCountsQuery } from '../../lib/queries.js';
9
+ import { groupTasks } from '../../lib/shared.js';
10
+ import { TaskRow } from './task-row.js';
11
+ import { TaskDetailPane } from './task-detail-pane.js';
12
+ import { ViewTabs } from './view-tabs.js';
13
+ import { AgendaView } from './agenda-view.js';
14
+ import { TriageView } from './triage-view.js';
15
+
16
+ /** @typedef {import('../../lib/queries.js').Task} Task */
17
+ /** @typedef {import('../../lib/queries.js').TaskStatus} TaskStatus */
18
+ /** @typedef {import('../../lib/shared.js').GroupKey} GroupKey */
19
+ /** @typedef {import('../../lib/shared.js').TaskView} TaskView */
20
+
21
+ const VIEW_STORAGE_KEY = 'ikenga-tasks-view';
22
+
23
+ /** @returns {TaskView} */
24
+ function loadView() {
25
+ try {
26
+ const v = localStorage.getItem(VIEW_STORAGE_KEY);
27
+ if (v === 'tasks' || v === 'agenda' || v === 'triage') return v;
28
+ } catch {
29
+ /* localStorage unavailable (sandboxed iframe) — fall through */
30
+ }
31
+ return 'tasks';
32
+ }
33
+
34
+ /** @type {Array<{ value: '' | TaskStatus, label: string }>} */
35
+ const STATUS_OPTIONS = [
36
+ { value: '', label: 'Open' },
37
+ { value: 'pending', label: 'Pending' },
38
+ { value: 'in_progress', label: 'In progress' },
39
+ { value: 'blocked', label: 'Blocked' },
40
+ { value: 'completed', label: 'Completed' },
41
+ ];
42
+
43
+ /** @param {{ activeFeature?: string | null }} props */
44
+ export function TasksView({ activeFeature } = {}) {
45
+ /** @type {[string | null, (v: string | null) => void]} */
46
+ const [selectedId, setSelectedId] = useState(/** @type {string | null} */ (null));
47
+ /** @type {['' | TaskStatus, (v: '' | TaskStatus) => void]} */
48
+ const [statusFilter, setStatusFilter] = useState(/** @type {'' | TaskStatus} */ (''));
49
+ const [ownerFilter, setOwnerFilter] = useState('');
50
+ const [categoryFilter, setCategoryFilter] = useState('');
51
+ const [search, setSearch] = useState('');
52
+ const [showAutoClosed, setShowAutoClosed] = useState(true);
53
+ /** @type {[Set<GroupKey>, (f: (prev: Set<GroupKey>) => Set<GroupKey>) => void]} */
54
+ const [collapsed, setCollapsed] = useState(/** @type {Set<GroupKey>} */ (new Set(['later'])));
55
+ const [view, setView] = useState(loadView);
56
+
57
+ /** @param {TaskView} v */
58
+ function changeView(v) {
59
+ setView(v);
60
+ try {
61
+ localStorage.setItem(VIEW_STORAGE_KEY, v);
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ }
66
+
67
+ // Shell side-menu selection (host.pkg.setMenu → royaltiSuite.activeFeature).
68
+ // View ids switch the mounted view; `f:<group>` filter ids jump the Tasks
69
+ // list to that group (expanding it, surfacing auto-closed when relevant).
70
+ useEffect(() => {
71
+ if (!activeFeature) return;
72
+ if (activeFeature === 'tasks' || activeFeature === 'agenda' || activeFeature === 'triage') {
73
+ setView(activeFeature);
74
+ return;
75
+ }
76
+ if (activeFeature.startsWith('f:')) {
77
+ const key = /** @type {GroupKey} */ (activeFeature.slice(2)); // today|overdue|autoclosed
78
+ setView('tasks');
79
+ if (key === 'autoclosed') setShowAutoClosed(true);
80
+ setCollapsed((prev) => {
81
+ const next = new Set(prev);
82
+ next.delete(key);
83
+ return next;
84
+ });
85
+ // Defer so the Tasks view + group are mounted before we scroll to it.
86
+ requestAnimationFrame(() => {
87
+ const el = document.querySelector(`.tk-list [data-group="${key}"]`);
88
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
89
+ });
90
+ }
91
+ }, [activeFeature]);
92
+
93
+ // Server-side health counts — drive the Triage tab badge (and the Triage
94
+ // view's stat cards), correct independent of the list filter + 200-row cap.
95
+ const { data: triageCounts } = useQuery(triageCountsQuery());
96
+ const triageBadge = triageCounts ? triageCounts.needsAttention : null;
97
+
98
+ const { data, isLoading, error } = useQuery({
99
+ queryKey: queryKeys.tasks.list(
100
+ `${statusFilter || 'open'}|${ownerFilter}|${categoryFilter}|${showAutoClosed ? 'ac' : 'no-ac'}`,
101
+ ),
102
+ /** @returns {Promise<Task[]>} */
103
+ queryFn: async () => {
104
+ /** @type {string[]} */
105
+ const where = [];
106
+ /** @type {(string|number|null)[]} */
107
+ const params = [];
108
+
109
+ if (statusFilter) {
110
+ where.push('status = ?');
111
+ params.push(statusFilter);
112
+ } else if (showAutoClosed) {
113
+ // active OR (completed AND auto-closed by task-health) — the `%` lives
114
+ // in the LIKE pattern param, not the SQL text.
115
+ where.push(
116
+ "(status IN ('pending','in_progress','blocked') OR (status = 'completed' AND outcome_notes LIKE ?))",
117
+ );
118
+ params.push('Auto-closed by task-health%');
119
+ } else {
120
+ where.push("status IN ('pending','in_progress','blocked')");
121
+ }
122
+ if (ownerFilter) {
123
+ where.push('assigned_to = ?');
124
+ params.push(ownerFilter);
125
+ }
126
+ if (categoryFilter) {
127
+ where.push('category = ?');
128
+ params.push(categoryFilter);
129
+ }
130
+ if (search.trim()) {
131
+ where.push('title LIKE ?'); // SQLite LIKE is case-insensitive (ASCII) ≈ ilike
132
+ params.push(`%${search.trim()}%`);
133
+ }
134
+
135
+ const sql =
136
+ `SELECT ${TASKS_LIST_COLUMNS} FROM tasks` +
137
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
138
+ ' ORDER BY due_date ASC NULLS LAST, created_at DESC LIMIT 200';
139
+
140
+ const rows = await hostDbQuery(sql, params);
141
+ return /** @type {Task[]} */ (rows);
142
+ },
143
+ });
144
+
145
+ const groups = useMemo(
146
+ () => (data ? groupTasks(data, showAutoClosed) : []),
147
+ [data, showAutoClosed],
148
+ );
149
+
150
+ const openCount = useMemo(
151
+ () => data?.filter((t) => t.status !== 'completed' && t.status !== 'cancelled').length ?? 0,
152
+ [data],
153
+ );
154
+ const autoClosedCount = useMemo(
155
+ () =>
156
+ data?.filter(
157
+ (t) =>
158
+ t.status === 'completed' &&
159
+ !!t.outcome_notes &&
160
+ t.outcome_notes.startsWith('Auto-closed by task-health'),
161
+ ).length ?? 0,
162
+ [data],
163
+ );
164
+
165
+ const filterActive = !!statusFilter || !!ownerFilter || !!categoryFilter || !!search.trim();
166
+
167
+ const [creating, setCreating] = useState(false);
168
+
169
+ // Create = dispatch, NOT anon insert. Anon RLS on `tasks` only grants UPDATE
170
+ // of status/completed_at — never INSERT — so a new task cannot be written
171
+ // client-side. Instead we seed a user turn into the shell's active Claude
172
+ // session (host.sendToActiveSession); the agent creates the task via its
173
+ // privileged path. Disabled in standalone (no host to dispatch to).
174
+ async function createTask() {
175
+ if (creating || isStandalone()) return;
176
+ setCreating(true);
177
+ try {
178
+ await hostSendToActiveSession(
179
+ 'Create a new task. Ask me for the title, owner, priority, and due date, then add it to the tasks table.',
180
+ );
181
+ } catch (e) {
182
+ console.warn('[tasks] create dispatch failed', e);
183
+ } finally {
184
+ setCreating(false);
185
+ }
186
+ }
187
+
188
+ /** @param {GroupKey} key */
189
+ function toggleGroup(key) {
190
+ setCollapsed((prev) => {
191
+ const next = new Set(prev);
192
+ if (next.has(key)) next.delete(key);
193
+ else next.add(key);
194
+ return next;
195
+ });
196
+ }
197
+
198
+ return html`
199
+ <div class="tk-screen">
200
+ <div class="tk-frame" style=${{ flex: 1 }}>
201
+ <div class="tk-frame-head">
202
+ <div class="tk-frame-title-wrap">
203
+ <${Icon} name="check-square" size=${18} className="tk-frame-title-mark" />
204
+ <div>
205
+ <h2 class="tk-frame-title">
206
+ Tasks
207
+ <span class="tk-frame-count">(${openCount} open · ${autoClosedCount} auto-closed)</span>
208
+ </h2>
209
+ <div class="tk-frame-sub">
210
+ Cross-cutting work — humans + agents. Click a row to inspect.
211
+ </div>
212
+ </div>
213
+ </div>
214
+ <div style=${{ display: 'flex', gap: 6, alignItems: 'center' }}>
215
+ <${Button}
216
+ size="sm"
217
+ type="button"
218
+ disabled=${creating || isStandalone()}
219
+ title=${isStandalone() ? 'Dispatch unavailable in standalone preview' : 'Dispatch a task to your Chi'}
220
+ onClick=${createTask}
221
+ >
222
+ <${Icon} name=${creating ? 'loader' : 'plus'} size=${12} className=${creating ? 'tk-spin' : undefined} />
223
+ ${creating ? 'Dispatching…' : 'New task'}
224
+ </${Button}>
225
+ </div>
226
+ </div>
227
+
228
+ ${isStandalone() &&
229
+ html`<${ViewTabs} view=${view} onChange=${changeView} triageCount=${triageBadge} />`}
230
+
231
+ ${view === 'agenda' && html`<${AgendaView} tasks=${data ?? []} filterActive=${filterActive} />`}
232
+ ${view === 'triage' && html`<${TriageView} listTasks=${data ?? []} />`}
233
+
234
+ ${view === 'tasks' && html`
235
+ <div class="tk-filterbar">
236
+ <div class="input-search-wrap">
237
+ <${Icon} name="search" size=${13} />
238
+ <input
239
+ type="text"
240
+ value=${search}
241
+ onInput=${(e) => setSearch(e.target.value)}
242
+ placeholder="Search title…"
243
+ />
244
+ </div>
245
+ <span class="label">Status</span>
246
+ <select
247
+ value=${statusFilter}
248
+ onChange=${(e) => setStatusFilter(/** @type {'' | TaskStatus} */ (e.target.value))}
249
+ >
250
+ ${STATUS_OPTIONS.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}
251
+ </select>
252
+ <span class="label">Owner</span>
253
+ <select value=${ownerFilter} onChange=${(e) => setOwnerFilter(e.target.value)}>
254
+ <option value="">Anyone</option>
255
+ <option value="nedjamez">Me</option>
256
+ <option value="cfo-agent">cfo-agent</option>
257
+ <option value="cmo-agent">cmo-agent</option>
258
+ <option value="cto-agent">cto-agent</option>
259
+ <option value="cpo-agent">cpo-agent</option>
260
+ <option value="vp-sales-agent">vp-sales-agent</option>
261
+ <option value="blog-writer">blog-writer</option>
262
+ </select>
263
+ <span class="label">Category</span>
264
+ <select value=${categoryFilter} onChange=${(e) => setCategoryFilter(e.target.value)}>
265
+ <option value="">All</option>
266
+ <option value="sales">sales</option>
267
+ <option value="finance">finance</option>
268
+ <option value="marketing">marketing</option>
269
+ <option value="technical">technical</option>
270
+ <option value="product">product</option>
271
+ <option value="communication">communication</option>
272
+ <option value="operations">operations</option>
273
+ </select>
274
+ <button
275
+ type="button"
276
+ class=${cn('tk-toggle', showAutoClosed && 'is-on')}
277
+ onClick=${() => setShowAutoClosed((v) => !v)}
278
+ >
279
+ <span class="checkbox"></span>
280
+ Show auto-closed
281
+ </button>
282
+ <div class="spacer"></div>
283
+ <span class="label">${openCount} open · ${autoClosedCount} auto-closed</span>
284
+ </div>
285
+
286
+ <div class="tk-split">
287
+ <div class="tk-list">
288
+ ${isLoading && html`
289
+ <div class="tk-loading">
290
+ <${Icon} name="loader" size=${16} className="tk-spin" />
291
+ Loading…
292
+ </div>
293
+ `}
294
+ ${error instanceof Error && html`
295
+ <div class="tk-error">
296
+ <${Icon} name="alert-circle" size=${16} />
297
+ <div>
298
+ <p class="t">Failed to load tasks</p>
299
+ <p class="d">${error.message}</p>
300
+ </div>
301
+ </div>
302
+ `}
303
+ ${data && data.length === 0 && !isLoading && html`
304
+ <div class="tk-empty-box">No tasks match.</div>
305
+ `}
306
+ ${groups.map((g) => {
307
+ const isCollapsed = collapsed.has(g.key);
308
+ return html`
309
+ <div key=${g.key}>
310
+ <div
311
+ role="button"
312
+ tabIndex=${0}
313
+ aria-expanded=${!isCollapsed}
314
+ data-group=${g.key}
315
+ class=${cn(
316
+ 'tk-group-head',
317
+ g.key === 'overdue' && 'is-overdue',
318
+ g.key === 'autoclosed' && 'is-autoclosed',
319
+ isCollapsed && 'is-collapsed',
320
+ )}
321
+ onClick=${() => toggleGroup(g.key)}
322
+ onKeyDown=${(e) => {
323
+ if (e.key === 'Enter' || e.key === ' ') {
324
+ e.preventDefault();
325
+ toggleGroup(g.key);
326
+ }
327
+ }}
328
+ >
329
+ <span class="tk-group-label">
330
+ <${Icon} name="chevron-down" size=${10} className="chev" />
331
+ ${g.label}
332
+ </span>
333
+ <span class="ct">${g.tasks.length}</span>
334
+ </div>
335
+ ${!isCollapsed &&
336
+ g.tasks.map((t) => html`
337
+ <${TaskRow}
338
+ key=${t.id}
339
+ task=${t}
340
+ selected=${selectedId === t.id}
341
+ onSelect=${setSelectedId}
342
+ />
343
+ `)}
344
+ </div>
345
+ `;
346
+ })}
347
+ </div>
348
+
349
+ <div class="tk-divider"></div>
350
+
351
+ <div class="tk-detail">
352
+ ${selectedId
353
+ ? html`<${TaskDetailPane} taskId=${selectedId} density="full" onNavigateTask=${setSelectedId} />`
354
+ : html`<div class="tk-empty">Select a task</div>`}
355
+ </div>
356
+ </div>
357
+ `}
358
+ </div>
359
+ </div>
360
+ `;
361
+ }
@@ -0,0 +1,124 @@
1
+ // Triage / Health view — ported from routes/tasks/_components/-triage-view.tsx.
2
+
3
+ import { html, cn, useMemo, useQuery } from '../../lib/ui.js';
4
+ import { triageCountsQuery } from '../../lib/queries.js';
5
+ import {
6
+ assigneeIsAgent,
7
+ avatarInitial,
8
+ buildTriage,
9
+ dueLabel,
10
+ priorityClass,
11
+ relativeAgo,
12
+ } from '../../lib/shared.js';
13
+
14
+ /** @typedef {import('../../lib/queries.js').Task} Task */
15
+ /** @typedef {import('../../lib/queries.js').TriageCounts} TriageCounts */
16
+ /** @typedef {import('../../lib/shared.js').TriageBucketKey} TriageBucketKey */
17
+
18
+ /** @type {Array<{ key: TriageBucketKey, label: string, sub: string, cls: string }>} */
19
+ const STAT_META = [
20
+ { key: 'overdue', label: 'Overdue', sub: 'past due', cls: 'is-danger' },
21
+ { key: 'stale', label: 'Stale > 7d', sub: 'no activity', cls: 'is-warn' },
22
+ { key: 'unassigned', label: 'Unassigned', sub: 'no owner', cls: 'is-sys' },
23
+ { key: 'blocked', label: 'Blocked', sub: 'awaiting dep', cls: 'is-danger' },
24
+ ];
25
+
26
+ /** @param {{ task: Task }} props */
27
+ function MiniRow({ task }) {
28
+ const isAgent = assigneeIsAgent(task);
29
+ const due = dueLabel(task.due_date);
30
+ const stale = relativeAgo(task.updated_at);
31
+ return html`
32
+ <div class="tr-mini">
33
+ <span class=${cn('pri-dot', priorityClass(task.priority))}></span>
34
+ <span class="title">${task.title}</span>
35
+ ${task.assigned_to
36
+ ? html`
37
+ <span class=${cn('tk-assignee', isAgent && 'is-agent')}>
38
+ ${isAgent
39
+ ? html`<span class="dot"></span>`
40
+ : html`<span class="avatar">${avatarInitial(task.assigned_to)}</span>`}
41
+ ${task.assigned_to}
42
+ </span>
43
+ `
44
+ : html`<span class="tk-execmode is-approval_required">needs owner</span>`}
45
+ <span class=${cn('age', due.cls === 'is-overdue' && 'is-bad')}>
46
+ ${due.cls === 'is-overdue' ? due.label : stale}
47
+ </span>
48
+ </div>
49
+ `;
50
+ }
51
+
52
+ /** @param {{ listTasks: Task[] }} props */
53
+ export function TriageView({ listTasks }) {
54
+ const { data: counts } = useQuery(triageCountsQuery());
55
+ const buckets = useMemo(() => buildTriage(listTasks), [listTasks]);
56
+
57
+ /** @param {TriageBucketKey} k @returns {number|null} */
58
+ const countFor = (k) => (counts ? counts[k] : null);
59
+ const needAttention = counts ? counts.needsAttention : null;
60
+
61
+ return html`
62
+ <div class="tr-wrap">
63
+ <div class="tr-stats">
64
+ ${STAT_META.map((s) => {
65
+ const n = countFor(s.key);
66
+ return html`
67
+ <div key=${s.key} class=${cn('tr-stat', s.cls)}>
68
+ <div class="n">${n ?? '—'}</div>
69
+ <div class="k">${s.label}</div>
70
+ <div class="sub">${s.sub}</div>
71
+ </div>
72
+ `;
73
+ })}
74
+ </div>
75
+
76
+ ${buckets.map((b) => {
77
+ const total = countFor(b.key);
78
+ if (b.sample.length === 0 && (total == null || total === 0)) return null;
79
+ return html`
80
+ <div key=${b.key} class="tr-bucket">
81
+ <div class="tr-bucket-head">
82
+ ${b.label}
83
+ <span class="ct">${total ?? b.sample.length}</span>
84
+ </div>
85
+ ${b.sample.length > 0
86
+ ? html`
87
+ ${b.sample.map((t) => html`<${MiniRow} key=${t.id} task=${t} />`)}
88
+ ${total != null && total > b.sample.length && html`
89
+ <div class="tr-more">showing ${b.sample.length} of ${total}</div>
90
+ `}
91
+ `
92
+ : html`<div class="tr-more">${total} match — not in the current list view</div>`}
93
+ </div>
94
+ `;
95
+ })}
96
+
97
+ <div class="tr-health">
98
+ <span class="score">${healthGrade(counts)}</span>
99
+ <span>
100
+ <b style=${{ color: 'var(--fg)' }}>Backlog health.</b>${' '}
101
+ ${needAttention != null
102
+ ? `${needAttention} need attention (overdue + unassigned).`
103
+ : 'Computing…'}${' '}
104
+ Clear the overdue items and the sidebar${' '}
105
+ <span style=${{ color: 'var(--achievement)' }}>Overdue</span> badge drops to 0.
106
+ </span>
107
+ </div>
108
+ </div>
109
+ `;
110
+ }
111
+
112
+ /**
113
+ * @param {TriageCounts|undefined} c
114
+ * @returns {string}
115
+ */
116
+ function healthGrade(c) {
117
+ if (!c) return '·';
118
+ const pressure = c.overdue * 2 + c.unassigned + c.blocked;
119
+ if (pressure === 0) return 'A';
120
+ if (pressure <= 3) return 'A-';
121
+ if (pressure <= 6) return 'B+';
122
+ if (pressure <= 10) return 'B';
123
+ return 'C';
124
+ }
@@ -0,0 +1,38 @@
1
+ // In-body view switcher — ported from routes/tasks/_components/-view-tabs.tsx.
2
+
3
+ import { html, cn, Icon } from '../../lib/ui.js';
4
+
5
+ /** @typedef {import('../../lib/shared.js').TaskView} TaskView */
6
+
7
+ /** @type {Array<{ key: TaskView, label: string, icon: string }>} */
8
+ const TABS = [
9
+ { key: 'tasks', label: 'Tasks', icon: 'check-square' },
10
+ { key: 'agenda', label: 'Agenda', icon: 'calendar-days' },
11
+ { key: 'triage', label: 'Triage', icon: 'stethoscope' },
12
+ ];
13
+
14
+ /**
15
+ * @param {{ view: TaskView, onChange: (v: TaskView) => void, triageCount?: number | null }} props
16
+ */
17
+ export function ViewTabs({ view, onChange, triageCount }) {
18
+ return html`
19
+ <div class="ip-tabs" role="tablist" aria-label="Tasks views">
20
+ ${TABS.map(({ key, label, icon }) => html`
21
+ <button
22
+ key=${key}
23
+ type="button"
24
+ role="tab"
25
+ aria-selected=${view === key}
26
+ class=${cn('ip-tab', view === key && 'is-on')}
27
+ onClick=${() => onChange(key)}
28
+ >
29
+ <${Icon} name=${icon} size=${13} />
30
+ <span>${label}</span>
31
+ ${key === 'triage' && triageCount != null && triageCount > 0 && html`
32
+ <span class="ip-tab-badge">${triageCount}</span>
33
+ `}
34
+ </button>
35
+ `)}
36
+ </div>
37
+ `;
38
+ }
@@ -0,0 +1,49 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>Tasks</title>
8
+ <!-- No <link rel="stylesheet">: WebKitGTK can't load subresources (link/
9
+ fetch/img) from the about:srcdoc document the shell mounts — only
10
+ inlined scripts work (see the bootstrap comment below). So tasks.css
11
+ ships as a JS string (lib/tasks-css.js) and app.js injects it as an
12
+ inline <style>. The block below is just the boot/token shim. -->
13
+ <style>
14
+ :root {
15
+ --tasks-bg: var(--bg-base, #fff);
16
+ --tasks-fg: var(--fg, #111);
17
+ --tasks-muted: var(--fg-muted, #666);
18
+ --tasks-danger: var(--danger, #c44);
19
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
20
+ }
21
+ * { box-sizing: border-box; }
22
+ html, body, #root { height: 100%; }
23
+ body { margin: 0; background: var(--tasks-bg); color: var(--tasks-fg); font: 14px/1.4 var(--font-sans, system-ui); -webkit-font-smoothing: antialiased; }
24
+ #root > .boot { padding: 1rem 1.25rem; font-size: 0.85rem; color: var(--tasks-muted); }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <div id="root"><div class="boot">Loading…</div></div>
29
+ <!--
30
+ Why this looks indirect: pkg_content/mod.rs inlines `<script src="./x">`
31
+ into the srcdoc HTML to work around a WebKitGTK subresource-from-srcdoc
32
+ bug. But the resulting inline module is rooted at `about:srcdoc`, so its
33
+ static `import './foo.js'` and even `import('./foo.js')` fail with
34
+ "does not resolve to a valid URL". Resolving against `document.baseURI`
35
+ (which reflects the host-injected `<base href>`) gives a real URL the
36
+ module loader accepts. Once app.js is loaded as a fetched file, its
37
+ OWN url is real and relative imports inside it work normally.
38
+ -->
39
+ <script type="module">
40
+ try {
41
+ await import(new URL('app.js', document.baseURI).href);
42
+ } catch (e) {
43
+ const root = document.getElementById('root');
44
+ root.innerHTML = `<div class="boot" style="color:var(--tasks-danger)">Failed to load app.js: ${e.message ?? e}</div>`;
45
+ throw e;
46
+ }
47
+ </script>
48
+ </body>
49
+ </html>