@lovek4d/kickflip 0.10.1 → 0.11.1
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 +19 -16
- package/SKILL.md +7 -7
- package/bin/event-lines.mjs +1 -1
- package/board/bin/board.mjs +30 -17
- package/board/domain.mjs +7 -7
- package/board/headless.mjs +8 -8
- package/board/public/app-cards.js +2 -0
- package/board/public/app-header.js +8 -0
- package/board/public/app-lanes.js +43 -19
- package/board/public/app-snapshot.js +1 -2
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +0 -1
- package/board/public/app.js +25 -27
- package/board/public/col-resize.js +25 -44
- package/board/public/dialogs.js +2 -2
- package/board/public/new-task.js +2 -2
- package/board/public/prompts.js +1 -1
- package/board/public/prototype-kit.js +1 -1
- package/board/public/prototype-switcher.js +3 -8
- package/board/public/prototype.html +2 -6
- package/board/public/router.js +16 -22
- package/board/public/shell.js +3 -39
- package/board/public/style.css +47 -95
- package/board/public/ui-cards.js +29 -22
- package/board/public/ui-enums.js +3 -3
- package/board/public/ui-page-header.js +2 -2
- package/board/public/ui-prompts.js +1 -1
- package/board/public/ui-questions.js +2 -3
- package/board/public/ui.js +10 -2
- package/board/public/view-hub.js +1 -1
- package/board/public/view-questions.js +7 -26
- package/board/server.mjs +18 -18
- package/board/store.mjs +32 -32
- package/hub/hub.mjs +8 -8
- package/hub/prompts.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/slack.mjs +4 -2
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/board/public/app-needs.js +0 -248
|
@@ -76,7 +76,7 @@ BoardView.part((B) => {
|
|
|
76
76
|
const openReviews = new Set();
|
|
77
77
|
const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
|
|
78
78
|
open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
|
|
79
|
-
// Opens PR number's review and its lane or fold, for a
|
|
79
|
+
// Opens PR number's review and its lane or fold, for a push's #review=<number>.
|
|
80
80
|
function openReview(number) {
|
|
81
81
|
openReviews.add(number);
|
|
82
82
|
const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
|
|
@@ -85,17 +85,43 @@ BoardView.part((B) => {
|
|
|
85
85
|
$(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// docs/adr/2026-11-12-0600-the-todo-header-has-a-filter.md
|
|
89
|
+
// The Todo header's filter: one box per property a Todo card can have, all checked until the owner unchecks one. An
|
|
90
|
+
// unchecked box hides every Todo card with its property and nothing else, so a card that is held and blocked hides
|
|
91
|
+
// when either box is unchecked. The unchecked boxes are kept on this device (store 'todoFilter'); the menu's open state is in memory.
|
|
92
|
+
const FILTER_BOXES = [
|
|
93
|
+
['ready', 'Ready', (i) => ['ready', 'staging'].includes(i.phase)],
|
|
94
|
+
['discussing', 'Discussing', (i) => ['discussing', 'new'].includes(i.phase)],
|
|
95
|
+
['planning', 'Planning', (i) => ['grilling', 'prototyping'].includes(i.phase)],
|
|
96
|
+
['blocked', 'Blocked', (i) => Boolean(i.blocked)],
|
|
97
|
+
['held', 'Held', (i) => Boolean(i.held)],
|
|
98
|
+
];
|
|
99
|
+
const FUNNEL = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></svg>';
|
|
100
|
+
let filterOpen = false;
|
|
101
|
+
const unchecked = () => store.get('todoFilter', []);
|
|
102
|
+
const hiddenByFilter = (it) => FILTER_BOXES.some(([id, , has]) => unchecked().includes(id) && has(it));
|
|
103
|
+
const filterHtml = () => {
|
|
104
|
+
const off = unchecked();
|
|
105
|
+
const menu = filterOpen ? `<div class="lane-filter-menu" role="group" aria-label="Show in Todo">${FILTER_BOXES.map(([id, name]) =>
|
|
106
|
+
`<button type="button" class="lane-filter-box" role="checkbox" aria-checked="${!off.includes(id)}" data-todo-filter="${id}"><span class="box"></span>${name}</button>`).join('')}</div>` : '';
|
|
107
|
+
return { button: `<button type="button" class="lane-filter${off.length ? ' on' : ''}" aria-label="Filter Todo" aria-expanded="${filterOpen}" title="${off.length ? 'Filtered' : 'Filter'}">${FUNNEL}</button>`, menu };
|
|
108
|
+
};
|
|
109
|
+
|
|
88
110
|
// shown is the board's items less pending deletes.
|
|
89
111
|
function lanesHtml(d, shown) {
|
|
90
112
|
const laneState = store.get('lanes', {});
|
|
91
113
|
return reviewsHtml(d) + LANES.map((lane) => {
|
|
92
114
|
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
93
|
-
const
|
|
115
|
+
const all = shown.filter((i) => i.lane === lane.id);
|
|
116
|
+
const items = lane.id === 'todo' ? all.filter((i) => !hiddenByFilter(i)) : all;
|
|
117
|
+
if (lane.id === 'todo') shown = shown.filter((i) => i.lane !== 'todo' || items.includes(i));
|
|
118
|
+
const filter = lane.id === 'todo' ? filterHtml() : null;
|
|
94
119
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
95
120
|
const open = items.filter((i) => !i.fold);
|
|
96
121
|
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + fileFolds(d, items);
|
|
97
122
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
98
|
-
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
123
|
+
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : lane.id === 'todo' && items.length < all.length ? `${items.length} of ${all.length}` : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}${filter ? filter.button : ''}</summary>
|
|
124
|
+
${filter ? filter.menu : ''}
|
|
99
125
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
100
126
|
${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
|
|
101
127
|
</details>`;
|
|
@@ -172,12 +198,11 @@ BoardView.part((B) => {
|
|
|
172
198
|
return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
|
|
173
199
|
}
|
|
174
200
|
|
|
175
|
-
// Opens the band holding card key
|
|
176
|
-
function openBand({ key
|
|
177
|
-
const lane =
|
|
178
|
-
const band = (state.board?.waveBands || []).find((b) => b.
|
|
201
|
+
// Opens the band holding card key, for a #selectedCard= link.
|
|
202
|
+
function openBand({ key }) {
|
|
203
|
+
const lane = state.board?.items.find((i) => i.key === key)?.lane || 'todo';
|
|
204
|
+
const band = (state.board?.waveBands || []).find((b) => b.keys.includes(key));
|
|
179
205
|
if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
|
|
180
|
-
return band;
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
B.on(B.root, 'toggle', (ev) => {
|
|
@@ -199,8 +224,15 @@ BoardView.part((B) => {
|
|
|
199
224
|
catch { B.notice('Could not send. Is the host awake?'); }
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
227
|
+
const filterBtn = ev.target.closest('button.lane-filter');
|
|
228
|
+
if (filterBtn) { ev.preventDefault(); filterOpen = !filterOpen; B.render(); return; }
|
|
229
|
+
const box = ev.target.closest('[data-todo-filter]');
|
|
230
|
+
if (box) {
|
|
231
|
+
const id = box.dataset.todoFilter;
|
|
232
|
+
store.set('todoFilter', unchecked().includes(id) ? unchecked().filter((x) => x !== id) : [...unchecked(), id]);
|
|
233
|
+
B.render();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
204
236
|
const more = ev.target.closest('[data-lane-more]');
|
|
205
237
|
if (more) {
|
|
206
238
|
if (more.hasAttribute('data-fetch')) {
|
|
@@ -213,7 +245,7 @@ BoardView.part((B) => {
|
|
|
213
245
|
}
|
|
214
246
|
const pb = ev.target.closest('button[data-plan]');
|
|
215
247
|
if (pb) {
|
|
216
|
-
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in
|
|
248
|
+
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Waiting on you. Nothing is implemented until you answer. Start?'))) return;
|
|
217
249
|
// Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
218
250
|
const keys = pb.dataset.plan.split(' ');
|
|
219
251
|
BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
|
|
@@ -224,14 +256,6 @@ BoardView.part((B) => {
|
|
|
224
256
|
} catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
|
|
225
257
|
return;
|
|
226
258
|
}
|
|
227
|
-
const show = ev.target.closest('a[data-band-show]');
|
|
228
|
-
if (show) {
|
|
229
|
-
ev.preventDefault();
|
|
230
|
-
if (!openBand({ slug: show.dataset.bandShow })) return;
|
|
231
|
-
const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
|
|
232
|
-
B.render();
|
|
233
|
-
$(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
234
|
-
}
|
|
235
259
|
});
|
|
236
260
|
|
|
237
261
|
// A band's head's fold and its name's goal popover. A long-press on the name (touch only;
|
|
@@ -38,11 +38,10 @@ BoardView.part((B) => {
|
|
|
38
38
|
if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// The lines a fresh board can change: each lane count
|
|
41
|
+
// The lines a fresh board can change: each lane count and thread message. A card shows its own sign below.
|
|
42
42
|
function lines() {
|
|
43
43
|
const out = new Map();
|
|
44
44
|
for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
|
|
45
|
-
$$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
|
|
46
45
|
for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
|
|
47
46
|
return out;
|
|
48
47
|
}
|
|
@@ -20,13 +20,13 @@ BoardView.part((B) => {
|
|
|
20
20
|
// card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
|
|
21
21
|
const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
|
|
22
22
|
const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
23
|
-
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
23
|
+
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', item?.resolvedBy === BUTTON.implement && 'implement-resolves', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
24
24
|
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
25
25
|
const sends = (kind, item) => {
|
|
26
26
|
const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
|
|
27
27
|
return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
|
|
28
28
|
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
|
|
29
|
-
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind
|
|
29
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind.startsWith('todo-ready') ? ' ready' : ''}${kind.includes('implement-resolves') ? ' resolves' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
|
|
30
30
|
};
|
|
31
31
|
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
32
32
|
function syncSends(el, key) {
|
package/board/public/app.html
CHANGED
|
@@ -77,7 +77,6 @@ window.BoardBoot = (function () {
|
|
|
77
77
|
<script src="/_hub/app/app-cards.js"></script>
|
|
78
78
|
<script src="/_hub/app/app-lanes.js"></script>
|
|
79
79
|
<script src="/_hub/app/app-asks.js"></script>
|
|
80
|
-
<script src="/_hub/app/app-needs.js"></script>
|
|
81
80
|
<script src="/_hub/app/app-header.js"></script>
|
|
82
81
|
<script src="/_hub/app/app-arch.js"></script>
|
|
83
82
|
<script src="/_hub/app/app-reader.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -198,7 +198,6 @@ window.BoardView = (function () {
|
|
|
198
198
|
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
199
199
|
for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
200
200
|
B.moveStrips(d.items, strips);
|
|
201
|
-
B.renderNeeds(d);
|
|
202
201
|
B.renderBanner(d);
|
|
203
202
|
});
|
|
204
203
|
B.flashChanged();
|
|
@@ -206,23 +205,16 @@ window.BoardView = (function () {
|
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #selectedCard=<file>/<id> (or the older #item=) opens that card
|
|
208
|
+
// #selectedCard=<file>/<id> (or the older #item=) opens that card; push notifications link here, with &reply to land in the thread's reply box
|
|
210
209
|
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
211
210
|
let focusKey = null;
|
|
212
211
|
let replyKey = null;
|
|
213
212
|
let hashRead = false;
|
|
214
213
|
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
215
214
|
const returnToPlace = () => B.restorePlace({ linked: /^#(?:selectedCard|item|review)=/.test(location.hash) });
|
|
216
|
-
// The pane's pill the fragment names opens once the pane has loaded; a phone has no pane, its pill is the Waiting on
|
|
217
|
-
// you page (shell.js go).
|
|
218
|
-
function openPill(id, tries = 50) {
|
|
219
|
-
if (!id || !matchMedia('(min-width: 900px)').matches || B.focusQuestion?.(id) || tries <= 0) return;
|
|
220
|
-
B.later(100, () => openPill(id, tries - 1));
|
|
221
|
-
}
|
|
222
215
|
function readHash() {
|
|
223
|
-
const { selectedCard,
|
|
216
|
+
const { selectedCard, review, reply } = AppRouter.fragmentOf(location.hash);
|
|
224
217
|
if (review && state.board) { B.openReview(review); return; }
|
|
225
|
-
openPill(openItemsCard);
|
|
226
218
|
if (!selectedCard) return;
|
|
227
219
|
focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
|
|
228
220
|
replyKey = reply ? focusKey : null;
|
|
@@ -245,15 +237,6 @@ window.BoardView = (function () {
|
|
|
245
237
|
ev.preventDefault();
|
|
246
238
|
readHash(); render(); scrollToFocus();
|
|
247
239
|
});
|
|
248
|
-
// Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
|
|
249
|
-
function focusOnBoard({ key, slug }) {
|
|
250
|
-
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
251
|
-
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
252
|
-
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
253
|
-
render();
|
|
254
|
-
if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
240
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
258
241
|
function scrollToFocus(final = true) {
|
|
259
242
|
if (!focusKey) return;
|
|
@@ -360,6 +343,26 @@ window.BoardView = (function () {
|
|
|
360
343
|
$('#downLanding').hidden = false;
|
|
361
344
|
B.root.classList.add('down');
|
|
362
345
|
}
|
|
346
|
+
// A board that loaded but threw while drawing shows the landing in its place with the error; the link stays online, and
|
|
347
|
+
// the next board that draws clean clears it (land).
|
|
348
|
+
function drawFailed(err) {
|
|
349
|
+
console.error(err);
|
|
350
|
+
state.down = { state: 'draw', error: err };
|
|
351
|
+
$('#downLanding').innerHTML = BoardUI.drawLanding(err);
|
|
352
|
+
$('#downLanding').hidden = false;
|
|
353
|
+
B.root.classList.add('down');
|
|
354
|
+
}
|
|
355
|
+
B.on(B.root, 'click', (ev) => {
|
|
356
|
+
const what = state.down?.error && ev.target.closest?.('button[data-draw]')?.dataset.draw;
|
|
357
|
+
if (what === 'reload') location.reload();
|
|
358
|
+
else if (what === 'copy') {
|
|
359
|
+
navigator.clipboard?.writeText(state.down.error.stack || String(state.down.error)).then(() => B.toast('Copied'), () => B.toast('Couldn’t copy'));
|
|
360
|
+
} else if (what === 'more') {
|
|
361
|
+
const pre = $('#downLanding .down-landing-stack');
|
|
362
|
+
pre.hidden = !pre.hidden;
|
|
363
|
+
ev.target.textContent = pre.hidden ? 'Show details' : 'Hide details';
|
|
364
|
+
}
|
|
365
|
+
});
|
|
363
366
|
B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
|
|
364
367
|
|
|
365
368
|
// A board from the server, a load's or an events POST's, or a patch from the board on screen (ui-patch.js). One built
|
|
@@ -380,7 +383,7 @@ window.BoardView = (function () {
|
|
|
380
383
|
B.setView('fresh');
|
|
381
384
|
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
382
385
|
B.linkUp();
|
|
383
|
-
render(load);
|
|
386
|
+
try { render(load); } catch (e) { drawFailed(e); return true; }
|
|
384
387
|
scrollToFocus();
|
|
385
388
|
returnToPlace();
|
|
386
389
|
B.site.syncBadge();
|
|
@@ -431,7 +434,7 @@ window.BoardView = (function () {
|
|
|
431
434
|
signalled();
|
|
432
435
|
render();
|
|
433
436
|
}
|
|
434
|
-
Object.assign(B, { render,
|
|
437
|
+
Object.assign(B, { render, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
435
438
|
B.headerWired();
|
|
436
439
|
const sent = BoardUI.sentNotice.take(B.route);
|
|
437
440
|
if (sent) B.toast(sent);
|
|
@@ -439,7 +442,7 @@ window.BoardView = (function () {
|
|
|
439
442
|
if (document.hidden) return;
|
|
440
443
|
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
441
444
|
});
|
|
442
|
-
B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
|
|
445
|
+
B.readCopy().then(paintCopy).catch((e) => console.error(e)).then(() => { if (B.alive()) loadBoard(); });
|
|
443
446
|
B.signal?.(signalled);
|
|
444
447
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
445
448
|
}
|
|
@@ -454,20 +457,15 @@ window.BoardView = (function () {
|
|
|
454
457
|
function markup({ title = '', shared = null } = {}) {
|
|
455
458
|
const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
|
|
456
459
|
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
|
|
457
|
-
<div class="split">
|
|
458
460
|
<main>
|
|
459
461
|
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
460
462
|
<section id="pauseBanner" class="hub-view" hidden></section>
|
|
461
463
|
<section id="answers" class="answer-banner" hidden></section>
|
|
462
|
-
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
463
464
|
<div id="tryMain"></div>
|
|
464
465
|
<div id="lanes"></div>
|
|
465
466
|
<div id="downLanding" hidden></div>
|
|
466
467
|
${BoardUI.boardFoot()}
|
|
467
468
|
</main>
|
|
468
|
-
<div class="col-handle" data-col="pane" role="separator" aria-orientation="vertical" aria-label="Resize the Waiting on you pane" tabindex="0"></div>
|
|
469
|
-
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
470
|
-
</div>
|
|
471
469
|
${BoardNewTask.markup()}
|
|
472
470
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
473
471
|
<div class="sheet-body">
|
|
@@ -1,72 +1,53 @@
|
|
|
1
|
-
// Drag-resizing of the desktop
|
|
2
|
-
//
|
|
3
|
-
// and
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// stored widths land before the first paint of the board.
|
|
1
|
+
// Drag-resizing of the sidebar rail on a desktop (docs/adr/2026-11-10-1600-the-desktop-columns-drag-and-this-device-keeps-their-widths.md).
|
|
2
|
+
// The `.col-handle[data-col="sidebar"]` drags the rail's edge. Its width is clamped to 220–420px, kept per device for
|
|
3
|
+
// every board, and set on <html> as --sb-w, which style.css and sidebar.css read. A double-click on the handle forgets
|
|
4
|
+
// the width; ←/→ on the focused handle moves it 16px. Loaded after the body's first elements so the stored width lands
|
|
5
|
+
// before the first paint of the board.
|
|
7
6
|
(function () {
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
7
|
+
const KEY = 'hub:sidebarWidth';
|
|
8
|
+
const VAR = '--sb-w';
|
|
9
|
+
const MIN = 220, MAX = 420, STEP = 16;
|
|
11
10
|
const html = document.documentElement;
|
|
12
11
|
const wide = () => window.matchMedia('(min-width: 900px)').matches;
|
|
13
|
-
const read = (
|
|
14
|
-
const write = (
|
|
15
|
-
const forget = (
|
|
16
|
-
const stored = (
|
|
17
|
-
// The sidebar's width now, which the pane's room depends on.
|
|
18
|
-
const sidebarNow = (viewport) => (html.classList.contains('sb-collapsed') ? 0 : clamp('sidebar', stored('sidebar') ?? DEFAULT_SIDEBAR, viewport, 0));
|
|
12
|
+
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
|
13
|
+
const write = (v) => { try { localStorage.setItem(KEY, v); } catch { /* private mode or full: kept for this page only */ } };
|
|
14
|
+
const forget = () => { try { localStorage.removeItem(KEY); } catch { /* nothing kept */ } };
|
|
15
|
+
const stored = () => { const n = Number(read()); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
|
|
17
|
+
const clamp = (px) => Math.min(MAX, Math.max(MIN, px));
|
|
18
|
+
function paint() {
|
|
19
|
+
const px = stored();
|
|
20
|
+
if (px === null) html.style.removeProperty(VAR);
|
|
21
|
+
else html.style.setProperty(VAR, `${clamp(px)}px`);
|
|
25
22
|
}
|
|
26
|
-
function paint(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
html.style.setProperty(VARS[col], `${col === 'pane' ? clamp('pane', px, window.innerWidth, sidebarNow(window.innerWidth)) : clamp('sidebar', px)}px`);
|
|
30
|
-
}
|
|
31
|
-
function paintAll() { paint('sidebar'); paint('pane'); }
|
|
32
|
-
function set(col, px) { write(KEYS[col], String(Math.round(px))); paintAll(); }
|
|
33
|
-
function reset(col) { forget(KEYS[col]); paintAll(); }
|
|
34
|
-
paintAll();
|
|
35
|
-
window.addEventListener('resize', paintAll);
|
|
23
|
+
function set(px) { write(String(Math.round(px))); paint(); }
|
|
24
|
+
function reset() { forget(); paint(); }
|
|
25
|
+
paint();
|
|
36
26
|
|
|
37
|
-
// The width
|
|
38
|
-
const current = (
|
|
39
|
-
const edge = el.getBoundingClientRect();
|
|
40
|
-
return col === 'sidebar' ? edge.left + edge.width / 2 : window.innerWidth - (edge.left + edge.width / 2);
|
|
41
|
-
};
|
|
42
|
-
const wanted = (col, x) => (col === 'sidebar' ? x : window.innerWidth - x);
|
|
27
|
+
// The width the handle's drag edge is at now.
|
|
28
|
+
const current = (el) => { const edge = el.getBoundingClientRect(); return edge.left + edge.width / 2; };
|
|
43
29
|
|
|
44
30
|
document.addEventListener('pointerdown', (e) => {
|
|
45
31
|
const el = e.target.closest?.('.col-handle');
|
|
46
32
|
if (!el || !wide() || e.button > 0) return;
|
|
47
|
-
const col = el.dataset.col;
|
|
48
33
|
e.preventDefault();
|
|
49
34
|
el.setPointerCapture?.(e.pointerId);
|
|
50
35
|
el.classList.add('dragging');
|
|
51
36
|
html.classList.add('col-dragging');
|
|
52
|
-
const move = (m) => set(
|
|
37
|
+
const move = (m) => set(m.clientX);
|
|
53
38
|
const end = () => { el.classList.remove('dragging'); html.classList.remove('col-dragging'); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', end); el.removeEventListener('pointercancel', end); };
|
|
54
39
|
el.addEventListener('pointermove', move);
|
|
55
40
|
el.addEventListener('pointerup', end);
|
|
56
41
|
el.addEventListener('pointercancel', end);
|
|
57
42
|
});
|
|
58
43
|
document.addEventListener('dblclick', (e) => {
|
|
59
|
-
|
|
60
|
-
if (el && wide()) reset(el.dataset.col);
|
|
44
|
+
if (e.target.closest?.('.col-handle') && wide()) reset();
|
|
61
45
|
});
|
|
62
46
|
document.addEventListener('keydown', (e) => {
|
|
63
47
|
const el = e.target.closest?.('.col-handle');
|
|
64
48
|
if (!el || !wide() || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
65
|
-
const col = el.dataset.col;
|
|
66
49
|
e.preventDefault();
|
|
67
|
-
|
|
68
|
-
const dx = (e.key === 'ArrowRight' ? STEP : -STEP) * (col === 'sidebar' ? 1 : -1);
|
|
69
|
-
set(col, (stored(col) ?? current(col, el)) + dx);
|
|
50
|
+
set((stored() ?? current(el)) + (e.key === 'ArrowRight' ? STEP : -STEP));
|
|
70
51
|
});
|
|
71
52
|
|
|
72
53
|
window.ColResize = { clamp, set, reset };
|
package/board/public/dialogs.js
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
const team = esc(draft.team || '');
|
|
59
59
|
const note = draft.project === '__new'
|
|
60
60
|
? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
|
|
61
|
-
: `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when
|
|
61
|
+
: `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when it goes ready to build. Its badge opens it in Linear.</p>`;
|
|
62
62
|
return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
|
|
63
63
|
}
|
|
64
64
|
function render() {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
let detail;
|
|
69
69
|
if (draft.source === 'github' && off) detail = why;
|
|
70
70
|
else if (draft.source === 'none') detail = '<p class="muted small">Every card is a task, numbered T-1, T-2… on this board, with no ticket. Its badge reads Task.</p>';
|
|
71
|
-
else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when
|
|
71
|
+
else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when it goes ready to build; its badge then reads #41.</span></span></div>`;
|
|
72
72
|
else detail = linearDetail();
|
|
73
73
|
root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
|
|
74
74
|
if (draft.source === 'linear' && ready()) {
|
package/board/public/new-task.js
CHANGED
|
@@ -77,8 +77,8 @@ globalThis.BoardNewTask = (function () {
|
|
|
77
77
|
B.on($('#sheetClose'), 'click', closeSheet);
|
|
78
78
|
B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
|
|
79
79
|
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
80
|
-
// Discuss has the session
|
|
81
|
-
// session marks the card ready (docs/adr/2026-11-
|
|
80
|
+
// Discuss has the session reply to the task, starting its discussion; Implement sends the ask with quick: true, and
|
|
81
|
+
// the session marks the card ready (docs/adr/2026-11-12-0500-a-new-task-is-a-conversation.md).
|
|
82
82
|
B.on($('#askForm'), 'submit', async (ev) => {
|
|
83
83
|
ev.preventDefault();
|
|
84
84
|
$('#askText').blur();
|
package/board/public/prompts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem)
|
|
1
|
+
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem) on the hub page,
|
|
2
2
|
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
|
|
3
3
|
// Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
|
|
4
4
|
// since it covers every later call like it until the session ends. Several questions, or one taking several picks,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
|
|
4
4
|
// public/prototypes/<topic>/<KEY>.js, is a module:
|
|
5
5
|
// export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
|
|
6
|
-
// A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.
|
|
6
|
+
// A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.key-hint).
|
|
7
7
|
// render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
|
|
8
8
|
// and paint, so nothing in it, position: fixed included, can cover the viewer's bar, its ✕ or the app shell.
|
|
9
9
|
// kit.sidebar(data?, {here, switching, device, head, height}) ui-sidebar.js sidebar() as an open drawer card in the
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The full-bleed prototype viewer's controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
2
|
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
|
|
3
3
|
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
|
|
4
|
-
// docs/adr/2026-11-
|
|
4
|
+
// docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md).
|
|
5
5
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
6
6
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
|
|
7
7
|
// render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
|
|
8
8
|
// (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
|
|
9
9
|
// pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
|
|
10
10
|
// right goes back as its back arrow does, and the footer, full width on the bottom edge at every width, holds the
|
|
11
|
-
// ‹ take › pager.
|
|
11
|
+
// ‹ take › pager. ←/→
|
|
12
12
|
// and a sideways swipe switch unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
|
|
13
13
|
// the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
|
|
14
14
|
// the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
|
|
@@ -118,10 +118,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
118
118
|
.ps-pill { width:min(640px, 100%); }
|
|
119
119
|
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
120
120
|
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
121
|
-
}
|
|
122
|
-
.ps-pane main.proto-main { padding-top:0; }
|
|
123
|
-
.ps-pane .ps-top { display:none; }
|
|
124
|
-
.ps-pane .ps-states { flex-wrap:wrap; overflow-x:visible; justify-content:center; }`;
|
|
121
|
+
}`;
|
|
125
122
|
|
|
126
123
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
127
124
|
|
|
@@ -132,8 +129,6 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
132
129
|
const qid = params.get('q');
|
|
133
130
|
const site = globalThis.ClaudeSite;
|
|
134
131
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
135
|
-
// Framed in the board's right pane (app-needs.js openViewer), whose top bar holds the way back and the Round pill.
|
|
136
|
-
if (window.frameElement?.closest('.needs-pane')) document.documentElement.classList.add('ps-pane');
|
|
137
132
|
let take = startTake(keys, params.get('variant'), initial);
|
|
138
133
|
let question = null;
|
|
139
134
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
#stage .top { position: static; }
|
|
23
23
|
#stage .hub-view main { padding-bottom: 16px; }
|
|
24
24
|
/* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
|
|
25
|
-
viewer's footer
|
|
25
|
+
viewer's footer. */
|
|
26
26
|
#stage:has(> .kit-served) { max-width: none; padding: 0; }
|
|
27
27
|
.kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
|
|
28
|
-
.ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
|
|
29
28
|
.kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
|
30
29
|
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
31
30
|
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
|
@@ -50,10 +49,8 @@
|
|
|
50
49
|
#stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
|
|
51
50
|
@media (min-width: 900px) { #stage .kit-sidebar.sidebar { width: 272px; } }
|
|
52
51
|
/* Everything a take draws shows at every width: the board's width-gated hides are undone here (style.css hides
|
|
53
|
-
.
|
|
52
|
+
.key-hint below 600px). render-check.mjs refuses a take whose box is
|
|
54
53
|
still hidden at one of its two widths, so a gate missed here fails the round instead of drawing blank. */
|
|
55
|
-
#stage .needs-deck { display: block; }
|
|
56
|
-
#stage .needs-pane { display: block; position: static; height: auto; overflow: visible; border-left: 0; padding: 0; }
|
|
57
54
|
#stage .key-hint { display: inline; }
|
|
58
55
|
#stage .kit-sheet-strip { padding: 32px 0 0; border-radius: 12px; background: rgba(0,0,0,.35); overflow: hidden; }
|
|
59
56
|
#stage .kit-sheet { max-width: 560px; margin: 0 auto; border: 1px solid var(--line); border-bottom: 0; border-radius: 16px 16px 0 0; background: var(--panel); color: var(--text); box-shadow: 0 -8px 30px rgba(0,0,0,.18); }
|
|
@@ -99,7 +96,6 @@
|
|
|
99
96
|
<script src="app-cards.js"></script>
|
|
100
97
|
<script src="app-lanes.js"></script>
|
|
101
98
|
<script src="app-asks.js"></script>
|
|
102
|
-
<script src="app-needs.js"></script>
|
|
103
99
|
<script src="app-header.js"></script>
|
|
104
100
|
<script src="app-arch.js"></script>
|
|
105
101
|
<script src="app-reader.js"></script>
|
package/board/public/router.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
4
|
// A view is {kind, …}:
|
|
5
5
|
// start `/`, which the shell turns into the last board
|
|
6
|
-
// board {route, hash} /<route>/, hash such as #selectedCard=<key
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #selectedCard=<key>, or the older #item=<key>&reply
|
|
7
7
|
// settings {route} /<route>/settings.html, a request to open the modal over that board
|
|
8
8
|
// questions {route, id} /<route>/questions.html#<id>
|
|
9
9
|
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
|
|
@@ -81,24 +81,23 @@ globalThis.AppRouter = (function () {
|
|
|
81
81
|
|
|
82
82
|
// The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
83
83
|
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
84
|
-
// A board's prototype page
|
|
84
|
+
// A board's prototype page.
|
|
85
85
|
const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
|
|
86
86
|
function questionOf(view, boards) {
|
|
87
87
|
return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
function levelOf(view, { boards = new Set(), wide = false } = {}) {
|
|
89
|
+
function levelOf(view, { boards = new Set() } = {}) {
|
|
91
90
|
if (view?.kind === 'board') return 0;
|
|
92
91
|
if (view?.kind === 'questions') return 1;
|
|
93
|
-
return questionOf(view, boards) ?
|
|
92
|
+
return questionOf(view, boards) ? 2 : null;
|
|
94
93
|
}
|
|
95
94
|
// → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
|
|
96
95
|
// which the board keeps open.
|
|
97
|
-
function parentsOf(view, { boards = new Set(), card = null
|
|
96
|
+
function parentsOf(view, { boards = new Set(), card = null } = {}) {
|
|
98
97
|
const q = questionOf(view, boards);
|
|
99
98
|
if (!q && view?.kind !== 'questions') return [];
|
|
100
99
|
const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
|
|
101
|
-
return q
|
|
100
|
+
return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
// Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
|
|
@@ -111,8 +110,8 @@ globalThis.AppRouter = (function () {
|
|
|
111
110
|
// boards() → the board routes now.
|
|
112
111
|
// → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
|
|
113
112
|
// 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
|
|
114
|
-
function listen({ boards, go
|
|
115
|
-
const b = () => ({ boards: boards()
|
|
113
|
+
function listen({ boards, go }) {
|
|
114
|
+
const b = () => ({ boards: boards() });
|
|
116
115
|
const mark = (pushed) => (pushed ? { pushed: true } : {});
|
|
117
116
|
let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
|
|
118
117
|
let landing = null;
|
|
@@ -225,32 +224,27 @@ globalThis.AppRouter = (function () {
|
|
|
225
224
|
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
226
225
|
}
|
|
227
226
|
|
|
228
|
-
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card),
|
|
229
|
-
//
|
|
230
|
-
// → {selectedCard,
|
|
227
|
+
// A board's fragment is &-joined keys: selectedCard=<key> (the focused card), review=<PR number>, and a bare reply.
|
|
228
|
+
// The older #item=<key> reads as selectedCard.
|
|
229
|
+
// → {selectedCard, review, reply}, each null or false when absent.
|
|
231
230
|
function fragmentOf(hash) {
|
|
232
|
-
const out = { selectedCard: null,
|
|
231
|
+
const out = { selectedCard: null, review: null, reply: false };
|
|
233
232
|
const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
|
|
234
233
|
for (const part of String(hash).replace(/^#/, '').split('&')) {
|
|
235
234
|
const eq = part.indexOf('=');
|
|
236
235
|
const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
|
|
237
236
|
if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
|
|
238
|
-
else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
|
|
239
237
|
else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
|
|
240
238
|
else if (k === 'reply') out.reply = true;
|
|
241
239
|
}
|
|
242
240
|
return out;
|
|
243
241
|
}
|
|
244
242
|
// hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
|
|
245
|
-
// alone in its fragment, so a change to
|
|
243
|
+
// alone in its fragment, so a change to the card replaces it; reply is spent once the card is shown.
|
|
246
244
|
function hashWith(hash, changes) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
const parts = [];
|
|
250
|
-
if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
|
|
251
|
-
if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
|
|
252
|
-
return parts.length ? '#' + parts.join('&') : '';
|
|
245
|
+
const next = { selectedCard: fragmentOf(hash).selectedCard, ...changes };
|
|
246
|
+
return next.selectedCard ? `#selectedCard=${encodeURIComponent(next.selectedCard)}` : '';
|
|
253
247
|
}
|
|
254
248
|
|
|
255
|
-
return { viewOf, urlOf, mounted, entryOf,
|
|
249
|
+
return { viewOf, urlOf, mounted, entryOf, listen, fragmentOf, hashWith };
|
|
256
250
|
})();
|