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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11190-design-lint-names-principle-7-when-a-car → main 8 commits fork 5 threads · 5 unresolved
plugins/claude-code/.claude-plugin/plugin.json +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "forge", |
| 3 | 3 | "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).", | |
| 5 | 5 | "mcpServers": { |
| 6 | 6 | "forge": { |
| 7 | 7 | "type": "http", |
scripts/design-lint.ts +59 −1
| @@ -436,6 +436,64 @@ export function scanBanner(root: string): string[] { | ||
| 436 | 436 | .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)`)); |
| 437 | 437 | } |
| 438 | 438 | |
| 439 | /** | |
| 440 | * Principle 7 (#11190): a fact is a `dl` row, not a card. A `card(` from | |
| 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 | ||
| 439 | 497 | const BASELINE = "docs/design/baseline.json"; |
| 440 | 498 | const DESIGN = "docs/DESIGN.md"; |
| 441 | 499 | const SECTIONS = "docs/design/sections"; |
| @@ -452,7 +510,7 @@ if (import.meta.main) { | ||
| 452 | 510 | } else { |
| 453 | 511 | const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8")); |
| 454 | 512 | 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)]; | |
| 456 | 514 | for (const p of problems) console.error(p); |
| 457 | 515 | if (problems.length) process.exit(1); |
| 458 | 516 | 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 | ||
| 353 | 353 | ); |
| 354 | 354 | } |
| 355 | 355 | |
| 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>`; | |
| 364 | 363 | |
| 365 | 364 | /** What sits above the asks after a bulk step: a preview to confirm, a receipt, or why nothing was done. */ |
| 366 | 365 | export interface AsksTop { |
| @@ -627,10 +626,7 @@ const bulkCard = (head: string, body: Html, foot: Html | null): Html => | ||
| 627 | 626 | }); |
| 628 | 627 | |
| 629 | 628 | 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>`, | |
| 634 | 630 | }); |
| 635 | 631 | |
| 636 | 632 | /** 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 { | ||
| 29 | 29 | export function visionPreviewCard(preview: VisionPreview): Html { |
| 30 | 30 | const head = html`${icon("code")}<h2>Direction after the merge</h2>`; |
| 31 | 31 | 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>`; | |
| 36 | 36 | const same = preview.today === preview.after; |
| 37 | 37 | return card( |
| 38 | 38 | 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"; | ||
| 26 | 26 | import { html, type Html } from "../html.ts"; |
| 27 | 27 | import { flatten, noteLine } from "./note.ts"; |
| 28 | 28 | 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"; | |
| 30 | 30 | import { changeHref, tellHref } from "./nouns.ts"; |
| 31 | 31 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 32 | 32 | import { appPanels } from "./previews.ts"; |
| @@ -325,17 +325,14 @@ export async function nowCard( | ||
| 325 | 325 | ]; |
| 326 | 326 | const now = |
| 327 | 327 | nowRows.length === 0 |
| 328 | ? card( | |
| 329 | html`<p class="muted small"> | |
| 328 | ? html`<section class="stack"> | |
| 329 | <h2>Now</h2> | |
| 330 | <p class="muted"> | |
| 330 | 331 | ${deployTailing |
| 331 | 332 | ? "Production is coming up — the log above has it." |
| 332 | 333 | : "Nothing yet. Tell the crew something."} |
| 333 | </p>`, | |
| 334 | { | |
| 335 | head: html`${icon("activity")} | |
| 336 | <h2>Now</h2>`, | |
| 337 | }, | |
| 338 | ) | |
| 334 | </p> | |
| 335 | </section>` | |
| 339 | 336 | : listCard(nowRows, { |
| 340 | 337 | head: html`${icon("activity")} |
| 341 | 338 | <h2>Now</h2> |
src/web/studio/tell.ts +2 −5
| @@ -394,11 +394,8 @@ async function tellClosingPull( | ||
| 394 | 394 | ): Promise<Html> { |
| 395 | 395 | const base = repoBase(repo); |
| 396 | 396 | 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. |
||
| 402 | 399 | { head: html`${icon("pull")} <h2>Closes with</h2>` }, |
| 403 | 400 | )}${previewBlock(ctx, repo, pull)}`; |
| 404 | 401 | } |
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 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | 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"; | |
| 4 | 4 | |
| 5 | 5 | /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */ |
| 6 | 6 | const DESIGN = [ |
| @@ -257,3 +257,32 @@ describe("design check: the quiet-page rules (#8775)", () => { | ||
| 257 | 257 | expect(compareQuiet(debt, scanQuiet("."))).toEqual([]); |
| 258 | 258 | }, 60_000); |
| 259 | 259 | }); |
| 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 () => { | ||
| 17 | 17 | const md = await Bun.file(`${dir}/commands/forge.md`).text(); |
| 18 | 18 | const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string; |
| 19 | 19 | 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. |
||
| 21 | 21 | for (const v of verbs) expect(plugin.description).toContain(v); |
| 22 | 22 | }); |
| 23 | 23 |
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.