@lovek4d/kickflip 0.11.0 → 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.
@@ -1,248 +0,0 @@
1
- // The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
2
- // (prompts.js). It draws twice, and the stylesheet shows one by width
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 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;
8
- // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
9
- // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
10
- // a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
11
- // here at that width (shell.js go, focusQuestion), and a click 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).
14
- // Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
15
- // Needs app-core.js.
16
- BoardView.part((B) => {
17
- const { $, plural, state, toast } = B;
18
- const { esc, escText, markdown, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
19
-
20
- const prompts = PromptAnswers.wire(B, {
21
- list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
22
- paint: () => { if (state.board) renderNeeds(state.board); },
23
- notify: (text) => toast(text),
24
- after: () => B.loadBoard(),
25
- });
26
-
27
- // From 900px, the pane beside the lanes, the same width the stylesheet splits the board at.
28
- const wide = matchMedia('(min-width: 900px)');
29
-
30
- function renderNeeds(d) {
31
- const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
32
- renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
33
- renderDeck(d, needs);
34
- renderAsk(d);
35
- if (viewer) paintViewer(d);
36
- }
37
-
38
- // The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
39
- function renderAsk(d) {
40
- const open = (d.owner || []).filter((q) => q.state === 'open').length;
41
- const ask = $('#questionsLink');
42
- const badge = ask.querySelector('.q-badge');
43
- badge.hidden = !open;
44
- badge.textContent = open ? String(open) : '';
45
- ask.setAttribute('aria-label', open ? `Open questions, ${open} open` : 'Open questions');
46
- }
47
-
48
- function renderList(needs) {
49
- const parts = (line) => [
50
- line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
51
- line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
52
- line.action ? `<a href="${esc(line.action.url)}">${escText(line.action.label)}</a>` : '',
53
- ].filter(Boolean).join(' · ');
54
- const w = $('#waiting');
55
- w.hidden = needs.length === 0;
56
- w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
57
- <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
58
- : line.key ? `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
59
- : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${escText(line.title)}</a> <span class="muted small">· review held for you</span></li>`
60
- : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
61
- : `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
62
- }
63
-
64
- // The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
65
- // order; any other line as itself. → [{id, what, html}]
66
- function deckOf(d, needs) {
67
- const items = d.items || [];
68
- const links = BoardUI.linkContext(items);
69
- const owner = (d.owner || []).filter((q) => q.state === 'open');
70
- const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
71
- return needs.flatMap((line) => {
72
- if (line.prompt) return [{ id: `p:${line.prompt.id}`, what: 'Claude asks', html: `<ul class="deck-prompt">${promptItem(line.prompt, prompts.state(line.prompt))}</ul>` }];
73
- const what = line.key ? line.title.split(':')[0] : line.title;
74
- const topic = line.questions && topicOf(line);
75
- const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
76
- const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
77
- if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q, links) }));
78
- const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
79
- : line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
80
- : line.action ? [line.action.label, line.action.url, '']
81
- : line.review ? ['Open the review', line.url, ` data-review-open="${line.review}"`]
82
- : ['Open', line.url, ''];
83
- const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
84
- return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
85
- 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>` }];
86
- });
87
- }
88
- // The marked option, the question's drafted pick, reads Send “<label>”.
89
- const questionHtml = (q, links) => {
90
- const marked = getDraft(q.id).choice;
91
- const optionHtml = (o) => {
92
- const armed = o.id === marked;
93
- const label = armed ? `Send “${esc(o.label)}”?` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
94
- 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 ? `<div class="det">${markdown(o.detail, links)}</div>` : ''}</button>`;
95
- };
96
- return `<h3>${escText(q.question)}</h3>${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
97
- ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
98
- <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
99
- };
100
-
101
- // 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.
102
- let showing = null;
103
- let deck = [];
104
- function renderDeck(d, needs) {
105
- const el = $('#needsDeck');
106
- const before = deck.findIndex((x) => x.id === showing);
107
- deck = deckOf(d, needs);
108
- el.hidden = deck.length === 0;
109
- if (!deck.length) { el.innerHTML = ''; showing = null; return; }
110
- const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
111
- showing = now.id;
112
- const at = deck.indexOf(now);
113
- const open = (d.owner || []).filter((q) => q.state === 'open').length;
114
- const drafted = (d.owner || []).filter((q) => q.state === 'open' && getDraft(q.id).choice != null).length;
115
- BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
116
- <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
117
- ${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>` : ''}
118
- ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
119
- }
120
-
121
- // The deck's index one step from the thing on screen; the ends hold.
122
- const stepFrom = (step) => Math.min(Math.max(deck.findIndex((x) => x.id === showing) + step, 0), deck.length - 1);
123
- B.on(B.root, 'click', (ev) => {
124
- if (!ev.target.closest?.('#needsDeck')) return;
125
- const step = ev.target.closest('[data-deck-prev]') ? -1 : ev.target.closest('[data-deck-next]') ? 1 : 0;
126
- if (step) {
127
- showing = deck[stepFrom(step)].id;
128
- renderNeeds(state.board);
129
- return;
130
- }
131
- const opt = ev.target.closest('button[data-deck-pick]');
132
- if (!opt) return;
133
- const id = opt.closest('[data-deck]').dataset.deck.slice(2);
134
- const choice = opt.dataset.deckPick;
135
- const draft = getDraft(id);
136
- if (draft.choice !== choice) {
137
- setDraft(id, { ...draft, choice });
138
- renderNeeds(state.board);
139
- return;
140
- }
141
- sendAnswer((state.board.owner || []).find((q) => q.id === id), choice, opt);
142
- });
143
-
144
- // The marked option's tap sends it with the question's drafted note and files, as the questions page's Send does, and
145
- // spins until the board shows the question answered, which takes it off the deck. Files still uploading hold it.
146
- async function sendAnswer(q, choice, button) {
147
- const composer = 'q:' + q.id;
148
- if (BoardAttach.busy(composer, B.site.root)) { toast('Wait for the upload to finish'); return; }
149
- const files = BoardAttach.files(composer, B.site.root);
150
- const answer = { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, getDraft(q.id)).trim(), ...(files.length ? { files } : {}) };
151
- const sent = () => { setDraft(q.id, {}); BoardAttach.clear(composer, B.site.root); };
152
- try {
153
- await waitOn(button, BoardUI.pending.within(B.send('owner-answers', { answers: [answer] }, sent), B.stillWaiting));
154
- } catch (e) { B.failed(e); }
155
- }
156
-
157
- // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
158
- let questions = null;
159
- // A click on a pane pill, or on the topic's title, focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
160
- // wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
161
- function openOnBoard(id) {
162
- if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
163
- else if (id.startsWith('wave:') || id.startsWith('file:')) B.focusOnBoard({ slug: id.slice(5) });
164
- }
165
- function fitPane() {
166
- if (wide.matches === Boolean(questions)) return;
167
- if (state.board) renderNeeds(state.board);
168
- if (questions) { closeViewer(); questions.unmount(); questions.slot.remove(); questions = null; return; }
169
- $('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
170
- const slot = $('#needsPane .needs-questions');
171
- questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true, openOnBoard, onTopic: (id) => B.saveFragment({ openItemsCard: id }) }), { slot });
172
- questions.mount();
173
- }
174
- // The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
175
- // or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
176
- // is no pane or it hasn't loaded, so the shell asks again.
177
- function focusQuestion(id) {
178
- closeViewer();
179
- const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
180
- if (!questions?.B.openTopic(topic?.id ?? id)) return false;
181
- const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
182
- pill?.focus();
183
- pill?.scrollIntoView({ block: 'nearest' });
184
- return true;
185
- }
186
-
187
- // 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,
188
- // shell.js go): a top bar of ‹ Open questions, ⤢ and the card's Round pill over the prototype page framed at the
189
- // pane's width, which shows its footer at the bottom of the pane (prototype-switcher.js .ps-pane). ⤢ widens the pane
190
- // over the lanes and back. ‹ returns to Waiting on you with the round's question focused, as Choose this does through
191
- // the shell. viewer: {url, q, widened, sheet} while one is open. → false while there is no pane, so the shell asks again.
192
- let viewer = null;
193
- function openViewer(url) {
194
- if (!questions) return false;
195
- viewer = { url, q: new URL(url, location.href).searchParams.get('q'), widened: viewer?.widened ?? false, sheet: false };
196
- paintViewer();
197
- return true;
198
- }
199
- // board: the board download it shows, which renderNeeds passes before state.board holds it.
200
- function paintViewer(board = state.board) {
201
- const pane = $('#needsPane');
202
- pane.classList.toggle('viewing', Boolean(viewer));
203
- pane.classList.toggle('widened', Boolean(viewer?.widened));
204
- let box = pane.querySelector('.pane-viewer');
205
- if (!viewer) { box?.remove(); return; }
206
- if (!box) {
207
- pane.insertAdjacentHTML('beforeend', '<div class="pane-viewer"><div class="pv-top"></div><iframe class="pv-frame" title="Prototype"></iframe></div>');
208
- box = pane.querySelector('.pane-viewer');
209
- }
210
- const card = (board?.items || []).find((it) => it.prototypes?.rounds?.some((r) => r.q === viewer.q));
211
- const rounds = card?.prototypes.rounds;
212
- box.querySelector('.pv-top').innerHTML = `<button type="button" class="pv-back" data-pv="back">‹ Open questions</button><span class="pv-grow"></span>
213
- <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)}`;
214
- box.querySelector('.ps-sheet')?.remove();
215
- if (viewer.sheet && rounds) box.insertAdjacentHTML('beforeend', BoardUI.roundSheet({ ref: card.ref, rounds, qid: viewer.q }));
216
- const frame = box.querySelector('iframe.pv-frame');
217
- if (frame.getAttribute('src') !== viewer.url) frame.setAttribute('src', viewer.url);
218
- }
219
- function closeViewer() {
220
- if (!viewer) return;
221
- viewer = null;
222
- paintViewer();
223
- }
224
- B.on(B.root, 'click', (ev) => {
225
- if (!viewer || !ev.target.closest?.('#needsPane .pane-viewer')) return;
226
- const round = ev.target.closest('[data-ps-round]')?.dataset.psRound;
227
- if (round) { openViewer(round); return; }
228
- const act = ev.target.closest('[data-pv], [data-ps-top]');
229
- if (!act) return;
230
- if (act.dataset.pv === 'back') { focusQuestion(viewer.q); return; }
231
- if (act.dataset.pv === 'widen') viewer.widened = !viewer.widened;
232
- else viewer.sheet = !viewer.sheet;
233
- paintViewer();
234
- });
235
- // A push to a card's thread (#selectedCard=<key>&reply, board/store.mjs threadUrl), the switcher's for a question
236
- // already picked among them, closes the viewer, so the thread it sent the owner to shows. The older #item=<key>&reply
237
- // reads the same.
238
- B.on(window, 'hashchange', () => {
239
- const at = AppRouter.fragmentOf(location.hash);
240
- if (at.selectedCard && at.reply) closeViewer();
241
- });
242
-
243
- fitPane();
244
- B.on(wide, 'change', fitPane);
245
- B.atUnmount(() => { questions?.unmount(); questions = null; });
246
-
247
- Object.assign(B, { renderNeeds, focusQuestion, openViewer });
248
- });