Public: anyone can read this; the crew can change it.

Design: the board (/board) is built from the design system, and its board-* classes leave §5d #3183

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/3165-design-the-board-board-is-built-from-the → main 2 commits fork

  • Merged. mason/3165-design-the-board-board-is-built-from-the was merged into main as c06ee40.

Review

Sign in to review this change.
13 files changed +354 −433
docs/DESIGN.md +3 −1
@@ -153,6 +153,7 @@ specification.
153153 | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count |
154154 | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged` | — | `●` in the tone |
155155 | **sha** `sha()` | `.sha` | — | first 7 chars | the same 7 chars, dim |
156 | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` |
156157 | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name |
157158 | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar |
158159 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
@@ -204,6 +205,7 @@ specification.
204205 | **run log** | `pre.run-log` | `data-run` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script appends the run's lines from the page's one `/events` stream and follows the bottom until the reader scrolls up; the page never reloads itself | a scrolling pane that follows the tail |
205206 | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents |
206207 | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard |
208 | **board** (`pages/board.ts`) | `.board` (`.board-grid`, `.board-region`) | `data-wall` (across the room: no keys, no pointer, larger), `data-focus=working\|landed\|decided\|today` | the one whole-screen layout: four live regions fill a second monitor ≥1100px and each list scrolls on its own, one column below; inside, only §5 parts (list card rows, badges, meters, `kbd`); a swapped region's heading wears `data-fresh` for a moment | four panes, one focused by its number |
207209
208210 Two things are deliberately **not** in the vocabulary: skeleton loaders (the
209211 server renders whole pages — there is nothing to skeletonise) and a custom
@@ -390,7 +392,7 @@ gone, so this list only shrinks.
390392 - **`style/content.ts`, the finder** — the finder's footnote: `.finder-note`
391393 - **`style/content.ts`, the editor** — the editor's parts beyond the §5 row: `.editor-mirror` `.editor-path` `.editor-preview` `.editor-stack` `.editor-tab` `.mirrored` `.ml`
392394 - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits`
393 - **`pages/board.ts`** — the board, the live wall of the crews at work: `.board` `.board-about` `.board-answer` `.board-app` `.board-app-mode` `.board-app-name` `.board-app-nums` `.board-app-week` `.board-asked` `.board-asks` `.board-bar` `.board-brand` `.board-card` `.board-clock` `.board-count` `.board-decision` `.board-decision-main` `.board-declined` `.board-declined-row` `.board-declined-says` `.board-dot` `.board-elapsed` `.board-fill` `.board-forge` `.board-grid` `.board-group` `.board-h` `.board-inferred` `.board-key` `.board-keys` `.board-kind` `.board-landed` `.board-landed-meta` `.board-landed-title` `.board-list` `.board-live` `.board-outcome` `.board-p` `.board-parked-row` `.board-pillar` `.board-pillars` `.board-pillars-h` `.board-quiet` `.board-region` `.board-run` `.board-run-meta` `.board-run-title` `.board-run-who` `.board-scope` `.board-spend` `.board-spend-detail` `.board-state` `.board-sub` `.board-sub-name` `.board-sub-scope` `.board-subs` `.board-thumb` `.board-top` `.board-when` `.board-window` `.board-window-name` `.board-windows` `.spend-table` `.spend-totals`
395 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
394396 - **`pages/flow.ts`** — /flow's pipeline bars, one `held-${kind}` per reason a stage holds work: `.flow-bar` `.flow-out` `.held-bar` `.held-done_on_main` `.held-key` `.held-open_pull` `.held-parked_twice` `.held-planner` `.held-red_fix` `.held-run_cap` `.held-running` `.held-setting` `.held-stalled` `.held-startable`
395397 - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why`
396398 - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap`
docs/design/baseline.json −9
@@ -1,14 +1,5 @@
11 {
22 "files": {
3 "src/web/pages/board.ts": {
4 "colour": 0,
5 "size": 6,
6 "motion": 3,
7 "forced": 0,
8 "focus": 0,
9 "input": 0,
10 "target": 0
11 },
123 "src/web/pages/conflicts.ts": {
134 "colour": 0,
145 "size": 1,
src/web/client.ts +8 −1
@@ -1926,8 +1926,15 @@ export const boardJs = /* js */ `
19261926
19271927 // ---- the dot: whether the stream is connected ----
19281928 var live = doc.getElementById('board-live');
1929 var TONES = { open: 'success', reconnecting: 'attn' };
19291930 doc.addEventListener('forge:live', function (e) {
1930 if (live && e.detail) live.setAttribute('data-state', e.detail.state);
1931 if (!live || !e.detail) return;
1932 live.setAttribute('data-state', e.detail.state);
1933 var dot = live.querySelector('.dot');
1934 var tone = TONES[e.detail.state];
1935 if (!dot) return;
1936 if (tone) dot.setAttribute('data-tone', tone);
1937 else dot.removeAttribute('data-tone');
19311938 });
19321939
19331940 // ---- keys: f fullscreen, 1–4 one region, 0 all. Escape is the browser's. ----
src/web/pages/board.ts +266 −403
@@ -77,7 +77,19 @@ import { repoBase, signInFirst } from "../chrome.ts";
7777 import { boardJs, liveRegionsJs } from "../client.ts";
7878 import { html, when, type Html } from "../html.ts";
7979 import { page, redirect } from "../layout.ts";
80 import { avatar, icon, time, tokenCount } from "../ui.ts";
80 import {
81 avatar,
82 badge,
83 dot,
84 icon,
85 kbd,
86 listCard,
87 meter,
88 pill,
89 time,
90 tokenCount,
91 type Tone,
92 } from "../ui.ts";
8193 import { voiceFor } from "../studio/voice.ts";
8294 import {
8395 perShippedWords,
@@ -117,6 +129,22 @@ function stateWord(run: Run): StateWord {
117129 return run.notBefore === null ? "queued" : "paused";
118130 }
119131
132 /** How a run's state word wears its badge: working is what moves. */
133 const STATE_LOOK: Record<StateWord, Parameters<typeof badge>[1]> = {
134 working: { tone: "attn" },
135 queued: { variant: "secondary" },
136 paused: { variant: "outline" },
137 };
138
139 /** What came of a decision, in the tone of the thing it came to. */
140 const OUTCOME_TONE: Readonly<Record<string, Tone>> = {
141 shipped: "success",
142 green: "success",
143 red: "danger",
144 shown: "attn",
145 closed: "attn",
146 };
147
120148 const KIND_WORDS: Record<DecisionKind, string> = {
121149 next: "next issue",
122150 land: "ship or show",
@@ -149,10 +177,6 @@ function reviewWords(stats: ReviewStats): string | null {
149177 return parts.length === 0 ? null : parts.join(" · ");
150178 }
151179
152 /** A whole percentage, for the width rules in `boardCss`. */
153 const pct = (n: number): number =>
154 Math.max(0, Math.min(100, Math.round(n * 100)));
155
156180 /** The apps on the board: every one the viewer can see, or the named few. */
157181 interface BoardScope {
158182 readonly repos: readonly Repo[];
@@ -213,34 +237,36 @@ export function boardPage(ctx: Ctx): Response {
213237 const wall = ctx.url.searchParams.has("wall");
214238
215239 const body = html`<div class="board" ${wall ? html`data-wall=""` : null}>
216 <header class="board-top">
217 <a class="board-brand" href="/"><span class="mark"></span>forge</a>
218 <span class="board-scope"
240 <header class="bar">
241 <a class="wordmark" href="/"><span class="mark"></span>forge</a>
242 <span class="muted small hide-sm" id="board-scope"
219243 >${
220244 repos.length === 1
221245 ? repos[0]!.name
222246 : `${repos.length} app${repos.length === 1 ? "" : "s"}`
223247 }</span
224248 >
225 <a class="board-asked" href="/asked">What you asked for</a>
249 <a class="muted small" href="/asked">What you asked for</a>
226250 <span class="spacer"></span>
227 <span class="board-keys" aria-hidden="true"
228 ><kbd>f</kbd> fullscreen <kbd>1</kbd>–<kbd>4</kbd> one region
229 <kbd>0</kbd> all</span
251 ${
252 wall
253 ? null
254 : html`<span class="muted small hide-sm" aria-hidden="true"
255 >${kbd(["f"])} fullscreen ${kbd(["1"])}–${kbd(["4"])} one region
256 ${kbd(["0"])} all</span
257 >`
258 }
259 <span id="board-live" data-state="connecting" title="The live stream"
260 >${dot()}</span
230261 >
231 <span
232 class="board-live"
233 id="board-live"
234 data-state="connecting"
235 title="The live stream"
236 ><span class="board-dot"></span
237 ></span>
238 <time class="board-clock" id="board-clock"></time>
262 <time class="num small muted" id="board-clock"></time>
239263 </header>
240 ${forgeStrip(ctx)} ${declinedStrip(ctx, scope)} ${reviewStrip(ctx, scope)}
241 ${asksStrip(ctx)}
242 ${returnsStrip(ctx, scope)}
243 ${windowsStrip(ctx)}
264 <div class="stack">
265 ${forgeStrip(ctx)} ${declinedStrip(ctx, scope)} ${reviewStrip(ctx, scope)}
266 ${asksStrip(ctx)}
267 ${returnsStrip(ctx, scope)}
268 ${windowsStrip(ctx)}
269 </div>
244270 <div class="board-grid">
245271 ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)}
246272 ${today(ctx, scope)}
@@ -275,24 +301,45 @@ function region(
275301 data-live="${live}"
276302 aria-labelledby="${id}-h"
277303 >
278 <h2 class="board-h" id="${id}-h">
279 <span class="board-key" aria-hidden="true">${key}</span>${title}
280 ${when(
281 rows.length > 0,
282 () => html`<span class="board-count">${rows.length}</span>`,
283 )}
304 <h2 id="${id}-h">
305 <kbd aria-hidden="true">${key}</kbd>${title}
306 ${when(rows.length > 0, () => pill(rows.length))}
284307 </h2>
285308 ${lead}
286 ${
287 rows.length === 0
288 ? html`<p class="board-quiet">${quiet}</p>`
289 : html`<ul class="board-list">
290 ${rows}
291 </ul>`
292 }
309 ${rows.length === 0 ? html`<p class="muted">${quiet}</p>` : listCard(rows)}
293310 </section>`;
294311 }
295312
313 /**
314 * One row of a region: §5's list row, keyed so a swap is the same row, with
315 * a line of meta for each fact it has — an absent fact is no line — and
316 * whatever sits under them (a meter) after.
317 */
318 function boardRow(o: {
319 key: string;
320 href: string;
321 lead?: Html;
322 title: Html | string;
323 titleAttr?: string;
324 meta: readonly (Html | string | null)[];
325 under?: Html;
326 trail?: Html;
327 }): Html {
328 return html`<li>
329 <a class="row" data-key="${o.key}" href="${o.href}">
330 ${o.lead ? html`<span class="row-lead">${o.lead}</span>` : null}
331 <span class="row-main">
332 <span class="row-title" ${o.titleAttr ? html`title="${o.titleAttr}"` : null}
333 >${o.title}</span
334 >
335 ${o.meta.map((m) => (m === null ? null : html`<span class="row-meta">${m}</span>`))}
336 ${o.under ?? null}
337 </span>
338 ${o.trail ? html`<span class="row-trail">${o.trail}</span>` : null}
339 </a>
340 </li>`;
341 }
342
296343 /** An app's address as the big view wrote it: `owner/name`, nothing else. */
297344 const APP_SLUG = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
298345
@@ -317,7 +364,7 @@ export function bigRedirect(ctx: Ctx): Response {
317364 function sentinelSpan(app: Ctx["app"]): Html {
318365 const n = sentinelNotice(app.db);
319366 if (n === null) return html``;
320 return html`<a class="board-forge-need" id="sentinel" href="${n.href}">${n.text}</a>`;
367 return html`<a data-need id="sentinel" href="${n.href}">${dot("attn")} ${n.text}</a>`;
321368 }
322369
323370 /**
@@ -336,7 +383,7 @@ function forgeStrip(ctx: Ctx): Html | null {
336383 const ok = v.ok && fh.ok && verdict === undefined;
337384 const lane = laneLine(v.lane);
338385 return html`<div
339 class="board-forge"
386 class="bar small muted"
340387 id="forge"
341388 data-live="run.queued,run.started,run.finished,app.*"
342389 data-ok="${ok ? "yes" : "no"}"
@@ -344,16 +391,16 @@ function forgeStrip(ctx: Ctx): Html | null {
344391 role="status"
345392 >
346393 ${sentinelSpan(ctx.app)}
347 ${rollout === null || said(rollout) ? "" : html`<span class="board-forge-need">${rollout}</span>`}
348 ${verdict === undefined ? "" : html`<span class="board-forge-need">${verdictLine(verdict)}</span>`}
349 ${others.map((i) => html`<span class="board-forge-need">${i.key}: ${i.message}</span>`)}
350 ${lane === null ? "" : html`<span class="board-forge-lane">Lane: ${lane}</span>`}
394 ${rollout === null || said(rollout) ? "" : html`<span data-need>${dot("attn")} ${rollout}</span>`}
395 ${verdict === undefined ? "" : html`<span data-need>${dot("attn")} ${verdictLine(verdict)}</span>`}
396 ${others.map((i) => html`<span data-need>${dot("attn")} ${i.key}: ${i.message}</span>`)}
397 ${lane === null ? "" : html`<span>Lane: ${lane}</span>`}
351398 ${
352399 ok
353 ? html`<span class="board-forge-ok">All fine</span>`
400 ? html`<span>${dot("success")} All fine</span>`
354401 : v.degraded.map(
355402 (d) =>
356 html`<a class="board-forge-need" href="${d.href}">${needLine(d)}</a>`,
403 html`<a data-need href="${d.href}">${dot("attn")} ${needLine(d)}</a>`,
357404 )
358405 }
359406 </div>`;
@@ -394,14 +441,16 @@ function reviewStrip(ctx: Ctx, scope: BoardScope): Html | null {
394441 if (!none && waits.length === 0) return [];
395442 const oldest = waits.map((w) => w.since).sort()[0];
396443 return [
397 html`<a class="board-asks" href="/asks"
444 html`<a class="muted small" href="/asks"
398445 ><b>${repo.name}</b>: ${none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${waits.length === 0
399446 ? null
400447 : html` · <b>${waits.length}</b> waiting since ${oldest!.slice(0, 10)}`}</a
401448 >`,
402449 ];
403450 });
404 return lines.length === 0 ? null : html`<div id="review-waits">${lines}</div>`;
451 return lines.length === 0
452 ? null
453 : html`<div class="stack stack-tight" id="review-waits">${lines}</div>`;
405454 }
406455
407456 // ---- what asked you, and what came of it ----
@@ -421,7 +470,7 @@ function asksStrip(ctx: Ctx): Html | null {
421470 const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString();
422471 const m = askMeasure(ctx.app.db, user.id, since);
423472 if (m.raised + m.acted + m.dismissed + m.expired === 0) return null;
424 return html`<a class="board-asks" id="asks-measure" href="/asks"
473 return html`<a class="muted small" id="asks-measure" href="/asks"
425474 >Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted
426475 on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a
427476 >`;
@@ -458,7 +507,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
458507 });
459508 if (r.spent.runs === 0) return null;
460509 const href = `/board/returns${apps ? `?apps=${encodeURIComponent(apps)}` : ""}`;
461 return html`<a class="board-asks" id="returns-line" href="${href}"
510 return html`<a class="muted small" id="returns-line" href="${href}"
462511 >Tokens, ${ASKS_WINDOW_DAYS} days: <b>${usd(r.spent.usd)}</b> → <b>${r.landed}</b> landed
463512 ${r.onLanded.perChange.changes === 0
464513 ? null
@@ -489,7 +538,7 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null {
489538 const parked = parkedTwice(ctx.app.db, owned);
490539 if (declines.length === 0 && parked.length === 0) return null;
491540 return html`<div
492 class="board-declined"
541 class="stack"
493542 id="declined"
494543 data-live="run.queued,run.started,run.finished,issue.*,pull.*"
495544 role="status"
@@ -497,37 +546,42 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null {
497546 ${declines.map((d) => {
498547 const base = `/${d.repo.ownerName}/${d.repo.name}`;
499548 return html`<form
500 class="board-declined-row"
549 class="card"
550 data-tone="attn"
501551 method="post"
502552 action="${base}/issues/${d.issue.number}/comments"
503553 >
504 <span class="board-declined-says"
505 ><b>${d.repo.name}</b>: the planner declined the direction and waits
506 for you. <q>${d.words}</q></span
507 >
508 <input type="hidden" name="back" value="board" />
509 <input
510 class="input"
511 name="body"
512 required
513 aria-label="Answer the planner on #${d.issue.number}"
514 placeholder="Answer the planner"
515 />
516 <button class="btn" data-variant="primary" type="submit">Answer</button>
554 <div class="edit-row">
555 <span class="grow"
556 ><b>${d.repo.name}</b>: the planner declined the direction and waits
557 for you. <q>${d.words}</q></span
558 >
559 <input type="hidden" name="back" value="board" />
560 <input
561 class="input"
562 name="body"
563 required
564 aria-label="Answer the planner on #${d.issue.number}"
565 placeholder="Answer the planner"
566 />
567 <button class="btn" data-variant="primary" type="submit">Answer</button>
568 </div>
517569 </form>`;
518570 })}
519571 ${parked.map((issue) => {
520572 const repo = scope.byId.get(issue.repoId)!;
521 return html`<div class="board-declined-row board-parked-row">
522 <span class="board-declined-says"
523 ><b>${repo.name}</b>: the crew parked #${issue.number} twice and
524 handed it to the planner —
525 ${truncateAtWord(issue.title, TITLE_MAX)}.
526 ${issue.reasons.map(
527 (reason) => html`<q>${truncateAtWord(reason, REASON_MAX)}</q> `,
528 )}</span
529 >
530 <a class="btn" href="${repoBase(repo)}/issues/${issue.number}">Open</a>
573 return html`<div class="card" data-tone="attn" data-parked="">
574 <div class="edit-row">
575 <span class="grow"
576 ><b>${repo.name}</b>: the crew parked #${issue.number} twice and
577 handed it to the planner —
578 ${truncateAtWord(issue.title, TITLE_MAX)}.
579 ${issue.reasons.map(
580 (reason) => html`<q>${truncateAtWord(reason, REASON_MAX)}</q> `,
581 )}</span
582 >
583 <a class="btn" href="${repoBase(repo)}/issues/${issue.number}">Open</a>
584 </div>
531585 </div>`;
532586 })}
533587 </div>`;
@@ -538,7 +592,7 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null {
538592 /** Figures in a row, `inferred` said once when every one is, else on each. */
539593 function figures(parts: readonly FigureWords[]): Html {
540594 const all = parts.every((p) => p.inferred);
541 const tag = html`<i class="board-inferred">inferred</i>`;
595 const tag = badge("inferred", { variant: "outline" });
542596 return html`${parts.map(
543597 (p, n) =>
544598 html`${n > 0 ? " · " : null}<span
@@ -576,58 +630,53 @@ function windowsStrip(ctx: Ctx): Html | null {
576630 const st = all.get(s.id);
577631 if (st === undefined) return null;
578632 return html`<a
579 class="board-sub"
633 class="card"
580634 href="/settings/subscriptions"
581635 data-key="sub:${s.id}"
582636 >
583 <span class="board-sub-name">${s.name}</span>
584 ${st.windows.map((w) => {
585 const words = windowWords(w);
586 return html`<span class="board-window" data-window="${w.window}"
587 ><span class="board-window-name">${words.name}</span>
588 ${
589 words.usedPct === null
590 ? null
591 : html`<span class="board-bar"
592 ><span class="board-fill" data-w="${words.usedPct}"></span
593 ></span>`
594 }
595 <span class="board-window-nums"
596 >${figures([words.used, words.remaining, words.resets])}${when(
597 w.tokensIn + w.tokensOut > 0,
598 () => html` · ${tokenCount(w.tokensIn + w.tokensOut, "counted")}`,
599 )}</span
600 ></span
601 >`;
602 })}
603 ${when(
604 scopeWords(s.id) !== "",
605 () => html`<span class="board-sub-scope">for ${scopeWords(s.id)}</span>`,
606 )}
637 <span class="card-body bar small muted">
638 <b>${s.name}</b>
639 ${st.windows.map((w) => {
640 const words = windowWords(w);
641 return html`<span class="bar" data-window="${w.window}"
642 >${words.name}
643 ${
644 words.usedPct === null
645 ? null
646 : meter(
647 words.usedPct,
648 `${words.name} used`,
649 words.usedPct >= 100 ? "attn" : undefined,
650 )
651 }
652 <span class="num"
653 >${figures([words.used, words.remaining, words.resets])}${when(
654 w.tokensIn + w.tokensOut > 0,
655 () => html` · ${tokenCount(w.tokensIn + w.tokensOut, "counted")}`,
656 )}</span
657 ></span
658 >`;
659 })}
660 ${when(scopeWords(s.id) !== "", () => html`<span>for ${scopeWords(s.id)}</span>`)}
661 </span>
607662 </a>`;
608663 };
609664 const crew = pool.filter((s) => s.id === PLATFORM || scopes.has(s.id));
610665 const apart = pool.filter((s) => s.id !== PLATFORM && !scopes.has(s.id));
666 const group = (name: string, key: string, subs: readonly Subscription[]): Html =>
667 html`<div class="stack stack-tight" data-group="${key}">
668 <span class="muted small">${name}</span>${subs.map(card)}
669 </div>`;
611670 return html`<div
612 class="board-windows"
671 class="stack"
613672 id="windows"
614673 data-live="run.queued,run.started,run.finished,usage.*"
615674 aria-label="Subscription windows"
616675 >
617 <div class="board-subs" data-group="crew">
618 <span class="board-group">The crew's</span>${crew.map(card)}
619 </div>
620 ${when(
621 apart.length > 0,
622 () =>
623 html`<div class="board-subs" data-group="apart">
624 <span class="board-group">Not offered to the crew</span>${apart.map(
625 card,
626 )}
627 </div>`,
628 )}
629 <details class="board-spend-detail" id="spend-detail">
630 <summary>Spend by credential, app, agent, issue and kind</summary>
676 ${group("The crew's", "crew", crew)}
677 ${when(apart.length > 0, () => group("Not offered to the crew", "apart", apart))}
678 <details id="spend-detail">
679 <summary class="muted small">Spend by credential, app, agent, issue and kind</summary>
631680 ${spendView(readSpend(ctx.app, now))}
632681 </details>
633682 </div>`;
@@ -668,33 +717,24 @@ function working(ctx: Ctx, scope: BoardScope): Html {
668717 );
669718 const issue = run.issueId === null ? undefined : about.get(run.issueId);
670719 const word = stateWord(run);
671 return html`<li>
672 <a
673 class="board-card board-run"
674 data-state="${word}"
675 data-key="run:${run.id}"
676 href="${repoBase(repo)}/runs/${run.id}"
677 >
678 ${avatar(who)}
679 <span class="board-run-who"><b>${who}</b> · ${repo.name}</span>
680 <span class="board-elapsed" data-started="${at(run)}"></span>
681 <span class="board-run-title"
682 >${issue?.title ?? (run.summary || "on the repository")}</span
683 >
684 <span class="board-run-meta">
685 <span class="board-state">${word}</span>
686 <span
687 >${
688 word === "paused" ? "resumes soon" : reasonWords(run.reason)
689 }</span
690 >
691 ${when(
720 return boardRow({
721 key: `run:${run.id}`,
722 href: `${repoBase(repo)}/runs/${run.id}`,
723 lead: avatar(who, "md"),
724 title: issue?.title ?? (run.summary || "on the repository"),
725 titleAttr: issue?.title ?? run.summary,
726 meta: [
727 html`<b>${who}</b> · ${repo.name} ·
728 ${word === "paused" ? "resumes soon" : reasonWords(run.reason)}${when(
692729 run.costUsd > 0 && scope.ownerFacts.has(repo.id),
693 () => html`<span class="num">${money(run.costUsd)}</span>`,
694 )}
695 </span>
696 </a>
697 </li>`;
730 () => html` · <span class="num">${money(run.costUsd)}</span>`,
731 )}`,
732 ],
733 trail: html`${badge(word, STATE_LOOK[word])}<span
734 class="num"
735 data-started="${at(run)}"
736 ></span>`,
737 });
698738 });
699739 return region(
700740 "working",
@@ -742,30 +782,18 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
742782 scope.ownerFacts.has(repo.id),
743783 );
744784 const told = toldBy(db, pull);
745 return html`<li>
746 <a
747 class="board-card board-landed"
748 data-key="pull:${pull.id}"
749 href="${repoBase(repo)}/pulls/${pull.number}"
750 >
751 <span class="board-thumb"
752 >${
753 picture === undefined
754 ? icon("merge")
755 : html`<img
756 src="${picture}"
757 width="34"
758 height="46"
759 alt=""
760 loading="lazy"
761 />`
762 }</span
763 >
764 <span class="board-landed-title">${pull.title}</span>
765 <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span>
766 <span class="board-landed-meta">${repo.name} · ${author}${told === null ? "" : html` · told by ${told}`}</span>
767 </a>
768 </li>`;
785 return boardRow({
786 key: `pull:${pull.id}`,
787 href: `${repoBase(repo)}/pulls/${pull.number}`,
788 lead:
789 picture === undefined
790 ? icon("merge")
791 : html`<img src="${picture}" width="34" height="46" alt="" loading="lazy" />`,
792 title: pull.title,
793 titleAttr: pull.title,
794 meta: [html`${repo.name} · ${author}${told === null ? "" : html` · told by ${told}`}`],
795 trail: time(pull.mergedAt ?? pull.createdAt),
796 });
769797 });
770798 return region(
771799 "landed",
@@ -803,18 +831,15 @@ function byPillar(ctx: Ctx, scope: BoardScope): Html | null {
803831 ? milestoneHref(base, umbrella.get(name)!)
804832 : `${base}/issues?label=${encodeURIComponent(name)}`;
805833 return [
806 html`<p class="board-pillars" data-app="${repo.name}">
807 <span class="board-pillars-h"
834 html`<p class="bar small muted" data-pillars="" data-app="${repo.name}">
835 <span
808836 >${scope.repos.length === 1 ? "" : `${repo.name} · `}merged in
809837 ${PILLAR_DAYS} days</span
810838 >
811839 ${view.merged.map((count) =>
812840 count.pillar === null
813 ? html`<span class="board-pillar" data-zero="no"
814 >${NO_PILLAR} <b>${count.merged}</b></span
815 >`
841 ? html`<span data-zero="no">${NO_PILLAR} <b>${count.merged}</b></span>`
816842 : html`<a
817 class="board-pillar"
818843 data-zero="${count.merged === 0 ? "yes" : "no"}"
819844 href="${hrefOf(count.pillar)}"
820845 >${count.pillar} <b>${count.merged}</b></a
@@ -829,7 +854,7 @@ function byPillar(ctx: Ctx, scope: BoardScope): Html | null {
829854 // ---- decided ----
830855
831856 /** A hand-over's row says why, and what the decider saw (`crew/handover.ts`). */
832 function handContextLine(d: Decision): Html {
857 function handContextLine(d: Decision): string {
833858 const s = d.state as Partial<HandContext> & { reason?: string };
834859 const seen = [
835860 // One person's pins and mutes stay out of a line other owners read.
@@ -839,9 +864,7 @@ function handContextLine(d: Decision): Html {
839864 `${s.recent?.length ?? 0} past decisions`,
840865 `${s.evidence?.reasons.length ?? 0} park notes`,
841866 ].filter((x): x is string => x !== null);
842 return html`<p class="muted small board-context">
843 ${s.reason ?? d.answer} Saw: ${s.direction ? `the direction, ` : ""}${seen.join(", ")}.
844 </p>`;
867 return `${s.reason ?? d.answer} Saw: ${s.direction ? `the direction, ` : ""}${seen.join(", ")}.`;
845868 }
846869
847870 /** The most rows a ledger view lists; past it the view says how many it left out. */
@@ -888,34 +911,23 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
888911 : `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`;
889912 const answer =
890913 d.kind === "next" && issue !== undefined ? `#${issue.number}` : d.answer;
891 return html`<li>
892 <a
893 class="board-card board-decision"
894 data-key="decision:${d.id}"
895 href="${href}"
896 data-kind="${d.kind}"
897 >
898 <span class="board-kind">${KIND_WORDS[d.kind]}</span>
899 <span class="board-decision-main">
900 <span class="board-answer">${answer}</span>
901 <span class="board-about"
902 >${issue?.title ?? repo.name} ·
903 ${d.by === "rule" ? "by the rule" : `by ${d.by}`}</span
904 >
905 </span>
906 <span class="board-p" title="${Math.round(d.p * 100)}% sure">
907 <span class="board-bar"
908 ><span class="board-fill" data-w="${pct(d.p)}"></span
909 ></span>
910 <span class="num">${Math.round(d.p * 100)}%</span>
911 </span>
912 <span class="board-outcome" data-outcome="${d.outcome ?? ""}"
913 >${d.outcome ?? "—"}</span
914 >
915 <span class="board-when">${time(d.createdAt)}</span>
916 </a>
917 ${d.kind === "hand" ? handContextLine(d) : null}
918 </li>`;
914 const sure = Math.round(d.p * 100);
915 const hand = d.kind === "hand" ? handContextLine(d) : null;
916 return boardRow({
917 key: `decision:${d.id}`,
918 href,
919 title: answer,
920 meta: [
921 `${KIND_WORDS[d.kind]} · ${issue?.title ?? repo.name} · ${d.by === "rule" ? "by the rule" : `by ${d.by}`}`,
922 hand === null ? null : html`<span title="${hand}">${hand}</span>`,
923 ],
924 under: meter(sure, `${sure}% sure`),
925 trail: html`${
926 d.outcome === null
927 ? html`<span class="muted">—</span>`
928 : badge(d.outcome, { tone: OUTCOME_TONE[d.outcome] ?? "default" })
929 }<span class="num muted small" title="${sure}% sure">${sure}%</span>${time(d.createdAt)}`,
930 });
919931 });
920932 return region(
921933 "decided",
@@ -926,7 +938,7 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
926938 pile === null ? "Nothing decided yet." : PILE_EMPTY[pile],
927939 pile === null
928940 ? null
929 : html`<p class="muted small board-context">
941 : html`<p class="muted small">
930942 ${PILE_LEAD[pile]}${total > decisions.length
931943 ? ` Showing the newest ${decisions.length} of ${total}.`
932944 : ""}
@@ -953,7 +965,7 @@ function deciderLine(ctx: Ctx, scope: BoardScope, since: string): Html | null {
953965 if (n.closed + n.deferred + n.asked === 0) return null;
954966 const link = (pile: DeciderPile) =>
955967 html`<a href="${boardHref(ctx, pile)}#decided">${n[pile]}</a>`;
956 return html`<p class="board-context muted small" id="decider-day">
968 return html`<p class="muted small" id="decider-day">
957969 The decider closed ${link("closed")}, deferred ${link("deferred")}, asked
958970 you about ${link("asked")}.
959971 </p>`;
@@ -998,44 +1010,27 @@ function today(ctx: Ctx, scope: BoardScope): Html {
9981010 : s.paused === "no crew"
9991011 ? "no crew"
10001012 : repo.autonomy;
1001 return html`<li>
1002 <a
1003 class="board-card board-app"
1004 data-key="app:${repo.ownerName}/${repo.name}"
1005 href="${repoBase(repo)}"
1006 ${s.paused === null ? null : html`data-paused="${s.paused}"`}
1007 >
1008 <span class="board-app-name"
1009 >${repo.name} <span class="board-app-mode">${note}</span></span
1010 >
1011 <span class="board-app-nums">
1012 <span
1013 ><b>${money(s.spentTodayUsd)}</b> of
1014 $${s.ceilingUsd.toFixed(0)}</span
1015 >
1016 <span><b>${runs}</b> run${runs === 1 ? "" : "s"}</span>
1017 <span><b>${shipped}</b> shipped</span>
1018 </span>
1019 <span class="board-app-week"
1020 >${perShippedWords(week.get(repo.id))} this week</span
1021 >
1022 ${when(
1023 holdLine !== null,
1024 () => html`<span class="board-app-week board-app-hold"
1025 >${holdLine}</span
1026 >`,
1027 )}
1028 ${when(
1029 review !== null,
1030 () => html`<span class="board-app-week board-app-review"
1031 >${review}</span
1032 >`,
1033 )}
1034 <span class="board-spend"
1035 ><span class="board-fill" data-w="${pct(share)}"></span
1036 ></span>
1037 </a>
1038 </li>`;
1013 return boardRow({
1014 key: `app:${repo.ownerName}/${repo.name}`,
1015 href: repoBase(repo),
1016 title: html`${repo.name}
1017 ${s.paused === null
1018 ? html`<span class="muted small">${note}</span>`
1019 : badge(note, { tone: "attn" })}`,
1020 meta: [
1021 html`<span><b>${money(s.spentTodayUsd)}</b> of $${s.ceilingUsd.toFixed(0)}</span>
1022 · <span><b>${runs}</b> run${runs === 1 ? "" : "s"}</span> ·
1023 <span><b>${shipped}</b> shipped</span>`,
1024 `${perShippedWords(week.get(repo.id))} this week`,
1025 holdLine,
1026 review,
1027 ],
1028 under: meter(
1029 share * 100,
1030 `${money(s.spentTodayUsd)} of its $${s.ceilingUsd.toFixed(0)} ceiling today`,
1031 s.paused === "ceiling" ? "attn" : undefined,
1032 ),
1033 });
10391034 });
10401035 return region(
10411036 "today",
@@ -1052,180 +1047,48 @@ function today(ctx: Ctx, scope: BoardScope): Html {
10521047
10531048 // ---- style ----
10541049
1055 /** Width rules for the bars: a whole percentage as `data-w`, since an inline style is not allowed. */
1056 const widths = Array.from(
1057 { length: 101 },
1058 (_, n) => `.board-fill[data-w="${n}"] { width: ${n}%; }`,
1059 ).join("\n");
1060
10611050 /**
1062 * The board's own components, assembled into the served stylesheet by
1063 * `web/style.ts` rather than sent inline with the page. Every rule here is
1064 * a `.board-*` selector nothing else in the sheet mentions, so the
1065 * `components` layer is where they read the same as they always did.
1051 * The board (docs/DESIGN.md §5): the one layout no other component draws —
1052 * four live regions that fill a second monitor, each scrolling on its own,
1053 * one key to show one. Everything inside them is §5's: rows in a list card,
1054 * badges, meters, kbd. Assembled into the served stylesheet by
1055 * `web/style.ts`, last in the `components` layer. The spend tables
1056 * (`spend.ts`) are drawn on the board first and keep their two rules here.
10661057 */
10671058 export const boardCss = /* css */ `
10681059 @layer components {
1069 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; font-size: 16px; }
1070 .board-top { display: flex; align-items: center; gap: 14px; padding: 14px var(--pad) 0; color: var(--faint); font-size: 13px; }
1071 .board-brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 650; letter-spacing: -0.03em; font-size: 15px; color: var(--muted-fg); }
1072 .board-brand:hover { color: var(--fg); }
1073 .board-brand .mark { width: 10px; height: 10px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--merged)); }
1074 .board-scope { color: var(--faint); }
1075 .board-keys { display: none; align-items: center; gap: 6px; }
1076 .board-keys kbd { height: 18px; min-width: 18px; font-size: 10.5px; }
1077 .board-live { display: inline-flex; align-items: center; }
1078 .board-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--faint); transition: background-color 200ms var(--ease), box-shadow 200ms var(--ease); }
1079 .board-live[data-state="open"] .board-dot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); }
1080 .board-live[data-state="reconnecting"] .board-dot { background: var(--attn); }
1081 .board-clock { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--muted-fg); min-width: 5ch; text-align: right; }
1082 .board-asked { font-size: 13px; color: var(--muted-fg); text-decoration: none; }
1083 .board-asked:hover { color: var(--fg); }
1084 .board-forge { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; min-height: 28px; padding: 6px var(--pad) 0; font-size: 13px; color: var(--faint); }
1085 .board-forge[data-ok="no"] { color: var(--attn); }
1086 .board-asks { display: block; padding: 6px var(--pad) 0; font-size: 13px; color: var(--faint); text-decoration: none; }
1087 .board-asks b { color: var(--fg); font-weight: 600; }
1088 .board-declined { display: flex; flex-direction: column; gap: 8px; padding: 10px var(--pad) 0; }
1089 .board-declined-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(12em, 22em) auto; gap: 10px; align-items: center; padding: 10px 14px; border: 1px solid color-mix(in srgb, var(--attn) 45%, var(--border)); border-radius: var(--radius, 10px); }
1090 .board-parked-row { grid-template-columns: minmax(0, 1fr) auto; }
1091 .board-declined-says { color: var(--attn); line-height: 1.35; }
1092 .board-declined-says q { color: var(--fg); }
1093 .board[data-wall] .board-declined-row .input, .board[data-wall] .board-declined-row .btn { display: none; }
1094 a.board-forge-need { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
1095 .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 12px var(--pad) var(--pad); min-height: 0; }
1096 .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
1097 .board-h {
1098 position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 10px;
1099 margin: 0; padding: 4px 0 12px; background: var(--bg);
1100 font-size: 12.5px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-fg);
1101 }
1102 .board-key { font-family: var(--mono); font-size: 11px; font-weight: 500; letter-spacing: 0; color: var(--faint); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; line-height: 16px; }
1103 .board-count { color: var(--faint); font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
1104 .board-region[data-fresh] .board-h { color: var(--accent); }
1105 .board-list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
1106 .board-quiet { margin: 2px 0 0; color: var(--faint); font-size: 1em; }
1107 .board-pillars { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0 0 12px; font-size: 13px; color: var(--muted-fg); }
1108 .board-pillars-h { color: var(--faint); }
1109 .board-pillar b { color: var(--fg); font-variant-numeric: tabular-nums; }
1110 .board-pillar[data-zero="yes"], .board-pillar[data-zero="yes"] b { color: var(--faint); }
1111 a.board-pillar:hover { color: var(--fg); }
1112 .board-card {
1113 display: grid; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius);
1114 background: var(--card); color: inherit; transition: border-color 140ms var(--ease);
1115 }
1116 .board-card:hover { border-color: var(--border-strong); color: inherit; }
1117 .board-when { color: var(--faint); font-size: .875em; font-variant-numeric: tabular-nums; white-space: nowrap; }
1118 .board-bar { display: block; height: 5px; border-radius: 999px; background: var(--muted); overflow: hidden; }
1119 .board-fill { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width 400ms var(--ease); }
1120 ${widths}
1121
1122 /* Working now: the crew member and app, what it is on, why, and the clock. */
1123 .board-run { grid-template-columns: auto minmax(0, 1fr) auto; gap: 2px 12px; align-items: start; padding: 14px 16px; }
1124 .board-run .avatar { grid-row: 1 / span 3; width: 36px; height: 36px; font-size: 14px; margin-top: 3px; }
1125 .board-run-who { font-size: .9em; color: var(--muted-fg); }
1126 .board-run-who b { color: var(--fg); font-weight: 600; }
1127 .board-elapsed { grid-column: 3; grid-row: 1 / span 2; font-family: var(--mono); font-size: 1.5em; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1.2; min-width: 4ch; text-align: right; }
1128 .board-run-title { grid-column: 2; font-size: 1.2em; font-weight: 550; line-height: 1.3; letter-spacing: -0.01em; text-wrap: pretty; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
1129 .board-run-meta { grid-column: 2 / span 2; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; font-size: .9em; color: var(--muted-fg); }
1130 .board-state { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; color: var(--fg); }
1131 .board-state::before { content: ""; width: 8px; height: 8px; border-radius: 999px; background: var(--faint); }
1132 .board-run[data-state="working"] { border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); }
1133 .board-run[data-state="working"] .board-state { color: var(--accent); }
1134 .board-run[data-state="working"] .board-state::before { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
1135 .board-run[data-state="working"] .board-elapsed { color: var(--accent); }
1136 .board-run[data-state="queued"] .board-elapsed { color: var(--muted-fg); }
1137 .board-run[data-state="paused"] .board-state { color: var(--attn); }
1138 .board-run[data-state="paused"] .board-state::before { background: var(--attn); }
1139 /* Nothing on the wall animates on its own. A perpetual animation keeps the
1140 browser painting every frame all day — three pulsing dots measured at over
1141 100% CPU under software rendering. The ticking clocks are the sign of life. */
1142
1143 /* Landed: the production look on a phone, the change, who, when. */
1144 .board-landed { grid-template-columns: auto minmax(0, 1fr) auto; gap: 2px 14px; align-items: center; padding: 10px 14px; }
1145 .board-thumb { grid-row: 1 / span 2; display: flex; align-items: center; justify-content: center; width: 34px; height: 46px; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; background: var(--muted); color: var(--merged); }
1146 .board-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; }
1147 .board-landed-title { font-size: 1.05em; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1148 .board-landed-meta { grid-column: 2; font-size: .875em; color: var(--muted-fg); }
1149 .board-landed .board-when { grid-column: 3; grid-row: 1 / span 2; }
1150
1151 /* Decided: the kind, the answer, how sure, what came of it. */
1152 .board-decision { grid-template-columns: 6.5em minmax(0, 1fr) 7em 4.5em auto; gap: 12px; align-items: center; padding: 10px 14px; }
1153 .board-kind { font-size: .75em; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted-fg); }
1154 .board-decision-main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
1155 .board-answer { font-size: 1.05em; font-weight: 600; }
1156 .board-about { font-size: .875em; color: var(--muted-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1157 .board-p { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: .75em; color: var(--muted-fg); }
1158 .board-p .board-bar { flex: 1; }
1159 .board-outcome { font-size: .875em; color: var(--faint); }
1160 .board-outcome[data-outcome="shipped"], .board-outcome[data-outcome="green"] { color: var(--success); }
1161 .board-outcome[data-outcome="red"] { color: var(--danger); }
1162 .board-outcome[data-outcome="shown"], .board-outcome[data-outcome="closed"] { color: var(--attn); }
1163
1164 /* Today: each app that runs on its own, its spend against the ceiling. */
1165 .board-app { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; align-items: baseline; padding: 12px 16px; }
1166 .board-app-name { font-size: 1.1em; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1167 .board-app-mode { font-size: .8em; font-weight: 500; color: var(--muted-fg); margin-left: 4px; }
1168 .board-app[data-paused] .board-app-mode { color: var(--attn); }
1169 .board-app-nums { display: flex; gap: 14px; font-size: .875em; color: var(--muted-fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
1170 .board-app-nums b { color: var(--fg); font-weight: 600; }
1171 .board-spend { grid-column: 1 / -1; display: block; height: 6px; border-radius: 999px; background: var(--muted); overflow: hidden; }
1172 .board-app[data-paused="ceiling"] .board-fill { background: var(--attn); }
1173
1174 /* Windows: each subscription's 5-hour and weekly window, admin only. */
1175 .board-windows { display: flex; flex-wrap: wrap; gap: 8px 12px; padding: 8px var(--pad) 0; font-size: 13px; }
1176 .board-sub { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); color: var(--muted-fg); }
1177 .board-sub:hover { border-color: var(--border-strong); color: var(--muted-fg); }
1178 .board-sub-name { font-weight: 600; color: var(--fg); }
1179 .board-subs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
1180 .board-group { font-size: .85em; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; }
1181 .board-subs[data-group="apart"] .board-sub { border-style: dashed; }
1182 .board-sub-scope { color: var(--faint); }
1183 .board-spend-detail { flex-basis: 100%; }
1184 .board-spend-detail summary { cursor: pointer; color: var(--muted-fg); }
1060 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; }
1061 .board > header, .board > .stack { padding: 12px var(--pad) 0; }
1062 .board #board-live { display: inline-flex; align-items: center; }
1063 .board #board-clock { min-width: 5ch; text-align: right; }
1064 .board [data-started] { min-width: 7ch; text-align: right; }
1065 .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 16px var(--pad) var(--pad); min-height: 0; }
1066 .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
1067 .board-region > h2 { display: flex; align-items: center; gap: 8px; margin: 0; }
1068 .board-region[data-fresh] > h2 { color: var(--accent); }
1069 .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; }
1070 .board-region .row-main > meter { margin-top: 4px; }
1071 .board [data-window] > meter { width: 56px; }
1072 .board[data-wall] #declined :is(.input, .btn) { display: none; }
11851073 .spend-table { margin-top: 8px; }
11861074 .spend-table caption { text-align: left; color: var(--muted-fg); }
11871075 .spend-totals { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 8px 0 0; }
1188 .board-window { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; }
1189 .board-window-name { color: var(--faint); }
1190 .board-window .board-bar { width: 56px; }
1191 .board-window[data-window] .board-fill[data-w="100"] { background: var(--attn); }
1192 .board-inferred { font-style: normal; font-size: .85em; color: var(--faint); border: 1px dashed var(--border); border-radius: 4px; padding: 0 4px; }
1193 .board-app-week { grid-column: 1 / -1; font-size: .875em; color: var(--muted-fg); font-variant-numeric: tabular-nums; }
11941076
1195 /* Wide: four regions fill the window, each scrolling on its own. */
1077 /* Wide: four regions fill the window, each list scrolling on its own. */
11961078 @media (min-width: 1100px) {
1197 .board { flex: none; height: 100dvh; min-height: 0; }
1198 .board-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
1199 .board-region { overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; padding-right: 2px; }
1200 .board-keys { display: inline-flex; }
1079 .board { height: 100dvh; min-height: 0; }
1080 .board-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); }
1081 .board-region > .card { min-height: 0; overflow: auto; scrollbar-width: thin; }
12011082 .board[data-focus] .board-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
1202 .board[data-focus] { font-size: 19px; }
1203 }
1204 @media (min-width: 1600px) { .board { font-size: 17px; } .board[data-focus] { font-size: 21px; } }
1205 /* Narrow: stacked, and the decision row folds. */
1206 @media (max-width: 719px) {
1207 .board-elapsed { font-size: 1.05em; }
1208 .board-run-title { -webkit-line-clamp: 3; }
1209 .board-landed-title { white-space: normal; text-wrap: pretty; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
1210 .board-app { grid-template-columns: minmax(0, 1fr); }
1211 .board-app-name { white-space: normal; }
1212 .board-decision { grid-template-columns: minmax(0, 1fr) auto; }
1213 .board-kind { grid-column: 1 / -1; }
1214 .board-p { grid-column: 1; }
1215 .board-outcome { grid-column: 2; grid-row: 2; }
1216 .board-decision .board-when { grid-column: 2; grid-row: 3; }
1217 .board-app-nums { flex-wrap: wrap; white-space: normal; gap: 6px 12px; }
1218 .board-scope { display: none; }
1219 .board-declined-row { grid-template-columns: minmax(0, 1fr) auto; }
1220 .board-declined-says { grid-column: 1 / -1; }
12211083 }
12221084 /* One region, full bleed. */
12231085 .board[data-focus] .board-region { display: none; }
1224 /* The wall mode: read from across the room, and nobody at the keys. */
1225 .board[data-wall] { font-size: 20px; cursor: none; }
1226 .board[data-wall] .board-keys { display: none; }
1227 @media (min-width: 1600px) { .board[data-wall] { font-size: 23px; } }
12281086 .board[data-focus="working"] #working, .board[data-focus="landed"] #landed,
12291087 .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; }
1088 /* Read from across the room: one region, or the wall mode, at a larger
1089 scale — the same rows, every size in proportion. Nobody is at the keys. */
1090 .board[data-focus] .board-region > * { zoom: 1.3; }
1091 .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; }
1092 .board[data-wall] { cursor: none; }
12301093 }
12311094 `;
src/web/style/components.ts +11
@@ -216,6 +216,17 @@ a.label:hover { color: color-mix(in srgb, var(--label) 90%, var(--fg)); }
216216 .dot[data-tone="attn"] { background: var(--attn); }
217217 .dot[data-tone="merged"] { background: var(--merged); }
218218
219 /* ---- meter: a share as a bar — spend against a ceiling, a window used, how sure ---- */
220 meter {
221 display: block; width: 100%; height: 6px; appearance: none;
222 background: var(--muted); border: 0; border-radius: 999px; overflow: hidden;
223 }
224 meter::-webkit-meter-bar { height: 6px; background: var(--muted); border: 0; border-radius: 999px; }
225 meter::-webkit-meter-optimum-value, meter::-webkit-meter-suboptimum-value, meter::-webkit-meter-even-less-good-value { background: var(--muted-fg); border-radius: 999px; }
226 meter::-moz-meter-bar { background: var(--muted-fg); border-radius: 999px; }
227 meter[data-tone="attn"]::-webkit-meter-optimum-value, meter[data-tone="attn"]::-webkit-meter-suboptimum-value, meter[data-tone="attn"]::-webkit-meter-even-less-good-value { background: var(--attn); }
228 meter[data-tone="attn"]::-moz-meter-bar { background: var(--attn); }
229
219230 kbd {
220231 display: inline-flex; align-items: center; justify-content: center;
221232 min-width: 20px; height: 20px; padding: 0 5px;
src/web/ui.ts +15
@@ -667,6 +667,21 @@ export function dot(tone: Tone = "default"): Html {
667667 ></span>`;
668668 }
669669
670 /**
671 * A share as a bar: a whole percentage, 0–100, and what it is a share of in
672 * words for a screen reader. `attn` when the share is a limit reached.
673 */
674 export function meter(percent: number, label: string, tone?: "attn"): Html {
675 const value = Math.max(0, Math.min(100, Math.round(percent)));
676 return html`<meter
677 min="0"
678 max="100"
679 value="${value}"
680 aria-label="${label}"
681 ${tone ? html`data-tone="${tone}"` : null}
682 >${value}%</meter>`;
683 }
684
670685 /** A short object id in a monospace chip — always the first 7 characters. */
671686 export function sha(oid: string, href?: string): Html {
672687 const short = oid.slice(0, 7);
test/board-windows.test.ts +1 −1
@@ -306,7 +306,7 @@ describe("efficiency and standing, grouped", () => {
306306 expect(page).toContain("account 1");
307307 expect(page).toContain("5-hour window");
308308 expect(page).toContain("weekly window");
309 expect(page).toContain('class="board-inferred">inferred</i>');
309 expect(page).toMatch(/data-variant="outline"\s*>inferred</);
310310 expect(page).toContain(">full<");
311311 // ($3.00 + $1.50 + $0.50) ÷ 2 merged this week = $2.50.
312312 expect(page).toContain("$2.50 per shipped change this week");
test/board.test.ts +43 −11
@@ -29,6 +29,8 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2929 import { submitReview } from "../src/core/reviews.ts";
3030 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3131 import { serve, type Servers } from "../src/http/server.ts";
32 import { boardCss } from "../src/web/pages/board.ts";
33 import { meter } from "../src/web/ui.ts";
3234
3335 describe("the board", () => {
3436 let dataDir: string;
@@ -146,7 +148,7 @@ describe("the board", () => {
146148 expect(html).toContain("Nothing has shipped yet.");
147149 expect(html).toContain("Nothing decided yet.");
148150 // The one app that runs on its own, with its ceiling.
149 expect(html).toContain('class="board-card board-app"');
151 expect(html).toContain('data-key="app:');
150152 expect(html).toMatch(/\$0\.00<\/b> of\s+\$20/);
151153 expect(html).toContain('id="board-live"');
152154 expect(html).toContain('id="board-clock"');
@@ -245,8 +247,8 @@ describe("the board", () => {
245247 html.indexOf('id="working"'),
246248 html.indexOf('id="landed"'),
247249 );
248 expect(working).toContain('data-state="working"');
249 expect(working).toContain('data-state="queued"');
250 expect(working).toContain(">working</span");
251 expect(working).toContain(">queued</span");
250252 expect(working.indexOf("on the cart")).toBeLessThan(
251253 working.indexOf("Make the notes page load"),
252254 );
@@ -271,7 +273,7 @@ describe("the board", () => {
271273 html.indexOf('id="today"'),
272274 );
273275 expect(decided).toContain("ship or show");
274 expect(decided).toContain('data-w="82"');
276 expect(decided).toContain('value="82"');
275277 expect(decided).toContain("by typesafe");
276278 expect(decided).toContain("next issue");
277279 expect(decided).toContain(`#${told.value.number}`);
@@ -341,6 +343,24 @@ describe("the board", () => {
341343 expect(home).toContain("Board");
342344 });
343345
346 test("built from the design system: no page-local class but §5's board, one column under 1100px", async () => {
347 const html = await (await get("/board")).text();
348 const body = html.slice(html.indexOf('<div class="board"'), html.indexOf("<script"));
349 const classes = new Set(
350 [...body.matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1]!.split(/\s+/)),
351 );
352 expect([...classes].filter((c) => c.startsWith("board")).sort()).toEqual([
353 "board",
354 "board-grid",
355 "board-region",
356 ]);
357 // The four regions sit side by side only on a monitor; a phone reads one
358 // column, each region after the last.
359 const sheet = boardCss.slice(0, boardCss.indexOf("@media (min-width: 1100px)"));
360 expect(sheet).not.toContain("grid-template-columns");
361 expect(boardCss.slice(sheet.length)).toContain(".board-grid { grid-template-columns");
362 });
363
344364 test("?wall is the wall mode: the same live regions, marked for the far screen", async () => {
345365 const plain = await (await get("/board")).text();
346366 expect(plain).not.toContain("data-wall");
@@ -349,7 +369,7 @@ describe("the board", () => {
349369 const html = await res.text();
350370 expect(html).toMatch(/<div class="board"\s+data-wall="">/);
351371 expect(html).toMatch(/id="working"\s+data-live="run\.queued,run\.started,run\.finished"/);
352 expect(html).toMatch(/class="board-scope"\s*>notes</);
372 expect(html).toMatch(/id="board-scope"\s*>notes</);
353373 });
354374
355375 // The big view's every address, each its own test: all land on the wall mode.
@@ -496,9 +516,9 @@ describe("the board's forge strip", () => {
496516 try {
497517 const text = await board("root");
498518 expect(text).toContain("All fine");
499 expect(text).not.toContain("board-forge-need");
519 expect(text).not.toContain("data-need");
500520 expect(text).not.toContain("error-note");
501 expect(text).toContain("board-card board-run");
521 expect(text).toContain('data-key="run:');
502522 } finally {
503523 changeCapacity(app, admin, "cells.egress", "enforce");
504524 }
@@ -517,9 +537,9 @@ describe("the board's forge strip", () => {
517537 expect(blocked).toContain("needs you: forge stuck: quota refused &lt;i&gt;8Gi&lt;/i&gt;");
518538 expect(blocked).not.toContain("<i>8Gi</i>");
519539 // One line, said once, and the board beneath it still renders.
520 expect(blocked.match(/class="board-forge-need"/g)?.length).toBe(1);
540 expect(blocked.match(/<(?:a|span) data-need/g)?.length).toBe(1);
521541 expect(blocked).not.toContain("rollout blocked");
522 expect(blocked).toContain("board-card board-run");
542 expect(blocked).toContain('data-key="run:');
523543 expect(await board("bob")).not.toContain("quota refused");
524544 } finally {
525545 delete process.env["FORGE_GUARD_DIR"];
@@ -558,8 +578,9 @@ describe("the board's forge strip", () => {
558578 try {
559579 expect(hunt(app, "root/stuck").filed).toBeGreaterThan(0);
560580 const seen = await board("root");
561 expect(seen).toMatch(/<a class="board-forge-need" id="sentinel" href="\/root\/stuck\/issues">The sentinel has \d+ open findings?<\/a>/);
562 expect(seen).not.toContain('class="board-forge-ok" id="sentinel"');
581 expect(seen).toMatch(/<a data-need id="sentinel" href="\/root\/stuck\/issues">[\s\S]*?The sentinel has \d+ open findings?<\/a>/);
582 // A need, so it wears the attention dot, never the fine one.
583 expect(seen).toMatch(/id="sentinel"[^>]*>\s*<span\s+class="dot"\s+data-tone="attn"/);
563584 expect(await board("bob")).not.toMatch(/sentinel/i);
564585 const guest = await (await fetch(`${servers.url}/board`)).text();
565586 expect(guest).not.toMatch(/sentinel/i);
@@ -569,3 +590,14 @@ describe("the board's forge strip", () => {
569590 }
570591 });
571592 });
593
594 describe("meter", () => {
595 test("a whole percentage between 0 and 100, said in words, attn only when asked", () => {
596 expect(meter(82.4, "82% sure").text).toMatch(/value="82"[\s\S]*aria-label="82% sure"/);
597 expect(meter(140, "over").text).toContain('value="100"');
598 expect(meter(-3, "under").text).toContain('value="0"');
599 expect(meter(50, "half").text).not.toContain("data-tone");
600 expect(meter(100, "at the ceiling", "attn").text).toContain('data-tone="attn"');
601 expect(meter(1, '<b>"x"</b>').text).not.toContain("<b>");
602 });
603 });
test/decider-day.test.ts +1 −1
@@ -150,7 +150,7 @@ describe("the decider's day on Today's card", () => {
150150 html.indexOf('id="decided"'),
151151 html.indexOf('id="today"'),
152152 );
153 return (decided.match(/class="board-card board-decision"/g) ?? []).length;
153 return (decided.match(/data-key="decision:/g) ?? []).length;
154154 };
155155 expect(await rowsOf("closed")).toBe(2);
156156 expect(await rowsOf("deferred")).toBe(2);
test/forge-degraded.test.ts +2 −2
@@ -83,7 +83,7 @@ test("a burst of errors: the board says so, one page, no more; clear for five mi
8383 await at(0);
8484 const burst = await board();
8585 expect(burst).toMatch(/data-ok="no"/);
86 expect(burst).toMatch(/class="board-forge-need" href="\/settings\/capacity#errors">20 errors in the last hour/);
86 expect(burst).toMatch(/data-need href="\/settings\/capacity#errors">[\s\S]*?20 errors in the last hour/);
8787 expect(burst).not.toContain("All fine");
8888 await at(4);
8989 expect(posts).toHaveLength(0);
@@ -110,5 +110,5 @@ test("a burst of errors: the board says so, one page, no more; clear for five mi
110110 const calm = await board();
111111 expect(calm).toContain("All fine");
112112 expect(calm).toMatch(/data-ok="yes"/);
113 expect(calm).not.toContain("board-forge-need");
113 expect(calm).not.toContain("data-need");
114114 });
test/pillars.test.ts +1 −1
@@ -655,7 +655,7 @@ describe("pillars", () => {
655655 expect("pillar" in data.issues[0]).toBe(false);
656656 expect(await get("/alice/plain/map")).not.toContain('id="map-pillars"');
657657 expect(await get("/board?apps=plain")).not.toContain(
658 'class="board-pillars"',
658 'data-pillars',
659659 );
660660 });
661661
test/returns.test.ts +1 −1
@@ -298,7 +298,7 @@ describe("what the tokens bought", () => {
298298
299299 test("the owner sees one line on the board, one tap to the breakdown, and the same over the API and MCP", async () => {
300300 const board = await (await get("/board", { cookie: aliceCookie })).text();
301 const line = /<a class="board-asks" id="returns-line"[^>]*>([\s\S]*?)<\/a>/
301 const line = /<a class="muted small" id="returns-line"[^>]*>([\s\S]*?)<\/a>/
302302 .exec(board)?.[1]
303303 ?.replace(/\s+/g, " ");
304304 expect(line).toContain("<b>$4.00</b> → <b>1</b> landed");
test/studio.test.ts +2 −2
@@ -252,11 +252,11 @@ describe("the studio", () => {
252252 expect(css).toContain(".studio-tell textarea");
253253 expect(css).toContain(".studio-ask-actions");
254254 expect(css).toContain("@keyframes studio-flash");
255 expect(css).toContain('.board-fill[data-w="100"]');
255 expect(css).toContain('.board[data-focus="working"] #working');
256256 // In the `components` layer, last: an inline block used to win over every
257257 // layer, and last in `components` is the nearest place that still reads
258258 // the same — a `content` rule or a utility must keep out-specifying them.
259 for (const rule of [".studio-tell textarea", ".board-h"]) {
259 for (const rule of [".studio-tell textarea", ".board-region > h2"]) {
260260 expect(css.indexOf(rule)).toBeGreaterThan(
261261 css.indexOf("@layer components {"),
262262 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs