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

The family and chain pages' lineage list draw §5's row, and .lineage, .lineage-children, .lineage-here leave the markup #4213

Merged opened this

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

Merged

scout/forge:scout/4134-the-family-and-chain-pages-lineage-list → main 6 commits fork 1 thread · 1 unresolved

  • Merged. scout/4134-the-family-and-chain-pages-lineage-list was merged into main as 50337cd.

Review

Sign in to review this change.
8 files changed +80 −53
docs/DESIGN.md +2 −2
@@ -158,7 +158,7 @@ specification.
158158 | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` |
159159 | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name |
160160 | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-links`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar |
161 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `fold` makes the row a `<details>`'s `<summary>`, its lead the caret turned when open (/map's lanes); `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
161 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`, `.row-children`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `fold` makes the row a `<details>`'s `<summary>`, its lead the caret turned when open (/map's lanes); `children` nests the rows under a row in a `<ul class="row-children">` inside its `<li>` — a tree (/family), a step in under a rule, real list nesting for assistive tech; `aria-current="true"` on the `<li>` (`current`) tints the row; `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
162162 | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line |
163163 | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry |
164164 | **table** | `.table` | `data-dense`; `.num` cells right-aligned | — | aligned columns |
@@ -393,7 +393,7 @@ gone, so this list only shrinks.
393393 - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on: `.bar` `.bare` `.foot` `.grow` `.hide-lg` `.hide-sm` `.js-only` `.live-pill` `.mark` `.mb` `.mt` `.muted` `.narrow` `.narrow-wide` `.num` `.reduced` `.shell` `.shell-wide` `.skip` `.small` `.spacer` `.sr-only` `.stack` `.stack-tight` `.topbar` `.topbar-tools` `.truncate` `.wordmark`
394394 - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.live-status` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-lead` `.passkey-note` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search`
395395 - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list`
396 - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, following, lineage: `.act` `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.connect-more` `.connect-path` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.following` `.following-actions` `.following-eyebrow` `.following-foot` `.following-head` `.following-sentence` `.fork-bar` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.handoff` `.handoff-links` `.headroom` `.issue-head` `.issue-meta` `.lineage` `.lineage-children` `.lineage-here` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep`
396 - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, following, lineage: `.act` `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.connect-more` `.connect-path` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.following` `.following-actions` `.following-eyebrow` `.following-foot` `.following-head` `.following-sentence` `.fork-bar` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.handoff` `.handoff-links` `.headroom` `.issue-head` `.issue-meta` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep`
397397 - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable`
398398 - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r`
399399 - **`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`
src/web/pages/chain.ts +8 −12
@@ -17,7 +17,7 @@ import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chro
1717 import { field, readForm } from "../form.ts";
1818 import { html, type Html } from "../html.ts";
1919 import { flashOrJson, page, redirect } from "../layout.ts";
20 import { btn, card } from "../ui.ts";
20 import { badge, btn, card, row } from "../ui.ts";
2121
2222 /** The events that change what the chain page says. */
2323 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken";
@@ -58,16 +58,12 @@ function lineRow(l: ChainLine, upstream?: string): Html {
5858 : "the root: follows nothing"
5959 : MODE_WORDS[l.mode];
6060 const ahead = l.ahead === null ? "" : l.ahead === 0 ? " · nothing of its own" : ` · ${l.ahead} of its own`;
61 return html`<li>
62 <div class="row">
63 <span class="row-main">
64 <span class="row-title"
65 >${l.repo}${where}${l.here ? html` <span class="lineage-here">you are here</span>` : null}</span
66 >
67 <span class="row-meta">${how}${ahead}${upstream === undefined ? null : ` · ${upstream}`}</span>
68 </span>
69 </div>
70 </li>`;
61 return row({
62 title: `${l.repo}${where}`,
63 meta: html`${how}${ahead}${upstream === undefined ? null : ` · ${upstream}`}`,
64 ...(l.here ? { trail: badge("you are here", { variant: "outline" }) } : {}),
65 current: l.here,
66 });
7167 }
7268
7369 /** Whether this repository is the forge code a spawned forge carries. */
@@ -138,7 +134,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
138134 data-live-repo="${repo.ownerName}/${repo.name}"
139135 >
140136 ${card(
141 html`<ul class="list lineage" aria-label="Forges, root first">
137 html`<ul class="list" aria-label="Forges, root first">
142138 ${view.lines.map((l) => lineRow(l, l.here && l.mode !== null ? upstreamWords(state) : undefined))}
143139 </ul>`,
144140 { pad: false },
src/web/pages/family.ts +19 −25
@@ -16,7 +16,7 @@ import { MAX_DEPTH } from "../../spawn/heartbeat.ts";
1616 import { pageHead } from "../chrome.ts";
1717 import { html, type Html } from "../html.ts";
1818 import { page } from "../layout.ts";
19 import { card, time } from "../ui.ts";
19 import { badge, card, row, time } from "../ui.ts";
2020
2121 interface Node {
2222 readonly label: string;
@@ -63,28 +63,22 @@ function standingNode(c: FamilyChild): Node {
6363 };
6464 }
6565
66 function row(n: Node, depth: number): Html {
67 const inner = html`<span class="row-main">
68 <span class="row-title"
69 >${n.label}${n.here ? html` <span class="lineage-here">you are here</span>` : null}</span
70 >
71 ${n.meta.length === 0
72 ? null
73 : html`<span class="row-meta"
74 >${n.meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}</span
75 >`}
76 </span>
77 ${n.badge === null ? null : html`<span class="row-trail">${n.badge}</span>`}`;
78 return html`<li>
79 ${n.href === null
80 ? html`<div class="row">${inner}</div>`
81 : html`<a class="row" href="${n.href}" rel="noopener noreferrer">${inner}</a>`}
82 ${n.children.length === 0 || depth + 1 >= MAX_DEPTH
83 ? null
84 : html`<ul class="lineage-children">
85 ${n.children.map((c) => row(c, depth + 1))}
86 </ul>`}
87 </li>`;
66 /** The tree as §5 rows: each forge's row holds the rows of the forges it sent. */
67 function treeRow(n: Node, depth: number): Html {
68 const trail = [n.here ? badge("you are here", { variant: "outline" }) : null, n.badge].filter(
69 (t): t is Html => t !== null,
70 );
71 const kids = depth + 1 >= MAX_DEPTH ? [] : n.children.map((c) => treeRow(c, depth + 1));
72 return row({
73 title: n.label,
74 ...(n.href === null ? {} : { href: n.href }),
75 ...(n.meta.length === 0
76 ? {}
77 : { meta: html`${n.meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}` }),
78 ...(trail.length === 0 ? {} : { trail: html`${trail}` }),
79 current: n.here === true,
80 children: kids,
81 });
8882 }
8983
9084 // GET /family
@@ -132,8 +126,8 @@ export async function familyPage(
132126 sub: "The forge this one came from, and the forges it sent. Children report in; nothing here reaches into one.",
133127 })}
134128 ${card(
135 html`<ul class="list lineage" aria-label="Forges, by who sent whom">
136 ${row(root, 0)}
129 html`<ul class="list" aria-label="Forges, by who sent whom">
130 ${treeRow(root, 0)}
137131 </ul>
138132 ${kids.more > 0
139133 ? html`<p class="muted small card-body">
src/web/style/components.ts +5
@@ -399,6 +399,11 @@ a.sha:hover { color: var(--accent); }
399399 }
400400 .row:focus-visible { outline-offset: -2px; }
401401 a.row:hover, .row[data-nav]:hover { background: var(--muted); color: inherit; }
402 .list li[aria-current="true"] > .row { background: var(--muted); }
403 /* A tree: the rows under a row hang from a rule, a step in. */
404 .row-children { list-style: none; margin: 0 0 0 22px; padding: 0; border-left: 1px solid var(--border); }
405 .row-children > li { border-top: 1px solid var(--border); }
406 @media (max-width: 719px) { .row-children { margin-left: 12px; } }
402407 a.row:hover .row-title { color: var(--accent); }
403408 .row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
404409 .row-title {
src/web/style/content.ts −13
@@ -393,19 +393,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
393393 .wall-cells li + li { margin-top: 3px; }
394394 form.inline { display: inline; margin-left: auto; }
395395
396 /* The family tree: nesting is indentation and a rule, never a scrollbar. */
397 .lineage-children {
398 list-style: none; margin: 0 0 0 22px; padding: 0;
399 border-left: 1px solid var(--border);
400 }
401 .lineage-children .row { padding-left: 12px; }
402 .lineage-here {
403 margin-left: 6px; font-family: var(--mono); font-size: var(--fs-min); font-weight: 500;
404 letter-spacing: 0.04em; color: var(--accent);
405 }
406 .lineage .row[aria-current="page"] { background: var(--muted); }
407 @media (max-width: 719px) { .lineage-children { margin-left: 12px; } }
408
409396 .terminal-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
410397
411398 /* The studio's front door: the public apps, live first, as pictures. */
src/web/ui.ts +6 −1
@@ -879,6 +879,9 @@ export interface RowOptions {
879879 data?: Readonly<Record<string, string>>;
880880 /** The row is the one chosen (`aria-current="true"`). */
881881 current?: boolean;
882 /** The rows under this one, already drawn (`row()`s): a nested `<ul>` inside
883 * the `<li>`, so a tree stays a tree to assistive tech and with no script. */
884 children?: readonly Html[];
882885 /** The row folds: it is the `<summary>` of a `<details>` holding `body`,
883886 * open when `open` says so. Its `lead` is the caret. */
884887 fold?: { id: string; open: boolean; body: Html };
@@ -916,7 +919,9 @@ export function row(o: RowOptions): Html {
916919 </details>`
917920 : o.href !== undefined
918921 ? html`<a href="${o.href}" ${attrs}>${inner}</a>`
919 : html`<div ${attrs}>${inner}</div>`}
922 : html`<div ${attrs}>${inner}</div>`}${o.children === undefined || o.children.length === 0

The old family link carried rel="noopener noreferrer" for child-forge URLs; this anchor does not. Add an optional rel (or external) option to RowOptions and pass it from family.ts.

923 ? null
924 : html`<ul class="row-children">${[...o.children]}</ul>`}
920925 </li>`;
921926 }
922927
test/family-heartbeat.test.ts +32
@@ -31,6 +31,7 @@ import { familyScreen } from "../src/tui/screens/family.ts";
3131 import { headlessTerminal } from "../src/tui/term/terminal.ts";
3232 import type { Ctx } from "../src/http/context.ts";
3333 import { familyPage } from "../src/web/pages/family.ts";
34 import { row } from "../src/web/ui.ts";
3435 import { settleApp } from "./helpers/tui.ts";
3536 import { quietLogs } from "./helpers/globals.ts";
3637
@@ -283,6 +284,37 @@ describe("family heartbeats", () => {
283284 expect(await text(ctxOf(true), () => ({ total: 0, running: 0, notRunning: [] }))).not.toContain("data-apps");
284285 });
285286
287 test("the family page draws §5's row: the old lineage names are gone, the words and links stay", async () => {
288 const ctx: Ctx = {
289 app: parent,
290 request: new Request("http://localhost/family"),
291 url: new URL("http://localhost/family"),
292 params: {},
293 auth: { user: { id: 1, username: "alice", isAdmin: true } as never, scope: undefined, via: "session" },
294 theme: undefined,
295 };
296 const page = await (await familyPage(ctx, two)).text();
297 expect(page).not.toMatch(/lineage(-children|-here)?\b/);
298 expect(page).toContain("you are here");
299 expect(page).toContain('aria-label="Forges, by who sent whom"');
300 expect(page).toContain('class="row"');
301 expect(page).toContain("version ");
302 // The tree stays a tree: what this forge sent is a list inside this forge's row.
303 expect(page).toContain('<ul class="row-children">');
304 expect(page.indexOf('aria-current="true"')).toBeLessThan(page.indexOf("row-children"));
305 });
306
307 test("row() nests children's rows inside its own li, after the row", () => {
308 const grand = row({ title: "grand" });
309 const child = row({ title: "child", children: [grand] });
310 const top = row({ title: "top", children: [child] }).text;
311 expect(top.indexOf("top")).toBeLessThan(top.indexOf("child"));
312 expect(top.indexOf("child")).toBeLessThan(top.indexOf("grand"));
313 expect(top.match(/<ul class="row-children">/g)?.length).toBe(2);
314 expect(row({ title: "x" }).text).not.toContain("row-children");
315 expect(row({ title: "x", children: [] }).text).not.toContain("row-children");
316 });
317
286318 test("appsLine counts what runs and says why the rest does not", () => {
287319 expect(appsLine({ total: 2, running: 0, notRunning: [{ slug: "a", why: "not started" }, { slug: "b", why: "boom: x" }] }))
288320 .toBe("0 of 2 apps running — a not deployed; b failing");
test/following.test.ts +8
@@ -1095,6 +1095,10 @@ describe("an open chain page hears a sever", () => {
10951095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken"');
10961096 expect(page).toContain('data-live-repo="kid/forge"');
10971097 expect(page).not.toContain("follows nothing since");
1098 expect(page).not.toMatch(/lineage(-children|-here)?\b/);
1099 expect(page).toContain('aria-label="Forges, root first"');
1100 expect(page).toContain("you are here");
1101 expect(page).toContain('action="/kid/forge/following/mode"');
10981102 });
10991103
11001104 test("a sever reaches the viewer's stream with the repo the region filters on, and a refetch shows it", async () => {
@@ -1124,5 +1128,9 @@ describe("an open chain page hears a sever", () => {
11241128 const page = await chainPage();
11251129 expect(page).toContain("follows nothing since");
11261130 expect(page).toContain('id="live-chain"');
1131 expect(page).not.toMatch(/lineage(-children|-here)?\b/);
1132 expect(page).toContain('aria-label="Forges, root first"');
1133 expect(page).toContain("you are here");
1134 expect(page).toContain('id="severed"');
11271135 });
11281136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs