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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11170-crew-and-flow-s-help-behind-lands-from-p → main 13 commits fork 4 threads · 4 unresolved
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 @@ | ||
| 4 | 4 | "api.github.com" |
| 5 | 5 | ], |
| 6 | 6 | "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"], | |
| 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"], | |
| 9 | 9 | "run": "bun run src/main.ts", |
| 10 | 10 | "install": "", |
| 11 | 11 | "health": "/healthz", |
src/core/looks.ts +1 −1
| @@ -70,7 +70,7 @@ export const DEFAULT_PAGE = "/"; | ||
| 70 | 70 | * column is truth. |
| 71 | 71 | */ |
| 72 | 72 | const pageSlug = (page: string): string => |
| 73 | page === DEFAULT_PAGE ? "" : page.slice(1).replace(/[/#]/g, "-"); | |
| 73 | page === DEFAULT_PAGE ? "" : page.slice(1).replace(/[/=&]/g, "-"); | |
| 74 | 74 | |
| 75 | 75 | /** |
| 76 | 76 | * 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 | ||
| 32 | 32 | import { verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; |
| 33 | 33 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 34 | 34 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; |
| 35 | import { MERGE_QUEUE_HELP } from "../../flow/help.ts"; | |
| 35 | 36 | import { pileSays } from "../../flow/pile.ts"; |
| 36 | 37 | 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"; | |
| 38 | 39 | import { parkedSays } from "../../flow/parked.ts"; |
| 39 | 40 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 40 | 41 | import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts"; |
| @@ -153,6 +154,10 @@ export function flowLines( | ||
| 153 | 154 | ...wrap(reportLine(db), inner), |
| 154 | 155 | "", |
| 155 | 156 | ...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), | |
| 156 | 161 | ]; |
| 157 | 162 | } |
| 158 | 163 |
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 { | ||
| 25 | 25 | ); |
| 26 | 26 | } |
| 27 | 27 | |
| 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 | ||
| 28 | 32 | /** Why ready issues cannot start, per autonomous repository (#2244). */ |
| 29 | 33 | export function heldCard(rows: readonly HeldRow[]): Html { |
| 30 | 34 | return html`<section class="settings-block" id="held"> |
| 31 | 35 | <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> | |
| 39 | 36 | ${rows.length === 0 |
| 40 | 37 | ? html`<p class="muted">No repository runs on its own.</p>` |
| 41 | 38 | : html`<div class="table-scroll"> |
src/web/pages/crew.ts +15 −20
| @@ -55,6 +55,7 @@ import { listAutonomousRepos, type Repo } from "../../core/repos.ts"; | ||
| 55 | 55 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 56 | 56 | import { field, readForm, type Form } from "../form.ts"; |
| 57 | 57 | import { html, raw, when, type Html } from "../html.ts"; |
| 58 | import { helpAsked, pageHelp } from "../page_help.ts"; | |
| 58 | 59 | import { flashTo, page } from "../layout.ts"; |
| 59 | 60 | import { |
| 60 | 61 | btn, |
| @@ -144,9 +145,9 @@ export function crewReadiness(ctx: Ctx): Html { | ||
| 144 | 145 | 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 |
| 145 | 146 | created, but nothing picks up their work until this is fixed.</div></div>`; |
| 146 | 147 | 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>`} | |
| 150 | 151 | </p> |
| 151 | 152 | ${when( |
| 152 | 153 | !crew.sandbox.enforced, |
| @@ -188,7 +189,6 @@ function agentFields(values: AgentValues, error?: string): Html { | ||
| 188 | 189 | label: m.label, |
| 189 | 190 | selected: m.id === values.model, |
| 190 | 191 | })), |
| 191 | hint: "An alias names the newest of its line, so the choice does not age.", | |
| 192 | 192 | ...(error !== undefined && error.startsWith("A model") ? { error } : {}), |
| 193 | 193 | })} |
| 194 | 194 | ${uiField({ |
| @@ -385,12 +385,6 @@ function scorecardCard( | ||
| 385 | 385 | </div>`, |
| 386 | 386 | { |
| 387 | 387 | 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.`, | |
| 394 | 388 | }, |
| 395 | 389 | ); |
| 396 | 390 | } |
| @@ -544,11 +538,7 @@ function modelReviewCard(ctx: Ctx): Html { | ||
| 544 | 538 | n === null ? html`<td class="num muted">—</td>` : html`<td class="num">${f(n)}</td>`; |
| 545 | 539 | const asked = r.changes.filter((c) => c.state === "asked"); |
| 546 | 540 | 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"> | |
| 552 | 542 | <table class="table" id="model-review"> |
| 553 | 543 | <thead> |
| 554 | 544 | <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 { | ||
| 627 | 617 | </div>`, |
| 628 | 618 | { |
| 629 | 619 | title: "Model review", |
| 630 | note: html`Weekly, from the last ${r.windowDays} days of the ledger`, | |
| 631 | 620 | }, |
| 632 | 621 | ); |
| 633 | 622 | } |
| @@ -664,7 +653,14 @@ function crewView( | ||
| 664 | 653 | const usernameError = |
| 665 | 654 | error !== undefined && /username/i.test(error) ? error : undefined; |
| 666 | 655 | const handover = crewHandover(ctx.app.db); |
| 656 | const review = readReview(ctx.app); | |
| 667 | 657 | 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))} | |
| 668 | 664 | ${knobsCard(ctx)} |
| 669 | 665 | ${when(spending.length > 0, () => |
| 670 | 666 | spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined), |
| @@ -791,14 +787,12 @@ function crewView( | ||
| 791 | 787 | value: values.username, |
| 792 | 788 | required: true, |
| 793 | 789 | 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.", | |
| 795 | 790 | ...(usernameError ? { error: usernameError } : {}), |
| 796 | 791 | })} |
| 797 | 792 | ${uiField({ |
| 798 | label: "Display name", | |
| 793 | label: "Display name (optional)", | |
| 799 | 794 | name: "displayName", |
| 800 | 795 | value: values.displayName, |
| 801 | hint: "Optional. Shown beside the username.", | |
| 802 | 796 | })} |
| 803 | 797 | ${agentFields(values, usernameError === undefined ? error : undefined)} |
| 804 | 798 | <div class="form-actions"> |
| @@ -1060,7 +1054,8 @@ function agentView(agent: Agent, values: AgentValues, error?: string): Html { | ||
| 1060 | 1054 | const at = `/settings/crew/${encodeURIComponent(agent.user.username)}`; |
| 1061 | 1055 | return html`<section class="settings-block"> |
| 1062 | 1056 | <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."]])} | |
| 1064 | 1059 | <form method="post" action="${at}"> |
| 1065 | 1060 | <div class="field"> |
| 1066 | 1061 | <label for="f-username">Username</label> |
src/web/pages/flow-panel.ts +12 −14
| @@ -7,6 +7,9 @@ | ||
| 7 | 7 | * count links back to the page that drew it with `?out=<what>[&hour=<0–23>]`. |
| 8 | 8 | */ |
| 9 | 9 | |
| 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"; | |
| 10 | 13 | import type { Reader } from "../../actions/telemetry.ts"; |
| 11 | 14 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; |
| 12 | 15 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| @@ -116,7 +119,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 116 | 119 | const newest = parked.newest[0]; |
| 117 | 120 | const listed = asked(url); |
| 118 | 121 | 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( | |
| 120 | 130 | html`<p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 121 | 131 | <strong>${verdict(stages, now)}</strong> |
| 122 | 132 | </p> |
| @@ -178,10 +188,6 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 178 | 188 | : html`<strong>${String(dups.length)} test file${dups.length === 1 ? " is" : "s are"} running in more than one check:</strong> |
| 179 | 189 | ${dups.slice(0, 5).map((d) => html`<span class="mono small">${d.file} (${d.checks.join(", ")})</span> `)}`} |
| 180 | 190 | <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>. | |
| 185 | 191 | </p>`, |
| 186 | 192 | { id: "flow-summary" }, |
| 187 | 193 | )} |
| @@ -208,7 +214,6 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 208 | 214 | <th scope="row"> |
| 209 | 215 | ${STAGE_LABEL[s.key]} |
| 210 | 216 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} |
| 211 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 212 | 217 | </th> |
| 213 | 218 | <td class="mono">${String(s.waiting)}</td> |
| 214 | 219 | <td class="mono">${s.key === "live" ? "—" : String(s.active)}</td> |
| @@ -253,7 +258,7 @@ function verifiedCard(rows: readonly VerifiedRow[], now: number): Html { | ||
| 253 | 258 | return card( |
| 254 | 259 | html`<h2>Verified after landing</h2> |
| 255 | 260 | ${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>` | |
| 257 | 262 | : rows.map( |
| 258 | 263 | (r) => |
| 259 | 264 | html`<p data-verified="${r.repo}" data-unverified-since="${r.unverifiedSince ?? ""}"> |
| @@ -285,13 +290,6 @@ function idleList(idle: readonly IdlePull[]): Html { | ||
| 285 | 290 | function queueCard(rows: readonly QueueRow[]): Html { |
| 286 | 291 | return card( |
| 287 | 292 | 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> | |
| 295 | 293 | ${rows.length === 0 |
| 296 | 294 | ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>` |
| 297 | 295 | : html`<div class="table-scroll"> |
src/web/pages/knobs.ts −1
| @@ -36,7 +36,6 @@ export function knobsCard(ctx: Ctx): Html { | ||
| 36 | 36 | const rows = readKnobs(ctx.app); |
| 37 | 37 | return html`<section id="knobs"> |
| 38 | 38 | <h2 class="mb">Knobs</h2> |
| 39 | <p class="muted small">Dials for the crew; a change applies on the next pump.</p> | |
| 40 | 39 | <dl class="status-rows"> |
| 41 | 40 | ${rows.map( |
| 42 | 41 | (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); } | ||
| 750 | 750 | } |
| 751 | 751 | .disclosure[open] > summary::before { transform: rotate(90deg); } |
| 752 | 752 | .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); } | |
| 753 | 757 | |
| 754 | 758 | /* ---- topbar search (always a real form, so JS-off still searches) ---- */ |
| 755 | 759 | .topbar-search { |
| @@ -1128,6 +1132,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 1128 | 1132 | @media (forced-colors: active) { |
| 1129 | 1133 | .input:focus-visible, .palette .palette-input:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; } |
| 1130 | 1134 | .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; } | |
| 1131 | 1136 | } |
| 1132 | 1137 | |
| 1133 | 1138 | /* 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 => { | ||
| 58 | 58 | expect(main.match(/<h1[\s>]/g)?.length).toBe(1); |
| 59 | 59 | expect(main).not.toContain('class="card'); |
| 60 | 60 | 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/); | |
| 62 | 62 | expect(main).toContain('action="/settings/crew"'); |
| 63 | 63 | // The danger notice (no runner in this test) wears a class the stylesheet really styles. |
| 64 | 64 | expect(main).toContain('class="toast" data-tone="danger" role="alert"'); |
test/forge-looks.test.ts +21 −3
| @@ -22,6 +22,9 @@ quietLogs(); | ||
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | 23 | "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts", |
| 24 | 24 | "/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", | |
| 25 | 28 | "/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", |
| 26 | 29 | "/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", |
| 27 | 30 | "/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> = { | ||
| 64 | 67 | "/apple-touch-icon.png": "an icon, not a page", |
| 65 | 68 | "/apple-touch-icon-precomposed.png": "an icon, not a page", |
| 66 | 69 | "/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", | |
| 68 | 71 | "/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", |
| 69 | 72 | "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts", |
| 70 | 73 | "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)", |
| @@ -106,7 +109,9 @@ const topLevel = () => | ||
| 106 | 109 | |
| 107 | 110 | const manifest = (): { looks: string[]; looksAsOwner: string[] } => |
| 108 | 111 | 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"); | |
| 110 | 115 | /** The top-level pages; a deeper one is the preview seed's, taken as the owner (test/looks-owner.test.ts). */ |
| 111 | 116 | const declared = (): string[] => manifest().looks.filter(isTopLevel); |
| 112 | 117 | |
| @@ -142,7 +147,20 @@ test("app.looks stays within 24 paths, each once", () => { | ||
| 142 | 147 | test("a look below the top level is the preview seed's, taken as the owner", () => { |
| 143 | 148 | const { looks, looksAsOwner } = manifest(); |
| 144 | 149 | 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); | |
| 146 | 151 | expect(looksAsOwner).toContain(p); |
| 147 | 152 | } |
| 148 | 153 | }); |
| 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'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 | }); |
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.