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

/crew and /flow's help behind `?` lands from pull #11032, with its 360 px looks, light and dark #11176

Merged opened this

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

Merged

scout/forge:scout/11170-crew-and-flow-s-help-behind-lands-from-p → main 13 commits fork 4 threads · 4 unresolved

  • Merged. scout/11170-crew-and-flow-s-help-behind-lands-from-p was merged into main as 4fe3b8e.

Review

Sign in to review this change.
16 files changed +284 −50
docs/features/page-help.md added +14
@@ -0,0 +1,14 @@
1 # Page help (`? Help`)
2
3 The crew pages (`/settings/crew`) and the flow panel (on `/boards`) keep the explanations that change no decision
4 behind one closed **? Help** disclosure: a 44px target with the word beside the glyph, so a sighted person knows what
5 it opens, and a visible focus ring. `test/notes-ceiling.test.ts` pins how many notes each page may show.
6
7 Try it: open `/settings/crew` or `/boards` and press **? Help**. Add `?help` to the URL (`/boards?help`,
8 `/settings/crew?help`) and it arrives open with its summary focused; that is how the forge's looks picture it
9 (`app.looks` in forge.json, taken as the owner), at phone and desktop width, light and dark.
10
11 The terminal: the Flow screen ends with the same stage and merge-queue explanations (`src/flow/help.ts`). It has no
12 crew explanations screen to bring in line: its crew screens are handover and splits, which carry their own wording.
13
14 Pinned by `test/page-help-open.test.ts` and `test/forge-looks.test.ts`.
forge.json +2 −2
@@ -4,8 +4,8 @@
44 "api.github.com"
55 ],
66 "app": {
7 "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/pinned", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
8 "looksAsOwner": ["/", "/board", "/flow", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
7 "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/boards?help", "/settings/crew", "/settings/crew?help", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
outdated

Looks cover only the closed ?. Box 1 needs the open, focused state at 360px and desktop in both schemes. /pinned was also dropped to fit the 24 cap.

8 "looksAsOwner": ["/", "/board", "/flow", "/boards?help", "/settings/crew", "/settings/crew?help", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
99 "run": "bun run src/main.ts",
1010 "install": "",
1111 "health": "/healthz",
src/core/looks.ts +1 −1
@@ -70,7 +70,7 @@ export const DEFAULT_PAGE = "/";
7070 * column is truth.
7171 */
7272 const pageSlug = (page: string): string =>
73 page === DEFAULT_PAGE ? "" : page.slice(1).replace(/[/#]/g, "-");
73 page === DEFAULT_PAGE ? "" : page.slice(1).replace(/[/#?=&]/g, "-");
7474
7575 /**
7676 * The fragment one picture is named by, on disk and in its URL: the page
src/flow/help.ts added +6
@@ -0,0 +1,6 @@
1 /**
2 * The /flow explanations that change no decision (#11170): said once, shown
3 * behind the page's `?` and at the foot of the terminal's Flow screen.
4 */
5 export const MERGE_QUEUE_HELP =
6 "Green, approved pulls join their repository's queue; each candidate is gated once and lands whole, or is searched for the red one. Where check slots are free the next candidate gates on top of this one, and a red one's halves gate at once. A pull found red is re-checked alone before it rides with others again.";
src/tui/screens/flow.ts +6 −1
@@ -32,9 +32,10 @@ import { keeperLine, type KeeperAsk, type KeeperRead } from "../../flow/keeper.t
3232 import { verifiedSays, type VerifiedRow } from "../../flow/verified.ts";
3333 import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts";
3434 import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts";
35 import { MERGE_QUEUE_HELP } from "../../flow/help.ts";
3536 import { pileSays } from "../../flow/pile.ts";
3637 import { panelFigures } from "../../flow/panel_cache.ts";
37 import { readPipeline, readStepStats, STAGE_LABEL, statWords, verdict } from "../../flow/pipeline.ts";
38 import { readPipeline, readStepStats, STAGE_HOLDS, STAGE_LABEL, statWords, verdict } from "../../flow/pipeline.ts";
3839 import { parkedSays } from "../../flow/parked.ts";
3940 import { firedAlarms } from "../../flow/alarms.ts";
4041 import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts";
@@ -153,6 +154,10 @@ export function flowLines(
153154 ...wrap(reportLine(db), inner),
154155 "",
155156 ...duplicateLines(dups, inner),
157 "",
158 "Help (the web's /boards?help):",
159 ...wrap(`Stages: ${stages.map((x) => `${STAGE_LABEL[x.key]}: ${STAGE_HOLDS[x.key]}`).join("; ")}.`, inner),
160 ...wrap(`Merge queue: ${MERGE_QUEUE_HELP}`, inner),
156161 ];
157162 }
158163
src/web/page_help.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * The one `?` of a page: the explanations that change no decision, kept out of
3 * the page so it answers at first sight. Closed until asked for, and open with
4 * its summary focused when the URL says `?help`, so a look can picture it.
5 */
6 import { html, type Html } from "./html.ts";
7
8 /** True when the request asks for the help open (`?help`). */
9 export const helpAsked = (url: string | URL): boolean => new URL(url, "http://forge.local").searchParams.has("help");
10
outdated

Principle 1 and 8: the summary shows only '?'. aria-label helps a screen reader but a sighted person gets a bare glyph. Show 'Help' beside it.

11 export const pageHelp = (notes: readonly (readonly [string, string | Html])[], open = false): Html =>
12 html`<details class="disclosure mb" data-page-help${open ? html` open` : html``}>
13 <summary${open ? html` autofocus` : html``}><span aria-hidden="true">?</span> Help</summary>
14 <dl class="status-rows">
15 ${notes.map(([name, text]) => html`<div class="status-row"><dt>${name}</dt><dd>${text}</dd></div>`)}
16 </dl>
17 </details>`;
src/web/pages/boards-held.ts +4 −7
@@ -25,17 +25,14 @@ function startableMeter(row: HeldRow): Html {
2525 );
2626 }
2727
28 /** What the held card counts: said once, behind the page's `?` (#10998). */
29 export const HELD_EXPLAINED =
30 "Each ready issue a builder could start, or the first filter that holds it: a run on it, an open pull, the planner, stalled, its run cap, parked twice, a red-main fix, a file 5 or more open pulls touch. When nothing can start and builders are free, the planner is asked to re-plan what is stalled or capped.";
31
2832 /** Why ready issues cannot start, per autonomous repository (#2244). */
2933 export function heldCard(rows: readonly HeldRow[]): Html {
3034 return html`<section class="settings-block" id="held">
3135 <h2>Ready, and why it waits</h2>
32 <p class="muted small">
33 Each ready issue a builder could start, or the first filter that holds
34 it: a run on it, an open pull, the planner, stalled, its run cap,
35 parked twice, a red-main fix, a file 5 or more open pulls touch. When nothing can
36 start and builders are free, the planner is asked to re-plan what is
37 stalled or capped.
38 </p>
3936 ${rows.length === 0
4037 ? html`<p class="muted">No repository runs on its own.</p>`
4138 : html`<div class="table-scroll">
src/web/pages/crew.ts +15 −20
@@ -55,6 +55,7 @@ import { listAutonomousRepos, type Repo } from "../../core/repos.ts";
5555 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
5656 import { field, readForm, type Form } from "../form.ts";
5757 import { html, raw, when, type Html } from "../html.ts";
58 import { helpAsked, pageHelp } from "../page_help.ts";
5859 import { flashTo, page } from "../layout.ts";
5960 import {
6061 btn,
@@ -144,9 +145,9 @@ export function crewReadiness(ctx: Ctx): Html {
144145 return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">The crew cannot run: ${crew.detail}. Agents can be
145146 created, but nothing picks up their work until this is fixed.</div></div>`;
146147 const funded = fundingOf(ctx.app.config.dataDir);
147 // One sentence: a forge its parent funds says so in the readiness line, not in a second paragraph (#10484).
148 return html`<p class="muted small mb">
149 ${icon("check")} ${crew.detail} is ready to run the crew${funded === undefined ? "." : html`, <span data-funded>${fundingWords(funded)}</span>.`}
148 // A word and a glyph; a forge its parent funds says so on the same line (#10484).
149 return html`<p class="mb">
150 ${icon("check")} <strong>Ready</strong> <span class="muted">${crew.detail}</span>${funded === undefined ? null : html` <span class="muted" data-funded>${fundingWords(funded)}</span>`}
150151 </p>
151152 ${when(
152153 !crew.sandbox.enforced,
@@ -188,7 +189,6 @@ function agentFields(values: AgentValues, error?: string): Html {
188189 label: m.label,
189190 selected: m.id === values.model,
190191 })),
191 hint: "An alias names the newest of its line, so the choice does not age.",
192192 ...(error !== undefined && error.startsWith("A model") ? { error } : {}),
193193 })}
194194 ${uiField({
@@ -385,12 +385,6 @@ function scorecardCard(
385385 </div>`,
386386 {
387387 title: "Scorecard",
388 note: html`Over the pulls each agent opened, and each team's members pooled (an
389 agent on two teams counts in both), in the last ${RECENT_DAYS} days
390 and over all time: review rounds are change requests per landed pull, red checks
391 the share of finished checks that failed; reverts (the landed pulls
392 a merged pull took back) and time to land (the mean from opening to
393 merge) are all time.`,
394388 },
395389 );
396390 }
@@ -544,11 +538,7 @@ function modelReviewCard(ctx: Ctx): Html {
544538 n === null ? html`<td class="num muted">—</td>` : html`<td class="num">${f(n)}</td>`;
545539 const asked = r.changes.filter((c) => c.state === "asked");
546540 return panel(
547 html`<p class="muted small">At most one change of model per agent a week, to the cheapest that holds its quality.
548 A step up always asks; a step down ${r.limits.auto ? "applies on its own" : "asks too"} (Settings → Limits).
549 Under ${r.limits.minLanded} landed changes nothing. A week after a change the result is reported, and the
550 change undone if quality fell. An ask nobody answers lapses after two weeks.</p>
551 <div class="table-scroll">
541 html`<div class="table-scroll">
552542 <table class="table" id="model-review">
553543 <thead>
554544 <tr><th>Agent</th><th>Model</th><th>Landed</th><th>Per landed</th><th>Red</th><th>Rework</th><th>Reverts</th><th>Hard</th></tr>
@@ -627,7 +617,6 @@ function modelReviewCard(ctx: Ctx): Html {
627617 </div>`,
628618 {
629619 title: "Model review",
630 note: html`Weekly, from the last ${r.windowDays} days of the ledger`,
631620 },
632621 );
633622 }
@@ -664,7 +653,14 @@ function crewView(
664653 const usernameError =
665654 error !== undefined && /username/i.test(error) ? error : undefined;
666655 const handover = crewHandover(ctx.app.db);
656 const review = readReview(ctx.app);
667657 return html`${crewReadiness(ctx)}
658 ${pageHelp([
659 ["Knobs", "Dials for the crew; a change applies on the next pump."],
660 ["New agent", "The username is its handle: the author on the pull requests it opens, and the first half of its branch names. The display name is shown beside it. A model alias names the newest of its line, so the choice does not age."],
661 ["Scorecard", `Over the pulls each agent opened, and each team's members pooled (an agent on two teams counts in both), in the last ${RECENT_DAYS} days and over all time: review rounds are change requests per landed pull, red checks the share of finished checks that failed; reverts (the landed pulls a merged pull took back) and time to land (the mean from opening to merge) are all time.`],
662 ["Model review", `At most one change of model per agent a week, to the cheapest that holds its quality. A step up always asks; a step down ${review.limits.auto ? "applies on its own" : "asks too"} (Settings → Limits). Under ${review.limits.minLanded} landed changes nothing. A week after a change the result is reported, and the change undone if quality fell. An ask nobody answers lapses after two weeks. Weekly, from the last ${review.windowDays} days of the ledger.`],
663 ], helpAsked(ctx.request.url))}
668664 ${knobsCard(ctx)}
669665 ${when(spending.length > 0, () =>
670666 spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined),
@@ -791,14 +787,12 @@ function crewView(
791787 value: values.username,
792788 required: true,
793789 pattern: "[A-Za-z0-9][A-Za-z0-9._-]*",
794 hint: "Its handle: the author on the pull requests it opens, and the first half of its branch names.",
795790 ...(usernameError ? { error: usernameError } : {}),
796791 })}
797792 ${uiField({
798 label: "Display name",
793 label: "Display name (optional)",
799794 name: "displayName",
800795 value: values.displayName,
801 hint: "Optional. Shown beside the username.",
802796 })}
803797 ${agentFields(values, usernameError === undefined ? error : undefined)}
804798 <div class="form-actions">
@@ -1060,7 +1054,8 @@ function agentView(agent: Agent, values: AgentValues, error?: string): Html {
10601054 const at = `/settings/crew/${encodeURIComponent(agent.user.username)}`;
10611055 return html`<section class="settings-block">
10621056 <h2>Settings</h2>
1063 <p class="muted small">added ${time(agent.createdAt)}</p>
1057 <span class="muted small">added ${time(agent.createdAt)}</span>
1058 ${pageHelp([["Model", "An alias names the newest of its line, so the choice does not age."]])}
10641059 <form method="post" action="${at}">
10651060 <div class="field">
10661061 <label for="f-username">Username</label>
src/web/pages/flow-panel.ts +12 −14
@@ -7,6 +7,9 @@
77 * count links back to the page that drew it with `?out=<what>[&hour=<0–23>]`.
88 */
99
10 import { MERGE_QUEUE_HELP } from "../../flow/help.ts";
11 import { helpAsked, pageHelp } from "../page_help.ts";
12 import { HELD_EXPLAINED } from "./boards-held.ts";
1013 import type { Reader } from "../../actions/telemetry.ts";
1114 import { queuesFor, systemFor } from "../../system/telemetry.ts";
1215 import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts";
@@ -116,7 +119,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
116119 const newest = parked.newest[0];
117120 const listed = asked(url);
118121 const shown = listed === null ? undefined : out.get(listed.key);
119 return html`${keeperRow(app.db, reader.kind === "owner")}${card(
122 const help = pageHelp([
123 ["Stages", html`${(Object.keys(STAGE_HOLDS) as PipelineKey[]).map((k, i) => html`${i === 0 ? "" : "; "}<strong>${STAGE_LABEL[k]}</strong>: ${STAGE_HOLDS[k]}`)}.`],
124 ["Ready, and why it waits", HELD_EXPLAINED],
125 ["Merge queue", MERGE_QUEUE_HELP],
126 ["Verified after landing", html`A repository that declares <span class="mono">check.verify</span> in forge.json has its main verified after landing.`],
127 ["Elsewhere", html`Limits are set on <a href="/settings/capacity">Capacity</a>. How the forge stands against its threats is on <a href="/resilience">Resilience</a>; where an outside agent can help is on <a href="/contribute">Contribute</a>.`],
128 ], helpAsked(url));
129 return html`${keeperRow(app.db, reader.kind === "owner")}${help}${card(
120130 html`<p id="bottleneck" data-stage="${worst?.key ?? ""}">
121131 <strong>${verdict(stages, now)}</strong>
122132 </p>
@@ -178,10 +188,6 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
178188 : html`<strong>${String(dups.length)} test file${dups.length === 1 ? " is" : "s are"} running in more than one check:</strong>
179189 ${dups.slice(0, 5).map((d) => html`<span class="mono small">${d.file} (${d.checks.join(", ")})</span> `)}`}
180190 <a href="/system">System</a>
181 </p>
182 <p class="muted small">
183 Limits are set on <a href="/settings/capacity">Capacity</a>. How the forge stands against its threats is on
184 <a href="/resilience">Resilience</a>; where an outside agent can help is on <a href="/contribute">Contribute</a>.
185191 </p>`,
186192 { id: "flow-summary" },
187193 )}
@@ -208,7 +214,6 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
208214 <th scope="row">
209215 ${STAGE_LABEL[s.key]}
210216 ${s === worst ? badge("bottleneck", { tone: "danger" }) : null}
211 <div class="muted small">${STAGE_HOLDS[s.key]}</div>
212217 </th>
213218 <td class="mono">${String(s.waiting)}</td>
214219 <td class="mono">${s.key === "live" ? "—" : String(s.active)}</td>
@@ -253,7 +258,7 @@ function verifiedCard(rows: readonly VerifiedRow[], now: number): Html {
253258 return card(
254259 html`<h2>Verified after landing</h2>
255260 ${rows.length === 0
256 ? html`<p class="muted">No repository verifies its main after landing yet: it declares <span class="mono">check.verify</span> in forge.json.</p>`
261 ? html`<p class="muted">None declare <span class="mono">check.verify</span></p>`
257262 : rows.map(
258263 (r) =>
259264 html`<p data-verified="${r.repo}" data-unverified-since="${r.unverifiedSince ?? ""}">
@@ -285,13 +290,6 @@ function idleList(idle: readonly IdlePull[]): Html {
285290 function queueCard(rows: readonly QueueRow[]): Html {
286291 return card(
287292 html`<h2>Merge queue</h2>
288 <p class="muted small">
289 Green, approved pulls join their repository's queue; each candidate
290 is gated once and lands whole, or is searched for the red one. Where
291 check slots are free the next candidate gates on top of this one, and
292 a red one's halves gate at once. A pull found red is re-checked alone
293 before it rides with others again.
294 </p>
295293 ${rows.length === 0
296294 ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>`
297295 : html`<div class="table-scroll">
src/web/pages/knobs.ts −1
@@ -36,7 +36,6 @@ export function knobsCard(ctx: Ctx): Html {
3636 const rows = readKnobs(ctx.app);
3737 return html`<section id="knobs">
3838 <h2 class="mb">Knobs</h2>
39 <p class="muted small">Dials for the crew; a change applies on the next pump.</p>
4039 <dl class="status-rows">
4140 ${rows.map(
4241 (r) => html`<div class="status-row" data-knob="${r.id}">
src/web/style/components.ts +5
@@ -750,6 +750,10 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
750750 }
751751 .disclosure[open] > summary::before { transform: rotate(90deg); }
752752 .disclosure > summary:hover { color: var(--accent); }
753 /* A page's "? Help" (page_help.ts): a 44px target a thumb can hit, and a ring a keyboard can see. */
754 .disclosure[data-page-help] > summary { width: fit-content; min-width: 44px; }
755 .disclosure[data-page-help] > summary::before { content: none; }
756 .disclosure[data-page-help] > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius); }
753757
754758 /* ---- topbar search (always a real form, so JS-off still searches) ---- */
755759 .topbar-search {
@@ -1128,6 +1132,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
11281132 @media (forced-colors: active) {
11291133 .input:focus-visible, .palette .palette-input:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
11301134 .color-field:focus-within { outline: 2px solid Highlight; outline-offset: 1px; }
1135 .disclosure[data-page-help] > summary:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
11311136 }
11321137
11331138 /* Asked for less motion, the page keeps every state and loses the movement.
test/crew-language.test.ts +1 −1
@@ -58,7 +58,7 @@ const speaksPlainly = (main: string): void => {
5858 expect(main.match(/<h1[\s>]/g)?.length).toBe(1);
5959 expect(main).not.toContain('class="card');
6060 expect(main).not.toContain("card-body");
61 expect(main).toMatch(/runner is up|cannot run|ready to run the crew|hosted inside/);
61 expect(main).toMatch(/runner is up|cannot run|Ready<\/strong>|hosted inside/);
6262 expect(main).toContain('action="/settings/crew"');
6363 // The danger notice (no runner in this test) wears a class the stylesheet really styles.
6464 expect(main).toContain('class="toast" data-tone="danger" role="alert"');
test/forge-looks.test.ts +21 −3
@@ -22,6 +22,9 @@ quietLogs();
2222 const NOT_A_PAGE: Record<string, string> = {
2323 "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts",
2424 "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts",
25 "/settings": "a form of fields; its slot went to the open help of /boards and /settings/crew (#11170); covered by test/account-help.test.ts",
26 "/new": "a form that fills from its link; its slot went to the open help of /boards and /settings/crew (#11170)",
27 "/pinned": "no picture: its slot went to /settings/crew, the crew page; covered by test/pinned-page.test.ts",
2528 "/data": "no picture: the cap of 24 looks is spent; a guest sees the not-found page and the page is covered by its own tests",
2629 "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts",
2730 "/forges": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests",
@@ -64,7 +67,7 @@ const NOT_A_PAGE: Record<string, string> = {
6467 "/apple-touch-icon.png": "an icon, not a page",
6568 "/apple-touch-icon-precomposed.png": "an icon, not a page",
6669 "/events": "a live event stream that never ends",
67 "/boards": "the list of boards; /board is pictured, and the slot went to the preview seed's repository as its owner (#8865); covered by test/boards-page.test.ts",
70 "/boards": "the list of boards, and where the flow panel and its help sit: pictured as /boards?help, taken as the owner (#11170), and /board is pictured; the slot went to the preview seed's repository as its owner (#8865); covered by test/boards-page.test.ts",
6871 "/changelog": "the merged changes in words; /releases is pictured, and the slot went to the preview seed's pull as its owner (#8865); covered by test/changelog.test.ts",
6972 "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts",
7073 "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)",
@@ -106,7 +109,9 @@ const topLevel = () =>
106109
107110 const manifest = (): { looks: string[]; looksAsOwner: string[] } =>
108111 JSON.parse(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8")).app;
109 const isTopLevel = (p: string) => p === "/" || /^\/[^/]+$/.test(p);
112 const isTopLevel = (p: string) => p === "/" || /^\/[^/?]+$/.test(p);
113 /** A page's `?help` look: the page with its help open and focused (#11170). */
114 const isHelpLook = (p: string) => p.endsWith("?help") && (isTopLevel(p.slice(0, -5)) || p === "/settings/crew?help");
110115 /** The top-level pages; a deeper one is the preview seed's, taken as the owner (test/looks-owner.test.ts). */
111116 const declared = (): string[] => manifest().looks.filter(isTopLevel);
112117
@@ -142,7 +147,20 @@ test("app.looks stays within 24 paths, each once", () => {
142147 test("a look below the top level is the preview seed's, taken as the owner", () => {
143148 const { looks, looksAsOwner } = manifest();
144149 for (const p of looks.filter((p) => !isTopLevel(p))) {
145 expect(p.startsWith("/preview-admin/sample")).toBe(true);
150 expect(p.startsWith("/preview-admin/sample") || p === "/settings/crew" || isHelpLook(p)).toBe(true);
146151 expect(looksAsOwner).toContain(p);
147152 }
148153 });
154
155 test("the help is pictured open: /boards (where the flow panel sits) and /settings/crew each have a ?help look, taken as the owner", async () => {
156 const { looks, looksAsOwner } = manifest();
157 for (const p of ["/boards?help", "/settings/crew?help"]) {
158 expect(looks).toContain(p);
159 expect(looksAsOwner).toContain(p);
160 const r = await get(p);
161 expect(r.status).toBe(200);
162 const body = await r.text();
163 expect(body).toMatch(/<details[^>]*data-page-help[^>]* open>/);
164 expect(body).toMatch(/<summary autofocus>/);
165 }
166 });
test/helpers/rendered-notes.ts added +15
@@ -0,0 +1,15 @@
1 /**
2 * The notes a rendered page stands with (#10998): every element wearing a
3 * note, hint, muted or stat-note class whose words make prose (four or more),
4 * outside the page's `?` help. The tag does not matter, so a paragraph turned
5 * into a span still counts; table dashes and one-word labels do not.
6 */
7 const NOTE = /<([a-z0-9]+)\b[^>]*\bclass="[^"]*\b(?:note|hint|muted|stat-note)\b[^"]*"[^>]*>([\s\S]*?)<\/\1>/g;
8
9 const withoutHelp = (page: string): string => page.replace(/<details[^>]*data-page-help[\s\S]*?<\/details>/g, "");
10
11 export const words = (fragment: string): number =>
12 (fragment.replace(/<[^>]*>/g, " ").match(/[A-Za-z]{2,}/g) ?? []).length;
13
14 export const renderedNotes = (page: string): string[] =>
15 [...withoutHelp(page).matchAll(NOTE)].map((m) => m[2] ?? "").filter((t) => words(t) >= 4);
test/notes-ceiling.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * DESIGN.md rule 2 (#10998): /crew and /flow answer at first sight. The notes
3 * they render (any tag, `note:` props included, outside the `?` help) are at
4 * least halved from main's, and pinned there.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp } from "../src/app.ts";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve } from "../src/http/server.ts";
15 import { flowPanel } from "../src/web/pages/flow-panel.ts";
16 import { promote } from "./helpers/members.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { renderedNotes, words } from "./helpers/rendered-notes.ts";
19
20 quietLogs();
21
22 let dir = "";
23 let app: ReturnType<typeof createApp>;
24 let servers: Awaited<ReturnType<typeof serve>>;
25 let cookie = "";
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-notes-ceiling-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 servers = await serve(app);
31 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", adminIfFirst: true });
32 if (!made.ok) throw new Error(String(made.error));
33 promote(app.db, made.value);
34 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
35 const bot = await createAgent(app, made.value, { username: "bot", brief: "Be brief." });
36 if (!bot.ok) throw new Error(String(bot.error));
37 });
38 afterAll(async () => {
39 servers.stop();
40 app.close();
41 await rm(dir, { recursive: true, force: true });
42 });
43
44 const crewPage = async (): Promise<string> => {
45 const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie } });
46 expect(res.status).toBe(200);
47 return res.text();
48 };
49 const flowPage = (): string => String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards"));
50
51 test("the counter sees a note whatever its tag, and not what sits behind ?", () => {
52 expect(renderedNotes('<p class="muted">Nothing queued in the hour.</p><span class="hint">Nothing queued in the hour.</span>')).toHaveLength(2);
53 expect(renderedNotes('<span class="stat-note">from the last 28 days</span>')).toHaveLength(1);
54 expect(renderedNotes('<td class="num muted">—</td><span class="muted">default</span>')).toHaveLength(0);
55 expect(renderedNotes('<details class="disclosure" data-page-help><summary>?</summary><p class="muted">Each one of these words</p></details>')).toHaveLength(0);
56 expect(words("a run on <b>it</b>, an open pull")).toBe(6);
57 });
58
59 // The same fixture on main renders 10 notes on /crew and 20 on /flow.
60 test("/crew renders at most 3 notes", async () => {
61 expect(renderedNotes(await crewPage()).length).toBeLessThanOrEqual(3);
62 });
63
64 test("/flow renders at most 10 notes", () => {
65 expect(renderedNotes(flowPage()).length).toBeLessThanOrEqual(10);
66 });
67
68 test("what moved is behind each page's ?, so no explanation is lost", async () => {
69 const help = (page: string): string => page.match(/<details[^>]*data-page-help[\s\S]*?<\/details>/)?.[0] ?? "";
70 const crew = help(await crewPage());
71 for (const t of ["Dials for the crew", "Over the pulls each agent opened", "At most one change of model", "the first half of its branch names", "names the newest of its line"])
72 expect(crew).toContain(t);
73 const flow = help(flowPage());
74 for (const t of ["Green, approved pulls join", "check.verify", "first filter that holds", "Checks queued to start", "/settings/capacity", "/resilience", "/contribute"])
75 expect(flow).toContain(t);
76 });
77
78 test("the hints that steer a choice stay at the field they explain", async () => {
79 const page = await crewPage();
80 const roles = page.slice(page.indexOf('id="agent-roles"'), page.indexOf("</fieldset>", page.indexOf('id="agent-roles"')));
81 expect(roles).toContain("A repository's team lists offer it only");
82 expect(page).toContain("0 uses the forge&#39;s default");
83 expect(page).toContain("appended to the prompt of every run");
84 });
85
86 test("the ? is the one place a stage's meaning lives: no hover-only title on /flow's stage names or the ? itself", async () => {
87 const flow = flowPage();
88 expect(flow).toContain('data-stage="');
89 expect(flow).not.toMatch(/<th scope="row"[^>]*title=/);
90 for (const page of [flow, await crewPage()]) {
91 const summary = page.match(/<details[^>]*data-page-help[^>]*>\s*<summary[^>]*>/)?.[0] ?? "";
92 expect(summary).toContain("<summary");
93 expect(summary).not.toContain("title=");
94 }
95 });
96
97 test("the ? the pages render is a 44×44 px target with a visible focus ring, at 360 px too", async () => {
98 // The stylesheet as served, every layer and @media block included.
99 const { css } = await import("../src/web/style.ts");
100 const flat = css.replace(/\/\*[\s\S]*?\*\//g, "");
101 const rules = [...flat.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, selector, body]) => ({
102 selector: (selector ?? "").trim(),
103 body: body ?? "",
104 }));
105 const declared = (selector: string): string =>
106 rules.filter((r) => r.selector.split(",").some((s) => s.trim() === selector)).map((r) => r.body).join(";");
107 for (const page of [flowPage(), await crewPage()]) {
108 const details = page.match(/<details[^>]*data-page-help[^>]*>/)?.[0] ?? "";
109 expect(details).toMatch(/class="[^"]*\bdisclosure\b/);
110 }
111 expect(declared(".disclosure > summary")).toContain("min-height: 44px");
112 expect(declared(".disclosure[data-page-help] > summary")).toContain("min-width: 44px");
113 expect(declared(".disclosure[data-page-help] > summary:focus-visible")).toMatch(/outline: 2px solid var\(--accent\)/);
114 // Nothing anywhere, a narrow-screen block included, takes either back.
115 const reaches = (selector: string): boolean =>
116 /\bsummary\b/.test(selector) && (/^summary\b/.test(selector) || /disclosure|data-page-help/.test(selector));
117 for (const { selector, body } of rules.filter((r) => reaches(r.selector))) {
118 expect(`${selector} { ${body} }`).not.toMatch(/min-(?:height|width):\s*(?:[0-3]?\d|4[0-3])(?:\.\d+)?px|outline:\s*(?:none|0)\b|max-(?:height|width):\s*\d/);
119 }
120 });
test/page-help-open.test.ts added +45
@@ -0,0 +1,45 @@
1 /** The page's `?` is closed until asked for, open and focused on `?help`, and says its word (#11170). */
2 import { expect, test } from "bun:test";
3 import { helpAsked, pageHelp } from "../src/web/page_help.ts";
4 import { lookSlot } from "../src/core/looks.ts";
5 import { flowLines } from "../src/tui/screens/flow.ts";
6 import { mkdtempSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp } from "../src/app.ts";
10 import { MERGE_QUEUE_HELP } from "../src/flow/help.ts";
11
12 const notes = [["Name", "What it means"]] as const;
13
14 test("closed by default, with a visible Help word", () => {
15 const h = String(pageHelp(notes));
16 expect(h).not.toMatch(/ open/);
17 expect(h).not.toContain("autofocus");
18 expect(h).toContain("Help</summary>");
19 });
20
21 test("open, summary focused, when asked", () => {
22 const h = String(pageHelp(notes, true));
23 expect(h).toContain("data-page-help open>");
24 expect(h).toContain("<summary autofocus>");
25 });
26
27 test("?help is asked by the query alone", () => {
28 expect(helpAsked("http://x/boards?help")).toBe(true);
29 expect(helpAsked(new URL("http://x/boards?a=1&help="))).toBe(true);
30 expect(helpAsked("http://x/boards")).toBe(false);
31 });
32
33 test("a look with a query has its own slot, never the plain page's", () => {
34 expect(lookSlot("/boards?help", "phone")).toBe("boards-help-phone");
35 expect(lookSlot("/boards?help", "phone")).not.toBe(lookSlot("/boards", "phone"));
36 });
37
38 test("the terminal's Flow screen carries the same explanations as the web's ?", () => {
39 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-help-")), port: 0 });
40 const text = flowLines(app.db, new Date(), [], undefined, []).join(" ").replace(/\s+/g, " ");
41 expect(text).toContain("/boards?help");
42 expect(text).toContain("Checks queued to start");
43 expect(text).toContain(MERGE_QUEUE_HELP.slice(0, 40));
44 app.close();
45 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs