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

Merged opened this

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

Merged

mason/forge:mason/10017-an-empty-list-says-so-in-a-plain-muted-b → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/10017-an-empty-list-says-so-in-a-plain-muted-b was merged into main as 004dc24.

Review

Sign in to review this change.
10 files changed +63 −9
docs/design/sections/5-components.md +1 −1
@@ -42,7 +42,7 @@ specification.
4242 | **breadcrumb** `crumbs()` | `.crumbs` | `.mono` | `aria-current` on the last | `a / b / c` |
4343 | **pagination** `pager()` | `.pager` | — | cursor links, absent ends | `‹ Newer Page 2 Older ›` |
4444 | **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 |
4646 | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section |
4747 | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter |
4848 | **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(
571571 const opts: DiffViewOptions =
572572 options instanceof Map ? { syntax: options } : (options ?? {});
573573 if (diff.files.length === 0) {
574 return html`<div class="card empty">No changes.</div>`;
574 return html`<div class="empty">No changes.</div>`;
575575 }
576576 const shown = diff.files.slice(0, MAX_DIFF_FILES);
577577 const hidden = diff.files.length - shown.length;
src/web/pages/map.ts +1 −1
@@ -1186,7 +1186,7 @@ export const mapCss = /* css */ `
11861186 #lanes { margin-top: 16px; }
11871187 #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); }
11881188 #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); }
11901190 #lanes details > .map-canvas { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
11911191 @media (prefers-reduced-motion: reduce) {
11921192 .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); }
919919 }
920920 .empty p { font-size: 13px; max-width: 320px; text-wrap: pretty; margin: 0; }
921921 .empty .btn { margin-top: 8px; }
922 .card.empty { border-style: dashed; box-shadow: none; }
923922
924923 /* ---- board panel grid: a board's panels, on /boards and pinned in the studio ---- */
925924 .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: {
13431343 note?: string;
13441344 action?: Html;
13451345 }): Html {
1346 return html`<div class="card empty">
1346 return html`<div class="empty">

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.

13471347 ${o.icon ? icon(o.icon, 20) : null}
13481348 <p class="empty-title">${o.title}</p>
13491349 ${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("&lt;b&gt;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", () => {
576576 empty: { title: "Nothing yet", note: "Say one." },
577577 }),
578578 );
579 expect(out).toContain('class="card empty"');
579 expect(out).toContain('class="empty"');
580580 expect(out).toContain("Nothing yet");
581581 expect(out).toContain("Say one.");
582582 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 () => {
6767 const html = await (await get(`/now/${stage}`)).text();
6868 expect(html).toContain(`<p class="muted">Nothing in ${NOW_WORDS[stage].toLowerCase()} right now.</p>`);
6969 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"');
7171 expect(html).not.toContain("<ul class=\"list");
7272 });
test/passkeys-page.test.ts +1 −1
@@ -40,7 +40,7 @@ const main = async () => {
4040 test("with none, the page says so in a sentence, not an empty-state box", async () => {
4141 const page = await main();
4242 expect(page).toContain("No passkeys yet.");
43 expect(page).not.toContain('class="card empty"');
43 expect(page).not.toContain('class="empty"');
4444 expect(page).toContain('id="passkey-add"');
4545 });
4646
test/traffic-history.test.ts +1 −1
@@ -197,7 +197,7 @@ test("an empty window is one sentence, not a card", async () => {
197197 expect(text).toContain("No requests in the last 15 minutes");
198198 expect(text).not.toContain("empty-title");
199199 }
200 expect(text).not.toContain('class="card empty"');
200 expect(text).not.toContain('class="empty"');
201201 });
202202
203203 test("a deleted repository is named, with how; a private one only to who answers for it", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs