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

Design: the home KPIs are §5 stat tiles, and the kpi* classes leave §5d #4048

Merged opened this

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

Merged

mason/forge:mason/3982-design-the-home-kpis-are-5-stat-tiles-an → main 2 commits fork

  • Merged. mason/3982-design-the-home-kpis-are-5-stat-tiles-an was merged into main as 89d8dcc.

Review

Sign in to review this change.
11 files changed +62 −63
docs/DESIGN.md +2 −2
@@ -145,7 +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 |
148 | **stat** `stat()` | `.stat` (`.stat-label`, `.stat-value`, `.stat-note`, `.stat-spark`; grid: `.stats`) | `data-tone=attn` | hover (`--border-strong`, only when `href` makes it a link, named by `aria` when given); fixed heights, so a changing value shifts no neighbour; value is `.num` with its full form in `title`; note clamps to two lines, its whole text in `noteTitle`; the sparkline carries its own `role=img` and `aria-label`, or is decorative; `.stats` auto-fits columns of at least 160px | a number under its label, a note under that, an optional sparkline top right |
149149 | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` |
150150 | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue |
151151 | **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 |
@@ -400,7 +400,7 @@ gone, so this list only shrinks.
400400 - **`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`
401401 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
402402 - **`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-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`
403 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs`
403 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi-spark` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs`
404404
405405 ---
406406
src/web/studio/feed_style.ts +1 −6
@@ -19,12 +19,7 @@ export const feedCss = /* css */ `
1919 .composer textarea { width: 100%; min-height: 64px; resize: vertical; }
2020 .composer-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
2121 .composer-bar select { min-width: 0; max-width: 60%; }
22 /* The figures row: three cards at a fixed height, so first paint is final; the note wraps into two reserved lines. */
23 .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 12px; }
24 .kpi { display: grid; grid-template-rows: 20px 32px 40px 24px; align-content: start; gap: 2px; padding: 12px 14px; color: inherit; text-decoration: none; }
25 .kpi:hover { border-color: var(--border-strong); }
26 .kpi-value { font-size: 16px; line-height: 32px; font-weight: 600; }
27 .kpi-note { line-height: 20px; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow-wrap: anywhere; }
22 /* A day sparkline outside a stat tile: the app settings page's visitors card (daySpark). */
2823 .kpi-spark { display: block; color: var(--muted-fg); max-width: 100%; }
2924 /* Your apps: a narrow column on a desk, a tab away on a phone. */
3025 .home-tabs { display: none; margin-bottom: 12px; }
src/web/studio/kpi_row.ts +9 −13
@@ -3,8 +3,8 @@
33 * hour with a day's sparkline, the live version and since when, the median
44 * check time, the crew builders running of their slots, the crew's spend
55 * today of the daily ceiling, the age of the last off-box backup, visitors with 30 days as a sparkline, accounts signed in, and page speed.
6 * Server-rendered, one fixed-height card each, so nothing moves when it
7 * paints. Only the studio's words: shipped, change, check time.
6 * Server-rendered, one fixed-height §5 stat tile each, so nothing moves when
7 * it paints. Only the studio's words: shipped, change, check time.
88 */
99
1010 import type { Database } from "bun:sqlite";
@@ -14,7 +14,7 @@ import { cachedKpis } from "./home_cache.ts";
1414 import { escapeHtml, html, raw, type Html } from "../html.ts";
1515 import { BOOTED } from "../../release/gate.ts";
1616 import { running } from "../../kernel/version.ts";
17 import { sha, time } from "../ui.ts";
17 import { sha, stat, time } from "../ui.ts";
1818
1919 const W = 96;
2020 const H = 24;
@@ -25,8 +25,9 @@ function at(i: number, n: number, v: number, top: number): [string, string] {
2525 return [(i * step).toFixed(1), (H - 2 - (v / top) * (H - 4)).toFixed(1)];
2626 }
2727
28 function svg(label: string, body: string): Html {
29 return raw(`<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}">${body}</svg>`);
28 /** `cls` is for a sparkline outside a stat tile (the app settings page's), which styles itself. */
29 function svg(label: string, body: string, cls = ""): Html {
30 return raw(`<svg${cls === "" ? "" : ` class="${cls}"`} viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}">${body}</svg>`);
3031 }
3132
3233 /** A series oldest first as a fixed-size SVG polyline; the numbers are the only input. */
@@ -62,7 +63,7 @@ export function daySpark(days: readonly (number | null)[], label: string): Html
6263 : `<polyline points="${r.map((p) => p.join(",")).join(" ")}" fill="none" stroke="currentColor" stroke-width="1.5"/>`,
6364 )
6465 .join("");
65 return svg(escapeHtml(label), body);
66 return svg(escapeHtml(label), body, "kpi-spark");
6667 }
6768
6869 /** The last 24 hours, newest first in, oldest on the left out. */
@@ -76,12 +77,7 @@ export function visitorsSpark(days: readonly number[]): Html {
7677 }
7778
7879 function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null, tone: "attn" | null = null): Html {
79 return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}">
80 <span class="small muted">${label}</span>
81 <span class="kpi-value num" title="${full}">${value}</span>
82 <span class="kpi-note small muted" title="${title}">${note}</span>
83 ${extra}
84 </a>`;
80 return stat({ label, href, value, title: full, note, noteTitle: title, aria: `${label}: ${full}, ${title}`, spark: extra, ...(tone === null ? {} : { tone }) });
8581 }
8682
8783 /** The version this forge is running, and since when: the same source as `/releases`. */
@@ -93,7 +89,7 @@ export function liveNow(): { sha: string; at: string } | null {
9389 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html {
9490 const overdue = backupOverdue(k.backupAt, now);
9591 const rate = k.landed / k.landedHours.length;
96 return html`<section class="kpis" aria-label="The last 24 hours, as of this page load">
92 return html`<section class="stats mb" aria-label="The last 24 hours, as of this page load">
9793 ${tile(
9894 "Shipped per hour",
9995 "/board",
src/web/style/components.ts +1
@@ -160,6 +160,7 @@ fieldset.field > .check + .check { margin-top: 8px; }
160160 background: var(--card); color: var(--fg); text-decoration: none;
161161 }
162162 a.stat:hover { border-color: var(--border-strong); }
163 .stat[data-tone="attn"] { border-color: color-mix(in srgb, var(--attn) 40%, var(--border)); }
163164 .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); }
164165 .stat-value { display: block; height: 32px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 20px; font-weight: 600; line-height: 32px; }
165166 .stat-note {
src/web/ui.ts +17 −13
@@ -238,25 +238,29 @@ export function badge(
238238 }
239239
240240 /** 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. */
241 * with its full form in `title`; the note is clamped to two lines, its whole
242 * text in `noteTitle`; `spark` is trusted markup (an inline SVG) the caller
243 * built, labelled by its own `role="img"` and `aria-label` or by nothing. A
244 * link when `href` is set, named by `aria` when given; `tone` marks it. */
243245 export function stat(o: {
244246 label: string;
245 value: string | number;
247 value: string | number | Html;
246248 title?: string;
247 note?: string;
249 note?: string | Html;
250 noteTitle?: string;
248251 href?: string;
249 spark?: Html;
252 aria?: string;
253 tone?: "attn";
254 spark?: Html | null;
250255 }): 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}`;
256 const noteTitle = o.noteTitle === undefined ? null : html` title="${o.noteTitle}"`;
257 const body = html`<span class="stat-label">${o.label}</span><span class="stat-value num" title="${o.title ?? String(o.value)}">${o.value}</span>${o.note
258 ? html`<span class="stat-note"${noteTitle}>${o.note}</span>`
259 : null}${o.spark ? html`<span class="stat-spark">${o.spark}</span>` : null}`;
260 const tone = o.tone === undefined ? null : html` data-tone="${o.tone}"`;
257261 return o.href !== undefined
258 ? html`<a class="stat" href="${o.href}">${body}</a>`
259 : html`<div class="stat">${body}</div>`;
262 ? html`<a class="stat" href="${o.href}"${tone}${o.aria === undefined ? null : html` aria-label="${o.aria}"`}>${body}</a>`
263 : html`<div class="stat"${tone}>${body}</div>`;
260264 }
261265
262266 /** The one badge whose value is a state machine, so it renders identically
test/client-home.test.ts +2 −2
@@ -244,8 +244,8 @@ describe("a client's first screen", () => {
244244 });
245245
246246 test("a client sees no forge-wide figures, lean or whole", async () => {
247 expect(await page(owner)).not.toContain('class="kpis"');
248 expect(await page(owner, "/?all=1")).not.toContain('class="kpis"');
247 expect(await page(owner)).not.toContain('class="stats mb"');
248 expect(await page(owner, "/?all=1")).not.toContain('class="stats mb"');
249249 });
250250
251251 test("queued agents and no running ones read as about to start", async () => {
test/home-cache.test.ts +3 −3
@@ -31,7 +31,7 @@ afterAll(async () => {
3131
3232 test("a warm home reads neither the KPIs nor the panels again; a write does", async () => {
3333 const a = await home(f.ownerCookie);
34 expect(a).toContain('class="kpis"');
34 expect(a).toContain('class="stats mb"');
3535 expect(a).toContain("<h2>Pin A</h2>");
3636 const kpi = kpiCache(f.app.db);
3737 const panel = panelCache(f.app.db);
@@ -50,8 +50,8 @@ test("the owner's KPIs and panels never render for another viewer, warm or not",
5050 await home(f.ownerCookie);
5151 for (const cookie of [f.peerCookie, undefined]) {
5252 const page = await home(cookie);
53 expect(page).not.toContain('class="kpis"');
54 expect(page).not.toContain('class="card kpi"');
53 expect(page).not.toContain('class="stats mb"');
54 expect(page).not.toContain('class="stat"');
5555 expect(page).not.toContain("Pin A");
5656 }
5757 });
test/home-kpi-page.test.ts +9 −6
@@ -59,9 +59,12 @@ test("home renders the nine tiles; only an admin sees them", async () => {
5959 view(day, "a", 1900, null);
6060 view(yesterday, "z", 400, 160);
6161 const a = await home(admin);
62 expect(a).toContain('class="kpis"');
63 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(9);
64 for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`);
62 expect(a).toContain('class="stats mb"');
63 expect((a.match(/class="stat"/g) ?? []).length).toBe(9);
64 for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`);
65 // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home.
66 for (const name of ["kpis", "kpi", "kpi-value", "kpi-note", "kpi-spark"]) expect(a).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b(?!-)`));
67 expect(a).toContain('role="img" aria-label="Shipped each hour over the last 24 hours"');
6568
6669 // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days;
6770 // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms.
@@ -70,14 +73,14 @@ test("home renders the nine tiles; only an admin sees them", async () => {
7073 expect(a).toContain(">4 in 7 days<");
7174 expect(a).toContain(">400 ms<");
7275 expect(a).toContain(">reacts in 120 ms<");
73 expect((a.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3);
76 expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3);
7477
7578 // The figures count every repository, so a non-admin sees none.
7679 expect(a).toContain("no ceiling");
7780 expect(a).toContain("last 30 days");
78 expect(await home(bob)).not.toContain('class="kpis"');
81 expect(await home(bob)).not.toContain('class="stats mb"');
7982 expect(await home(bob)).not.toContain("last 30 days");
80 expect(await home()).not.toContain('class="kpis"');
83 expect(await home()).not.toContain('class="stats mb"');
8184 expect(await home()).not.toContain("last 30 days");
8285 });
8386
test/home-kpi-row.test.ts +11 −11
@@ -5,7 +5,7 @@ import { NO_REACH, readReach } from "../src/flow/reach.ts";
55 import { describe, expect, test } from "bun:test";
66 import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts";
77 import { running } from "../src/kernel/version.ts";
8 import { feedCss } from "../src/web/studio/feed_style.ts";
8 import { components } from "../src/web/style/components.ts";
99 import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts";
1010
1111 const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0));
@@ -75,24 +75,24 @@ describe("home kpi row", () => {
7575 });
7676
7777 test("the note wraps into two reserved lines instead of cutting off on a phone", () => {
78 const rule = (sel: string) => new RegExp(`\\${sel} \\{([^}]*)\\}`).exec(feedCss)![1]!;
79 expect(rule(".kpi-note")).not.toContain("nowrap");
80 expect(rule(".kpi-note")).toContain("line-height: 20px");
81 expect(rule(".kpi-note")).toContain("line-clamp: 2");
82 expect(rule(".kpi")).toContain("grid-template-rows: 20px 32px 40px 24px");
78 const rule = (sel: string) => new RegExp(`\\${sel} \\{([^}]*)\\}`).exec(components)![1]!;
79 expect(rule(".stat-note")).not.toContain("nowrap");
80 expect(rule(".stat-note")).toContain("line-clamp: 2");
81 expect(rule(".stat-note")).toContain("height: 36px");
82 expect(rule(".stat")).toContain("height: 104px");
8383 });
8484
8585 test("the builders tile says running of slots, titles it, and links to the crew", () => {
8686 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
8787 expect(out).toContain('href="/settings/crew"');
88 expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<');
88 expect(out).toContain('class="stat-value num" title="2 of 4">2 of 4<');
8989 expect(out).toContain('title="Crew builders running now, of 4 slots"');
9090 expect(out).toContain('aria-label="Builders running: 2 of 4, Crew builders running now, of 4 slots"');
9191 });
9292
9393 test("with no slots set the builders tile shows a dash and says so", () => {
9494 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
95 expect(out).toContain('class="kpi-value num" title="—">—<');
95 expect(out).toContain('class="stat-value num" title="—">—<');
9696 expect(out).toContain('title="No slots set">No slots set<');
9797 expect(out).toContain(`aria-label="Builders running: —, No slots set"`);
9898 expect(out).not.toContain("Builders running: 0 of 0");
@@ -155,7 +155,7 @@ describe("the spend tile", () => {
155155
156156 test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => {
157157 const out = row({ todayUsd: 12.34, ceilingUsd: 40 });
158 expect(out).toContain('class="kpi-value num" title="$12.34 of $40.00">$12 of $40<');
158 expect(out).toContain('class="stat-value num" title="$12.34 of $40.00">$12 of $40<');
159159 expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the daily ceiling"');
160160 expect(out).toContain(">of the daily ceiling<");
161161 });
@@ -198,7 +198,7 @@ describe("home kpi row: the last off-box backup", () => {
198198 const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
199199 const row = (backupAt: string | null) =>
200200 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, reach: NO_REACH, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString();
201 const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0];
201 const tile = (out: string) => /<a class="stat"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0];
202202
203203 test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => {
204204 const out = row(hoursAgo(2));
@@ -246,7 +246,7 @@ describe("the reach tiles", () => {
246246 const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 };
247247 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
248248 for (const v of ["5 today", "31 in 7 days", "3 of 12", "signed in today", "1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`);
249 expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3);
249 expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3);
250250 expect(out).not.toContain("INP");
251251 expect(out).not.toContain("p75");
252252 });
test/home-kpis.test.ts +5 −5
@@ -137,16 +137,16 @@ describe("home kpi row titles", () => {
137137 ).toString();
138138
139139 test("every shortened value and note carries its whole text in a title", () => {
140 expect(full).toContain('class="kpi-value num" title="abcdef0123456789"');
141 expect(full).toContain('class="kpi-value num" title="0.25"');
142 expect(full).toContain('class="kpi-value num" title="3m 05s"');
140 expect(full).toContain('class="stat-value num" title="abcdef0123456789"');
141 expect(full).toContain('class="stat-value num" title="0.25"');
142 expect(full).toContain('class="stat-value num" title="3m 05s"');
143143 expect(full).toContain('title="Live since 2026-10-19T11:00:00.000Z"');
144144 expect(full).toContain('title="6 changes in 24 h"');
145145 });
146146
147147 test("no value or note lacks a title, and none brings back a git word", () => {
148 expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(18);
149 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(18);
148 expect(full.match(/class="stat-(value num|note)"/g)).toHaveLength(18);
149 expect(full.match(/class="stat-(value num|note)" title=/g)).toHaveLength(18);
150150 expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i);
151151 });
152152 });
test/kpi-spark.test.ts +2 −2
@@ -50,13 +50,13 @@ describe("daySpark", () => {
5050 describe("the numbers-only sparklines are unchanged", () => {
5151 test("visitorsSpark", () => {
5252 expect(visitorsSpark([0, 2, 4]).toString()).toBe(
53 `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`,
53 `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`,
5454 );
5555 });
5656
5757 test("sparkline, newest first in, oldest on the left", () => {
5858 expect(sparkline([2, 0]).toString()).toBe(
59 `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`,
59 `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`,
6060 );
6161 });
6262 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs