Public: anyone can read this; the crew can change it.
§5 gains a stat tile: `stat()` in ui.ts and a `.stats` grid, listed in DESIGN.md's component table #3997
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3981-5-gains-a-stat-tile-stat-in-ui-ts-and-a → main 1 commit fork
4 files
changed +76
−0
docs/DESIGN.md +1
| @@ -145,6 +145,7 @@ specification. | ||
| 145 | 145 | | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line | |
| 146 | 146 | | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` | |
| 147 | 147 | | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color | |
| 148 | | **stat** `stat()` | `.stat` (`.stat-label`, `.stat-value`, `.stat-note`, `.stat-spark`; grid: `.stats`) | — | hover (`--border-strong`, only when `href` makes it a link); fixed heights, so a changing value shifts no neighbour; value is `.num` with its full form in `title`; note clamps to two lines; `.stats` auto-fits columns of at least 160px | a number under its label, a note under that, an optional sparkline top right | | |
| 148 | 149 | | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` | |
| 149 | 150 | | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue | |
| 150 | 151 | | **name mark** `nameMark()` | `.name-mark[data-mark]` | `owner` / `crew`, from the account | never typed: escaped names cannot make it | beside the name, after the link | |
src/web/style/components.ts +17
| @@ -152,6 +152,23 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 152 | 152 | .passkey-lead > .btn { width: 100%; } |
| 153 | 153 | .passkey-lead > .passkey-note:not(:empty) { margin: 8px 0 0; } |
| 154 | 154 | |
| 155 | /* ---- stat tile ---- fixed heights: a changing value shifts nothing */ | |
| 156 | .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } | |
| 157 | .stat { | |
| 158 | position: relative; display: block; box-sizing: border-box; height: 104px; padding: 10px 12px; | |
| 159 | overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); | |
| 160 | background: var(--card); color: var(--fg); text-decoration: none; | |
| 161 | } | |
| 162 | a.stat:hover { border-color: var(--border-strong); } | |
| 163 | .stat-label { display: block; height: 18px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-min); line-height: 18px; color: var(--muted-fg); } | |
| 164 | .stat-value { display: block; height: 32px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 20px; font-weight: 600; line-height: 32px; } | |
| 165 | .stat-note { | |
| 166 | display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; | |
| 167 | height: 36px; font-size: var(--fs-min); line-height: 18px; color: var(--muted-fg); | |
| 168 | } | |
| 169 | .stat-spark { position: absolute; top: 10px; right: 12px; width: 56px; height: 24px; color: var(--muted-fg); } | |
| 170 | .stat-spark svg { display: block; width: 100%; height: 100%; } | |
| 171 | ||
| 155 | 172 | /* ---- badge / label chip / pill / dot ---- */ |
| 156 | 173 | .badge { |
| 157 | 174 | display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; |
src/web/ui.ts +22
| @@ -237,6 +237,28 @@ export function badge( | ||
| 237 | 237 | >`; |
| 238 | 238 | } |
| 239 | 239 | |
| 240 | /** A number with a label: one tile of a `.stats` grid. The value is `.num` | |
| 241 | * with its full form in `title`; the note is clamped to two lines; `spark` is | |
| 242 | * trusted markup (an inline SVG) the caller built. A link when `href` is set. */ | |
| 243 | export function stat(o: { | |
| 244 | label: string; | |
| 245 | value: string | number; | |
| 246 | title?: string; | |
| 247 | note?: string; | |
| 248 | href?: string; | |
| 249 | spark?: Html; | |
| 250 | }): Html { | |
| 251 | const body = html`<span class="stat-label">${o.label}</span | |
| 252 | ><span class="stat-value num" title="${o.title ?? String(o.value)}" | |
| 253 | >${String(o.value)}</span | |
| 254 | >${o.note ? html`<span class="stat-note">${o.note}</span>` : null}${o.spark | |
| 255 | ? html`<span class="stat-spark" aria-hidden="true">${o.spark}</span>` | |
| 256 | : null}`; | |
| 257 | return o.href !== undefined | |
| 258 | ? html`<a class="stat" href="${o.href}">${body}</a>` | |
| 259 | : html`<div class="stat">${body}</div>`; | |
| 260 | } | |
| 261 | ||
| 240 | 262 | /** The one badge whose value is a state machine, so it renders identically |
| 241 | 263 | * on issues, pulls and lists. */ |
| 242 | 264 | export function stateBadge( |
test/ui-stat.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { raw } from "../src/web/html.ts"; | |
| 3 | import { stat } from "../src/web/ui.ts"; | |
| 4 | import { components } from "../src/web/style/components.ts"; | |
| 5 | ||
| 6 | test("stat renders a div tile without href", () => { | |
| 7 | const h = String(stat({ label: "Pulls", value: 1234, title: "1,234 pulls" })); | |
| 8 | expect(h).toContain('<div class="stat">'); | |
| 9 | expect(h).toContain('title="1,234 pulls"'); | |
| 10 | expect(h).toContain("stat-value num"); | |
| 11 | expect(h).not.toContain("stat-note"); | |
| 12 | expect(h).not.toContain("<a "); | |
| 13 | }); | |
| 14 | ||
| 15 | test("stat is a link with href, and shows note and spark", () => { | |
| 16 | const h = String( | |
| 17 | stat({ label: "L", value: "7", note: "n", href: "/x", spark: raw("<svg></svg>") }), | |
| 18 | ); | |
| 19 | expect(h).toContain('<a class="stat" href="/x">'); | |
| 20 | expect(h).toContain('<span class="stat-note">n</span>'); | |
| 21 | expect(h).toContain("<svg></svg>"); | |
| 22 | }); | |
| 23 | ||
| 24 | test("stat escapes label, value, note and href", () => { | |
| 25 | const h = String(stat({ label: "<b>", value: "<i>", note: "<script>", href: '/"x' })); | |
| 26 | expect(h).not.toContain("<b>"); | |
| 27 | expect(h).not.toContain("<i>"); | |
| 28 | expect(h).not.toContain("<script>"); | |
| 29 | expect(h).toContain("<b>"); | |
| 30 | expect(h).toContain("<script>"); | |
| 31 | expect(h).not.toContain('"x'); | |
| 32 | }); | |
| 33 | ||
| 34 | test("the stats grid is defined", () => { | |
| 35 | expect(components).toContain("minmax(160px, 1fr)"); | |
| 36 | }); |
new above
offline