@lovek4d/kickflip 0.6.1 → 0.7.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 +29 -17
- package/SKILL.md +12 -8
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +46 -32
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +13 -4
- package/board/public/app-lanes.js +2 -2
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +2 -2
- package/board/public/style.css +58 -11
- package/board/public/ui-cards.js +44 -27
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +8 -5
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +4 -2
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -2
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +25 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
package/board/onboarding.mjs
CHANGED
|
@@ -8,4 +8,4 @@ What this card does:
|
|
|
8
8
|
- Ask the owner what the project is for and which words they use for its parts; their wording goes in verbatim.
|
|
9
9
|
- If the repo already has a CONTEXT.md or ADRs, extend them rather than start again.
|
|
10
10
|
`,b=[{id:"onboard-build",title:"Prepare .kickflip/build",notes:p,needsNoBuild:!0},{id:"onboard-context",title:"Write the repo's CONTEXT.md glossary and first ADR",notes:u}];function T(e,i){try{return c("git",["-C",e,"ls-tree",`${i}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
|
|
11
|
-
`).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),
|
|
11
|
+
`).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
|
package/board/prototype-page.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var d=Object.defineProperty;var r=(t,e)=>d(t,"name",{value:e,configurable:!0});import c from"node:fs/promises";import o from"node:path";import{fileURLToPath as u}from"node:url";import{takeFile as f}from"./store.mjs";const g=o.join(o.dirname(u(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const l=`prototypes/${e.topic}/${a.key}.js`,i=await c.stat(f(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:i?.isFile()?`${l}?v=${$(i)}`:null,file:l}})),s=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#selectedCard=${encodeURIComponent(e.card)}`:"./",title:s,controls:!1},y={prototypeTitle:k(s),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await c.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,l)=>y[l])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
|
package/board/public/app-arch.js
CHANGED
|
@@ -16,7 +16,7 @@ BoardView.part((B) => {
|
|
|
16
16
|
+ `<button type="button" class="arch-run" data-arch-open="${esc(last.key)}">Open ${esc(last.ref)}</button>`;
|
|
17
17
|
}
|
|
18
18
|
const lastHtml = last ? `<div class="arch-last"><span>Last review: <b>${esc(last.wave)}</b> · ${esc(day(last.at))}</span>`
|
|
19
|
-
+ `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#
|
|
19
|
+
+ `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#selectedCard=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
|
|
20
20
|
const waits = a.waits ? `<p class="arch-waits" role="status">${esc(a.waits)}</p>` : '';
|
|
21
21
|
return `${lastHtml}${waits}<button type="button" class="primary arch-run" data-arch-run${starting ? BoardUI.BUSY : a.waits ? ' disabled' : ''}>Run a review of ${esc(a.repo)}</button>`
|
|
22
22
|
+ '<p class="muted">Claude reads the repo and makes a wave. Its first card asks you one question per finding.</p>';
|
|
@@ -36,7 +36,7 @@ BoardView.part((B) => {
|
|
|
36
36
|
|
|
37
37
|
function openCard(key) {
|
|
38
38
|
sheet.close();
|
|
39
|
-
const hash = `#
|
|
39
|
+
const hash = `#selectedCard=${encodeURIComponent(key)}`;
|
|
40
40
|
if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
41
41
|
else location.hash = hash;
|
|
42
42
|
}
|
|
@@ -33,7 +33,7 @@ BoardView.part((B) => {
|
|
|
33
33
|
if (!open) return;
|
|
34
34
|
const key = open.dataset.answer;
|
|
35
35
|
markSeen(d.items.find((i) => i.key === key));
|
|
36
|
-
const hash = `#
|
|
36
|
+
const hash = `#selectedCard=${encodeURIComponent(key)}`;
|
|
37
37
|
if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
|
|
38
38
|
location.hash = hash;
|
|
39
39
|
});
|
|
@@ -249,7 +249,7 @@ BoardView.part((B) => {
|
|
|
249
249
|
</div>`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
const cardLink = (l) => `<a class="card-link" href="#
|
|
252
|
+
const cardLink = (l) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
|
|
253
253
|
// A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
|
|
254
254
|
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
255
255
|
const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
|
|
@@ -311,8 +311,10 @@ BoardView.part((B) => {
|
|
|
311
311
|
};
|
|
312
312
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
313
313
|
|
|
314
|
-
//
|
|
315
|
-
|
|
314
|
+
// A bumped New card takes no second bump, which would only move it behind the earlier bumps
|
|
315
|
+
// (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
|
|
316
|
+
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
|
|
317
|
+
const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
|
|
316
318
|
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
317
319
|
// Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
|
|
318
320
|
// questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
@@ -322,7 +324,7 @@ BoardView.part((B) => {
|
|
|
322
324
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
323
325
|
<summary class="work">
|
|
324
326
|
<span class="status ${it.lane}"></span>
|
|
325
|
-
<div class="title-wrap">${workLines(it, { unread, top:
|
|
327
|
+
<div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it), ...band })}</div>
|
|
326
328
|
</summary>
|
|
327
329
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
328
330
|
</details>`;
|
|
@@ -389,6 +391,13 @@ BoardView.part((B) => {
|
|
|
389
391
|
drawn = true;
|
|
390
392
|
}
|
|
391
393
|
|
|
394
|
+
// A card the owner opens is the URL's selected card. A card restored open on load fires toggle too, so the tap is
|
|
395
|
+
// what counts, not the toggle.
|
|
396
|
+
B.on(B.root, 'click', (ev) => {
|
|
397
|
+
const card = ev.target.closest?.('details.item > summary')?.parentNode;
|
|
398
|
+
if (card && !card.open) B.saveFragment({ selectedCard: card.dataset.key });
|
|
399
|
+
});
|
|
400
|
+
|
|
392
401
|
B.on(B.root, 'toggle', (ev) => {
|
|
393
402
|
const el = ev.target;
|
|
394
403
|
if (!el.matches?.('details.item')) return;
|
|
@@ -170,7 +170,7 @@ BoardView.part((B) => {
|
|
|
170
170
|
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
|
|
171
171
|
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
|
|
172
172
|
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
173
|
-
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#
|
|
173
|
+
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
174
174
|
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
175
175
|
const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
|
|
176
176
|
return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
|
|
@@ -188,7 +188,7 @@ BoardView.part((B) => {
|
|
|
188
188
|
+ (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// Opens the band holding card key or named slug, for a #
|
|
191
|
+
// Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
|
|
192
192
|
function openBand({ key, slug }) {
|
|
193
193
|
const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
|
|
194
194
|
if (band) store.set(bandStore(band.slug), true);
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
|
|
2
2
|
// (prompts.js). It draws twice, and the stylesheet shows one by width
|
|
3
3
|
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
|
|
4
|
-
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's
|
|
5
|
-
//
|
|
4
|
+
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's option marked on the first tap,
|
|
5
|
+
// turning into Send “<label>”, and sent with the question's drafted note and files on a tap on it
|
|
6
|
+
// (docs/adr/2026-11-07-1212-a-deck-option-sends-on-a-second-tap.md), a prompt's buttons answering it, Prev and Next
|
|
7
|
+
// beside a dot for each thing waiting, and All open questions at its foot;
|
|
6
8
|
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
7
9
|
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
8
10
|
// a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
|
|
9
|
-
// here at that width (shell.js go, focusQuestion), and a pill
|
|
10
|
-
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md).
|
|
11
|
+
// here at that width (shell.js go, focusQuestion), and two taps on a pill focus its card on the board
|
|
12
|
+
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
|
|
13
|
+
// Waiting on you until its ‹ Open questions (openViewer).
|
|
11
14
|
// Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
|
|
12
15
|
// Needs app-core.js.
|
|
13
16
|
BoardView.part((B) => {
|
|
14
17
|
const { $, plural, state, toast } = B;
|
|
15
|
-
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
|
|
18
|
+
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
|
|
16
19
|
|
|
17
20
|
const prompts = PromptAnswers.wire(B, {
|
|
18
21
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -29,6 +32,7 @@ BoardView.part((B) => {
|
|
|
29
32
|
renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
|
|
30
33
|
renderDeck(d, needs);
|
|
31
34
|
renderAsk(d);
|
|
35
|
+
if (viewer) paintViewer(d);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
|
|
@@ -80,11 +84,17 @@ BoardView.part((B) => {
|
|
|
80
84
|
html: `<h3>${line.key ? `<a href="${esc(line.url)}">${escText(title)}</a>` : escText(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
|
|
81
85
|
});
|
|
82
86
|
}
|
|
87
|
+
// The marked option, the question's drafted pick, reads Send “<label>”.
|
|
83
88
|
const questionHtml = (q) => {
|
|
84
|
-
const
|
|
89
|
+
const marked = getDraft(q.id).choice;
|
|
90
|
+
const optionHtml = (o) => {
|
|
91
|
+
const armed = o.id === marked;
|
|
92
|
+
const label = armed ? `Send “${esc(o.label)}”` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
|
|
93
|
+
return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`;
|
|
94
|
+
};
|
|
85
95
|
return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
|
|
86
96
|
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
87
|
-
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(
|
|
97
|
+
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
|
|
88
98
|
};
|
|
89
99
|
|
|
90
100
|
// The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
|
|
@@ -98,46 +108,73 @@ BoardView.part((B) => {
|
|
|
98
108
|
if (!deck.length) { el.innerHTML = ''; showing = null; return; }
|
|
99
109
|
const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
|
|
100
110
|
showing = now.id;
|
|
111
|
+
const at = deck.indexOf(now);
|
|
101
112
|
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
102
113
|
const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
|
|
103
114
|
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
104
115
|
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
|
|
105
|
-
${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-
|
|
116
|
+
${deck.length > 1 ? `<div class="deck-bar"><button type="button" class="ghost" data-deck-prev${at === 0 ? ' disabled' : ''}>← Prev</button><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-next${at === deck.length - 1 ? ' disabled' : ''}>Next →</button></div>` : ''}
|
|
106
117
|
${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// The deck's index one step from the thing on screen; the ends hold.
|
|
121
|
+
const stepFrom = (step) => Math.min(Math.max(deck.findIndex((x) => x.id === showing) + step, 0), deck.length - 1);
|
|
109
122
|
B.on(B.root, 'click', (ev) => {
|
|
110
123
|
if (!ev.target.closest?.('#needsDeck')) return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
showing = deck[(
|
|
124
|
+
const step = ev.target.closest('[data-deck-prev]') ? -1 : ev.target.closest('[data-deck-next]') ? 1 : 0;
|
|
125
|
+
if (step) {
|
|
126
|
+
showing = deck[stepFrom(step)].id;
|
|
114
127
|
renderNeeds(state.board);
|
|
115
128
|
return;
|
|
116
129
|
}
|
|
117
130
|
const opt = ev.target.closest('button[data-deck-pick]');
|
|
118
131
|
if (!opt) return;
|
|
119
132
|
const id = opt.closest('[data-deck]').dataset.deck.slice(2);
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
const choice = opt.dataset.deckPick;
|
|
134
|
+
const draft = getDraft(id);
|
|
135
|
+
if (draft.choice !== choice) {
|
|
136
|
+
setDraft(id, { ...draft, choice });
|
|
137
|
+
renderNeeds(state.board);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
sendAnswer((state.board.owner || []).find((q) => q.id === id), choice, opt);
|
|
124
141
|
});
|
|
125
142
|
|
|
143
|
+
// The marked option's tap sends it with the question's drafted note and files, as the questions page's Send does, and
|
|
144
|
+
// spins until the board shows the question answered, which takes it off the deck. Files still uploading hold it.
|
|
145
|
+
async function sendAnswer(q, choice, button) {
|
|
146
|
+
const composer = 'q:' + q.id;
|
|
147
|
+
if (BoardAttach.busy(composer, B.site.root)) { toast('Wait for the upload to finish'); return; }
|
|
148
|
+
const files = BoardAttach.files(composer, B.site.root);
|
|
149
|
+
const answer = { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, getDraft(q.id)).trim(), ...(files.length ? { files } : {}) };
|
|
150
|
+
const sent = () => { setDraft(q.id, {}); BoardAttach.clear(composer, B.site.root); };
|
|
151
|
+
try {
|
|
152
|
+
await waitOn(button, BoardUI.pending.within(B.send('owner-answers', { answers: [answer] }, sent), B.stillWaiting));
|
|
153
|
+
} catch (e) { B.failed(e); }
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
127
157
|
let questions = null;
|
|
158
|
+
// Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
159
|
+
// wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
160
|
+
function openOnBoard(id) {
|
|
161
|
+
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
162
|
+
else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
163
|
+
}
|
|
128
164
|
function fitPane() {
|
|
129
165
|
if (wide.matches === Boolean(questions)) return;
|
|
130
166
|
if (state.board) renderNeeds(state.board);
|
|
131
|
-
if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
167
|
+
if (questions) { closeViewer(); questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
132
168
|
$('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
|
|
133
169
|
const slot = $('#needsPane .needs-questions');
|
|
134
|
-
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
|
|
170
|
+
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true, openOnBoard, onTopic: (id) => B.saveFragment({ openItemsCard: id }) }), { slot });
|
|
135
171
|
questions.mount();
|
|
136
172
|
}
|
|
137
173
|
// The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
|
|
138
174
|
// or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
|
|
139
175
|
// is no pane or it hasn't loaded, so the shell asks again.
|
|
140
176
|
function focusQuestion(id) {
|
|
177
|
+
closeViewer();
|
|
141
178
|
const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
|
|
142
179
|
if (!questions?.B.openTopic(topic?.id ?? id)) return false;
|
|
143
180
|
const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
|
|
@@ -145,18 +182,62 @@ BoardView.part((B) => {
|
|
|
145
182
|
pill?.scrollIntoView({ block: 'nearest' });
|
|
146
183
|
return true;
|
|
147
184
|
}
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
|
|
186
|
+
// From 900px a prototype opens in the pane (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md,
|
|
187
|
+
// shell.js go): a top bar of ‹ Open questions, ⤢ and the card's Round pill over the prototype page framed at the
|
|
188
|
+
// pane's width, which shows its footer at the bottom of the pane (prototype-switcher.js .ps-pane). ⤢ widens the pane
|
|
189
|
+
// over the lanes and back. ‹ returns to Waiting on you with the round's question focused, as Choose this does through
|
|
190
|
+
// the shell. viewer: {url, q, widened, sheet} while one is open. → false while there is no pane, so the shell asks again.
|
|
191
|
+
let viewer = null;
|
|
192
|
+
function openViewer(url) {
|
|
193
|
+
if (!questions) return false;
|
|
194
|
+
viewer = { url, q: new URL(url, location.href).searchParams.get('q'), widened: viewer?.widened ?? false, sheet: false };
|
|
195
|
+
paintViewer();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// board: the board download it shows, which renderNeeds passes before state.board holds it.
|
|
199
|
+
function paintViewer(board = state.board) {
|
|
200
|
+
const pane = $('#needsPane');
|
|
201
|
+
pane.classList.toggle('viewing', Boolean(viewer));
|
|
202
|
+
pane.classList.toggle('widened', Boolean(viewer?.widened));
|
|
203
|
+
let box = pane.querySelector('.pane-viewer');
|
|
204
|
+
if (!viewer) { box?.remove(); return; }
|
|
205
|
+
if (!box) {
|
|
206
|
+
pane.insertAdjacentHTML('beforeend', '<div class="pane-viewer"><div class="pv-top"></div><iframe class="pv-frame" title="Prototype"></iframe></div>');
|
|
207
|
+
box = pane.querySelector('.pane-viewer');
|
|
208
|
+
}
|
|
209
|
+
const card = (board?.items || []).find((it) => it.prototypes?.rounds?.some((r) => r.q === viewer.q));
|
|
210
|
+
const rounds = card?.prototypes.rounds;
|
|
211
|
+
box.querySelector('.pv-top').innerHTML = `<button type="button" class="pv-back" data-pv="back">‹ Open questions</button><span class="pv-grow"></span>
|
|
212
|
+
<button type="button" class="pv-widen" data-pv="widen" aria-pressed="${viewer.widened}" aria-label="Widen over the lanes" title="Widen over the lanes">⤢</button>${BoardUI.roundPill(rounds, viewer.q)}`;
|
|
213
|
+
box.querySelector('.ps-sheet')?.remove();
|
|
214
|
+
if (viewer.sheet && rounds) box.insertAdjacentHTML('beforeend', BoardUI.roundSheet({ ref: card.ref, rounds, qid: viewer.q }));
|
|
215
|
+
const frame = box.querySelector('iframe.pv-frame');
|
|
216
|
+
if (frame.getAttribute('src') !== viewer.url) frame.setAttribute('src', viewer.url);
|
|
217
|
+
}
|
|
218
|
+
function closeViewer() {
|
|
219
|
+
if (!viewer) return;
|
|
220
|
+
viewer = null;
|
|
221
|
+
paintViewer();
|
|
222
|
+
}
|
|
223
|
+
B.on(B.root, 'click', (ev) => {
|
|
224
|
+
if (!viewer || !ev.target.closest?.('#needsPane .pane-viewer')) return;
|
|
225
|
+
const round = ev.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
226
|
+
if (round) { openViewer(round); return; }
|
|
227
|
+
const act = ev.target.closest('[data-pv], [data-ps-top]');
|
|
228
|
+
if (!act) return;
|
|
229
|
+
if (act.dataset.pv === 'back') { focusQuestion(viewer.q); return; }
|
|
230
|
+
if (act.dataset.pv === 'widen') viewer.widened = !viewer.widened;
|
|
231
|
+
else viewer.sheet = !viewer.sheet;
|
|
232
|
+
paintViewer();
|
|
156
233
|
});
|
|
234
|
+
// A push to a card's thread (#item=<key>&reply, board/store.mjs pushUrl), the switcher's for a question already picked
|
|
235
|
+
// among them, closes the viewer, so the thread it sent the owner to shows.
|
|
236
|
+
B.on(window, 'hashchange', () => { if (/^#item=[^&]+&reply$/.test(location.hash)) closeViewer(); });
|
|
237
|
+
|
|
157
238
|
fitPane();
|
|
158
239
|
B.on(wide, 'change', fitPane);
|
|
159
240
|
B.atUnmount(() => { questions?.unmount(); questions = null; });
|
|
160
241
|
|
|
161
|
-
Object.assign(B, { renderNeeds, focusQuestion });
|
|
242
|
+
Object.assign(B, { renderNeeds, focusQuestion, openViewer });
|
|
162
243
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
|
|
2
2
|
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
4
|
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
5
|
// A link in it closes it first, then opens through the shell.
|
|
6
|
-
// A log written as a stream (headless.mjs: one stream-json event per line) reads as
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as the run's transcript, the whole of
|
|
7
|
+
// it as GET /api/transcript's rows: what Claude says, and each tool call as one line whose output opens on a tap, with a
|
|
8
|
+
// raw toggle for the file as it is (docs/adr/2026-11-07-1224-view-transcript-reads-the-whole-run-as-rows.md); any other
|
|
9
|
+
// log reads as its text. Every 3 seconds until the run's result arrives it reads on from where it left off, and follows
|
|
10
|
+
// new lines while read at its end.
|
|
9
11
|
BoardView.part((B) => {
|
|
10
12
|
const { $, state } = B;
|
|
11
13
|
const { esc, markdown, linkContext } = BoardUI;
|
|
@@ -19,31 +21,13 @@ BoardView.part((B) => {
|
|
|
19
21
|
let log = null;
|
|
20
22
|
let stopReading = null;
|
|
21
23
|
|
|
22
|
-
//
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
24
|
+
// A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
|
|
25
|
+
function rowHtml(r, i) {
|
|
26
|
+
const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
|
|
27
|
+
if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
|
|
28
|
+
const on = log.opened.has(i);
|
|
29
|
+
return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
|
|
29
30
|
}
|
|
30
|
-
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
-
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
-
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
-
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
-
// result have none; the raw view shows them.
|
|
35
|
-
function rowsOf(e) {
|
|
36
|
-
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
-
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
-
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
-
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
-
const content = e.message.content;
|
|
41
|
-
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
-
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
-
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
-
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
-
}
|
|
46
|
-
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
31
|
|
|
48
32
|
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
33
|
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
@@ -57,11 +41,11 @@ BoardView.part((B) => {
|
|
|
57
41
|
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
42
|
}
|
|
59
43
|
function drawLog() {
|
|
60
|
-
const {
|
|
61
|
-
body.innerHTML = !
|
|
62
|
-
: !stream ? `<pre class="reader-text">${esc(
|
|
63
|
-
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
-
:
|
|
44
|
+
const { rows, stream, raw, text } = log;
|
|
45
|
+
body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
46
|
+
: !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
|
|
47
|
+
: raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
|
|
48
|
+
: rows.map(rowHtml).join('');
|
|
65
49
|
drawTools();
|
|
66
50
|
drawMore();
|
|
67
51
|
}
|
|
@@ -73,24 +57,44 @@ BoardView.part((B) => {
|
|
|
73
57
|
drawMore();
|
|
74
58
|
}
|
|
75
59
|
|
|
60
|
+
// Reads the transcript on from where the last read ended; an output joins its call's line.
|
|
76
61
|
async function readLog(n) {
|
|
77
62
|
let answer;
|
|
78
63
|
try {
|
|
79
|
-
const r = await fetch(B.site.url(`api/
|
|
64
|
+
const r = await fetch(B.site.url(`api/transcript?file=${encodeURIComponent(log.file)}&from=${log.next}`), { cache: 'no-store' });
|
|
80
65
|
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
66
|
} catch { answer = null; }
|
|
82
67
|
if (n !== opened) return;
|
|
83
68
|
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
-
if (answer && answer.
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
if (answer && (!log.read || answer.next !== log.next)) {
|
|
70
|
+
let added = 0;
|
|
71
|
+
for (const row of answer.rows) {
|
|
72
|
+
if (row.for) { log.calls.get(row.for).out = row.body; continue; }
|
|
73
|
+
if (row.id) log.calls.set(row.id, row);
|
|
74
|
+
log.rows.push(row);
|
|
75
|
+
added++;
|
|
76
|
+
}
|
|
77
|
+
Object.assign(log, { read: true, next: answer.next, stream: log.stream || answer.stream, finished: log.finished || answer.finished });
|
|
78
|
+
if (!log.following) log.unseen += added;
|
|
89
79
|
drawLog();
|
|
90
80
|
if (log.following) toEnd();
|
|
81
|
+
if (log.raw) readRaw(n);
|
|
91
82
|
}
|
|
92
83
|
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
84
|
}
|
|
85
|
+
// The raw toggle's file as it is: GET /api/log, its last 256 KiB.
|
|
86
|
+
async function readRaw(n) {
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
90
|
+
const answer = await r.json();
|
|
91
|
+
text = r.ok ? answer.text : answer.error;
|
|
92
|
+
} catch { text = 'Could not load this log.'; }
|
|
93
|
+
if (n !== opened || !log.raw) return;
|
|
94
|
+
log.text = text;
|
|
95
|
+
drawLog();
|
|
96
|
+
if (log.following) toEnd();
|
|
97
|
+
}
|
|
94
98
|
|
|
95
99
|
function show(title, ref) {
|
|
96
100
|
opened++;
|
|
@@ -107,8 +111,8 @@ BoardView.part((B) => {
|
|
|
107
111
|
return opened;
|
|
108
112
|
}
|
|
109
113
|
function openLog(file) {
|
|
110
|
-
const n = show(`${file.split('-')[0]}
|
|
111
|
-
log = { file,
|
|
114
|
+
const n = show(`${file.split('-')[0]} transcript`, file);
|
|
115
|
+
log = { file, read: false, next: 0, rows: [], calls: new Map(), opened: new Set(), stream: false, finished: false, raw: false, text: null, following: true, unseen: 0 };
|
|
112
116
|
readLog(n);
|
|
113
117
|
}
|
|
114
118
|
async function openNotes(search) {
|
|
@@ -147,8 +151,14 @@ BoardView.part((B) => {
|
|
|
147
151
|
if (ev.target === sheet) { close(); return; }
|
|
148
152
|
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
153
|
if (act === 'close') close();
|
|
150
|
-
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
154
|
+
else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
|
|
151
155
|
else if (act === 'end') follow();
|
|
156
|
+
const call = ev.target.closest('[data-call]');
|
|
157
|
+
if (call) {
|
|
158
|
+
const i = Number(call.dataset.call);
|
|
159
|
+
if (!log.opened.delete(i)) log.opened.add(i);
|
|
160
|
+
drawLog();
|
|
161
|
+
}
|
|
152
162
|
const a = ev.target.closest('a[href]');
|
|
153
163
|
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
164
|
ev.preventDefault();
|
|
@@ -144,8 +144,8 @@ BoardView.part((B) => {
|
|
|
144
144
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
145
145
|
const id = f.dataset.composer;
|
|
146
146
|
const files = BoardAttach.files(id, B.site.root);
|
|
147
|
-
// An empty box still sends Implement, the card's build action: the board server
|
|
148
|
-
//
|
|
147
|
+
// An empty box still sends Implement, the card's build action: the board server stages the card for the
|
|
148
|
+
// orchestrator's review (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
149
149
|
const intent = flag ? null : button.dataset.intent || null;
|
|
150
150
|
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
151
151
|
const key = f.dataset.reply;
|
package/board/public/app.html
CHANGED
|
@@ -22,7 +22,7 @@ window.BoardBoot = (function () {
|
|
|
22
22
|
const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
|
|
23
23
|
let open = [];
|
|
24
24
|
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
25
|
-
const focus =
|
|
25
|
+
const focus = /[#&](?:selectedCard|item)=([^&]+)/.exec(location.hash);
|
|
26
26
|
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
27
27
|
let since = null;
|
|
28
28
|
try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
|
package/board/public/app.js
CHANGED
|
@@ -173,7 +173,7 @@ window.BoardView = (function () {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// The board itself, after every part: rendering, the
|
|
176
|
+
// The board itself, after every part: rendering, the URL fragment, loading and the change signal.
|
|
177
177
|
function start(B) {
|
|
178
178
|
const { $, $$, store, state, steady, markSeen } = B;
|
|
179
179
|
const { setHTML, preloadKeys } = BoardUI;
|
|
@@ -206,31 +206,41 @@ window.BoardView = (function () {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #
|
|
209
|
+
// #selectedCard=<wave>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
|
|
210
210
|
// (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
211
|
let focusKey = null;
|
|
212
212
|
let replyKey = null;
|
|
213
213
|
let hashRead = false;
|
|
214
214
|
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
215
|
-
const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
|
|
215
|
+
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
|
+
}
|
|
216
222
|
function readHash() {
|
|
217
|
-
const review =
|
|
218
|
-
if (review && state.board) { B.openReview(
|
|
219
|
-
|
|
220
|
-
if (!
|
|
221
|
-
focusKey =
|
|
222
|
-
|
|
223
|
-
replyKey = m[2] ? focusKey : null;
|
|
223
|
+
const { selectedCard, openItemsCard, review, reply } = AppRouter.fragmentOf(location.hash);
|
|
224
|
+
if (review && state.board) { B.openReview(review); return; }
|
|
225
|
+
openPill(openItemsCard);
|
|
226
|
+
if (!selectedCard) return;
|
|
227
|
+
focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
|
|
228
|
+
replyKey = reply ? focusKey : null;
|
|
224
229
|
state.openItems.add(focusKey);
|
|
225
230
|
state.restored.delete(focusKey);
|
|
226
231
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
227
232
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
228
233
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
229
234
|
}
|
|
235
|
+
// The owner's own moves rewrite the fragment in place, with no history entry and no hashchange.
|
|
236
|
+
B.saveFragment = (changes) => {
|
|
237
|
+
const hash = AppRouter.hashWith(location.hash, changes);
|
|
238
|
+
if (hash !== location.hash) history.replaceState(history.state, '', location.pathname + location.search + hash);
|
|
239
|
+
};
|
|
230
240
|
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
231
241
|
// A card link whose hash is already the page's fires no hashchange, so its tap opens the card here.
|
|
232
242
|
B.on(B.root, 'click', (ev) => {
|
|
233
|
-
const a = ev.target.closest?.('a[href^="#item="]');
|
|
243
|
+
const a = ev.target.closest?.('a[href^="#item="], a[href^="#selectedCard="]');
|
|
234
244
|
if (!a || ev.defaultPrevented || a.getAttribute('href') !== location.hash || !state.board) return;
|
|
235
245
|
ev.preventDefault();
|
|
236
246
|
readHash(); render(); scrollToFocus();
|
|
@@ -239,7 +249,7 @@ window.BoardView = (function () {
|
|
|
239
249
|
function focusOnBoard({ key, slug }) {
|
|
240
250
|
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
241
251
|
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
242
|
-
if (key) { location.hash =
|
|
252
|
+
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
243
253
|
render();
|
|
244
254
|
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
245
255
|
return true;
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
const { BUILD_PHASES, START_STEPS } = BoardUI;
|
|
16
16
|
const build = () => [...BUILD_PHASES];
|
|
17
17
|
return [
|
|
18
|
-
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps:
|
|
19
|
-
card: { builder: { planner: true, kind: 'grill', progress: 'Grilling · the notes', phase: null, at: ago(2) } } },
|
|
20
|
-
{ id: 'prototype', name: 'Prototyping', lane: 'todo', when: 'A prototype planner draws a Todo card’s takes', steps:
|
|
21
|
-
card: { builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
18
|
+
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps: [],
|
|
19
|
+
card: { phase: 'grilling', builder: { planner: true, kind: 'grill', progress: 'Grilling · the notes', phase: null, at: ago(2) } } },
|
|
20
|
+
{ id: 'prototype', name: 'Prototyping', lane: 'todo', when: 'A prototype planner draws a Todo card’s takes', steps: [],
|
|
21
|
+
card: { phase: 'prototyping', builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
22
22
|
{ id: 'build', name: 'Build', lane: 'progress', when: 'A builder works the card, step by step', steps: build(),
|
|
23
23
|
card: { builder: { progress: 'Testing · the suite', phase: 'testing', at: ago(2) } } },
|
|
24
24
|
{ id: 'check', name: 'Orchestrator review', lane: 'progress', when: 'The orchestrator checks the builder’s calls, on step 4', steps: build(),
|