Public: anyone can read this; the crew can change it.
Design: a new forge's claim link and the client list use the design system, and claim-link-box/forges-sent leave §5d #4060
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3871-design-a-new-forge-s-claim-link-and-the → main 12 commits fork
5 files
changed +39
−47
docs/DESIGN.md +1 −1
| @@ -398,7 +398,7 @@ gone, so this list only shrinks. | ||
| 398 | 398 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 399 | 399 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
| 400 | 400 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.log-tail-row` `.pinned-failures` `.pinned-slot` `.run-jump` `.run-live` `.run-steer` `.tests-bar` `.tests-words` `.txt` `.who` |
| 401 | - **`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` | |
| 401 | - **`style/content.ts`, search results** — search results, the inbox's ask cards: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.live-toggle` `.needs-you` `.no-hits` | |
| 402 | 402 | - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own |
| 403 | 403 | - **`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` |
| 404 | 404 | - **`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` |
src/web/pages/forges.ts +15 −24
| @@ -14,7 +14,7 @@ import { html, type Html } from "../html.ts"; | ||
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | 15 | import { reissueDoor } from "../../spawn/door.ts"; |
| 16 | 16 | import { readForm } from "../form.ts"; |
| 17 | import { btn, card, empty, time } from "../ui.ts"; | |
| 17 | import { badge, btn, card, empty, row, time } from "../ui.ts"; | |
| 18 | 18 | import { noRoom } from "../../spawn/door.ts"; |
| 19 | 19 | import { claimLinkBody } from "./new-forge.ts"; |
| 20 | 20 | |
| @@ -31,6 +31,7 @@ function runningNote(r: ClientRow): Html | null { | ||
| 31 | 31 | function forgeRow(r: ClientRow): Html { |
| 32 | 32 | const state = claimState(r); |
| 33 | 33 | const meta = [ |
| 34 | state === "unknown" ? html`it did not answer just now` : null, | |
| 34 | 35 | r.for === null ? null : html`for ${r.for}`, |
| 35 | 36 | r.apps.length === 0 ? null : html`${r.apps.join(", ")}`, |
| 36 | 37 | runningNote(r), |
| @@ -39,28 +40,18 @@ function forgeRow(r: ClientRow): Html { | ||
| 39 | 40 | ? null |
| 40 | 41 | : html`last activity ${time(r.lastActivityAt)}`, |
| 41 | 42 | ].filter((m): m is Html => m !== null); |
| 42 | return html`<li data-forge="${r.name}" data-claimed="${state}"> | |
| 43 | <div class="row"> | |
| 44 | <span class="row-main"> | |
| 45 | <a class="row-title" href="${r.url}">${r.name}</a> | |
| 46 | <span class="row-meta" | |
| 47 | >${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}</span | |
| 48 | > | |
| 49 | </span> | |
| 50 | <span class="row-trail" | |
| 51 | ><span | |
| 52 | class="pill" | |
| 53 | ${state === "unknown" ? html`title="It did not answer just now"` : null} | |
| 54 | ${state === "yes" ? html`data-tone="accent"` : null} | |
| 55 | >${CLAIM_WORDS[state]}</span | |
| 56 | >${state === "no" | |
| 57 | ? html`<form method="post" action="/forges/${r.name}/claim-link"> | |
| 58 | ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })} | |
| 59 | </form>` | |
| 60 | : null}</span | |
| 61 | > | |
| 62 | </div> | |
| 63 | </li>`; | |
| 43 | return row({ | |
| 44 | title: html`<a href="${r.url}">${r.name}</a>`, | |
| 45 | meta: html`${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}`, | |
| 46 | data: { forge: r.name, claimed: state }, | |
| 47 | trail: html`${badge(CLAIM_WORDS[state], { | |
| 48 | ...(state === "yes" ? { tone: "success" as const } : { variant: "outline" as const }), | |
| 49 | })}${state === "no" | |
| 50 | ? html`<form method="post" action="/forges/${r.name}/claim-link"> | |
| 51 | ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })} | |
| 52 | </form>` | |
| 53 | : null}`, | |
| 54 | }); | |
| 64 | 55 | } |
| 65 | 56 | |
| 66 | 57 | // GET /forges — an admin's only. |
| @@ -92,7 +83,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 92 | 83 | action, |
| 93 | 84 | }) |
| 94 | 85 | : card( |
| 95 | html`<ul class="list forges-sent"> | |
| 86 | html`<ul class="list setting-list"> | |
| 96 | 87 | ${rows.map(forgeRow)} |
| 97 | 88 | </ul>`, |
| 98 | 89 | { pad: false }, |
src/web/pages/new-forge.ts +8 −6
| @@ -26,7 +26,7 @@ import { notFoundPage, pageHead } from "../chrome.ts"; | ||
| 26 | 26 | import { field, readForm, type Form } from "../form.ts"; |
| 27 | 27 | import { html, type Html } from "../html.ts"; |
| 28 | 28 | import { page } from "../layout.ts"; |
| 29 | import { btn, card, field as formField } from "../ui.ts"; | |
| 29 | import { btn, card, field as formField, row } from "../ui.ts"; | |
| 30 | 30 | |
| 31 | 31 | interface Values { |
| 32 | 32 | readonly name: string; |
| @@ -355,11 +355,13 @@ export function claimLinkBody(link: string, note?: string): Html { | ||
| 355 | 355 | <p class="muted small mb"> |
| 356 | 356 | Send it as you would a password: to them alone, not to a group. |
| 357 | 357 | </p> |
| 358 | <p class="claim-link-box"><code class="mono" id="claim-link">${link}</code></p> | |
| 359 | <p class="mt"> | |
| 360 | ${btn("Copy the link", { type: "button", copy: "claim-link", icon: "copy", variant: "primary", size: "sm" })} | |
| 361 | ${btn("Forges you sent", { href: "/forges", size: "sm" })} | |
| 362 | </p> | |
| 358 | ${card(html`<ul class="list setting-list">${ | |
| 359 | row({ | |
| 360 | title: html`<code class="mono" id="claim-link">${link}</code>`, | |
| 361 | trail: btn("Copy the link", { type: "button", copy: "claim-link", icon: "copy", variant: "primary", size: "sm" }), | |
| 362 | }) | |
| 363 | }</ul>`, { pad: false })} | |
| 364 | <p class="mt">${btn("Forges you sent", { href: "/forges", size: "sm" })}</p> | |
| 363 | 365 | ${note === undefined ? null : html`<p class="small mt">${note}</p>`}`; |
| 364 | 366 | } |
| 365 | 367 |
src/web/style/content.ts −16
| @@ -1108,22 +1108,6 @@ textarea.editor:focus-visible { outline: none; } | ||
| 1108 | 1108 | border: 1px dashed var(--border); border-radius: var(--radius); |
| 1109 | 1109 | } |
| 1110 | 1110 | .no-hits .ico { color: var(--faint); flex: none; } |
| 1111 | /* The client list (#776): whom a forge is for is the point of the row, so | |
| 1112 | its line wraps rather than being cut off on a phone. */ | |
| 1113 | .forges-sent .row-meta { white-space: normal; } | |
| 1114 | .forges-sent .row-trail { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } | |
| 1115 | .forges-sent .row-trail form { margin: 0; } | |
| 1116 | .forges-sent a.row-title { color: var(--fg); } | |
| 1117 | @media (max-width: 560px) { | |
| 1118 | .forges-sent .row { flex-wrap: wrap; } | |
| 1119 | .forges-sent .row-trail { flex-basis: 100%; justify-content: flex-start; } | |
| 1120 | } | |
| 1121 | /* "+ New forge" (#426): the claim link, shown once, impossible to miss. */ | |
| 1122 | .claim-link-box { | |
| 1123 | margin: 0; padding: 12px 14px; font-size: 15px; | |
| 1124 | border: 1px solid var(--border); border-radius: var(--radius); | |
| 1125 | background: var(--muted); overflow-wrap: anywhere; | |
| 1126 | } | |
| 1127 | 1111 | |
| 1128 | 1112 | } |
| 1129 | 1113 | /* What needs you, pinned on the home: the bulk step's select as wide as |
test/client-list.test.ts +15
| @@ -198,8 +198,10 @@ describe("the client list", () => { | ||
| 198 | 198 | expect(maria).toContain("alice/bakery"); |
| 199 | 199 | expect(maria).toContain('href="https://maria--forge.test"'); |
| 200 | 200 | expect(maria).toMatch(/data-claimed="no"/); |
| 201 | expect(html).not.toContain("forges-sent"); | |
| 201 | 202 | const lou = html.slice(html.indexOf('data-forge="lou"')); |
| 202 | 203 | expect(lou).toMatch(/data-claimed="unknown"/); |
| 204 | expect(lou).toContain("it did not answer just now"); | |
| 203 | 205 | // The claim link itself is never kept, so never shown here. |
| 204 | 206 | expect(html).not.toContain(claimUrl.split("#")[1]!); |
| 205 | 207 | }); |
| @@ -259,3 +261,16 @@ describe("the client list", () => { | ||
| 259 | 261 | expect(rows.lou.claimed).toBeNull(); |
| 260 | 262 | }); |
| 261 | 263 | }); |
| 264 | ||
| 265 | describe("the claim link's markup", () => { | |
| 266 | test("is drawn from the design system and stays one tap to copy", async () => { | |
| 267 | const { claimLinkBody } = await import("../src/web/pages/new-forge.ts"); | |
| 268 | const out = claimLinkBody("https://x.test/claim#abc").text; | |
| 269 | expect(out).not.toContain("claim-link-box"); | |
| 270 | expect(out).toContain('id="claim-link"'); | |
| 271 | expect(out).toContain('class="row"'); | |
| 272 | // The long link wraps rather than ellipsizes: only .setting-list lifts nowrap. | |
| 273 | expect(out).toContain("list setting-list"); | |
| 274 | expect(out).toContain("Copy the link"); | |
| 275 | }); | |
| 276 | }); |
new above
offline