Public: anyone can read this; the crew can change it.
The chart frame becomes a §5 component: .chart-box, .chart and .big are named in §5's table and leave §5d #4331
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/4282-the-chart-frame-becomes-a-5-component-ch → main 8 commits fork 1 thread · 1 unresolved
8 files
changed +50
−13
docs/DESIGN.md +2 −1
| @@ -211,6 +211,7 @@ specification. | ||
| 211 | 211 | | **look** (`pages/looks.ts`) | `.look` in a `.looks` strip | `.look[data-slot=phone\|desktop\|thumb\|now]` sizes the frame; `.looks[data-compare]` sets two sides (before · main, then after · the pull) beside each other | a picture always carries its own `width`/`height`, so nothing shifts when it loads; a phone keeps 200px, a desktop takes the rest up to its own size, and under 640px a strip stacks; an attached picture is a `figure` in the strip with its line under it | the picture in the terminal (§6·0c) | |
| 212 | 212 | | **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; above the panels only a collapsed one-line `#notes` (`<details>`: closed by default, an open list stays open across live swaps), the strips below them; 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 | |
| 213 | 213 | | **stacked bar chart** `windowSvg()` (`fleet-graph.ts`) | `.plots`, `.plot` (`.plot-name`), `.plot-legend` | bars wear `fg-sN`, the `--series-N` token | server-drawn SVG with `width`/`height` set, so it holds its box before the stylesheet; gridlines `--border`, figures `--muted-fg` in mono, panel names `--faint`; `role="img"` with an `aria-label` totalling it, a `<title>` per bar, and a table of the same numbers beside it | a table of the same numbers | |
| 214 | | **chart** `chartBox()` (`ui.ts`) | `.chart-box` wrapping `drawChart`'s svg `.chart` (`.big` for the headline figure) | `.is-table` for a table panel | one query or series drawn on the server: the svg carries its own `width`/`height` and the box holds its space, so nothing shifts; `role="img"` with an `aria-label` of its title. Reach for it wherever a page draws a `boards/chart.ts` chart — `/system`'s memory pair and every board panel — and size it from the page (`.bp-*`, `.sys-pair`, `.bgrid`), never inside the frame | the same numbers as a table | | |
| 214 | 215 | |
| 215 | 216 | Two things are deliberately **not** in the vocabulary: skeleton loaders (the |
| 216 | 217 | server renders whole pages — there is nothing to skeletonise) and a custom |
| @@ -394,7 +395,7 @@ gone, so this list only shrinks. | ||
| 394 | 395 | A `.field` directly inside a `.bar` drops its bottom margin and the bar aligns to the end, so a labelled input and its button share a baseline |
| 395 | 396 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.live-status` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-lead` `.passkey-note` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search` |
| 396 | 397 | - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list` |
| 397 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.headroom` `.issue-head` `.issue-meta` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep` | |
| 398 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.commit-body` `.commit-head` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.headroom` `.issue-head` `.issue-meta` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep` | |
| 398 | 399 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
| 399 | 400 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 400 | 401 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
src/web/pages/boards-view.ts +2 −2
| @@ -18,11 +18,11 @@ import { liveRegionsJs } from "../client.ts"; | ||
| 18 | 18 | import { notFoundPage } from "../chrome.ts"; |
| 19 | 19 | import { html, raw, when, type Html } from "../html.ts"; |
| 20 | 20 | import { fragment, page } from "../layout.ts"; |
| 21 | import { btn, card, empty } from "../ui.ts"; | |
| 21 | import { btn, card, chartBox, empty } from "../ui.ts"; | |
| 22 | 22 | import { flowPanel } from "./flow-panel.ts"; |
| 23 | 23 | import { isPinned, listPins } from "../../boards/pins.ts"; |
| 24 | 24 | import { panelData, runFor } from "../../boards/panel_data.ts"; |
| 25 | import { cellsOf, chartBox, paramsOf, queryFields, readParams } from "./boards.ts"; | |
| 25 | import { cellsOf, paramsOf, queryFields, readParams } from "./boards.ts"; | |
| 26 | 26 | |
| 27 | 27 | /** The events the panels' queries read (runs, pulls, checks, issues, usage): any of them may change a chart. */ |
| 28 | 28 | const READS = "run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"; |
src/web/pages/boards.ts +2 −6
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * narrowed to that point's window and listed by the source's first column. |
| 8 | 8 | * Anyone else gets the not-found page. |
| 9 | 9 | */ |
| 10 | import { CHART_NAMES, CHARTS, drawChart, usesSeries, type Cell, type Chart } from "../../boards/chart.ts"; | |
| 10 | import { CHART_NAMES, CHARTS, usesSeries, type Cell, type Chart } from "../../boards/chart.ts"; | |
| 11 | 11 | import { runFor } from "../../boards/panel_data.ts"; |
| 12 | 12 | import { runQuery, type Query, type Row } from "../../boards/query.ts"; |
| 13 | 13 | import { gaps, SYSTEM } from "../../boards/sampler.ts"; |
| @@ -18,7 +18,7 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor | ||
| 18 | 18 | import { notFoundPage } from "../chrome.ts"; |
| 19 | 19 | import { html, raw, when } from "../html.ts"; |
| 20 | 20 | import { page } from "../layout.ts"; |
| 21 | import { btn, card, empty } from "../ui.ts"; | |
| 21 | import { btn, card, chartBox, empty } from "../ui.ts"; | |
| 22 | 22 | |
| 23 | 23 | const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const; |
| 24 | 24 | const AGGREGATES = ["count", "sum", "avg", "max"] as const; |
| @@ -82,10 +82,6 @@ export const cellsOf = (rows: readonly Row[], hrefs: readonly (string | null)[] | ||
| 82 | 82 | ...(hrefs[i] ? { href: hrefs[i]! } : {}), |
| 83 | 83 | })); |
| 84 | 84 | |
| 85 | /** A drawn chart in the box that holds its space. */ | |
| 86 | export const chartBox = (chart: Chart, cells: readonly Cell[], title: string) => | |
| 87 | html`<div class="chart-box${chart === "table" ? " is-table" : ""}">${raw(drawChart(chart, cells, title))}</div>`; | |
| 88 | ||
| 89 | 85 | const link = (p: Params) => { |
| 90 | 86 | const s = new URLSearchParams(); |
| 91 | 87 | for (const [k, v] of Object.entries(p)) if (v !== "") s.set(k, v); |
src/web/pages/system.ts +2 −3
| @@ -10,7 +10,6 @@ | ||
| 10 | 10 | * (RSS) beside its JS heap, hourly for 24 h (#2151): RSS climbing while the |
| 11 | 11 | * heap stays flat is native memory growth. Drawn on the server as SVG. |
| 12 | 12 | */ |
| 13 | import { drawChart } from "../../boards/chart.ts"; | |
| 14 | 13 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 15 | 14 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| 16 | 15 | import type { Ctx } from "../../http/context.ts"; |
| @@ -24,7 +23,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 24 | 23 | import { html, raw, type Html } from "../html.ts"; |
| 25 | 24 | import { page } from "../layout.ts"; |
| 26 | 25 | import { systemLiveJs } from "../system-live.ts"; |
| 27 | import { badge, card, empty, meter, pill, row } from "../ui.ts"; | |
| 26 | import { badge, card, chartBox, empty, meter, pill, row } from "../ui.ts"; | |
| 28 | 27 | |
| 29 | 28 | /** A group's memory, and a cell's beside its cap and who holds the cap. */ |
| 30 | 29 | const memoryOf = (g: { rssBytes: number; capBytes: number | null; capBy: "kernel" | "watch" | null }): Html => |
| @@ -80,7 +79,7 @@ function memorySection(ctx: Ctx): Html { | ||
| 80 | 79 | const hours = timePhase(ctx.request, "memory", () => memoryHours(ctx.app.db, Date.now())); |
| 81 | 80 | const series = (title: string, pick: (h: (typeof hours)[number]) => number) => |
| 82 | 81 | card( |
| 83 | html`<div class="chart-box">${raw(drawChart("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title))}</div>`, | |
| 82 | chartBox("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title), | |
| 84 | 83 | { head: html`<h2>${title}</h2>` }, |
| 85 | 84 | ); |
| 86 | 85 | return hours.length === 0 |
src/web/ui.ts +9
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | tokenNote, |
| 22 | 22 | type TokenBasis, |
| 23 | 23 | } from "../kernel/tokens-format.ts"; |
| 24 | import { drawChart, type Cell, type Chart } from "../boards/chart.ts"; | |
| 24 | 25 | import { icon, type IconName } from "./icons.ts"; |
| 25 | 26 | import { Html, html, raw, when } from "./html.ts"; |
| 26 | 27 | |
| @@ -724,6 +725,14 @@ export function meter(percent: number, label: string, tone?: "attn"): Html { | ||
| 724 | 725 | >${value}%</meter>`; |
| 725 | 726 | } |
| 726 | 727 | |
| 728 | /** | |
| 729 | * A drawn chart in the box that holds its space: `drawChart`'s svg `.chart` | |
| 730 | * inside `.chart-box`, which a table panel marks `.is-table`. | |
| 731 | */ | |
| 732 | export function chartBox(chart: Chart, cells: readonly Cell[], title: string): Html { | |
| 733 | return html`<div class="chart-box${chart === "table" ? " is-table" : ""}">${raw(drawChart(chart, cells, title))}</div>`; | |
| 734 | } | |
| 735 | ||
| 727 | 736 | /** A short object id in a monospace chip — always the first 7 characters. */ |
| 728 | 737 | export function sha(oid: string, href?: string): Html { |
| 729 | 738 | const short = oid.slice(0, 7); |
test/boards-page.test.ts +5 −1
| @@ -184,7 +184,9 @@ describe("every chart type from one seeded query", () => { | ||
| 184 | 184 | const count = (body: string, re: RegExp) => body.match(re)?.length ?? 0; |
| 185 | 185 | |
| 186 | 186 | test("line draws one point per day, the series set aside", async () => { |
| 187 | expect(count(await drawn("line"), /<circle cx="[^"]*" cy="[^"]*" r="4">/g)).toBe(3); | |
| 187 | const body = await drawn("line"); | |
| 188 | expect(count(body, /<circle cx="[^"]*" cy="[^"]*" r="4">/g)).toBe(3); | |
| 189 | expect(count(body, /<div class="chart-box"><svg [^>]*class="chart"/g)).toBe(1); | |
| 188 | 190 | }); |
| 189 | 191 | test("bar draws one bar per day", async () => { |
| 190 | 192 | expect(count(await drawn("bar"), /<rect class="bar"/g)).toBe(3); |
| @@ -206,6 +208,8 @@ describe("every chart type from one seeded query", () => { | ||
| 206 | 208 | const table = body.match(/<table class="table chart-table">[\s\S]*?<\/table>/)![0]; |
| 207 | 209 | expect(count(table, /<tr>/g)).toBe(1 + 5); |
| 208 | 210 | expect(count(body, /<table/g)).toBe(1); |
| 211 | // The panel is chartBox()'s frame, marked as a table (#4282). | |
| 212 | expect(body).toContain('<div class="chart-box is-table">'); | |
| 209 | 213 | }); |
| 210 | 214 | test("heat strip draws days × kinds cells; each filled cell links to its rows", async () => { |
| 211 | 215 | const body = await drawn("heat"); |
test/system-memory.test.ts +2
| @@ -80,6 +80,8 @@ describe("GET /system", () => { | ||
| 80 | 80 | expect(body).toContain("RSS (MiB, hourly mean)"); |
| 81 | 81 | expect(body).toContain("JS heap (MiB, hourly mean)"); |
| 82 | 82 | expect(body.match(/<svg/g)?.length).toBeGreaterThanOrEqual(2); |
| 83 | // Both series are drawn in chartBox()'s frame (#4282). | |
| 84 | expect(body.match(/<div class="chart-box"><svg [^>]*class="chart"/g)?.length).toBe(2); | |
| 83 | 85 | expect(body).toContain("120.0 MiB"); |
| 84 | 86 | expect(body).toContain("40.0 MiB"); |
| 85 | 87 | }); |
test/ui-chart-box.test.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The chart frame (#4282): `chartBox()` is the one place `.chart-box` is | |
| 3 | * written, around `drawChart`'s svg, with `.is-table` for a table panel. | |
| 4 | * /system and the board pages assert they send this frame in their own tests. | |
| 5 | */ | |
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { drawChart } from "../src/boards/chart.ts"; | |
| 8 | import { chartBox } from "../src/web/ui.ts"; | |
| 9 | ||
| 10 | const cells = [{ label: "a", value: 1 }, { label: "b", value: 2 }]; | |
| 11 | ||
| 12 | describe("chartBox", () => { | |
| 13 | test("wraps drawChart's svg in the frame", () => { | |
| 14 | expect(String(chartBox("line", cells, "Runs"))).toBe(`<div class="chart-box">${drawChart("line", cells, "Runs")}</div>`); | |
| 15 | expect(String(chartBox("number", cells, "Runs"))).toMatch(/^<div class="chart-box"><svg[^>]*class="chart"[\s\S]*<text class="big"/); | |
| 16 | }); | |
| 17 | ||
| 18 | test("a table panel carries is-table", () => { | |
| 19 | expect(String(chartBox("table", cells, "Runs"))).toStartWith(`<div class="chart-box is-table">`); | |
| 20 | expect(String(chartBox("bar", cells, "Runs"))).not.toContain("is-table"); | |
| 21 | }); | |
| 22 | ||
| 23 | test("the title is escaped", () => { | |
| 24 | expect(String(chartBox("line", cells, `<script>"`))).not.toContain("<script>"); | |
| 25 | }); | |
| 26 | }); |
new above
offline