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

design-lint names principle 7 when a card() wraps a single sentence, and the pages that do it today draw a row instead #11194

Merged opened this

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

Merged

mason/forge:mason/11190-design-lint-names-principle-7-when-a-car → main 8 commits fork 5 threads · 5 unresolved

  • Merged. mason/11190-design-lint-names-principle-7-when-a-car was merged into main as d43c707.

Review

Sign in to review this change.
9 files changed +127 −34
plugins/claude-code/.claude-plugin/plugin.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "name": "forge",
33 "version": "0.2.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, plan, preview, pulls, runs, log, asked).",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked).",
55 "mcpServers": {
66 "forge": {
77 "type": "http",
scripts/design-lint.ts +59 −1
@@ -436,6 +436,64 @@ export function scanBanner(root: string): string[] {
436436 .flatMap((f) => banner(readFileSync(f, "utf8")).map((b) => `${relative(root, f)}:${b.line} ${b.text} → a standing alert on home or the board: say it as one line on its own settings page and in the system page (a transient flash carries data-flash)`));
437437 }
438438
439 /**
440 * Principle 7 (#11190): a fact is a `dl` row, not a card. A `card(` from

Enforcement is textual and first-argument only: a card whose body is one <p> with a nested backtick, or an Html variable, passes silently. The comment says so; acceptable, noted.

441 * `src/web/ui.ts` whose body is one `<p>` or a bare line of text, with no list,
442 * form or `dl`, is a box around a sentence (principle 6). Textual and narrow:
443 * only a first argument that is an `html` template holding exactly that.
444 * The same box drawn through `listCard([row({ title })])` — one row with no
445 * link, lead, meta or trail — is refused too: a row is a thing you can open or
446 * read facts off; a lone title is a sentence. A template with a nested backtick
447 * (an `html` inside `${}`) is not examined: the scan reads the first argument
outdated

Principle 7: this flags only card(); the five fixes swapped to listCard with one row, which draws the same box. Make the rule (or the pages) close that gap.

448 * as flat text and passes what it cannot read.
449 * `CARD_ALLOW` is the short list of pages that cannot change yet, one reason
450 * each; it only shrinks.
451 */
452 export const CARD_ALLOW: Record<string, string> = {};
453 export type CardFinding = { line: number; text: string };
454
455 /** The first argument of the call whose `(` is at `open`: up to the top-level comma or the close. */
456 function firstArg(code: string, open: number): string {
457 let depth = 0, tpl = 0;
458 for (let i = open + 1; i < code.length; i++) {
459 const c = code[i]!;
460 if (c === "\\") { i++; continue; }
461 if (c === "`") { tpl = tpl ? 0 : 1; continue; }
462 if (tpl) continue;
463 if (c === "(" || c === "{" || c === "[") depth++;
464 else if (c === ")" || c === "}" || c === "]") { if (depth-- === 0) return code.slice(open + 1, i); }
465 else if (c === "," && depth === 0) return code.slice(open + 1, i);
466 }
467 return code.slice(open + 1);
468 }
469
470 export function loneSentenceCards(text: string): CardFinding[] {
471 const code = blanked(text);
472 const out: CardFinding[] = [];
473 for (const m of code.matchAll(/(?<![\w.$])card\(/g)) {
474 if (/function\s+$/.test(code.slice(Math.max(0, m.index! - 12), m.index!))) continue;
475 const arg = firstArg(code, m.index! + m[0].length - 1).trim();
476 const body = /^html`([^`]*)`$/.exec(arg)?.[1]?.trim();
477 if (body === undefined || /<(?:ul|ol|dl|form|table|pre|h[1-6]|div|section|details)\b/i.test(body)) continue;
478 const one = /^<p\b[^>]*>(?:(?!<p\b)[\s\S])*<\/p>$/i.test(body);
479 const bare = !/</.test(body) && /[A-Za-z]{2}/.test(body.replace(/\$\{[^}]*\}/g, ""));
480 if (one || bare) out.push({ line: code.slice(0, m.index!).split("\n").length, text: arg.replace(/\s+/g, " ").slice(0, 70) });
481 }
482 for (const m of code.matchAll(/(?<![\w.$])listCard\(/g)) {
483 if (/function\s+$/.test(code.slice(Math.max(0, m.index! - 12), m.index!))) continue;
484 const arg = firstArg(code, m.index! + m[0].length - 1).trim();
485 const inner = /^\[\s*row\(\{([\s\S]*)\}\)\s*,?\s*\]$/.exec(arg)?.[1];
486 if (inner === undefined || /(?<![\w.])row\(/.test(inner) || /(?:^|[{,])\s*(?:href|lead|meta|trail)\s*[:,}]/.test(inner.replace(/\s+/g, " ").replace(/^/, "{"))) continue;
487 out.push({ line: code.slice(0, m.index!).split("\n").length, text: arg.replace(/\s+/g, " ").slice(0, 70) });
488 }
489 return out;
490 }
491 export function scanCards(root: string): string[] {
492 return sourceFiles(root)
493 .filter((f) => !(relative(root, f) in CARD_ALLOW))
494 .flatMap((f) => loneSentenceCards(readFileSync(f, "utf8")).map((c) => `${relative(root, f)}:${c.line} card(${c.text}…) → principle 7 (docs/DESIGN.md): a card (or a listCard of one bare row) around one sentence is a box around a thing; put the sentence directly under the page head, or make it a dl row`));
495 }
496
439497 const BASELINE = "docs/design/baseline.json";
440498 const DESIGN = "docs/DESIGN.md";
441499 const SECTIONS = "docs/design/sections";
@@ -452,7 +510,7 @@ if (import.meta.main) {
452510 } else {
453511 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
454512 const debt: QuietDebt = JSON.parse(readFileSync(join(root, QUIET_DEBT), "utf8"));
455 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root)];
513 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...scanCards(root)];
456514 for (const p of problems) console.error(p);
457515 if (problems.length) process.exit(1);
458516 console.log("design: no colour, size or component added outside the system");
src/web/studio/asks_page.ts +8 −12
@@ -353,14 +353,13 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht
353353 );
354354 }
355355
356 const forgeCard = (d: Degraded): Html =>
357 card(html`<p><a href="${d.href}">${needLine(d)}</a></p>`, {
358 tone: "danger",
359 head: html`${icon("shield")}
360 <h2>The forge</h2>
361 <span class="spacer"></span>
362 ${btn("Health", { href: "/api/v1/health", size: "sm" })}`,
363 });
356 /** The forge is degraded: an alert line, marked in the danger tone, under its head; Health on its own row so it never wraps into the sentence. */
357 export const forgeCard = (d: Degraded): Html =>
outdated

Principle 7 asks for a sentence under the head, not for dropping the signal. The forge-is-degraded line used to be tone danger with a shield icon; now it is plain prose in a bare section, so the one thing on /asks that says the forge is unhealthy no longer stands out. Give the line role="alert" (as nothingDone does) and keep the danger colour through the existing token, not a card. The Health button inside a <p> will also wrap awkwardly at 360px; put it on its own row or drop it.

358 html`<section class="stack">
359 <h2>The forge</h2>
360 <p role="alert"><span class="dot" data-tone="danger" aria-hidden="true"></span> <a href="${d.href}">${needLine(d)}</a></p>
361 <div class="bar">${btn("Health", { href: "/api/v1/health", size: "sm" })}</div>
362 </section>`;
364363
365364 /** What sits above the asks after a bulk step: a preview to confirm, a receipt, or why nothing was done. */
366365 export interface AsksTop {
@@ -627,10 +626,7 @@ const bulkCard = (head: string, body: Html, foot: Html | null): Html =>
627626 });
628627
629628 const nothingDone = (message: string): AsksTop => ({
630 banner: card(html`<p>Nothing done: ${message}.</p>`, {
631 id: "asks-bulk-result",
632 tone: "danger",
633 }),
629 banner: html`<p id="asks-bulk-result" role="alert">Nothing done: ${message}.</p>`,
634630 });
635631
636632 /** The receipt of a bulk action on asks, for the person who acted; nothing for anyone else. */
src/web/studio/direction.ts +4 −4
@@ -29,10 +29,10 @@ export function directionMarked(direction: string): Html | null {
2929 export function visionPreviewCard(preview: VisionPreview): Html {
3030 const head = html`${icon("code")}<h2>Direction after the merge</h2>`;
3131 if (!preview.ok)
32 return card(html`<p class="muted small">An admin's merge would leave the direction as it is: ${preview.why}.</p>`, {
33 head,
34 id: "direction-preview",
35 });
32 return html`<section id="direction-preview" class="stack">
33 <h2>Direction after the merge</h2>
34 <p class="muted">An admin's merge would leave the direction as it is: ${preview.why}.</p>
35 </section>`;
3636 const same = preview.today === preview.after;
3737 return card(
3838 html`${same ? html`<p class="muted small">The merge changes nothing in the direction.</p>` : null}
src/web/studio/now.ts +6 −9
@@ -26,7 +26,7 @@ import { loadRepo, repoBase } from "../chrome.ts";
2626 import { html, type Html } from "../html.ts";
2727 import { flatten, noteLine } from "./note.ts";
2828 import { fragment, missingFragment } from "../layout.ts";
29 import { avatar, badge, btn, card, icon, listCard, row, time } from "../ui.ts";
29 import { avatar, badge, btn, icon, listCard, row, time } from "../ui.ts";
3030 import { changeHref, tellHref } from "./nouns.ts";
3131 import { LOG_TAIL_CAP, logBox } from "./log.ts";
3232 import { appPanels } from "./previews.ts";
@@ -325,17 +325,14 @@ export async function nowCard(
325325 ];
326326 const now =
327327 nowRows.length === 0
328 ? card(
329 html`<p class="muted small">
328 ? html`<section class="stack">
329 <h2>Now</h2>
330 <p class="muted">
330331 ${deployTailing
331332 ? "Production is coming up — the log above has it."
332333 : "Nothing yet. Tell the crew something."}
333 </p>`,
334 {
335 head: html`${icon("activity")}
336 <h2>Now</h2>`,
337 },
338 )
334 </p>
335 </section>`
339336 : listCard(nowRows, {
340337 head: html`${icon("activity")}
341338 <h2>Now</h2>
src/web/studio/tell.ts +2 −5
@@ -394,11 +394,8 @@ async function tellClosingPull(
394394 ): Promise<Html> {
395395 const base = repoBase(repo);
396396 const { word, note } = await pullWord(wordCtx, pull);
397 return html`${card(
398 html`<p>
399 <a href="${changeHref(base, pull.number)}">${pull.title}</a>
400 ${wordBadge(word)} <span class="muted small">${note}</span>
401 </p>`,
397 return html`${listCard(
398 [row({ href: changeHref(base, pull.number), title: pull.title, meta: html`${note}`, trail: wordBadge(word) })],

Principle 6: still a boxed one-row card. Fine as a row if it stays a list of pulls; check it looks right on a 360px phone and attach the looks.

402399 { head: html`${icon("pull")} <h2>Closes with</h2>` },
403400 )}${previewBlock(ctx, repo, pull)}`;
404401 }
test/asks-forge-alert.test.ts added +16
@@ -0,0 +1,16 @@
1 import { expect, test } from "bun:test";
2 import { forgeCard } from "../src/web/studio/asks_page.ts";
3
4 // Principle 7 took the card away; the line must still read as an alert.
5 test("a degraded forge on /asks is an alert line in the danger tone, with Health on its own row", () => {
6 const out = String(forgeCard({ cause: "errors", sentence: "Errors <now>.", since: "2020-01-01T00:00:00.000Z", href: "/x" }));
7 const line = /<p role="alert">([\s\S]*?)<\/p>/.exec(out);
8 expect(line).not.toBeNull();
9 expect(line![1]).toContain('class="dot" data-tone="danger"');
10 expect(line![1]).toContain('href="/x"');
11 expect(line![1]).not.toContain("/api/v1/health");
12 expect(out).toContain('<div class="bar">');
13 expect(out).toContain("/api/v1/health");
14 expect(out).not.toContain("<now>");
15 expect(out).not.toContain('class="card');
16 });
test/design-lint.test.ts +30 −1
@@ -1,6 +1,6 @@
11 import { describe, expect, test } from "bun:test";
22 import { readdirSync, readFileSync } from "node:fs";
3 import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, countFile, designText, documentedClasses, locate, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
3 import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, scanCards, countFile, designText, documentedClasses, locate, loneSentenceCards, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
44
55 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
66 const DESIGN = [
@@ -257,3 +257,32 @@ describe("design check: the quiet-page rules (#8775)", () => {
257257 expect(compareQuiet(debt, scanQuiet("."))).toEqual([]);
258258 }, 60_000);
259259 });
260
261 describe("design check: a card around one sentence (#11190, principle 7)", () => {
262 test("flags a card that wraps one <p> or a bare line, not one holding a list or form", () => {
263 expect(loneSentenceCards("x = card(html`<p>Nothing yet.</p>`, { head });")).toHaveLength(1);
264 expect(loneSentenceCards("x = card(\n html`Nothing yet.`,\n { head });")).toHaveLength(1);
265 expect(loneSentenceCards("x = card(html`<p>a</p><ul class=\"list\"></ul>`);")).toEqual([]);
266 expect(loneSentenceCards("x = card(html`<form method=\"post\"><p>a</p></form>`);")).toEqual([]);
267 expect(loneSentenceCards("x = card(html`<dl><div><dt>a</dt><dd>b</dd></div></dl>`);")).toEqual([]);
268 expect(loneSentenceCards("function card(f, d) { return 1; } card(f, d);")).toEqual([]);
269 });
270
271 test("a listCard of one bare row is the same box; a row with a link or facts is a row", () => {
272 expect(loneSentenceCards("x = listCard([row({ title: `Nothing yet.` })], { head });")).toHaveLength(1);
273 expect(loneSentenceCards("x = listCard([row({ title: msg })]);")).toHaveLength(1);
274 expect(loneSentenceCards("x = listCard([row({ href: h, title: t })], { head });")).toEqual([]);
275 expect(loneSentenceCards("x = listCard([row({ title: t, meta: m, trail: b })]);")).toEqual([]);
276 expect(loneSentenceCards("x = listCard([row({ title: a }), row({ title: b })]);")).toEqual([]);
277 expect(loneSentenceCards("x = listCard(rows, { head });")).toEqual([]);
278 });
279
280 test("a template with a nested backtick is not examined (the scan reads flat text)", () => {
281 expect(loneSentenceCards("x = card(html`<p>${a ? `b` : `c`}</p>`, { head });")).toEqual([]);
282 });
283
284 test("no page draws one", () => {
285 const bad = scanCards(".");
286 expect(bad).toEqual([]);
287 });
288 });
test/plugin-claude-oauth.test.ts +1 −1
@@ -17,7 +17,7 @@ test("the description names every verb the command handles", async () => {
1717 const md = await Bun.file(`${dir}/commands/forge.md`).text();
1818 const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string;
1919 const verbs = list.split(",").map((v) => v.trim());
20 expect(verbs.length).toBe(10);
20 expect(verbs.length).toBe(12);

Unrelated to #11190 (fixes main's red verb test with the plugin.json change). The direction asks for small single-purpose pulls; name it in the description or land it separately.

2121 for (const v of verbs) expect(plugin.description).toContain(v);
2222 });
2323

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs