@lovek4d/kickflip 0.4.0 → 0.5.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 +32 -16
- package/SKILL.md +8 -6
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/bin/board.mjs +50 -28
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +4 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +6 -89
- package/board/public/app-cards.js +19 -10
- package/board/public/app-core.js +1 -20
- package/board/public/app-reader.js +174 -0
- package/board/public/app-threads.js +49 -35
- package/board/public/app.html +2 -0
- package/board/public/app.js +28 -16
- package/board/public/hub.css +1 -0
- package/board/public/new-task.js +121 -0
- package/board/public/shell.js +4 -0
- package/board/public/sidebar.css +1 -0
- package/board/public/style.css +91 -3
- package/board/public/ui-cards.js +46 -21
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +47 -18
- package/board/public/ui-sidebar.js +9 -3
- package/board/public/ui-threads.js +25 -12
- package/board/public/ui.js +42 -12
- package/board/public/view-frame.js +1 -0
- package/board/public/view-questions.js +45 -16
- package/board/public/view-settings.js +38 -19
- package/board/server.mjs +20 -18
- package/board/store.mjs +15 -13
- package/board/worktree.mjs +20 -20
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +9 -12
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/readiness.mjs +2 -2
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/package.json +1 -1
- package/shared/owner-check.mjs +3 -0
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
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
3
|
const { $, store, state, toast, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton,
|
|
4
|
+
const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
|
|
5
|
+
|
|
6
|
+
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
|
+
// A /prototype or /grill word in a message, the last one winning, and the message without it.
|
|
8
|
+
const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
|
|
9
|
+
const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
|
|
5
10
|
|
|
6
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
12
|
|
|
@@ -48,26 +53,43 @@ BoardView.part((B) => {
|
|
|
48
53
|
const edits = editsIn(key);
|
|
49
54
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
50
55
|
const view = viewOf(key);
|
|
51
|
-
const
|
|
56
|
+
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
|
|
57
|
+
const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
|
|
58
|
+
const sig = JSON.stringify([lines, [...edits.keys()], view]);
|
|
52
59
|
if (el.dataset.sig === sig) return;
|
|
53
60
|
el.dataset.sig = sig;
|
|
54
61
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
55
62
|
if (!el.querySelector('.msgs')) {
|
|
56
63
|
const draft = store.get('draft:' + key, '');
|
|
57
|
-
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="
|
|
64
|
+
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>`)}
|
|
65
|
+
<div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
|
|
58
66
|
<div class="pending-files" data-files></div>
|
|
59
|
-
<div class="row">${attachButton()}${protoButton(key)}
|
|
67
|
+
<div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
|
|
60
68
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
69
|
+
syncFlag(el.querySelector('form'));
|
|
61
70
|
}
|
|
62
71
|
syncDone(el, key);
|
|
63
|
-
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(
|
|
72
|
+
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
64
73
|
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
65
74
|
B.flashChanged?.(el);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
78
|
+
// The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
|
|
79
|
+
// with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
|
|
80
|
+
const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
|
|
81
|
+
const HINT = 'Tap the eye or the fire to request a prototype or a grill';
|
|
82
|
+
const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
|
|
83
|
+
function syncFlag(f) {
|
|
84
|
+
const chosen = f.dataset.flag || null;
|
|
85
|
+
f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
|
|
86
|
+
f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
|
|
87
|
+
const hint = f.querySelector('[data-hint]');
|
|
88
|
+
if (!hint) return;
|
|
89
|
+
const flag = flagOf(f);
|
|
90
|
+
const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
|
|
91
|
+
hint.classList.toggle('on', Boolean(flag));
|
|
92
|
+
hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
|
|
71
93
|
}
|
|
72
94
|
|
|
73
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,7 +121,9 @@ BoardView.part((B) => {
|
|
|
99
121
|
|
|
100
122
|
B.on(B.root, 'input', (ev) => {
|
|
101
123
|
const f = ev.target.closest('form[data-reply]');
|
|
102
|
-
if (!f || ev.target.tagName !== 'TEXTAREA'
|
|
124
|
+
if (!f || ev.target.tagName !== 'TEXTAREA') return;
|
|
125
|
+
syncFlag(f);
|
|
126
|
+
if (quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
103
127
|
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
104
128
|
});
|
|
105
129
|
B.on(B.root, 'click', async (ev) => {
|
|
@@ -109,28 +133,13 @@ BoardView.part((B) => {
|
|
|
109
133
|
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
|
|
110
134
|
catch { toast('Could not send'); }
|
|
111
135
|
});
|
|
112
|
-
B.on(B.root, 'click',
|
|
113
|
-
const b = ev.target.closest('form[data-reply] button[data-proto]');
|
|
136
|
+
B.on(B.root, 'click', (ev) => {
|
|
137
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
114
138
|
if (!b) return;
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
|
|
120
|
-
const n = protoNotice(pending, latest);
|
|
121
|
-
if (!n.bump) { B.notice(n.text); return; }
|
|
122
|
-
if (!(await B.confirm(n.text))) return;
|
|
123
|
-
await B.bumpAsk(pending, b);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
127
|
-
try {
|
|
128
|
-
await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
|
|
129
|
-
b.disabled = true;
|
|
130
|
-
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
131
|
-
toast('Asked Claude for prototypes');
|
|
132
|
-
loadThread(key, { force: true });
|
|
133
|
-
} catch { toast('Could not send'); }
|
|
139
|
+
const f = b.closest('form');
|
|
140
|
+
const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
|
|
141
|
+
if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
|
|
142
|
+
syncFlag(f);
|
|
134
143
|
});
|
|
135
144
|
B.on(B.root, 'submit', async (ev) => {
|
|
136
145
|
const f = ev.target.closest('form[data-reply]');
|
|
@@ -138,15 +147,19 @@ BoardView.part((B) => {
|
|
|
138
147
|
ev.preventDefault();
|
|
139
148
|
const ta = f.querySelector('textarea');
|
|
140
149
|
ta.blur();
|
|
141
|
-
const
|
|
150
|
+
const flag = flagOf(f);
|
|
151
|
+
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
142
152
|
const id = f.dataset.composer;
|
|
143
153
|
const files = BoardAttach.files(id, B.site.root);
|
|
144
|
-
if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
154
|
+
if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
145
155
|
const key = f.dataset.reply;
|
|
156
|
+
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
|
|
146
157
|
try {
|
|
147
158
|
await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
148
|
-
() => B.sentHere(key, () => B.send('item-message',
|
|
159
|
+
() => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
|
|
149
160
|
BoardAttach.clear(id, B.site.root);
|
|
161
|
+
delete f.dataset.flag;
|
|
162
|
+
syncFlag(f);
|
|
150
163
|
loadThread(f.dataset.reply, { force: true });
|
|
151
164
|
} catch { B.notice('Could not send. Is the host awake?'); }
|
|
152
165
|
});
|
|
@@ -154,6 +167,7 @@ BoardView.part((B) => {
|
|
|
154
167
|
B.on(B.root, 'attach-change', (ev) => {
|
|
155
168
|
const b = ev.target.querySelector('button[type=submit]');
|
|
156
169
|
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
170
|
+
if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
|
|
157
171
|
});
|
|
158
172
|
|
|
159
173
|
Object.assign(B, {
|
package/board/public/app.html
CHANGED
|
@@ -64,6 +64,7 @@ window.BoardBoot = (function () {
|
|
|
64
64
|
<script src="/_hub/app/linear-key.js"></script>
|
|
65
65
|
<script src="/_hub/app/sidebar.js"></script>
|
|
66
66
|
<script src="/_hub/app/app.js"></script>
|
|
67
|
+
<script src="/_hub/app/new-task.js"></script>
|
|
67
68
|
<script src="/_hub/app/app-core.js"></script>
|
|
68
69
|
<script src="/_hub/app/app-threads.js"></script>
|
|
69
70
|
<script src="/_hub/app/app-cards.js"></script>
|
|
@@ -72,6 +73,7 @@ window.BoardBoot = (function () {
|
|
|
72
73
|
<script src="/_hub/app/app-needs.js"></script>
|
|
73
74
|
<script src="/_hub/app/app-header.js"></script>
|
|
74
75
|
<script src="/_hub/app/app-arch.js"></script>
|
|
76
|
+
<script src="/_hub/app/app-reader.js"></script>
|
|
75
77
|
<script src="/_hub/app/app-banner.js"></script>
|
|
76
78
|
<script src="/_hub/app/app-snapshot.js"></script>
|
|
77
79
|
<script src="/_hub/app/view-settings.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -86,7 +86,26 @@ window.BoardView = (function () {
|
|
|
86
86
|
}
|
|
87
87
|
toasts.toast(text);
|
|
88
88
|
};
|
|
89
|
-
const
|
|
89
|
+
const store = storeFor(route);
|
|
90
|
+
// iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
|
|
91
|
+
// So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
|
|
92
|
+
const quiet = new Map();
|
|
93
|
+
function quieted(draftKey, ta) {
|
|
94
|
+
const until = quiet.get(draftKey) || 0;
|
|
95
|
+
if (until <= Date.now()) return false;
|
|
96
|
+
if (until !== Infinity) ta.value = '';
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async function sendDraft(draftKey, ta, current, send) {
|
|
100
|
+
ta.blur();
|
|
101
|
+
quiet.set(draftKey, Infinity);
|
|
102
|
+
try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
|
|
103
|
+
store.set(draftKey, '');
|
|
104
|
+
const now = current();
|
|
105
|
+
if (now) now.value = '';
|
|
106
|
+
quiet.set(draftKey, Date.now() + 1000);
|
|
107
|
+
}
|
|
108
|
+
const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
|
|
90
109
|
return {
|
|
91
110
|
B,
|
|
92
111
|
start() { alive = true; },
|
|
@@ -100,7 +119,7 @@ window.BoardView = (function () {
|
|
|
100
119
|
|
|
101
120
|
function create(root, siteRoot, opts = {}) {
|
|
102
121
|
const { B, start: begin, stop } = base(root, siteRoot);
|
|
103
|
-
const store =
|
|
122
|
+
const { store } = B;
|
|
104
123
|
// board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
|
|
105
124
|
// header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
|
|
106
125
|
// {thread, data, text}, data being what ask-edited sends besides the text.
|
|
@@ -158,6 +177,7 @@ window.BoardView = (function () {
|
|
|
158
177
|
// Renders open threads from state.threadCache; loadBoard and opening a card load them.
|
|
159
178
|
function render(load = null) {
|
|
160
179
|
const d = state.board;
|
|
180
|
+
BoardUI.pending.settle(d);
|
|
161
181
|
const title = $(':scope > .top h1');
|
|
162
182
|
if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
|
|
163
183
|
if (d.title && document.title !== d.title) document.title = d.title;
|
|
@@ -360,20 +380,7 @@ window.BoardView = (function () {
|
|
|
360
380
|
<div id="tryMain"></div>
|
|
361
381
|
<div id="lanes"></div>
|
|
362
382
|
</main>
|
|
363
|
-
|
|
364
|
-
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
365
|
-
<form id="askForm" data-composer="ask">
|
|
366
|
-
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
367
|
-
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
368
|
-
<div class="pending-files" data-files></div>
|
|
369
|
-
<div class="tk-seg ask-seg" role="group" aria-label="Before building"><button type="button" id="askDiscuss" aria-pressed="true">Discuss</button><button type="button" id="askQuick" aria-pressed="false">Quick build</button></div>
|
|
370
|
-
<p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
|
|
371
|
-
<div class="row">
|
|
372
|
-
<span class="muted small" id="askStatus"></span>
|
|
373
|
-
<button type="submit" class="primary">Send</button>
|
|
374
|
-
</div>
|
|
375
|
-
</form>
|
|
376
|
-
</dialog>
|
|
383
|
+
${BoardNewTask.markup()}
|
|
377
384
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
378
385
|
<div class="sheet-body">
|
|
379
386
|
<div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
|
|
@@ -396,6 +403,11 @@ window.BoardView = (function () {
|
|
|
396
403
|
<div id="archBody" class="arch-body"></div>
|
|
397
404
|
</div>
|
|
398
405
|
</dialog>
|
|
406
|
+
<dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
|
|
407
|
+
<div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
|
|
408
|
+
<div class="reader-body" tabindex="0"></div>
|
|
409
|
+
<button type="button" class="reader-new" data-reader="end" hidden></button>
|
|
410
|
+
</dialog>
|
|
399
411
|
<dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
|
|
400
412
|
<div class="sheet-body">
|
|
401
413
|
<div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
|
package/board/public/hub.css
CHANGED
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
.hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
|
|
88
88
|
.hub-view .remembered p { margin:0; }
|
|
89
89
|
.hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
|
|
90
|
+
.hub-view :is(.hub-modal, .sheet) code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
90
91
|
.hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
91
92
|
|
|
92
93
|
@media (max-width: 899px) {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The + button and the sheet that sends a new task as an ask event, on every page of a board: the board view's markup
|
|
2
|
+
// (app.js) and each other view of a board (shell.js) put BoardNewTask.markup() in their root and wire it with
|
|
3
|
+
// BoardNewTask.wire(B) once mounted. A board's view sends through B.send, which lands the board the ask changed; any
|
|
4
|
+
// other view sends through its site. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
|
|
5
|
+
globalThis.BoardNewTask = (function () {
|
|
6
|
+
const markup = () => `<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
7
|
+
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
8
|
+
<form id="askForm" data-composer="ask">
|
|
9
|
+
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
10
|
+
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
11
|
+
<div class="pending-files" data-files></div>
|
|
12
|
+
<div class="tk-seg ask-seg" role="group" aria-label="Before building"><button type="button" id="askDiscuss" aria-pressed="true">Discuss</button><button type="button" id="askQuick" aria-pressed="false">Quick build</button></div>
|
|
13
|
+
<p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
|
|
14
|
+
<div class="row">
|
|
15
|
+
<span class="muted small" id="askStatus"></span>
|
|
16
|
+
<button type="submit" class="primary">Send</button>
|
|
17
|
+
</div>
|
|
18
|
+
</form>
|
|
19
|
+
</dialog>`;
|
|
20
|
+
|
|
21
|
+
function wire(B) {
|
|
22
|
+
const { $, store, toast, quieted, sendDraft } = B;
|
|
23
|
+
const { attachButton, waitOn } = BoardUI;
|
|
24
|
+
const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
|
|
25
|
+
// The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
|
|
26
|
+
const sheet = $('#sheet');
|
|
27
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
28
|
+
if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
|
|
29
|
+
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
30
|
+
BoardAttach.paint('ask', B.site.root);
|
|
31
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
32
|
+
if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
|
|
33
|
+
// ⌘↵ (Ctrl+↵ off a Mac) sends the ask from its box.
|
|
34
|
+
B.on($('#askText'), 'keydown', (ev) => {
|
|
35
|
+
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
|
|
36
|
+
ev.preventDefault();
|
|
37
|
+
$('#askForm').requestSubmit();
|
|
38
|
+
});
|
|
39
|
+
$('#askText').value = store.get('draft:ask', '');
|
|
40
|
+
B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
|
|
41
|
+
let stopFitting = null;
|
|
42
|
+
const stopFit = () => { stopFitting?.(); stopFitting = null; };
|
|
43
|
+
B.on(sheet, 'close', stopFit);
|
|
44
|
+
B.atUnmount(stopFit);
|
|
45
|
+
function openSheet() {
|
|
46
|
+
if (sheet.open) return;
|
|
47
|
+
stopFit();
|
|
48
|
+
stopFitting = BoardDialogs.fitSheet(sheet);
|
|
49
|
+
sheet.classList.remove('closing');
|
|
50
|
+
sheet.showModal();
|
|
51
|
+
$('#askText').focus();
|
|
52
|
+
$('#compose').setAttribute('aria-expanded', 'true');
|
|
53
|
+
}
|
|
54
|
+
function closeSheet() {
|
|
55
|
+
if (!sheet.open || sheet.classList.contains('closing')) return;
|
|
56
|
+
$('#compose').setAttribute('aria-expanded', 'false');
|
|
57
|
+
if (reduceMotion.matches) { sheet.close(); return; }
|
|
58
|
+
sheet.classList.add('closing');
|
|
59
|
+
B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
|
|
60
|
+
}
|
|
61
|
+
B.on($('#compose'), 'click', openSheet);
|
|
62
|
+
// n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
|
|
63
|
+
// nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
|
|
64
|
+
// own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
|
|
65
|
+
// defaultPrevented and doesn't count.
|
|
66
|
+
const TWICE_MS = 500;
|
|
67
|
+
let lastN = -Infinity;
|
|
68
|
+
const newTaskKey = (ev) => {
|
|
69
|
+
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
70
|
+
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
71
|
+
const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
72
|
+
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
|
|
73
|
+
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
74
|
+
lastN = -Infinity;
|
|
75
|
+
ev.preventDefault();
|
|
76
|
+
openSheet();
|
|
77
|
+
};
|
|
78
|
+
B.newTaskKey = newTaskKey;
|
|
79
|
+
B.on(document, 'keydown', newTaskKey);
|
|
80
|
+
B.on($('#sheetClose'), 'click', closeSheet);
|
|
81
|
+
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
82
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
83
|
+
// Discuss or Quick build, above Send: Discuss, the default, has the session grill or prototype the task at once; Quick
|
|
84
|
+
// build sends the ask with quick: true, and the session marks the card ready. Each send goes back to Discuss
|
|
85
|
+
// (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
86
|
+
const CHOICE_HELP = {
|
|
87
|
+
discuss: 'Discuss: Claude grills or prototypes it first, then waits for you.',
|
|
88
|
+
quick: 'Quick build: for changes you’re sure about; Claude marks it ready and builds.',
|
|
89
|
+
};
|
|
90
|
+
let quick = false;
|
|
91
|
+
function choose(isQuick) {
|
|
92
|
+
quick = isQuick;
|
|
93
|
+
$('#askDiscuss').setAttribute('aria-pressed', String(!quick));
|
|
94
|
+
$('#askQuick').setAttribute('aria-pressed', String(quick));
|
|
95
|
+
$('#askChoiceHelp').textContent = CHOICE_HELP[quick ? 'quick' : 'discuss'];
|
|
96
|
+
}
|
|
97
|
+
choose(false);
|
|
98
|
+
B.on(B.root, 'click', (ev) => {
|
|
99
|
+
const b = ev.target.closest('#askDiscuss, #askQuick');
|
|
100
|
+
if (b) choose(b.id === 'askQuick');
|
|
101
|
+
});
|
|
102
|
+
B.on($('#askForm'), 'submit', async (ev) => {
|
|
103
|
+
ev.preventDefault();
|
|
104
|
+
$('#askText').blur();
|
|
105
|
+
const text = $('#askText').value.trim();
|
|
106
|
+
const files = BoardAttach.files('ask', B.site.root);
|
|
107
|
+
if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
|
|
108
|
+
$('#askStatus').textContent = '';
|
|
109
|
+
try {
|
|
110
|
+
await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
|
|
111
|
+
BoardAttach.clear('ask', B.site.root);
|
|
112
|
+
choose(false);
|
|
113
|
+
$('#askStatus').textContent = '';
|
|
114
|
+
closeSheet();
|
|
115
|
+
toast('Sent');
|
|
116
|
+
} catch { $('#askStatus').textContent = 'Could not send'; }
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { markup, wire };
|
|
121
|
+
})();
|
package/board/public/shell.js
CHANGED
|
@@ -200,7 +200,11 @@ window.AppShell = (function () {
|
|
|
200
200
|
if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
|
|
201
201
|
sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
|
|
202
202
|
scrollTo(0, 0);
|
|
203
|
+
// Every other page of a board has the board's + and its sheet too; the hub's own pages aren't a board's.
|
|
204
|
+
const plus = !board && boards().has(route);
|
|
205
|
+
if (plus) root.insertAdjacentHTML('beforeend', BoardNewTask.markup());
|
|
203
206
|
v.mount();
|
|
207
|
+
if (plus) BoardNewTask.wire(v.B);
|
|
204
208
|
if (modal) showSettings();
|
|
205
209
|
}
|
|
206
210
|
|
package/board/public/sidebar.css
CHANGED
|
@@ -84,6 +84,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
84
84
|
#failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
|
|
85
85
|
}
|
|
86
86
|
.sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
|
|
87
|
+
.sidebar .sb-row .icon.sb-start-icon { color: var(--accent); }
|
|
87
88
|
|
|
88
89
|
#newBoardSheet .nb-form { min-width: 0; }
|
|
89
90
|
#newBoardSheet .sheet-head { margin-bottom: 8px; }
|
package/board/public/style.css
CHANGED
|
@@ -257,6 +257,10 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
|
|
|
257
257
|
.sheet::backdrop { background: rgba(0,0,0,.35); touch-action: none; }
|
|
258
258
|
/* A phone sheet sits above the bottom bar, which already pads the safe area (--app-bottom). */
|
|
259
259
|
.sheet form { padding: 10px 16px max(12px, calc(env(safe-area-inset-bottom) - var(--app-bottom, 0px))); }
|
|
260
|
+
/* Modals never scroll sideways: content wraps at the modal's width, and overflow-x hidden is only the last guard. */
|
|
261
|
+
.sheet, .settings-modal { overflow-x: hidden; overflow-wrap: anywhere; }
|
|
262
|
+
.settings-body { overflow-x: hidden; overflow-wrap: anywhere; }
|
|
263
|
+
.sheet input, .sheet select, .sheet textarea, .settings-modal input, .settings-modal select, .settings-modal textarea { max-width: 100%; min-width: 0; }
|
|
260
264
|
/* A sheet's last row of buttons stays in view at the foot of its scrolling content. Sticking stops at the scroll
|
|
261
265
|
area's bottom padding, so the shadow covers what scrolls through that padding. */
|
|
262
266
|
.sheet-actions { position: sticky; bottom: 0; z-index: 1; padding-top: 8px; background: var(--panel); box-shadow: 0 40px 0 var(--panel); }
|
|
@@ -343,7 +347,11 @@ button.attach:disabled { opacity: .35; }
|
|
|
343
347
|
@keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--color-notice) 35%, transparent); } }
|
|
344
348
|
@media (prefers-reduced-motion: no-preference) { .phase-bars:not(.dim) i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
|
|
345
349
|
/* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
|
|
346
|
-
button
|
|
350
|
+
/* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
|
|
351
|
+
shorthand stays out so .sub's 12px wins. */
|
|
352
|
+
button.sub { display: block; width: 100%; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
|
|
353
|
+
font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
|
|
354
|
+
button.sub.agent.stalled { color: var(--color-danger); }
|
|
347
355
|
.stalled .working-dot { background: currentColor; }
|
|
348
356
|
.brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
|
|
349
357
|
.brand .hub { grid-row: 1 / span 2; align-self: center; }
|
|
@@ -361,10 +369,39 @@ button.sub.agent.stalled { display: block; width: 100%; padding: 0; border: 0; b
|
|
|
361
369
|
(docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). */
|
|
362
370
|
.sub.agent.planner, .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
|
|
363
371
|
.planner .working-dot { background: currentColor; }
|
|
372
|
+
/* Every status line on an In Progress card is the phase-bar orange, a planner's too, and every build or plan line on a
|
|
373
|
+
Todo card the owner's blue, dot included; a Todo card's planner dashes are that blue, the running one pulsing
|
|
374
|
+
(docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md). */
|
|
375
|
+
.sub.agent.in-progress { color: var(--color-notice); }
|
|
376
|
+
.sub.agent.in-todo { color: var(--accent); }
|
|
377
|
+
.sub.agent.in-progress .working-dot, .sub.agent.in-todo .working-dot { background: currentColor; }
|
|
378
|
+
.phase-bars.plan { --bar-fill: var(--accent); }
|
|
379
|
+
@keyframes pulse-fill-plan { 50% { background-color: color-mix(in srgb, var(--accent) 35%, transparent); } }
|
|
380
|
+
@media (prefers-reduced-motion: no-preference) { .phase-bars.plan:not(.dim) i.now { animation-name: pulse-fill-plan; } }
|
|
364
381
|
.msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
365
|
-
.msg.calls summary { cursor: pointer; color: var(--muted); }
|
|
366
|
-
.msg.calls
|
|
382
|
+
.msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
383
|
+
.msg.calls summary::-webkit-details-marker { display: none; }
|
|
384
|
+
.msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
|
|
385
|
+
.msg.calls ol { margin: 4px 0; padding-left: 23px; }
|
|
367
386
|
.agent-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; margin-right: 5px; vertical-align: 1px; }
|
|
387
|
+
/* Every thread status line's leading mark, icon or dot, sits centred in one 18px box, so the text after it always starts
|
|
388
|
+
at the same place; the box is 12px tall so line heights stay
|
|
389
|
+
(docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md). */
|
|
390
|
+
.mark { display: inline-block; position: relative; width: 18px; height: 12px; margin-right: 5px; vertical-align: -1px; flex: none; }
|
|
391
|
+
.mark > * { position: absolute; inset: 0; margin: auto; }
|
|
392
|
+
.mark .working-dot, .mark .agent-dot { margin: auto; vertical-align: 0; }
|
|
393
|
+
.mark-icon { width: 12px; height: 12px; }
|
|
394
|
+
/* New questions' plain question mark is a size up from its siblings. */
|
|
395
|
+
.mark-icon.big { width: 18px; height: 18px; }
|
|
396
|
+
.msg.new-questions a { color: inherit; }
|
|
397
|
+
/* A message's footer: the tap it came from, then Claude's pick-up to its right. */
|
|
398
|
+
.msg .reply { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; row-gap: 2px; }
|
|
399
|
+
.msg-foot .foot-status { display: inline-flex; align-items: center; }
|
|
400
|
+
/* The thread's eye and fire are toggles: pressed reads as chosen, and the hint under the box says what Send posts. */
|
|
401
|
+
form[data-reply] .tools { display: inline-flex; flex: none; }
|
|
402
|
+
button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
|
|
403
|
+
.flag-hint { margin: 4px 2px 0; min-height: 1.2em; color: var(--muted); font-size: 12px; }
|
|
404
|
+
.flag-hint.on { color: var(--accent); }
|
|
368
405
|
/* INT-30: card line order */
|
|
369
406
|
.line6 { margin-top: 4px; }
|
|
370
407
|
.card-time time { font-variant-numeric: tabular-nums; }
|
|
@@ -527,6 +564,9 @@ main > * { transition: opacity .25s ease; }
|
|
|
527
564
|
.view-questions .opt .lbl { font-weight: 600; }
|
|
528
565
|
.view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
529
566
|
.view-questions .opt .det { color: var(--muted); font-size: 13px; }
|
|
567
|
+
.view-questions .opt .suggested { margin-left: 6px; white-space: nowrap; }
|
|
568
|
+
.view-questions .opt .suggested-why { margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
|
|
569
|
+
.view-questions .opt .suggested-why a { color: var(--accent); }
|
|
530
570
|
.view-questions .note { margin-top: 4px; }
|
|
531
571
|
.view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
|
|
532
572
|
.view-questions .rep { background: var(--claude); border-radius: 9px; padding: 7px 10px; margin-top: 6px; font-size: 13px; }
|
|
@@ -543,9 +583,24 @@ main > * { transition: opacity .25s ease; }
|
|
|
543
583
|
.view-questions details.done button.ghost { font-size: 14px; text-align: left; padding: 6px 2px; }
|
|
544
584
|
.view-questions details.done .trow button { min-width: 0; }
|
|
545
585
|
.view-questions details.done .trow span { flex: none; }
|
|
586
|
+
.view-questions details.resolved-group > summary { list-style: none; min-height: 44px; display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 12px 0; }
|
|
587
|
+
.view-questions details.resolved-group > summary::-webkit-details-marker { display: none; }
|
|
588
|
+
.view-questions details.resolved-group > summary .caret { transition: transform .15s; }
|
|
589
|
+
.view-questions details.resolved-group[open] > summary .caret { transform: rotate(90deg); }
|
|
590
|
+
.view-questions details.resolved-group > summary .names { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
591
|
+
.view-questions .q.resolved button.chip { cursor: pointer; font: inherit; font-size: 12px; min-height: 28px; }
|
|
592
|
+
.view-questions .q.resolved button.chip:disabled { cursor: default; }
|
|
593
|
+
.view-questions .q.resolved .resolved-line { margin: 8px 0; }
|
|
594
|
+
.view-questions .q.resolved .opt { cursor: default; }
|
|
595
|
+
.view-questions .q.resolved .opt .det { display: none; }
|
|
596
|
+
.view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
|
|
597
|
+
.view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
|
|
598
|
+
.view-questions .q.resolved .reask-line { margin-top: 10px; }
|
|
546
599
|
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
|
|
547
600
|
display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
|
|
548
601
|
.view-questions .bar .send-hint { margin-left: auto; }
|
|
602
|
+
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
603
|
+
.view-questions:has(.bar) .fab { bottom: calc(var(--app-bottom, 0px) + var(--fab-edge) + 72px); }
|
|
549
604
|
.view-questions .bar #qstatus { min-width: 0; }
|
|
550
605
|
.view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
|
|
551
606
|
.view-questions .solo { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 4px 0; }
|
|
@@ -735,6 +790,39 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
735
790
|
.main-log-actions { display: flex; justify-content: flex-end; margin-top: 12px; }
|
|
736
791
|
.main-log-actions button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 6px 18px; }
|
|
737
792
|
#mainLogReason { color: var(--color-notice); overflow-wrap: anywhere; }
|
|
793
|
+
/* The reader sheet (app-reader.js): a builder's log or notes, 85% of the height from the bottom. Its body scrolls
|
|
794
|
+
down only: every line wraps (#252). */
|
|
795
|
+
.reader-sheet { height: 85dvh; max-height: 85dvh; padding: 0; overflow: hidden; }
|
|
796
|
+
.reader-sheet[open] { display: flex; flex-direction: column; }
|
|
797
|
+
.reader-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
|
|
798
|
+
.reader-head h2 { flex: none; margin: 0; font-size: 15px; }
|
|
799
|
+
.reader-ref { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
800
|
+
.reader-tools { flex: none; display: inline-flex; align-items: center; gap: 6px; }
|
|
801
|
+
.reader-tools:empty { display: none; }
|
|
802
|
+
.reader-tools button { height: 32px; padding: 0 12px; border-radius: 999px; font-size: 13px; }
|
|
803
|
+
.reader-tools button[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
|
|
804
|
+
.reader-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
|
|
805
|
+
.reader-status::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
|
806
|
+
.reader-status.on { color: var(--accent); }
|
|
807
|
+
.reader-status.on::before { background: var(--accent); animation: reader-pulse 1.4s ease-in-out infinite; }
|
|
808
|
+
@keyframes reader-pulse { 50% { opacity: .35; } }
|
|
809
|
+
.reader-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 6px 0 max(12px, env(safe-area-inset-bottom)); }
|
|
810
|
+
.reader-body > .muted { padding: 8px 14px; margin: 0; }
|
|
811
|
+
.reader-row { padding: 5px 14px; font-size: 13.5px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
|
|
812
|
+
.reader-row.text { padding-top: 9px; }
|
|
813
|
+
.reader-row.end { margin: 6px 14px 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--chip); }
|
|
814
|
+
.reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
|
|
815
|
+
.reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
816
|
+
.reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
817
|
+
.reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
|
|
818
|
+
.reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
819
|
+
.reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
|
|
820
|
+
.reader-md h1 { font-size: 17px; margin: 10px 0 6px; }
|
|
821
|
+
.reader-md h2, .reader-md h3 { font-size: 15px; }
|
|
822
|
+
.reader-md pre, .reader-md code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
823
|
+
.reader-md table { display: block; max-width: 100%; overflow-x: hidden; }
|
|
824
|
+
.reader-new { position: absolute; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); border: 0; border-radius: 999px; padding: 7px 14px; background: var(--accent); color: var(--on-accent); font-size: 13px; box-shadow: 0 4px 14px rgba(0,0,0,.25); }
|
|
825
|
+
.reader-new[hidden] { display: none; }
|
|
738
826
|
|
|
739
827
|
/* The Auto Review lane (ui-cards.js reviewLane): its cards read like the board's, and Posted earlier's lines like a fold's. */
|
|
740
828
|
.rv-card, .rv-line { border-top: 1px solid var(--line); }
|