Public: anyone can read this; the crew can change it.
An empty list says so in a plain muted block, not a boxed card, everywhere `empty()` is used #10018
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10017-an-empty-list-says-so-in-a-plain-muted-b → main 3 commits fork 1 thread · 1 unresolved
10 files
changed +63
−9
docs/design/sections/5-components.md +1 −1
| @@ -42,7 +42,7 @@ specification. | ||
| 42 | 42 | | **breadcrumb** `crumbs()` | `.crumbs` | `.mono` | `aria-current` on the last | `a / b / c` | |
| 43 | 43 | | **pagination** `pager()` | `.pager` | — | cursor links, absent ends | `‹ Newer Page 2 Older ›` | |
| 44 | 44 | | **timeline item** `tlEvent()` / `tlComment()` | `.tl-item` | `data-kind=comment\|event`, `data-tone` | — | a card, or one dim line with a glyph | |
| 45 | | **empty state** `empty()` | `.card.empty`, title `.empty-title` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered | | |
| 45 | | **empty state** `empty()` | `.empty` (plain muted block, no box), title `.empty-title` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered | | |
| 46 | 46 | | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section | |
| 47 | 47 | | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter | |
| 48 | 48 | | **prose** | `.prose` | `data-text=message`: a `<pre>` of written text that wraps, for commit messages and reports | 15px, 72ch measure; `data-text=message` is 12.5px | wrapped markdown | |
src/web/diff-view.ts +1 −1
| @@ -571,7 +571,7 @@ export function renderDiff( | ||
| 571 | 571 | const opts: DiffViewOptions = |
| 572 | 572 | options instanceof Map ? { syntax: options } : (options ?? {}); |
| 573 | 573 | if (diff.files.length === 0) { |
| 574 | return html`<div class="card empty">No changes.</div>`; | |
| 574 | return html`<div class="empty">No changes.</div>`; | |
| 575 | 575 | } |
| 576 | 576 | const shown = diff.files.slice(0, MAX_DIFF_FILES); |
| 577 | 577 | const hidden = diff.files.length - shown.length; |
src/web/pages/map.ts +1 −1
| @@ -1186,7 +1186,7 @@ export const mapCss = /* css */ ` | ||
| 1186 | 1186 | #lanes { margin-top: 16px; } |
| 1187 | 1187 | #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } |
| 1188 | 1188 | #lanes li[data-idle] .row-title { color: var(--muted-fg); } |
| 1189 | #lanes details > .card.empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |
| 1189 | #lanes details > .empty { border-top: 1px solid var(--border); } | |
| 1190 | 1190 | #lanes details > .map-canvas { border: 0; border-top: 1px solid var(--border); border-radius: 0; } |
| 1191 | 1191 | @media (prefers-reduced-motion: reduce) { |
| 1192 | 1192 | .map-card, .map-title { transition: none; } |
src/web/style/components.ts −1
| @@ -919,7 +919,6 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 919 | 919 | } |
| 920 | 920 | .empty p { font-size: 13px; max-width: 320px; text-wrap: pretty; margin: 0; } |
| 921 | 921 | .empty .btn { margin-top: 8px; } |
| 922 | .card.empty { border-style: dashed; box-shadow: none; } | |
| 923 | 922 | |
| 924 | 923 | /* ---- board panel grid: a board's panels, on /boards and pinned in the studio ---- */ |
| 925 | 924 | .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } |
src/web/ui.ts +1 −1
| @@ -1343,7 +1343,7 @@ export function empty(o: { | ||
| 1343 | 1343 | note?: string; |
| 1344 | 1344 | action?: Html; |
| 1345 | 1345 | }): Html { |
| 1346 | return html`<div class="card empty"> | |
| 1346 | return html`<div class="empty"> | |
| 1347 | 1347 | ${o.icon ? icon(o.icon, 20) : null} |
| 1348 | 1348 | <p class="empty-title">${o.title}</p> |
| 1349 | 1349 | ${o.note ? html`<p>${o.note}</p>` : null} ${o.action ?? null} |
test/empty-language.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** An empty list is a plain muted block, never a boxed card (#10017). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { renderDiff } from "../src/web/diff-view.ts"; | |
| 12 | import { empty } from "../src/web/ui.ts"; | |
| 13 | ||
| 14 | let dir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let cookie: string; | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-empty-lang-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | servers = await serve(app); | |
| 23 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "root", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 24 | if (!r.ok) throw new Error(r.error); | |
| 25 | cookie = `forge_session=${createSession(app.db, r.value.id).token}`; | |
| 26 | const repo = await createRepo(app, r.value, { name: "notes", visibility: "public" }); | |
| 27 | if (!repo.ok) throw new Error("repo"); | |
| 28 | }); | |
| 29 | afterAll(async () => { | |
| 30 | servers.stop(); | |
| 31 | app.close(); | |
| 32 | await rm(dir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | test("empty() is a plain block and escapes its title and note", () => { | |
| 36 | const out = String(empty({ title: "<b>None</b>", note: "<i>x</i>" })); | |
| 37 | expect(out).toContain('<div class="empty">'); | |
| 38 | expect(out).not.toContain("card empty"); | |
| 39 | expect(out).not.toContain("<b>"); | |
| 40 | expect(out).not.toContain("<i>"); | |
| 41 | expect(out).toContain("<b>None"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("an empty diff is the plain block", () => { | |
| 45 | const out = String(renderDiff({ files: [], additions: 0, deletions: 0 } as never)); | |
| 46 | expect(out).toContain('class="empty"'); | |
| 47 | expect(out).not.toContain("card empty"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a repository with no issues shows the plain block", async () => { | |
| 51 | const res = await fetch(servers.url + "/root/notes/issues", { headers: { cookie } }); | |
| 52 | const html = await res.text(); | |
| 53 | expect(html).toContain('class="empty"'); | |
| 54 | expect(html).not.toContain("card empty"); | |
| 55 | }); |
test/map.test.ts +1 −1
| @@ -576,7 +576,7 @@ describe("the map, per app and across the forge", () => { | ||
| 576 | 576 | empty: { title: "Nothing yet", note: "Say one." }, |
| 577 | 577 | }), |
| 578 | 578 | ); |
| 579 | expect(out).toContain('class="card empty"'); | |
| 579 | expect(out).toContain('class="empty"'); | |
| 580 | 580 | expect(out).toContain("Nothing yet"); |
| 581 | 581 | expect(out).toContain("Say one."); |
| 582 | 582 | expect(out).not.toContain("map-empty"); |
test/now-language.test.ts +1 −1
| @@ -67,6 +67,6 @@ test("an empty stage says so in one line", async () => { | ||
| 67 | 67 | const html = await (await get(`/now/${stage}`)).text(); |
| 68 | 68 | expect(html).toContain(`<p class="muted">Nothing in ${NOW_WORDS[stage].toLowerCase()} right now.</p>`); |
| 69 | 69 | expect(html.split(NOW_HOLDS[stage]).length - 1).toBe(1); |
| 70 | expect(html).not.toContain('class="card empty"'); | |
| 70 | expect(html).not.toContain('class="empty"'); | |
| 71 | 71 | expect(html).not.toContain("<ul class=\"list"); |
| 72 | 72 | }); |
test/passkeys-page.test.ts +1 −1
| @@ -40,7 +40,7 @@ const main = async () => { | ||
| 40 | 40 | test("with none, the page says so in a sentence, not an empty-state box", async () => { |
| 41 | 41 | const page = await main(); |
| 42 | 42 | expect(page).toContain("No passkeys yet."); |
| 43 | expect(page).not.toContain('class="card empty"'); | |
| 43 | expect(page).not.toContain('class="empty"'); | |
| 44 | 44 | expect(page).toContain('id="passkey-add"'); |
| 45 | 45 | }); |
| 46 | 46 |
test/traffic-history.test.ts +1 −1
| @@ -197,7 +197,7 @@ test("an empty window is one sentence, not a card", async () => { | ||
| 197 | 197 | expect(text).toContain("No requests in the last 15 minutes"); |
| 198 | 198 | expect(text).not.toContain("empty-title"); |
| 199 | 199 | } |
| 200 | expect(text).not.toContain('class="card empty"'); | |
| 200 | expect(text).not.toContain('class="empty"'); | |
| 201 | 201 | }); |
| 202 | 202 | |
| 203 | 203 | test("a deleted repository is named, with how; a private one only to who answers for it", async () => { |
Principles 6/7: good, no box. Update docs/design/sections/5-components.md row for empty state (still says
.card.empty) to match this markup.