|
|
@@ -77,7 +77,19 @@ import { repoBase, signInFirst } from "../chrome.ts"; |
| 77 | 77 |
import { boardJs, liveRegionsJs } from "../client.ts"; |
| 78 | 78 |
import { html, when, type Html } from "../html.ts"; |
| 79 | 79 |
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"; |
| 81 | 93 |
import { voiceFor } from "../studio/voice.ts"; |
| 82 | 94 |
import { |
| 83 | 95 |
perShippedWords, |
|
|
@@ -117,6 +129,22 @@ function stateWord(run: Run): StateWord { |
| 117 | 129 |
return run.notBefore === null ? "queued" : "paused"; |
| 118 | 130 |
} |
| 119 | 131 |
|
| 132 |
|
| 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 |
|
| 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 |
|
| 120 | 148 |
const KIND_WORDS: Record<DecisionKind, string> = { |
| 121 | 149 |
next: "next issue", |
| 122 | 150 |
land: "ship or show", |
|
|
@@ -149,10 +177,6 @@ function reviewWords(stats: ReviewStats): string | null { |
| 149 | 177 |
return parts.length === 0 ? null : parts.join(" · "); |
| 150 | 178 |
} |
| 151 | 179 |
|
| 152 | |
|
| 153 | |
const pct = (n: number): number => |
| 154 | |
Math.max(0, Math.min(100, Math.round(n * 100))); |
| 155 | |
|
| 156 | 180 |
|
| 157 | 181 |
interface BoardScope { |
| 158 | 182 |
readonly repos: readonly Repo[]; |
|
|
@@ -213,34 +237,36 @@ export function boardPage(ctx: Ctx): Response { |
| 213 | 237 |
const wall = ctx.url.searchParams.has("wall"); |
| 214 | 238 |
|
| 215 | 239 |
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" |
| 219 | 243 |
>${ |
| 220 | 244 |
repos.length === 1 |
| 221 | 245 |
? repos[0]!.name |
| 222 | 246 |
: `${repos.length} app${repos.length === 1 ? "" : "s"}` |
| 223 | 247 |
}</span |
| 224 | 248 |
> |
| 225 | |
<a class="board-asked" href="/asked">What you asked for</a> |
| 249 |
<a class="muted small" href="/asked">What you asked for</a> |
| 226 | 250 |
<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 |
| 230 | 261 |
> |
| 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> |
| 239 | 263 |
</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> |
| 244 | 270 |
<div class="board-grid"> |
| 245 | 271 |
${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)} |
| 246 | 272 |
${today(ctx, scope)} |
|
|
@@ -275,24 +301,45 @@ function region( |
| 275 | 301 |
data-live="${live}" |
| 276 | 302 |
aria-labelledby="${id}-h" |
| 277 | 303 |
> |
| 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))} |
| 284 | 307 |
</h2> |
| 285 | 308 |
${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)} |
| 293 | 310 |
</section>`; |
| 294 | 311 |
} |
| 295 | 312 |
|
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
|
| 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 |
|
| 296 | 343 |
|
| 297 | 344 |
const APP_SLUG = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/; |
| 298 | 345 |
|
|
|
@@ -317,7 +364,7 @@ export function bigRedirect(ctx: Ctx): Response { |
| 317 | 364 |
function sentinelSpan(app: Ctx["app"]): Html { |
| 318 | 365 |
const n = sentinelNotice(app.db); |
| 319 | 366 |
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>`; |
| 321 | 368 |
} |
| 322 | 369 |
|
| 323 | 370 |
|
|
|
@@ -336,7 +383,7 @@ function forgeStrip(ctx: Ctx): Html | null { |
| 336 | 383 |
const ok = v.ok && fh.ok && verdict === undefined; |
| 337 | 384 |
const lane = laneLine(v.lane); |
| 338 | 385 |
return html`<div |
| 339 | |
class="board-forge" |
| 386 |
class="bar small muted" |
| 340 | 387 |
id="forge" |
| 341 | 388 |
data-live="run.queued,run.started,run.finished,app.*" |
| 342 | 389 |
data-ok="${ok ? "yes" : "no"}" |
|
|
@@ -344,16 +391,16 @@ function forgeStrip(ctx: Ctx): Html | null { |
| 344 | 391 |
role="status" |
| 345 | 392 |
> |
| 346 | 393 |
${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>`} |
| 351 | 398 |
${ |
| 352 | 399 |
ok |
| 353 | |
? html`<span class="board-forge-ok">All fine</span>` |
| 400 |
? html`<span>${dot("success")} All fine</span>` |
| 354 | 401 |
: v.degraded.map( |
| 355 | 402 |
(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>`, |
| 357 | 404 |
) |
| 358 | 405 |
} |
| 359 | 406 |
</div>`; |
|
|
@@ -394,14 +441,16 @@ function reviewStrip(ctx: Ctx, scope: BoardScope): Html | null { |
| 394 | 441 |
if (!none && waits.length === 0) return []; |
| 395 | 442 |
const oldest = waits.map((w) => w.since).sort()[0]; |
| 396 | 443 |
return [ |
| 397 | |
html`<a class="board-asks" href="/asks" |
| 444 |
html`<a class="muted small" href="/asks" |
| 398 | 445 |
><b>${repo.name}</b>: ${none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${waits.length === 0 |
| 399 | 446 |
? null |
| 400 | 447 |
: html` · <b>${waits.length}</b> waiting since ${oldest!.slice(0, 10)}`}</a |
| 401 | 448 |
>`, |
| 402 | 449 |
]; |
| 403 | 450 |
}); |
| 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>`; |
| 405 | 454 |
} |
| 406 | 455 |
|
| 407 | 456 |
// ---- what asked you, and what came of it ---- |
|
|
@@ -421,7 +470,7 @@ function asksStrip(ctx: Ctx): Html | null { |
| 421 | 470 |
const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString(); |
| 422 | 471 |
const m = askMeasure(ctx.app.db, user.id, since); |
| 423 | 472 |
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" |
| 425 | 474 |
>Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted |
| 426 | 475 |
on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a |
| 427 | 476 |
>`; |
|
|
@@ -458,7 +507,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { |
| 458 | 507 |
}); |
| 459 | 508 |
if (r.spent.runs === 0) return null; |
| 460 | 509 |
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}" |
| 462 | 511 |
>Tokens, ${ASKS_WINDOW_DAYS} days: <b>${usd(r.spent.usd)}</b> → <b>${r.landed}</b> landed |
| 463 | 512 |
${r.onLanded.perChange.changes === 0 |
| 464 | 513 |
? null |
|
|
@@ -489,7 +538,7 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null { |
| 489 | 538 |
const parked = parkedTwice(ctx.app.db, owned); |
| 490 | 539 |
if (declines.length === 0 && parked.length === 0) return null; |
| 491 | 540 |
return html`<div |
| 492 | |
class="board-declined" |
| 541 |
class="stack" |
| 493 | 542 |
id="declined" |
| 494 | 543 |
data-live="run.queued,run.started,run.finished,issue.*,pull.*" |
| 495 | 544 |
role="status" |
|
|
@@ -497,37 +546,42 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null { |
| 497 | 546 |
${declines.map((d) => { |
| 498 | 547 |
const base = `/${d.repo.ownerName}/${d.repo.name}`; |
| 499 | 548 |
return html`<form |
| 500 | |
class="board-declined-row" |
| 549 |
class="card" |
| 550 |
data-tone="attn" |
| 501 | 551 |
method="post" |
| 502 | 552 |
action="${base}/issues/${d.issue.number}/comments" |
| 503 | 553 |
> |
| 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> |
| 517 | 569 |
</form>`; |
| 518 | 570 |
})} |
| 519 | 571 |
${parked.map((issue) => { |
| 520 | 572 |
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> |
| 531 | 585 |
</div>`; |
| 532 | 586 |
})} |
| 533 | 587 |
</div>`; |
|
|
@@ -538,7 +592,7 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null { |
| 538 | 592 |
/** Figures in a row, `inferred` said once when every one is, else on each. */ |
| 539 | 593 |
function figures(parts: readonly FigureWords[]): Html { |
| 540 | 594 |
const all = parts.every((p) => p.inferred); |
| 541 | |
const tag = html`<i class="board-inferred">inferred</i>`; |
| 595 |
const tag = badge("inferred", { variant: "outline" }); |
| 542 | 596 |
return html`${parts.map( |
| 543 | 597 |
(p, n) => |
| 544 | 598 |
html`${n > 0 ? " · " : null}<span |
|
|
@@ -576,58 +630,53 @@ function windowsStrip(ctx: Ctx): Html | null { |
| 576 | 630 |
const st = all.get(s.id); |
| 577 | 631 |
if (st === undefined) return null; |
| 578 | 632 |
return html`<a |
| 579 | |
class="board-sub" |
| 633 |
class="card" |
| 580 | 634 |
href="/settings/subscriptions" |
| 581 | 635 |
data-key="sub:${s.id}" |
| 582 | 636 |
> |
| 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> |
| 607 | 662 |
</a>`; |
| 608 | 663 |
}; |
| 609 | 664 |
const crew = pool.filter((s) => s.id === PLATFORM || scopes.has(s.id)); |
| 610 | 665 |
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>`; |
| 611 | 670 |
return html`<div |
| 612 | |
class="board-windows" |
| 671 |
class="stack" |
| 613 | 672 |
id="windows" |
| 614 | 673 |
data-live="run.queued,run.started,run.finished,usage.*" |
| 615 | 674 |
aria-label="Subscription windows" |
| 616 | 675 |
> |
| 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> |
| 631 | 680 |
${spendView(readSpend(ctx.app, now))} |
| 632 | 681 |
</details> |
| 633 | 682 |
</div>`; |
|
|
@@ -668,33 +717,24 @@ function working(ctx: Ctx, scope: BoardScope): Html { |
| 668 | 717 |
); |
| 669 | 718 |
const issue = run.issueId === null ? undefined : about.get(run.issueId); |
| 670 | 719 |
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( |
| 692 | 729 |
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 |
}); |
| 698 | 738 |
}); |
| 699 | 739 |
return region( |
| 700 | 740 |
"working", |
|
|
@@ -742,30 +782,18 @@ function landed(ctx: Ctx, scope: BoardScope): Html { |
| 742 | 782 |
scope.ownerFacts.has(repo.id), |
| 743 | 783 |
); |
| 744 | 784 |
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 |
}); |
| 769 | 797 |
}); |
| 770 | 798 |
return region( |
| 771 | 799 |
"landed", |
|
|
@@ -803,18 +831,15 @@ function byPillar(ctx: Ctx, scope: BoardScope): Html | null { |
| 803 | 831 |
? milestoneHref(base, umbrella.get(name)!) |
| 804 | 832 |
: `${base}/issues?label=${encodeURIComponent(name)}`; |
| 805 | 833 |
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 |
| 808 | 836 |
>${scope.repos.length === 1 ? "" : `${repo.name} · `}merged in |
| 809 | 837 |
${PILLAR_DAYS} days</span |
| 810 | 838 |
> |
| 811 | 839 |
${view.merged.map((count) => |
| 812 | 840 |
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>` |
| 816 | 842 |
: html`<a |
| 817 | |
class="board-pillar" |
| 818 | 843 |
data-zero="${count.merged === 0 ? "yes" : "no"}" |
| 819 | 844 |
href="${hrefOf(count.pillar)}" |
| 820 | 845 |
>${count.pillar} <b>${count.merged}</b></a |
|
|
@@ -829,7 +854,7 @@ function byPillar(ctx: Ctx, scope: BoardScope): Html | null { |
| 829 | 854 |
|
| 830 | 855 |
|
| 831 | 856 |
|
| 832 | |
function handContextLine(d: Decision): Html { |
| 857 |
function handContextLine(d: Decision): string { |
| 833 | 858 |
const s = d.state as Partial<HandContext> & { reason?: string }; |
| 834 | 859 |
const seen = [ |
| 835 | 860 |
|
|
|
@@ -839,9 +864,7 @@ function handContextLine(d: Decision): Html { |
| 839 | 864 |
`${s.recent?.length ?? 0} past decisions`, |
| 840 | 865 |
`${s.evidence?.reasons.length ?? 0} park notes`, |
| 841 | 866 |
].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(", ")}.`; |
| 845 | 868 |
} |
| 846 | 869 |
|
| 847 | 870 |
|
|
|
@@ -888,34 +911,23 @@ function decided(ctx: Ctx, scope: BoardScope): Html { |
| 888 | 911 |
: `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`; |
| 889 | 912 |
const answer = |
| 890 | 913 |
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 |
}); |
| 919 | 931 |
}); |
| 920 | 932 |
return region( |
| 921 | 933 |
"decided", |
|
|
@@ -926,7 +938,7 @@ function decided(ctx: Ctx, scope: BoardScope): Html { |
| 926 | 938 |
pile === null ? "Nothing decided yet." : PILE_EMPTY[pile], |
| 927 | 939 |
pile === null |
| 928 | 940 |
? null |
| 929 | |
: html`<p class="muted small board-context"> |
| 941 |
: html`<p class="muted small"> |
| 930 | 942 |
${PILE_LEAD[pile]}${total > decisions.length |
| 931 | 943 |
? ` Showing the newest ${decisions.length} of ${total}.` |
| 932 | 944 |
: ""} |
|
|
@@ -953,7 +965,7 @@ function deciderLine(ctx: Ctx, scope: BoardScope, since: string): Html | null { |
| 953 | 965 |
if (n.closed + n.deferred + n.asked === 0) return null; |
| 954 | 966 |
const link = (pile: DeciderPile) => |
| 955 | 967 |
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"> |
| 957 | 969 |
The decider closed ${link("closed")}, deferred ${link("deferred")}, asked |
| 958 | 970 |
you about ${link("asked")}. |
| 959 | 971 |
</p>`; |
|
|
@@ -998,44 +1010,27 @@ function today(ctx: Ctx, scope: BoardScope): Html { |
| 998 | 1010 |
: s.paused === "no crew" |
| 999 | 1011 |
? "no crew" |
| 1000 | 1012 |
: 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 |
}); |
| 1039 | 1034 |
}); |
| 1040 | 1035 |
return region( |
| 1041 | 1036 |
"today", |
|
|
@@ -1052,180 +1047,48 @@ function today(ctx: Ctx, scope: BoardScope): Html { |
| 1052 | 1047 |
|
| 1053 | 1048 |
|
| 1054 | 1049 |
|
| 1055 | |
|
| 1056 | |
const widths = Array.from( |
| 1057 | |
{ length: 101 }, |
| 1058 | |
(_, n) => `.board-fill[data-w="${n}"] { width: ${n}%; }`, |
| 1059 | |
).join("\n"); |
| 1060 | |
|
| 1061 | 1050 |
|
| 1062 | |
|
| 1063 | |
|
| 1064 | |
|
| 1065 | |
|
| 1051 |
|
| 1052 |
|
| 1053 |
|
| 1054 |
|
| 1055 |
|
| 1056 |
|
| 1066 | 1057 |
|
| 1067 | 1058 |
export const boardCss = ` |
| 1068 | 1059 |
@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; } |
| 1185 | 1073 |
.spend-table { margin-top: 8px; } |
| 1186 | 1074 |
.spend-table caption { text-align: left; color: var(--muted-fg); } |
| 1187 | 1075 |
.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; } |
| 1194 | 1076 |
|
| 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. */ |
| 1196 | 1078 |
@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; } |
| 1201 | 1082 |
.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; } |
| 1221 | 1083 |
} |
| 1222 | 1084 |
/* One region, full bleed. */ |
| 1223 | 1085 |
.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; } } |
| 1228 | 1086 |
.board[data-focus="working"] #working, .board[data-focus="landed"] #landed, |
| 1229 | 1087 |
.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; } |
| 1230 | 1093 |
} |
| 1231 | 1094 |
`; |