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

Merged opened this

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

Merged

scout/forge:scout/3981-5-gains-a-stat-tile-stat-in-ui-ts-and-a → main 1 commit fork

  • Merged. scout/3981-5-gains-a-stat-tile-stat-in-ui-ts-and-a was merged into main as 6d94b7a.

Review

Sign in to review this change.
4 files changed +76 −0
docs/DESIGN.md +1
@@ -145,6 +145,7 @@ specification.
145145 | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line |
146146 | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` |
147147 | **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 |
148149 | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` |
149150 | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue |
150151 | **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; }
152152 .passkey-lead > .btn { width: 100%; }
153153 .passkey-lead > .passkey-note:not(:empty) { margin: 8px 0 0; }
154154
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
155172 /* ---- badge / label chip / pill / dot ---- */
156173 .badge {
157174 display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px;
src/web/ui.ts +22
@@ -237,6 +237,28 @@ export function badge(
237237 >`;
238238 }
239239
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
240262 /** The one badge whose value is a state machine, so it renders identically
241263 * on issues, pulls and lists. */
242264 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("&lt;b&gt;");
30 expect(h).toContain("&lt;script&gt;");
31 expect(h).not.toContain('"x');
32 });
33
34 test("the stats grid is defined", () => {
35 expect(components).toContain("minmax(160px, 1fr)");
36 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs