@lovek4d/kickflip 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The sidebar of every site (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
|
|
2
|
+
// /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself:
|
|
3
|
+
// the app shell runs it through sidebar.js, and hub/hub.mjs renders the shell's first sidebar with both in a vm context.
|
|
4
|
+
// Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
|
|
5
|
+
(function () {
|
|
6
|
+
// ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
|
|
7
|
+
const { header } = globalThis.BoardUI || {};
|
|
8
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
9
|
+
const svg = (paths, size = 17) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
10
|
+
const ICONS = {
|
|
11
|
+
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
12
|
+
sliders: '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>',
|
|
13
|
+
pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
|
14
|
+
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
15
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
16
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
17
|
+
up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
|
|
18
|
+
};
|
|
19
|
+
const tile = (key, color) => `<span class="icon sm solid" style="--c:${color}" aria-hidden="true">${svg(ICONS[key])}</span>`;
|
|
20
|
+
const word = (tone, text) => `<span class="dot t-${tone}" aria-hidden="true"></span><span class="word t-${tone}">${esc(text)}</span>`;
|
|
21
|
+
const usageProducts = (data) => ['claude', 'codex'].filter((p) => data?.[p]);
|
|
22
|
+
const usageNames = { claude: 'Claude', codex: 'Codex' };
|
|
23
|
+
function usageClock(iso) {
|
|
24
|
+
const d = new Date(iso ?? '');
|
|
25
|
+
if (!Number.isFinite(d.getTime())) return '—';
|
|
26
|
+
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP])M/i, (_, x) => `${x.toLowerCase()}m`);
|
|
27
|
+
return new Date().toDateString() === d.toDateString() ? time : `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
|
28
|
+
}
|
|
29
|
+
const usageTone = (w) => !w ? 'none' : w.state === 'stale' ? 'stale' : w.used_percentage >= 90 ? 'full' : w.used_percentage >= 75 ? 'high' : 'ok';
|
|
30
|
+
const usageBar = (w) => `<span class="u-bar" aria-hidden="true"><i style="width:${w?.state === 'stale' ? 0 : Math.max(0, Math.min(100, w?.used_percentage || 0))}%"></i></span>`;
|
|
31
|
+
function usageWords(reading, key) {
|
|
32
|
+
const w = reading[key];
|
|
33
|
+
if (!w) return 'No reading';
|
|
34
|
+
if (w.state === 'stale') return `${Math.round(w.used_percentage)}% at ${esc(usageClock(reading.at))}`;
|
|
35
|
+
if (w.state === 'reset') return `Reset ${esc(usageClock(w.resets_at))}`;
|
|
36
|
+
return `<span class="u-num">${Math.round(w.used_percentage)}%</span> · resets ${esc(usageClock(w.resets_at))}`;
|
|
37
|
+
}
|
|
38
|
+
function sidebarUsage(data) {
|
|
39
|
+
const products = usageProducts(data);
|
|
40
|
+
return products.length ? `<span class="sb-usage">${products.map((p) => {
|
|
41
|
+
const r = data[p];
|
|
42
|
+
const w = r.five_hour;
|
|
43
|
+
const name = `<span class="u-name">${usageNames[p]}</span>`;
|
|
44
|
+
const words = !w ? `${name} · no reading yet`
|
|
45
|
+
: w.state === 'stale' ? `${name} · last seen ${esc(usageClock(r.at))}`
|
|
46
|
+
: w.state === 'reset' ? `${name} · reset ${esc(usageClock(w.resets_at))}`
|
|
47
|
+
: `${name} ${usageWords(r, 'five_hour')}`;
|
|
48
|
+
return `<span class="u-one u-tone-${usageTone(w)}"><span class="u-line">${words}</span>${usageBar(w)}</span>`;
|
|
49
|
+
}).join('')}</span>` : '';
|
|
50
|
+
}
|
|
51
|
+
// The utility rows, in the order they show.
|
|
52
|
+
const UTILITY = [
|
|
53
|
+
{ href: '/_hub/', title: 'Hub', icon: tile('pulse', '#64748b') },
|
|
54
|
+
];
|
|
55
|
+
const siteHref = (s) => `/${encodeURIComponent(s.route)}/`;
|
|
56
|
+
|
|
57
|
+
// Switch mode's keys (sidebar.js; docs/adr/0056-switch-mode-letters-come-from-names-in-join-order.md): each linked row the
|
|
58
|
+
// sidebar draws, boards first for numbering, as {href, title, board, num, letter, at}. The first nine boards take num '1'–'9'.
|
|
59
|
+
// Every row takes as letter the first a–z letter of its title not yet taken, at index `at` of the title's characters,
|
|
60
|
+
// claiming in this order: boards by `created`, then pages by `created`, a site with none after those with one, then
|
|
61
|
+
// the Hub rows. So a new board never takes another board's letter. A row with no letter left gets none.
|
|
62
|
+
function switchKeys(data) {
|
|
63
|
+
const sites = data?.sites || [];
|
|
64
|
+
const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
|
|
65
|
+
const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
|
|
66
|
+
...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
67
|
+
rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
|
|
68
|
+
const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
|
|
69
|
+
const taken = new Set();
|
|
70
|
+
for (const r of [...rows].sort((a, b) => a.rank - b.rank || when(a) - when(b))) {
|
|
71
|
+
const chars = [...r.title];
|
|
72
|
+
const at = chars.findIndex((c) => /^[a-z]$/.test(c.toLowerCase()) && !taken.has(c.toLowerCase()));
|
|
73
|
+
if (at < 0) continue;
|
|
74
|
+
r.letter = chars[at].toLowerCase();
|
|
75
|
+
r.at = at;
|
|
76
|
+
taken.add(r.letter);
|
|
77
|
+
}
|
|
78
|
+
return rows.map(({ href, title, board, num, letter, at }) => ({ href, title, board, num, letter, at }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// key: the row's switchKeys entry while switch mode shows, else undefined. The tile carries a board's number, or any
|
|
82
|
+
// other row's letter, and the title marks its letter; sidebar.css shows them in place of the glyph under .ks-on.
|
|
83
|
+
// mark: HTML after the title, such as Hub's moon.
|
|
84
|
+
function row({ href, icon, title, sub = '', extra = '', mark = '', on = false, cls = '', key }) {
|
|
85
|
+
const shown = key && (key.board ? key.num : key.letter);
|
|
86
|
+
const tileHtml = shown ? icon.replace(/<\/span>$/, `<span class="ks-key">${esc(shown)}</span></span>`) : icon;
|
|
87
|
+
const chars = [...title];
|
|
88
|
+
const titleHtml = key?.letter ? `${esc(chars.slice(0, key.at).join(''))}<span class="ks-u">${esc(chars[key.at])}</span>${esc(chars.slice(key.at + 1).join(''))}` : esc(title);
|
|
89
|
+
return `<a class="sb-row${on ? ' on' : ''}${cls ? ` ${cls}` : ''}" href="${esc(href)}"${on ? ' aria-current="page"' : ''}>${tileHtml}`
|
|
90
|
+
+ `<span class="sb-text"><span class="sb-title">${titleHtml}${mark}</span>${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What waits on the owner on every site but `here`: the count on the logo that opens the drawer.
|
|
94
|
+
function othersWaiting(data, here) {
|
|
95
|
+
return (data?.sites || []).reduce((n, s) => n + (s.route === here ? 0 : Number(s.count) || 0), 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function repoLabel(repo, home) {
|
|
99
|
+
if (!home) return repo;
|
|
100
|
+
return repo === home ? '~' : repo.startsWith(`${home}/`) ? `~${repo.slice(home.length)}` : repo;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The shortcuts sheet's rows (sidebar.js opens it on h h), grouped by where each key works. A new shortcut is added
|
|
104
|
+
// here; the code that answers its key lives with the view it acts on.
|
|
105
|
+
const SHORTCUTS = [
|
|
106
|
+
{ group: 'Anywhere', rows: [
|
|
107
|
+
{ keys: ['s', 's'], what: 'Switch boards and pages', sub: 'Then the key on a row' },
|
|
108
|
+
{ keys: ['h', 'h'], what: 'This help' },
|
|
109
|
+
{ keys: ['Esc'], what: 'Close', sub: 'The help, a sheet, the drawer or switch mode' },
|
|
110
|
+
] },
|
|
111
|
+
{ group: 'On a board', rows: [{ keys: ['n', 'n'], what: 'New card' }] },
|
|
112
|
+
{ group: 'Writing', rows: [{ keys: ['⌘', '↵'], what: 'Send', sub: 'Ctrl ↵ off a Mac' }] },
|
|
113
|
+
];
|
|
114
|
+
// The shortcuts sheet's content, inside its <dialog aria-labelledby="shortcutsTitle">.
|
|
115
|
+
function shortcuts() {
|
|
116
|
+
const keycaps = (keys) => `<span class="sc-keys">${keys.map((k) => `<kbd class="sc-key">${esc(k)}</kbd>`).join('')}</span>`;
|
|
117
|
+
const rowHtml = (r) => `<div class="sc-row"><span class="sc-what">${esc(r.what)}${r.sub ? `<span class="sc-sub">${esc(r.sub)}</span>` : ''}</span>${keycaps(r.keys)}</div>`;
|
|
118
|
+
return `<div class="sheet-body"><div class="sheet-head"><h2 id="shortcutsTitle">Keyboard shortcuts</h2></div>`
|
|
119
|
+
+ SHORTCUTS.map((g) => `<section class="sc-group" aria-label="${esc(g.group)}"><h3>${esc(g.group)}</h3>${g.rows.map(rowHtml).join('')}</section>`).join('')
|
|
120
|
+
+ '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function updateRow(update) {
|
|
124
|
+
return update ? `<button type="button" class="sb-row sb-update-row" data-app-reload${update === 'reloading' ? ' disabled' : ''}>${tile('up', 'var(--accent)')}`
|
|
125
|
+
+ `<span class="sb-text"><span class="sb-title">New version</span></span><span class="sb-go">${update === 'reloading' ? 'Reloading…' : 'Reload'}</span></button>` : '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
129
|
+
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
130
|
+
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
131
|
+
// update: null | 'ready' | 'reloading'.
|
|
132
|
+
function sidebar(data, { here = '', device = null, switching = false, update = null } = {}) {
|
|
133
|
+
const sites = data?.sites || [];
|
|
134
|
+
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
135
|
+
const paused = data?.pause?.state === 'paused';
|
|
136
|
+
const siteRow = (s) => {
|
|
137
|
+
const sub = paused && s.board ? word('wait', 'Paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
138
|
+
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
139
|
+
};
|
|
140
|
+
const boards = sites.filter((s) => s.board);
|
|
141
|
+
const pages = sites.filter((s) => !s.board);
|
|
142
|
+
const plus = `<span class="icon sb-plus" aria-hidden="true">${svg(ICONS.plus, 20)}</span>`;
|
|
143
|
+
const pending = (data?.starting || []).map((s) => `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
|
|
144
|
+
+ `<span class="sb-text"><span class="sb-title">${s.failed ? "Didn't start" : s.phase === 'cloning' ? 'Cloning…' : 'Starting…'}</span><span class="sb-sub">${esc(s.title)}${s.repo ? ` · ${esc(repoLabel(s.repo, data.home))}` : ''}${s.failed ? '' : s.phase === 'cloning' ? ' · Cloning from GitHub' : ' · Waiting for the board to answer'}</span>${s.error ? `<span class="sb-sub">${esc(s.error)}</span>` : ''}</span>`
|
|
145
|
+
+ (s.failed ? `<button type="button" class="sb-dismiss" data-dismiss-board="${esc(s.route)}" aria-label="Dismiss ${esc(s.title)}">Dismiss</button>` : '') + '</div>').join('');
|
|
146
|
+
const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
|
|
147
|
+
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', '#ef4444'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
|
|
148
|
+
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', '#ef4444'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
149
|
+
: '';
|
|
150
|
+
const usage = sidebarUsage(data?.usage);
|
|
151
|
+
const hub = paused ? { tone: 'wait', word: 'Paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
|
|
152
|
+
const n = data?.notifications || {};
|
|
153
|
+
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
154
|
+
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
155
|
+
: n.state === 'off' ? word('needs', `Notifications ${lower(n.text || 'Off')}`) : '';
|
|
156
|
+
// A phone shows no title tooltip, so there the moon only marks the hold; the hub page's banner says it.
|
|
157
|
+
const held = esc(`Notifications ${lower(n.text)}`);
|
|
158
|
+
const moon = n.state === 'held' ? `<span class="sb-moon" role="img" aria-label="${held}" title="${held}">${svg(ICONS.moon, 13)}</span>` : '';
|
|
159
|
+
const subs = {
|
|
160
|
+
'/_hub/': (hub ? `<span class="sb-status">${word(hub.tone, hub.word)}<span>${esc(hub.detail)}</span></span>` : '')
|
|
161
|
+
+ (notify ? `<span class="sb-status">${notify}</span>` : ''),
|
|
162
|
+
};
|
|
163
|
+
const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
|
|
164
|
+
const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
|
|
165
|
+
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
166
|
+
return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
167
|
+
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
168
|
+
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
|
|
169
|
+
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
170
|
+
+ `</div>${turnOn}</section>`
|
|
171
|
+
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
172
|
+
+ `<section class="sb-boards" aria-label="Boards"><div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`
|
|
173
|
+
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
177
|
+
})();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
|
|
2
|
+
// page may do meanwhile (docs/adr/0028-the-board-opens-on-the-last-visit.md). Needs ui.js.
|
|
3
|
+
(function () {
|
|
4
|
+
const VERSION = 1;
|
|
5
|
+
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
6
|
+
const SNAPSHOT_MAX = 900000;
|
|
7
|
+
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
8
|
+
|
|
9
|
+
// threads maps item keys to thread messages (app.js state.threadCache). Threads of `keep` go in first, then the
|
|
10
|
+
// rest while they fit under max; threads of items no longer on the board are dropped. Null when the board alone won't fit.
|
|
11
|
+
function packSnapshot(board, threads, { savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
12
|
+
const head = `{"v":${VERSION},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(board)},"threads":{`;
|
|
13
|
+
if (head.length + 2 > max) return null;
|
|
14
|
+
const onBoard = new Set(board.items.map((i) => i.key));
|
|
15
|
+
let out = head;
|
|
16
|
+
let n = 0;
|
|
17
|
+
for (const key of new Set([...keep, ...threads.keys()])) {
|
|
18
|
+
if (!onBoard.has(key) || !threads.has(key)) continue;
|
|
19
|
+
const part = `${n ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(threads.get(key))}`;
|
|
20
|
+
if (out.length + part.length + 2 > max) continue;
|
|
21
|
+
out += part;
|
|
22
|
+
n++;
|
|
23
|
+
}
|
|
24
|
+
return `${out}}}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write.
|
|
28
|
+
function unpackSnapshot(raw) {
|
|
29
|
+
let s;
|
|
30
|
+
try { s = JSON.parse(raw); } catch { return null; }
|
|
31
|
+
if (s?.v !== VERSION || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
32
|
+
const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
|
|
33
|
+
return { savedAt: s.savedAt, board: s.board, threads };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The cards whose threads load right after the board, so tapping one opens it filled; open cards load with the board.
|
|
37
|
+
const preloadKeys = (items, open) => {
|
|
38
|
+
const opened = new Set(open);
|
|
39
|
+
return items.filter((i) => ACTIVE_LANES.includes(i.lane) && !opened.has(i.key)).map((i) => i.key);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
|
|
43
|
+
// and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
|
|
44
|
+
// INT-61 block dims the same list.
|
|
45
|
+
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-confirm="bump"]', 'button[data-edit]', 'button[data-withdraw]',
|
|
46
|
+
'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
|
|
47
|
+
const liveControls = (view) => {
|
|
48
|
+
const fresh = view !== 'snapshot';
|
|
49
|
+
return { read: true, ask: true, cards: fresh, send: fresh, blocked: fresh ? '' : SNAPSHOT_BLOCKED };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
|
|
53
|
+
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
54
|
+
|
|
55
|
+
Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX });
|
|
56
|
+
})();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A card's thread: Claude's lines, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
|
|
4
|
+
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
5
|
+
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
6
|
+
function cardLink(link) {
|
|
7
|
+
const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
|
|
8
|
+
if (!key) return '';
|
|
9
|
+
let ask = key.startsWith('ask:');
|
|
10
|
+
try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
|
|
11
|
+
return `<a href="${esc(link)}">Open the ${ask ? 'task' : 'card'} →</a>`;
|
|
12
|
+
}
|
|
13
|
+
// A message's state comes from domain.mjs threadOf.
|
|
14
|
+
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
15
|
+
const msgFooter = (m) => ({
|
|
16
|
+
working: () => `<div class="reply muted small">${workingLine}</div>`,
|
|
17
|
+
waiting: () => '<div class="reply muted small">Waiting for Claude…</div>',
|
|
18
|
+
}[stateOf(m)] || (() => ''))();
|
|
19
|
+
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
20
|
+
// when other messages came between them. A reply with no time follows its message.
|
|
21
|
+
const withReplies = (msgs) => {
|
|
22
|
+
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
23
|
+
...(m.eventId && m.reply ? [{ m: { ts: m.reply.at || m.ts, who: 'claude', kind: 'reply', reply: m.reply }, of: m, at: String(m.reply.at || m.ts), i, part: 1 }] : [])]);
|
|
24
|
+
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
25
|
+
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
|
|
26
|
+
};
|
|
27
|
+
// A message being edited shows its edit box in place of its text; app-asks.js handles the form.
|
|
28
|
+
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}"><textarea rows="3">${esc(text)}</textarea>
|
|
29
|
+
<div class="row"><span></span><span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
|
|
30
|
+
const line = (m, links, edits) => (m.kind === 'reply'
|
|
31
|
+
? `<div class="msg claude reply-msg"><div class="who">Claude · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
32
|
+
: m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
|
|
33
|
+
: m.kind === 'status' ? `<div class="msg builder-status"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
34
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
35
|
+
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
36
|
+
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Claude' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
37
|
+
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
38
|
+
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
39
|
+
${m.eventId ? msgFooter(m) : ''}
|
|
40
|
+
</div>`);
|
|
41
|
+
|
|
42
|
+
const STEP = 3;
|
|
43
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'ask-link']);
|
|
44
|
+
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
45
|
+
// The badge's unread test (domain.mjs othersAt, ui-cards.js unreadCount).
|
|
46
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.who !== 'love' && String(m.ts) > seenAt;
|
|
47
|
+
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
48
|
+
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
49
|
+
const firstUnread = lines.findIndex((m) => unread(m, seenAt));
|
|
50
|
+
const unreadSpan = firstUnread < 0 ? 0 : counted.filter((i) => i >= firstUnread).length;
|
|
51
|
+
const want = Math.max(STEP, unreadSpan) + STEP * more;
|
|
52
|
+
const pinned = lines.findIndex((m) => m.eventId && (edits.has(m.eventId) || ['waiting', 'working'].includes(stateOf(m))));
|
|
53
|
+
const start = Math.min(counted.length > want ? counted[counted.length - want] : 0, pinned < 0 ? Infinity : pinned);
|
|
54
|
+
const hidden = lines.filter((m, i) => i < start && isMessage(m)).length;
|
|
55
|
+
return { start: hidden ? start : 0, hidden };
|
|
56
|
+
}
|
|
57
|
+
const moreButton = (hidden) => `<button type="button" class="thread-more" data-thread-more>Show more <span class="n">· ${hidden} previous message${hidden === 1 ? '' : 's'}</span></button>`;
|
|
58
|
+
|
|
59
|
+
// links: the thread's ui-links.js linkContext. edits: the open edit boxes by event id, {text}. view: {seenAt, more,
|
|
60
|
+
// upto}, the card's seen mark from before it opened, its Show more taps and its newest line's ts when it first drew,
|
|
61
|
+
// draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
|
|
62
|
+
function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
|
|
63
|
+
if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
|
|
64
|
+
const lines = withReplies(msgs);
|
|
65
|
+
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
66
|
+
const shown = lines.slice(start);
|
|
67
|
+
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
68
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The reply form's Done, just left of Send, on every card not in Done: an ask's sends ask-done, a work card's or
|
|
72
|
+
// follow-up's card-done, which reads "Closing…" until the session sets its status
|
|
73
|
+
// (docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md).
|
|
74
|
+
function doneButton(it, sending = false) {
|
|
75
|
+
if (!it?.finishable) return '';
|
|
76
|
+
const closing = it.kind !== 'ask' && (sending || it.closing);
|
|
77
|
+
return `<button type="button"${it.resolvedBy === BUTTON.markDone ? ' class="resolves"' : ''} data-confirm="finish" data-key="${esc(it.key)}"${closing ? ' disabled' : ''}>${closing ? 'Closing…' : 'Done'}</button>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
|
|
81
|
+
})();
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Renderers the board's pages share, and the base the other ui-*.js files add to BoardUI. Each returns an HTML
|
|
2
|
+
// string and touches no DOM or storage: ui.test.mjs runs these files in node:vm, where neither exists. Load this first.
|
|
3
|
+
// fetchJSON is the side pages' one way to read the board's API.
|
|
4
|
+
(function () {
|
|
5
|
+
// A bare ↗ can draw as an emoji tile on iOS; U+FE0E asks for its text glyph.
|
|
6
|
+
const LINK_ARROW = '↗\uFE0E';
|
|
7
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
8
|
+
const record = (records, key) => (Object.hasOwn(records || {}, key) ? records[key] : undefined);
|
|
9
|
+
const time = (iso) => new Date(iso).toLocaleString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
10
|
+
const ago = (iso) => {
|
|
11
|
+
if (!iso) return '';
|
|
12
|
+
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
13
|
+
if (s < 60) return 'just now';
|
|
14
|
+
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
|
15
|
+
if (s < 86400) return Math.round(s / 3600) + 'h ago';
|
|
16
|
+
return Math.round(s / 86400) + 'd ago';
|
|
17
|
+
};
|
|
18
|
+
const since = (iso) => {
|
|
19
|
+
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
20
|
+
return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
|
|
21
|
+
};
|
|
22
|
+
const clock = (d) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP]M)$/i, (_, m) => ` ${m.toLowerCase()}`);
|
|
23
|
+
// "ETA about 4:30 pm", with the day when it isn't today.
|
|
24
|
+
function etaLine(iso, now = new Date()) {
|
|
25
|
+
const d = new Date(iso);
|
|
26
|
+
const day = d.toDateString() === now.toDateString() ? '' : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, `;
|
|
27
|
+
return `ETA about ${day}${clock(d)}`;
|
|
28
|
+
}
|
|
29
|
+
const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
|
|
30
|
+
|
|
31
|
+
// Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
|
|
32
|
+
const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
|
|
33
|
+
const isImage = (f) => /^image\//.test(f.type || '');
|
|
34
|
+
function attachments(files) {
|
|
35
|
+
if (!files?.length) return '';
|
|
36
|
+
return `<div class="files">${files.map((f) => {
|
|
37
|
+
const url = uploadUrl(f);
|
|
38
|
+
if (!url) return `<span class="file-link">${esc(f.name)}</span>`;
|
|
39
|
+
return isImage(f)
|
|
40
|
+
? `<a class="file-thumb" href="${esc(url)}" target="_blank" rel="noopener" title="${esc(f.name)}"><img src="${esc(url)}" alt="${esc(f.name)}" loading="lazy"></a>`
|
|
41
|
+
: `<a class="file-link" href="${esc(url)}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
|
|
42
|
+
}).join('')}</div>`;
|
|
43
|
+
}
|
|
44
|
+
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
45
|
+
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
46
|
+
const icon = (body, size = 18) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
47
|
+
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
48
|
+
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
49
|
+
const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
|
|
50
|
+
// Asks Claude for throwaway prototypes of one item (docs/adr/0015-prototypes-come-on-request.md).
|
|
51
|
+
function protoButton(id, state = null) {
|
|
52
|
+
if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
|
|
53
|
+
return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="Ask for prototypes" title="Ask for prototypes">${EYE}</button>`;
|
|
54
|
+
}
|
|
55
|
+
// latestBump is the data.id of the newest ask-bump event.
|
|
56
|
+
const protoNotice = (eventId, latestBump) => (latestBump === eventId
|
|
57
|
+
? { text: 'Prototypes are being made.', bump: false }
|
|
58
|
+
: { text: 'Prototypes are being made. Bump them to the top of the queue?', bump: true });
|
|
59
|
+
const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
|
|
60
|
+
const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
|
|
61
|
+
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
62
|
+
const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
|
|
63
|
+
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
64
|
+
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}. A work card's is the same icon, which app-cards.js
|
|
65
|
+
// confirms before it sends card-bump {key} (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md).
|
|
66
|
+
const bumpButton = (it, top) => `<button class="ghost icon" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
67
|
+
const cardBumpButton = (key, top) => `<button class="ghost icon" data-confirm="bump" data-key="${esc(key)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
68
|
+
const workingLine = '<span class="working-dot" aria-hidden="true"></span>Working on it';
|
|
69
|
+
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
70
|
+
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
|
|
71
|
+
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
72
|
+
if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
|
|
73
|
+
const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
74
|
+
const tag = button ? 'button' : href == null ? 'span' : 'a';
|
|
75
|
+
return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
|
|
76
|
+
}
|
|
77
|
+
// A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
|
|
78
|
+
const pendingFiles = (list) => list.map((p) => `<span class="pfile ${esc(p.status)}" title="${esc(p.name)}">
|
|
79
|
+
${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
|
|
80
|
+
${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
|
|
81
|
+
${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
|
|
82
|
+
<button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`).join('');
|
|
83
|
+
|
|
84
|
+
// Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
|
|
85
|
+
// being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
|
|
86
|
+
// node stays when the new markup has one of its tag at its place, and an element with a key (keyOf) stays wherever
|
|
87
|
+
// under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
|
|
88
|
+
// element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
|
|
89
|
+
// its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
|
|
90
|
+
// matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
|
|
91
|
+
// parent rebuild reopens too.
|
|
92
|
+
const lastHtml = new WeakMap();
|
|
93
|
+
const openDetails = new Set();
|
|
94
|
+
const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
95
|
+
globalThis.document?.addEventListener('toggle', (ev) => {
|
|
96
|
+
if (ev.target.tagName !== 'DETAILS') return;
|
|
97
|
+
if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
|
|
98
|
+
}, true);
|
|
99
|
+
const KEYS = ['id', 'data-thread', 'data-event', 'data-edit-form', 'data-lane', 'data-fold'];
|
|
100
|
+
function keyOf(n) {
|
|
101
|
+
if (n.nodeType !== 1) return null;
|
|
102
|
+
if (n.tagName === 'DETAILS' && n.hasAttribute('data-key')) return `DETAILS|${n.getAttribute('data-key')}`;
|
|
103
|
+
const a = KEYS.find((k) => n.hasAttribute(k));
|
|
104
|
+
return a ? `${n.tagName}|${a}=${n.getAttribute(a)}` : null;
|
|
105
|
+
}
|
|
106
|
+
function keyedUnder(el, keep) {
|
|
107
|
+
const keyed = new Map();
|
|
108
|
+
const walk = (p) => {
|
|
109
|
+
for (const c of p.children) {
|
|
110
|
+
const k = keyOf(c);
|
|
111
|
+
if (k && !keyed.has(k)) keyed.set(k, c);
|
|
112
|
+
if (!(keep && c.matches(keep))) walk(c);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
walk(el);
|
|
116
|
+
return keyed;
|
|
117
|
+
}
|
|
118
|
+
const holdsFocus = (n) => n.contains(document.activeElement);
|
|
119
|
+
// insertBefore blurs the node it moves, and on iOS closes the keyboard; moveBefore keeps its focus, but moves only
|
|
120
|
+
// between connected nodes.
|
|
121
|
+
function move(parent, n, ref) {
|
|
122
|
+
if (parent.moveBefore && n.isConnected && parent.isConnected) parent.moveBefore(n, ref);
|
|
123
|
+
else parent.insertBefore(n, ref);
|
|
124
|
+
}
|
|
125
|
+
// Without moveBefore, the focused field's ancestors under el never move: each is patched into the node at the same
|
|
126
|
+
// depth on the path to the field's nearest keyed ancestor in the new markup, so a card that changes lanes takes over
|
|
127
|
+
// the old lane's node. → Map from each new markup node on that path to the old node it patches; empty when the
|
|
128
|
+
// paths' depths or tags differ, and app-core.js keepFocus refocuses the field after the move.
|
|
129
|
+
function focusSafe(el, next, keyed) {
|
|
130
|
+
const pins = new Map();
|
|
131
|
+
const a = document.activeElement;
|
|
132
|
+
if (el.moveBefore || !a || a === el || !el.contains(a)) return pins;
|
|
133
|
+
const olds = [];
|
|
134
|
+
for (let n = a; n !== el; n = n.parentNode) olds.unshift(n);
|
|
135
|
+
for (let i = olds.length - 1; i >= 0; i--) {
|
|
136
|
+
const k = keyOf(olds[i]);
|
|
137
|
+
const found = k && [...next.querySelectorAll('*')].find((d) => keyOf(d) === k);
|
|
138
|
+
if (!found) continue;
|
|
139
|
+
const news = [];
|
|
140
|
+
for (let n = found; n !== next; n = n.parentNode) news.unshift(n);
|
|
141
|
+
if (news.length !== i + 1 || news.some((n, j) => n.tagName !== olds[j].tagName)) return pins;
|
|
142
|
+
news.forEach((n, j) => { pins.set(n, olds[j]); keyed.delete(keyOf(olds[j])); });
|
|
143
|
+
return pins;
|
|
144
|
+
}
|
|
145
|
+
return pins;
|
|
146
|
+
}
|
|
147
|
+
// ctx: {keyed, keep, pins, pinned}, pinned being the old nodes in pins, which only their own new node takes.
|
|
148
|
+
function patchNode(old, next, ctx) {
|
|
149
|
+
if (old.nodeType !== 1) { if (old.nodeValue !== next.nodeValue) old.nodeValue = next.nodeValue; return; }
|
|
150
|
+
if (ctx.keep && old.matches(ctx.keep)) return;
|
|
151
|
+
for (const { name } of [...old.attributes]) if (!next.hasAttribute(name)) old.removeAttribute(name);
|
|
152
|
+
for (const { name, value } of [...next.attributes]) if (old.getAttribute(name) !== value) old.setAttribute(name, value);
|
|
153
|
+
if (old.tagName !== 'TEXTAREA') patchChildren(old, next, ctx);
|
|
154
|
+
}
|
|
155
|
+
function patchChildren(parent, next, ctx) {
|
|
156
|
+
const { keyed, pins, pinned } = ctx;
|
|
157
|
+
let cur = parent.firstChild;
|
|
158
|
+
for (const n of [...next.childNodes]) {
|
|
159
|
+
const k = keyOf(n);
|
|
160
|
+
let match = pins.get(n) || null;
|
|
161
|
+
if (!match && k) {
|
|
162
|
+
match = keyed.get(k) || null;
|
|
163
|
+
if (match?.contains(parent)) match = null;
|
|
164
|
+
if (match) keyed.delete(k);
|
|
165
|
+
} else if (!match && cur && !keyOf(cur) && !pinned.has(cur) && cur.nodeName === n.nodeName) match = cur;
|
|
166
|
+
if (!match) {
|
|
167
|
+
parent.insertBefore(n, cur);
|
|
168
|
+
if (n.nodeType === 1) {
|
|
169
|
+
for (const d of n.querySelectorAll('*')) {
|
|
170
|
+
const dk = n.contains(d) && keyOf(d);
|
|
171
|
+
const old = dk && keyed.get(dk);
|
|
172
|
+
if (!old || old.contains(n)) continue;
|
|
173
|
+
keyed.delete(dk);
|
|
174
|
+
move(d.parentNode, old, d);
|
|
175
|
+
d.remove();
|
|
176
|
+
patchNode(old, d, ctx);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (match === cur) cur = cur.nextSibling;
|
|
182
|
+
else if (match.parentNode === parent && holdsFocus(match)) {
|
|
183
|
+
// Moving the node that holds focus would blur it, so the siblings before it leave instead.
|
|
184
|
+
while (cur !== match) { const after = cur.nextSibling; cur.remove(); cur = after; }
|
|
185
|
+
cur = match.nextSibling;
|
|
186
|
+
} else move(parent, match, cur);
|
|
187
|
+
patchNode(match, n, ctx);
|
|
188
|
+
}
|
|
189
|
+
while (cur) { const after = cur.nextSibling; cur.remove(); cur = after; }
|
|
190
|
+
}
|
|
191
|
+
function setHTML(el, html, { keep = null } = {}) {
|
|
192
|
+
if (lastHtml.get(el) === html) return false;
|
|
193
|
+
if (!el.firstChild) el.innerHTML = html;
|
|
194
|
+
else {
|
|
195
|
+
const t = document.createElement('template');
|
|
196
|
+
t.innerHTML = html;
|
|
197
|
+
const keyed = keyedUnder(el, keep);
|
|
198
|
+
const pins = focusSafe(el, t.content, keyed);
|
|
199
|
+
patchChildren(el, t.content, { keyed, keep, pins, pinned: new Set(pins.values()) });
|
|
200
|
+
}
|
|
201
|
+
lastHtml.set(el, html);
|
|
202
|
+
if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
207
|
+
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
208
|
+
// then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
|
|
209
|
+
const RESTARTING = 'Board restarting…';
|
|
210
|
+
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
211
|
+
async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
212
|
+
let waited = 0;
|
|
213
|
+
let delay = 500;
|
|
214
|
+
for (;;) {
|
|
215
|
+
let failure;
|
|
216
|
+
try {
|
|
217
|
+
const r = await get(url, { cache: 'no-store' });
|
|
218
|
+
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
219
|
+
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
220
|
+
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
221
|
+
} catch { failure = new Error('Offline. Is the host awake?'); }
|
|
222
|
+
if (waited >= limit) throw failure;
|
|
223
|
+
onWait(waited);
|
|
224
|
+
await sleep(delay);
|
|
225
|
+
waited += delay;
|
|
226
|
+
delay = Math.min(delay * 2, 4000);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
globalThis.BoardUI = {
|
|
231
|
+
LINK_ARROW,
|
|
232
|
+
esc, record, time, ago, since, etaLine, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
|
|
233
|
+
attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
234
|
+
};
|
|
235
|
+
})();
|