@lovek4d/kickflip 0.5.3 → 0.6.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 +69 -37
- package/SKILL.md +19 -13
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +156 -83
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +1 -1
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,42 +1,36 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
|
-
const { $, store, state,
|
|
4
|
-
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton,
|
|
3
|
+
const { $, store, state, quieted, sendDraft, links } = B;
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
|
|
5
5
|
|
|
6
|
-
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
|
-
// A /prototype or /
|
|
8
|
-
const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
|
|
6
|
+
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
|
|
7
|
+
// A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
|
|
8
|
+
const SLASH = /(^|\s)\/(grill|prototype|visual)(?=\s|$)/g;
|
|
9
9
|
const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
|
|
10
10
|
|
|
11
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
12
12
|
|
|
13
13
|
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
|
|
14
14
|
// docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
|
|
15
|
+
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
15
16
|
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
16
|
-
// action: builder orange once the card is ready, quiet before.
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
|
|
18
|
+
// (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
|
|
19
|
+
// onto its own line under the row.
|
|
20
|
+
const REVIEW = new Set(['review', 'stale']);
|
|
21
|
+
const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
22
|
+
: REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
|
|
23
|
+
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
24
|
+
const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
|
|
25
|
+
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
|
|
26
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
|
|
27
|
+
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
28
|
+
function syncSends(el, key) {
|
|
25
29
|
const at = el.querySelector('form [data-sends]');
|
|
26
30
|
if (!at) return;
|
|
27
31
|
const item = state.board?.items.find((i) => i.key === key);
|
|
28
32
|
const kind = sendsOf(item);
|
|
29
|
-
|
|
30
|
-
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
|
|
31
|
-
const tools = el.querySelector('form .tools');
|
|
32
|
-
const bolt = tools.querySelector('[data-bolt]');
|
|
33
|
-
if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
|
|
34
|
-
else if (!todo) bolt?.remove();
|
|
35
|
-
const html = doneButton(item);
|
|
36
|
-
const current = el.querySelector('form [data-confirm="finish"]');
|
|
37
|
-
if (current?.outerHTML === html) return;
|
|
38
|
-
current?.remove();
|
|
39
|
-
if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
|
|
33
|
+
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind, item)); at.remove(); }
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
@@ -67,7 +61,7 @@ BoardView.part((B) => {
|
|
|
67
61
|
function renderThread(key, msgs) {
|
|
68
62
|
const el = $(`[data-thread="${CSS.escape(key)}"]`);
|
|
69
63
|
if (!el) return;
|
|
70
|
-
|
|
64
|
+
syncSends(el, key);
|
|
71
65
|
const edits = editsIn(key);
|
|
72
66
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
73
67
|
const view = viewOf(key);
|
|
@@ -79,21 +73,24 @@ BoardView.part((B) => {
|
|
|
79
73
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
80
74
|
if (!el.querySelector('.msgs')) {
|
|
81
75
|
const draft = store.get('draft:' + key, '');
|
|
82
|
-
|
|
76
|
+
// The row: What's left? at the left, the eye, fire, image and attach at its right, and the send buttons
|
|
77
|
+
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
78
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`, { attach: false })}
|
|
83
79
|
<div class="pending-files" data-files></div>
|
|
84
|
-
<div class="row"><
|
|
80
|
+
<div class="row"><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><span class="icons">${protoButton(key)}${grillButton(key)}${visualButton(key)}${attachButton()}</span>${sends('one')}</div></form>`;
|
|
85
81
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
86
82
|
}
|
|
87
|
-
|
|
83
|
+
syncSends(el, key);
|
|
88
84
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
89
85
|
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
90
86
|
B.flashChanged?.(el);
|
|
91
87
|
}
|
|
92
88
|
|
|
93
89
|
// docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
|
|
94
|
-
// The composer's eye asks for a prototype and its
|
|
95
|
-
// typed, as /prototype or /
|
|
96
|
-
|
|
90
|
+
// The composer's eye asks for a prototype, its fire for a grill and its image for a visual: a tap sends the request at
|
|
91
|
+
// once, with whatever is typed, as /prototype, /grill or /visual in the text does on Reply. Nothing typed sends the
|
|
92
|
+
// bare request (docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
93
|
+
const PLACEHOLDER = 'Reply in this thread… (/prototype, /grill or /visual asks for one)';
|
|
97
94
|
|
|
98
95
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
99
96
|
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
@@ -114,7 +111,7 @@ BoardView.part((B) => {
|
|
|
114
111
|
loadedAt.set(key, Date.now());
|
|
115
112
|
state.threadCache.set(key, msgs);
|
|
116
113
|
renderThread(key, msgs);
|
|
117
|
-
B.
|
|
114
|
+
B.saveCopySoon?.();
|
|
118
115
|
})
|
|
119
116
|
.catch(() => {})
|
|
120
117
|
.finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
|
|
@@ -136,14 +133,6 @@ BoardView.part((B) => {
|
|
|
136
133
|
try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
|
|
137
134
|
catch (e) { B.failed(e); }
|
|
138
135
|
});
|
|
139
|
-
// docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
|
|
140
|
-
B.on(B.root, 'click', async (ev) => {
|
|
141
|
-
const b = ev.target.closest('form[data-reply] button[data-bolt]');
|
|
142
|
-
if (!b) return;
|
|
143
|
-
const key = b.dataset.bolt;
|
|
144
|
-
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
|
|
145
|
-
catch { toast('Could not send'); }
|
|
146
|
-
});
|
|
147
136
|
// Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
|
|
148
137
|
async function post(f, flag, button) {
|
|
149
138
|
const ta = f.querySelector('textarea');
|
|
@@ -151,7 +140,8 @@ BoardView.part((B) => {
|
|
|
151
140
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
152
141
|
const id = f.dataset.composer;
|
|
153
142
|
const files = BoardAttach.files(id, B.site.root);
|
|
154
|
-
// An empty box still sends Implement, the card's build action
|
|
143
|
+
// An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
|
|
144
|
+
// starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
155
145
|
const intent = flag ? null : button.dataset.intent || null;
|
|
156
146
|
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
157
147
|
const key = f.dataset.reply;
|
|
@@ -166,24 +156,26 @@ BoardView.part((B) => {
|
|
|
166
156
|
try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
|
|
167
157
|
}
|
|
168
158
|
B.on(B.root, 'click', (ev) => {
|
|
169
|
-
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
170
|
-
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
|
|
159
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill], form[data-reply] button[data-visual]');
|
|
160
|
+
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : b.hasAttribute('data-grill') ? 'grill' : 'visual', b);
|
|
171
161
|
});
|
|
172
162
|
B.on(B.root, 'submit', (ev) => {
|
|
173
163
|
const f = ev.target.closest('form[data-reply]');
|
|
174
164
|
if (!f) return;
|
|
175
165
|
ev.preventDefault();
|
|
166
|
+
// Done on ⌥↵ (app-asks.js) confirms and closes the card as its tap does (app-cards.js).
|
|
167
|
+
if (ev.submitter?.dataset.confirm) { B.confirmTap(ev.submitter); return; }
|
|
176
168
|
post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
|
|
177
169
|
});
|
|
178
170
|
// A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
|
|
179
171
|
B.on(B.root, 'attach-change', (ev) => {
|
|
180
172
|
for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
181
|
-
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
173
|
+
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill], button[data-visual]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
182
174
|
});
|
|
183
175
|
|
|
184
176
|
Object.assign(B, {
|
|
185
177
|
thread, renderThread, loadThread,
|
|
186
178
|
repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
|
|
187
|
-
|
|
179
|
+
syncSends: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncSends(el, key); },
|
|
188
180
|
});
|
|
189
181
|
});
|
package/board/public/app.html
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
<title>Claude</title>
|
|
7
7
|
<script>
|
|
8
8
|
// The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
|
|
9
|
-
// below; app.js loadBoard takes the loads over when it mounts that board.
|
|
9
|
+
// below; app.js loadBoard takes the loads over when it mounts that board. With a copy of the board saved under this
|
|
10
|
+
// build (app-snapshot.js copyVersion), it asks only for the changes since the copy's number. A board's other views load their own, and a
|
|
10
11
|
// site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
|
|
11
12
|
// after one waits for it to load.
|
|
12
13
|
window.BoardBoot = (function () {
|
|
@@ -16,12 +17,16 @@ window.BoardBoot = (function () {
|
|
|
16
17
|
if (!route || route === '_hub') return null;
|
|
17
18
|
try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
|
|
18
19
|
const base = '/' + encodeURIComponent(route) + '/';
|
|
19
|
-
|
|
20
|
+
// A failed answer rides on the error, for the board view's BoardUI.downOf.
|
|
21
|
+
const unanswered = (r) => Object.assign(new Error(String(r.status)), { answer: r });
|
|
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; };
|
|
20
23
|
let open = [];
|
|
21
24
|
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
22
25
|
const focus = /^#item=([^&]+)/.exec(location.hash);
|
|
23
26
|
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
24
|
-
|
|
27
|
+
let since = null;
|
|
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 {}
|
|
29
|
+
return { route, board: get('api/board' + (since === null ? '' : '?since=' + since)), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
|
|
25
30
|
})();
|
|
26
31
|
</script>
|
|
27
32
|
<script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
|
|
@@ -41,7 +46,7 @@ window.BoardBoot = (function () {
|
|
|
41
46
|
<script src="/_hub/app/ui.js"></script>
|
|
42
47
|
<script src="/_hub/app/ui-links.js"></script>
|
|
43
48
|
<script src="/_hub/app/ui-enums.js"></script>
|
|
44
|
-
<script src="/_hub/app/ui-
|
|
49
|
+
<script src="/_hub/app/ui-progress-bar.js"></script>
|
|
45
50
|
<script src="/_hub/app/ui-order.js"></script>
|
|
46
51
|
<script src="/_hub/app/ui-patch.js"></script>
|
|
47
52
|
<script src="/_hub/app/ui-threads.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
|
|
7
7
|
// BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
|
|
8
8
|
// (view-*.js) the same site, $ and recorders.
|
|
9
|
-
// opts,
|
|
9
|
+
// opts, from the app shell (shell.js):
|
|
10
10
|
// signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
|
|
11
|
-
// live {fresh, generatedAt}
|
|
12
|
-
// this board was left fresh and hasn't changed since, so a stored last visit whose board has
|
|
13
|
-
// that generatedAt paints live, with the header time `fresh`, rather than dimmed
|
|
14
11
|
// Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
|
|
15
12
|
window.BoardView = (function () {
|
|
16
13
|
const parts = [];
|
|
@@ -29,6 +26,8 @@ window.BoardView = (function () {
|
|
|
29
26
|
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
30
27
|
// Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
|
|
31
28
|
for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
|
|
29
|
+
// The saved copy moved to IndexedDB (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
|
|
30
|
+
for (const k of old.filter((k) => k === 'boardSnapshots' || k.startsWith('boardSnapshot:'))) localStorage.removeItem(k);
|
|
32
31
|
for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
33
32
|
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
34
33
|
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
@@ -129,6 +128,8 @@ window.BoardView = (function () {
|
|
|
129
128
|
board: null,
|
|
130
129
|
fresh: null,
|
|
131
130
|
openItems: new Set(store.get('openItems', [])),
|
|
131
|
+
// The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
|
|
132
|
+
restored: new Set(store.get('openItems', [])),
|
|
132
133
|
threadCache: new Map(),
|
|
133
134
|
edits: new Map(),
|
|
134
135
|
githubPending: null,
|
|
@@ -144,7 +145,8 @@ window.BoardView = (function () {
|
|
|
144
145
|
const keep = (fn) => { if (B.alive()) keepers.push(fn); };
|
|
145
146
|
const place = places.get(B.route) || null;
|
|
146
147
|
places.delete(B.route);
|
|
147
|
-
Object.assign(B, { store, state, signal,
|
|
148
|
+
Object.assign(B, { store, state, signal, pause: opts.pause, away: opts.away, place, keep });
|
|
149
|
+
BoardUI.usagePause = opts.pause;
|
|
148
150
|
|
|
149
151
|
function mount() {
|
|
150
152
|
if (B.alive()) return;
|
|
@@ -220,6 +222,7 @@ window.BoardView = (function () {
|
|
|
220
222
|
focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
|
|
221
223
|
replyKey = m[2] ? focusKey : null;
|
|
222
224
|
state.openItems.add(focusKey);
|
|
225
|
+
state.restored.delete(focusKey);
|
|
223
226
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
224
227
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
225
228
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
@@ -263,24 +266,24 @@ window.BoardView = (function () {
|
|
|
263
266
|
ta.focus({ preventScroll: true });
|
|
264
267
|
}
|
|
265
268
|
|
|
266
|
-
// The
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (!
|
|
272
|
-
|
|
273
|
-
state.board =
|
|
274
|
-
for (const [key, msgs] of Object.entries(
|
|
275
|
-
state.fresh =
|
|
276
|
-
B.setView(
|
|
269
|
+
// The saved copy paints live before the first load, which asks only for the changes since its number; the lines
|
|
270
|
+
// that catch-up changes flash (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). A board that landed
|
|
271
|
+
// meanwhile stays.
|
|
272
|
+
// old: a copy another build saved, whose board the next load asks for whole.
|
|
273
|
+
function paintCopy(copy, { old = false } = {}) {
|
|
274
|
+
if (!copy || !B.alive() || state.view === 'fresh') return;
|
|
275
|
+
state.oldCopy = old;
|
|
276
|
+
state.board = copy.board;
|
|
277
|
+
for (const [key, msgs] of Object.entries(copy.threads)) state.threadCache.set(key, msgs);
|
|
278
|
+
state.fresh = { at: copy.savedAt, sig: null };
|
|
279
|
+
B.setView('fresh');
|
|
277
280
|
readHash();
|
|
278
281
|
hashRead = true;
|
|
279
282
|
render();
|
|
280
283
|
scrollToFocus(false);
|
|
281
284
|
returnToPlace();
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
B.holdForFlash();
|
|
286
|
+
B.linkUp();
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
// The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
|
|
@@ -291,6 +294,10 @@ window.BoardView = (function () {
|
|
|
291
294
|
globalThis.BoardBoot = null;
|
|
292
295
|
if (boot?.route && boot.route !== B.route) boot = null;
|
|
293
296
|
let preloaded = false;
|
|
297
|
+
// A load nothing answers gives up after this, so an asleep host reads as out of reach.
|
|
298
|
+
const LOAD_TIMEOUT = 20000;
|
|
299
|
+
// A failed answer, carried to showDown for BoardUI.downOf.
|
|
300
|
+
const unanswered = (r) => Object.assign(new Error(`board ${r.status}`), { answer: r });
|
|
294
301
|
async function loadBoard({ via = 'poll' } = {}) {
|
|
295
302
|
const load = B.boardLoad = (B.boardLoad || 0) + 1;
|
|
296
303
|
const started = boot;
|
|
@@ -298,12 +305,15 @@ window.BoardView = (function () {
|
|
|
298
305
|
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
299
306
|
let landed = false;
|
|
300
307
|
try {
|
|
301
|
-
// A reload names the
|
|
302
|
-
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch
|
|
303
|
-
// (docs/adr/2026-11-
|
|
304
|
-
const tag = state.view === 'fresh' ? state.board.version : null;
|
|
305
|
-
const
|
|
306
|
-
|
|
308
|
+
// A reload names the change number of the board on screen: an unchanged board answers 304 with no body
|
|
309
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch of what
|
|
310
|
+
// changed after it (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
|
|
311
|
+
const tag = state.view === 'fresh' && !state.oldCopy ? state.board.version : null;
|
|
312
|
+
const timeout = new AbortController();
|
|
313
|
+
const stopTimer = B.later(LOAD_TIMEOUT, () => timeout.abort());
|
|
314
|
+
const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', signal: timeout.signal, ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
|
|
315
|
+
.then((r) => (r.status === 304 ? null : r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r))))
|
|
316
|
+
.finally(stopTimer));
|
|
307
317
|
if (board) land(board, load); else B.linkUp();
|
|
308
318
|
landed = true;
|
|
309
319
|
if (!preloaded) {
|
|
@@ -312,12 +322,36 @@ window.BoardView = (function () {
|
|
|
312
322
|
}
|
|
313
323
|
} catch (e) {
|
|
314
324
|
B.linkDown();
|
|
325
|
+
if (state.view !== 'fresh' && B.alive()) await showDown(BoardUI.downOf(e.answer || null));
|
|
315
326
|
}
|
|
316
327
|
await Promise.all(threads);
|
|
317
328
|
if (landed) B.landed(via);
|
|
318
329
|
if (state.view === 'fresh') B.endFlash();
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
// A board out of reach with nothing on screen paints the copy another build saved, which the first paint skipped,
|
|
333
|
+
// guarded: a paint that throws leaves the landing, the kickflip mark and down's two lines in the board's place, and
|
|
334
|
+
// the landing retries every 5 s while the board can come back by itself
|
|
335
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
336
|
+
let triedOldCopy = false;
|
|
337
|
+
async function showDown(down) {
|
|
338
|
+
if (!triedOldCopy) {
|
|
339
|
+
triedOldCopy = true;
|
|
340
|
+
const old = await B.readCopy({ anyBuild: true });
|
|
341
|
+
try { paintCopy(old, { old: true }); } catch {
|
|
342
|
+
state.board = null;
|
|
343
|
+
B.setView(null);
|
|
344
|
+
}
|
|
345
|
+
if (!B.alive()) return;
|
|
346
|
+
if (state.view === 'fresh') { B.linkDown(); return; }
|
|
347
|
+
}
|
|
348
|
+
state.down = down;
|
|
349
|
+
$('#downLanding').innerHTML = BoardUI.downLanding(down);
|
|
350
|
+
$('#downLanding').hidden = false;
|
|
351
|
+
B.root.classList.add('down');
|
|
352
|
+
}
|
|
353
|
+
B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
|
|
354
|
+
|
|
321
355
|
// 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
|
|
322
356
|
// before the board on screen leaves it as it is, so a slow load can't undo a newer one; a newer patch from a board
|
|
323
357
|
// no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
|
|
@@ -327,6 +361,12 @@ window.BoardView = (function () {
|
|
|
327
361
|
if (!board) { signalled(); return false; }
|
|
328
362
|
const first = state.view !== 'fresh';
|
|
329
363
|
state.board = board;
|
|
364
|
+
state.oldCopy = false;
|
|
365
|
+
if (state.down) {
|
|
366
|
+
state.down = null;
|
|
367
|
+
$('#downLanding').hidden = true;
|
|
368
|
+
B.root.classList.remove('down');
|
|
369
|
+
}
|
|
330
370
|
B.setView('fresh');
|
|
331
371
|
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
332
372
|
B.linkUp();
|
|
@@ -334,7 +374,7 @@ window.BoardView = (function () {
|
|
|
334
374
|
scrollToFocus();
|
|
335
375
|
returnToPlace();
|
|
336
376
|
B.site.syncBadge();
|
|
337
|
-
B.
|
|
377
|
+
B.saveCopySoon();
|
|
338
378
|
return true;
|
|
339
379
|
}
|
|
340
380
|
|
|
@@ -382,24 +422,28 @@ window.BoardView = (function () {
|
|
|
382
422
|
render();
|
|
383
423
|
}
|
|
384
424
|
Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
385
|
-
const live = paintSnapshot();
|
|
386
425
|
B.headerWired();
|
|
387
|
-
if (live) B.linkUp();
|
|
388
426
|
const sent = BoardUI.sentNotice.take(B.route);
|
|
389
427
|
if (sent) B.toast(sent);
|
|
390
428
|
B.on(document, 'visibilitychange', () => {
|
|
391
429
|
if (document.hidden) return;
|
|
392
430
|
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
393
431
|
});
|
|
394
|
-
loadBoard();
|
|
432
|
+
B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
|
|
395
433
|
B.signal?.(signalled);
|
|
396
434
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
397
435
|
}
|
|
398
436
|
|
|
399
|
-
// shared:
|
|
437
|
+
// shared: {by, full} for someone the board is shared with, whose board opens with a strip saying what they can do there
|
|
438
|
+
// (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md). → the strip's inside
|
|
439
|
+
function sharedStrip({ by, full }) {
|
|
440
|
+
const from = `Shared with you${by ? ` by ${BoardUI.esc(by)}` : ''}.`;
|
|
441
|
+
return full ? `<b>Full.</b> ${from} You can answer and act. Merge and Board settings are ${by ? `${BoardUI.esc(by)}’s` : 'the owner’s'}.`
|
|
442
|
+
: `<b>View only.</b> ${from} You can look and open Visit links.`;
|
|
443
|
+
}
|
|
400
444
|
function markup({ title = '', shared = null } = {}) {
|
|
401
|
-
const strip = shared ? `<p class="view-only-strip"
|
|
402
|
-
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png',
|
|
445
|
+
const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
|
|
446
|
+
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
|
|
403
447
|
<div class="split">
|
|
404
448
|
<main>
|
|
405
449
|
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
@@ -407,6 +451,8 @@ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
|
407
451
|
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
408
452
|
<div id="tryMain"></div>
|
|
409
453
|
<div id="lanes"></div>
|
|
454
|
+
<div id="downLanding" hidden></div>
|
|
455
|
+
${BoardUI.boardFoot()}
|
|
410
456
|
</main>
|
|
411
457
|
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
412
458
|
</div>
|
|
@@ -446,5 +492,5 @@ ${BoardNewTask.markup()}
|
|
|
446
492
|
</dialog>`;
|
|
447
493
|
}
|
|
448
494
|
|
|
449
|
-
return { part, create, page, markup };
|
|
495
|
+
return { part, create, page, markup, sharedStrip };
|
|
450
496
|
})();
|
package/board/public/hub-kit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What the hub page and settings modals share, each through the view's B
|
|
1
|
+
// What the hub page and settings modals share, Board Settings' duration picker included, each through the view's B
|
|
2
2
|
// (app.js BoardView.page), so unmounting the view stops it all: loading and refreshing the view's data, posting its
|
|
3
3
|
// forms for JSON, and this device's notifications row.
|
|
4
4
|
(function () {
|
|
@@ -169,5 +169,65 @@
|
|
|
169
169
|
return { state: (line) => (state && on ? { ...state, sub: `On · ${line}` } : state), busy: () => busy, ready };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
// datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the server.
|
|
173
|
+
const localInput = (d) => {
|
|
174
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
175
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
176
|
+
};
|
|
177
|
+
function customBounds(input, reset = false) {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
input.min = localInput(new Date(now + 60_000));
|
|
180
|
+
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
181
|
+
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
|
|
185
|
+
// `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
|
|
186
|
+
// replaces it with when it ends and a button that posts `name`=off.
|
|
187
|
+
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
|
188
|
+
const { esc } = HubUI;
|
|
189
|
+
if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
|
|
190
|
+
if (!choices) return '';
|
|
191
|
+
const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
192
|
+
return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Wires every duration picker under B.root: Custom opens its picker, and closing it submits its form with the time
|
|
196
|
+
// chosen. → painted(), which a view calls after drawing a picker to set Custom's bounds and suggested time.
|
|
197
|
+
function durationPickers(B) {
|
|
198
|
+
let openPicker = null;
|
|
199
|
+
B.on(B.root, 'click', (e) => {
|
|
200
|
+
const input = e.target.closest?.('[data-dur-at]');
|
|
201
|
+
if (!input) return;
|
|
202
|
+
openPicker = input;
|
|
203
|
+
customBounds(input, input.value === input.defaultValue);
|
|
204
|
+
try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
|
|
205
|
+
});
|
|
206
|
+
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
207
|
+
B.on(B.root, 'pointerdown', (e) => {
|
|
208
|
+
const input = e.target.closest?.('.dur-choices button') && e.target.closest('.dur-chips')?.querySelector('[data-dur-at]');
|
|
209
|
+
if (!input) return;
|
|
210
|
+
openPicker = null;
|
|
211
|
+
input.value = input.defaultValue;
|
|
212
|
+
});
|
|
213
|
+
// Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
|
|
214
|
+
const saveCustom = (input) => {
|
|
215
|
+
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
216
|
+
const at = new Date(input.value);
|
|
217
|
+
if (Number.isNaN(at.getTime())) return;
|
|
218
|
+
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
219
|
+
input.form.requestSubmit();
|
|
220
|
+
openPicker = null;
|
|
221
|
+
input.defaultValue = input.value;
|
|
222
|
+
};
|
|
223
|
+
B.on(B.root, 'change', (e) => {
|
|
224
|
+
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
225
|
+
});
|
|
226
|
+
B.on(B.root, 'focusout', (e) => {
|
|
227
|
+
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
228
|
+
});
|
|
229
|
+
return { painted: () => { for (const input of B.root.querySelectorAll('[data-dur-at]')) customBounds(input, true); } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
globalThis.HubKit = { live, forms, device, durationPicker, durationPickers };
|
|
173
233
|
})();
|
package/board/public/hub.css
CHANGED
|
@@ -135,6 +135,8 @@
|
|
|
135
135
|
.hub-view .lk-form input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
|
|
136
136
|
|
|
137
137
|
.hub-view .ub-banner > button { flex: none; min-height: 32px; }
|
|
138
|
+
.hub-view .hold-banner.ub-paused { border-color: var(--line); background: var(--panel); }
|
|
139
|
+
.hub-view .hold-banner.ub-paused > svg { color: var(--muted); }
|
|
138
140
|
.hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
|
|
139
141
|
.hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
|
|
140
142
|
|
package/board/public/new-task.js
CHANGED
|
@@ -8,14 +8,16 @@ globalThis.BoardNewTask = (function () {
|
|
|
8
8
|
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
9
9
|
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
10
10
|
<div class="pending-files" data-files></div>
|
|
11
|
-
<div class="send-actions">${BoardUI.sendButton('
|
|
11
|
+
<div class="send-actions">${BoardUI.sendButton('Implement', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
|
|
12
12
|
<p class="muted small" id="askStatus" aria-live="polite"></p>
|
|
13
13
|
</form>
|
|
14
14
|
</dialog>`;
|
|
15
15
|
|
|
16
16
|
function wire(B) {
|
|
17
|
-
// A
|
|
18
|
-
|
|
17
|
+
// A View person looks only (shell.js marks the page shared-view without shared-full, and style.css hides the +), so
|
|
18
|
+
// n twice opens nothing.
|
|
19
|
+
const html = document.documentElement.classList;
|
|
20
|
+
if (html.contains('shared-view') && !html.contains('shared-full')) return;
|
|
19
21
|
const { $, store, toast, quieted, sendDraft } = B;
|
|
20
22
|
const { waitOn, keyedSend } = BoardUI;
|
|
21
23
|
const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
|
|
@@ -24,7 +26,7 @@ globalThis.BoardNewTask = (function () {
|
|
|
24
26
|
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
25
27
|
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
26
28
|
BoardAttach.paint('ask', B.site.root);
|
|
27
|
-
// ⌘↵ sends the ask as Discuss and ⌥↵ as
|
|
29
|
+
// ⌘↵ sends the ask as Discuss and ⌥↵ as Implement (ui.js keyedSend).
|
|
28
30
|
B.on($('#askText'), 'keydown', (ev) => {
|
|
29
31
|
const button = keyedSend(ev, $('#askForm'));
|
|
30
32
|
if (!button) return;
|
|
@@ -64,7 +66,7 @@ globalThis.BoardNewTask = (function () {
|
|
|
64
66
|
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
65
67
|
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
66
68
|
const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
67
|
-
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('
|
|
69
|
+
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('down')) { lastN = -Infinity; return; }
|
|
68
70
|
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
69
71
|
lastN = -Infinity;
|
|
70
72
|
ev.preventDefault();
|
|
@@ -75,7 +77,7 @@ globalThis.BoardNewTask = (function () {
|
|
|
75
77
|
B.on($('#sheetClose'), 'click', closeSheet);
|
|
76
78
|
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
77
79
|
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
78
|
-
// Discuss has the session grill or prototype the task at once;
|
|
80
|
+
// Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
|
|
79
81
|
// session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
80
82
|
B.on($('#askForm'), 'submit', async (ev) => {
|
|
81
83
|
ev.preventDefault();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Progress bars</title>
|
|
7
|
+
<link rel="stylesheet" href="colors.css">
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
9
|
+
<style>
|
|
10
|
+
/* The progress-bar review (progress-review.js): the board's lanes, each card one bar or status line drawn by the real
|
|
11
|
+
renderers, its colour the lane's --lane, which the lane's colour pick sets; the open card's editor beneath. */
|
|
12
|
+
.pr-lanes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
13
|
+
@media (max-width: 799px) { .pr-lanes { grid-template-columns: minmax(0, 1fr); } }
|
|
14
|
+
.pr-lane > h2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 6px; font-size: 13px; color: var(--lane); border-bottom: 2px solid var(--lane); padding-bottom: 4px; }
|
|
15
|
+
.pr-lane input[type="color"] { width: 32px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
|
|
16
|
+
.pr-lane .sub.agent:not(.waiting-gray, .quiet) { color: var(--lane); }
|
|
17
|
+
.pr-lane .phase-bars:not(.dim) { --bar-fill: var(--lane); }
|
|
18
|
+
.pr-lane .item[data-open] > summary { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
19
|
+
.pr-lane .card-time .sb-steps .phase-bars { gap: 2px; }
|
|
20
|
+
.pr-editor { margin-top: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
|
|
21
|
+
.pr-editor h2 { margin: 0 0 8px; font-size: 15px; }
|
|
22
|
+
.pr-editor label { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
|
|
23
|
+
.pr-editor :where(input, select) { font-size: 16px; }
|
|
24
|
+
.pr-editor ol { margin: 8px 0 0; padding: 0; list-style: none; }
|
|
25
|
+
.pr-editor li { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
|
26
|
+
.pr-editor li.over { outline: 2px dashed var(--accent); }
|
|
27
|
+
.pr-editor .grip { cursor: grab; color: var(--muted); }
|
|
28
|
+
.pr-send { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<!--header {"lead":"back","title":"Progress bars","backHref":"./","controls":false}-->
|
|
33
|
+
<main>
|
|
34
|
+
<p class="muted">Every progress bar and status line the board draws, in the lane it shows in. Tap one to rename it, move it to another lane or rename and reorder its steps; pick each lane's colour; then Send.</p>
|
|
35
|
+
<div id="review"></div>
|
|
36
|
+
</main>
|
|
37
|
+
<script src="ui.js"></script>
|
|
38
|
+
<script src="ui-links.js"></script>
|
|
39
|
+
<script src="ui-enums.js"></script>
|
|
40
|
+
<script src="ui-progress-bar.js"></script>
|
|
41
|
+
<script src="ui-cards.js"></script>
|
|
42
|
+
<script src="ui-sidebar.js"></script>
|
|
43
|
+
<script src="claude.js"></script>
|
|
44
|
+
<script src="progress-review.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|