Public: anyone can read this; the crew can change it.
The flows view is a picture first: sources on one side, followers on the other, in the home page's language #9658
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9595-the-flows-view-is-a-picture-first-source → main 4 commits fork 6 threads · 6 unresolved
docs/design/areas/flows.md +4
| @@ -1,3 +1,7 @@ | ||
| 1 | 1 | # Flows |
| 2 | 2 | |
| 3 | 3 | No new component class. A flow line is the existing `row` with one `badge`: its text is the line's state ("2 changes behind", "up to date", "not read lately") and its tone follows the link's health (`attn` for news or not read lately, `danger` for diverged or unreadable, default otherwise). An action under the *Flows in* card is the existing secondary `btn`. |
| 4 | ||
| 5 | The page draws them as a picture (#9595): `.flow-map` is a three-column grid, sources (`.flow-in`) on the left, the repository's name (`.flow-hub`, with `.flow-arrow` and `.flow-name`) between, followers (`.flow-out`) on the right. The columns stack below 900 px. All server-rendered, no script. | |
| 6 | ||
| 7 | The hub uses `--muted` for the arrows and `--fg` for the name; no accent is used on it, so the one-accent rule holds. The arrows are text glyphs and decoration only: the hub is `aria-hidden` because `repoHeader` already names the repository. Stacked, the reading order is *Flows in*, *Packages*, *Flows out*. The name wraps and the hub column is capped at 24ch so a long name cannot squeeze the sides. |
docs/features/flows.md +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Flows |
| 2 | 2 | |
| 3 | `/<owner>/<repo>/flows` is one picture per repository (#9434): on the left what flows in — the origin it was forked from, the upstream and sources it follows, the npm packages its `package.json` pins with the newest release upstream — and on the right what flows out: its forks and the mirror it pushes to. Each line has its state and when it last moved. | |
| 3 | `/<owner>/<repo>/flows` is one picture per repository (#9434): on the left what flows in — the origin it was forked from, the upstream and sources it follows, the npm packages its `package.json` pins with the newest release upstream — and on the right what flows out: its forks and the mirror it pushes to. Each line has its state and when it last moved. The page draws it as that picture (#9595): sources left, the repository's name between, followers right, stacked below 900 px as *Flows in*, *Packages*, *Flows out*; no script, and the arrows are decoration only. | |
| 4 | 4 | |
| 5 | 5 | - **No crew, no model.** Reading pins, comparing versions (`src/flows/semver.ts`), the gap (patch, minor, major), and the page are deterministic. Test: `test/flows.test.ts`, no credential. |
| 6 | 6 | - **Read upstream.** An admin of the repository taps *Read upstream releases now* (same-origin POST); the radar asks the npm registry for each exact pin and stores the answer in `dep_latest`. The page itself never touches the network. |
src/web/pages/flows.ts +20 −9
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * Flows (#9434): `/:owner/:name/flows` is one picture per repository — what |
| 3 | 3 | * flows in (origin, followed sources, pinned packages with their newest |
| 4 | 4 | * release) on one side and what flows out (forks, mirrors) on the other, |
| 5 | * each with its state and when it last moved. An admin can ask the radar to | |
| 5 | * each with its state and when it last moved, drawn left to right around | |
| 6 | * the repository (`flowMap`). An admin can ask the radar to | |
| 6 | 7 | * read upstream now. Server-rendered, no script; the read is from state the |
| 7 | 8 | * forge keeps, so it answers without the network or a model. |
| 8 | 9 | */ |
| @@ -22,7 +23,7 @@ import { html, type Html } from "../html.ts"; | ||
| 22 | 23 | import { flashTo, page } from "../layout.ts"; |
| 23 | 24 | import { badge, btn, type Tone, card, listCard, row, time } from "../ui.ts"; |
| 24 | 25 | |
| 25 | const lineRow = (l: FlowLine): Html => | |
| 26 | export const lineRow = (l: FlowLine): Html => | |
| 26 | 27 | row({ |
| 27 | 28 | ...(l.action ? { after: actionForm(l.action) } : {}), |
| 28 | 29 | ...(l.href === "" ? {} : { href: l.href }), |
| @@ -45,6 +46,22 @@ function side(title: string, lines: readonly Html[], empty: string): Html { | ||
| 45 | 46 | return card(lines.length === 0 ? html`<p class="muted">${empty}</p>` : listCard([...lines]), { head: html`${title}`, pad: lines.length === 0 }); |
| 46 | 47 | } |
| 47 | 48 | |
| 49 | /** | |
| 50 | * The picture: sources on the left, the repository in the middle, followers | |
| 51 | * on the right, in the page's own rows. Plain HTML and CSS, no script; below | |
| 52 | * 900 px the three stack in reading order. `inbound` rows are already built. | |
| 53 | */ | |
| 54 | export function flowMap(name: string, inbound: readonly Html[], packages: readonly Html[], outbound: readonly Html[]): Html { | |
| 55 | return html`<div class="flow-map"> | |
| 56 | <div class="flow-in"> | |
| 57 | ${side("Flows in", inbound, "Nothing flows in: no origin and no followed source.")} | |
| 58 | ${side("Packages", packages, "No pinned packages.")} | |
Principle 2: the hub is aria-hidden, so a screen reader never gets the repo name in the picture. repoHeader already names it, so this is acceptable, but then the arrows are decoration only; say so in the doc, and check the stacked order reads Flows in, Packages, Flows out. |
||
| 59 | </div> | |
| 60 | <div class="flow-hub" aria-hidden="true"><span class="flow-arrow">→</span><strong class="flow-name">${name}</strong><span class="flow-arrow">→</span></div> | |
| 61 | <div class="flow-out">${side("Flows out", outbound, "Nothing flows out: no forks, no mirror and no forge follows.")}</div> | |
| 62 | </div>`; | |
| 63 | } | |
| 64 | ||
| 48 | 65 | // GET /:owner/:name/flows |
outdated
flowRows is a one-line wrapper over lineRow that exists only so the test can call it. Export lineRow, or have flowMap take FlowLine[] and build rows itself, instead of adding a pass-through. |
||
| 49 | 66 | export async function flowsPage(ctx: Ctx): Promise<Response> { |
| 50 | 67 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -67,13 +84,7 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 67 | 84 | ); |
| 68 | 85 | const body = html`${repoHeader(ctx, repo, "flows")} |
| 69 | 86 | ${pageHead("Flows", { sub: "What comes in on the left, what goes out on the right." })} |
| 70 | <div class="split"> | |
| 71 | <div> | |
| 72 | ${side("Flows in", flows.inbound.map(lineRow), "Nothing flows in: no origin and no followed source.")} | |
| 73 | ${side("Packages", depRows, "No pinned packages.")} | |
| 74 | </div> | |
| 75 | <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks, no mirror and no forge follows.")}</div> | |
| 76 | </div> | |
| 87 | ${flowMap(repo.name, flows.inbound.map(lineRow), depRows, flows.outbound.map(lineRow))} | |
| 77 | 88 | ${admin |
| 78 | 89 | ? html`<form method="post" action="${repoBase(repo)}/flows/probe" class="mt">${btn("Compare heads now", { variant: "secondary" })}</form> |
| 79 | 90 | <form method="post" action="${repoBase(repo)}/flows/check" class="mt">${btn("Read upstream releases now", { variant: "secondary" })}</form>` |
src/web/style/content.ts +15
| @@ -341,6 +341,21 @@ form.inline { display: inline; margin-left: auto; } | ||
| 341 | 341 | .split > aside { position: sticky; top: calc(var(--topbar-h) + 16px); } |
| 342 | 342 | } |
| 343 | 343 | |
| 344 | /* Flows: sources left, the repository between, followers right (#9595). The | |
The comment says 'two hairline arrows' but they are text glyphs, not hairlines (principle 6 is about borders). Reword. A long repository name in the auto column also squeezes both side columns at 900px; let it wrap or cap its width. |
||
| 345 | hub is a name and two text arrows, decoration only and hidden from reading order; | |
| 346 | stacked on a phone, the arrows turn down. The name wraps and its column is | |
| 347 | capped so a long one cannot squeeze the two sides. */ | |
| 348 | .flow-map { display: grid; gap: 16px; margin-top: 16px; grid-template-columns: minmax(0, 1fr); overflow-wrap: anywhere; } | |
| 349 | .flow-map > * { min-width: 0; } | |
| 350 | .flow-hub { display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--muted); } | |
| 351 | .flow-hub .flow-arrow { display: inline-block; transform: rotate(90deg); } | |
| 352 | .flow-hub .flow-name { color: var(--fg); max-width: 100%; text-align: center; } | |
| 353 | @media (min-width: 900px) { | |
| 354 | .flow-map { grid-template-columns: minmax(0, 1fr) minmax(0, max-content) minmax(0, 1fr); gap: 24px; align-items: start; } | |
| 355 | .flow-hub { flex-direction: row; align-self: center; max-width: 24ch; gap: 12px; } | |
| 356 | .flow-hub .flow-arrow { transform: none; } | |
| 357 | } | |
| 358 | ||
| 344 | 359 | /* The studio's wide rail (home, an app): a column of cards that runs taller |
| 345 | 360 | than a screen, so it scrolls with the page instead of sticking, sits flush |
| 346 | 361 | under the tabs or header, and takes its two columns from 900px. */ |
test/flows.test.ts +42 −4
| @@ -15,9 +15,11 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 15 | 15 | import { MAX_SOURCES, probeLinks, type Reads } from "../src/flows/probe.ts"; |
| 16 | 16 | import { startRadar } from "../src/flows/start.ts"; |
| 17 | 17 | import { followHealth, plainUrl, readFlows } from "../src/flows/view.ts"; |
| 18 | import { flowMap, lineRow } from "../src/web/pages/flows.ts"; | |
| 18 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | 20 | import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; |
| 20 | 21 | import { seedPreview } from "../src/spawn/preview-seed.ts"; |
| 22 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 21 | 23 | |
| 22 | 24 | const v = (s: string) => parseSemver(s)!; |
| 23 | 25 | |
| @@ -52,6 +54,10 @@ let dir: string; | ||
| 52 | 54 | let app: App; |
| 53 | 55 | let servers: Servers; |
| 54 | 56 | const KEY = "flows-test-key"; |
| 57 | const sign = (id: string, name: string, role: string) => { | |
| 58 | const said = `${id}|${name}|${role}|${Date.now() + 60_000}`; | |
| 59 | return { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 60 | }; | |
| 55 | 61 | beforeAll(async () => { |
| 56 | 62 | dir = await mkdtemp(join(tmpdir(), "forge-flows-")); |
| 57 | 63 | app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY }); |
| @@ -148,10 +154,6 @@ describe("starting the radar", () => { | ||
| 148 | 154 | }); |
| 149 | 155 | |
| 150 | 156 | describe("the page", () => { |
| 151 | const sign = (id: string, name: string, role: string) => { | |
| 152 | const said = `${id}|${name}|${role}|${Date.now() + 60_000}`; | |
| 153 | return { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 154 | }; | |
| 155 | 157 | const url = () => `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`; |
| 156 | 158 | |
| 157 | 159 | test("answers for an admin with the action and a tab in the header; a cross-origin POST is refused and changes nothing", async () => { |
| @@ -342,3 +344,39 @@ describe("link health and actions (#9561)", () => { | ||
| 342 | 344 | }); |
| 343 | 345 | }); |
| 344 | 346 | }); |
| 347 | ||
| 348 | describe("the flows picture (#9595)", () => { | |
| 349 | const page = () => `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`; | |
outdated
A signed-header IIFE inlined in an object literal is hard to read. Use the file's existing signing helper (or extract one) so the test shows the order check, not header plumbing. Imports are also out of order (helpers/work.ts sits between src imports). |
||
| 350 | ||
| 351 | test("sources on the left, the repository between, followers on the right, no script", async () => { | |
| 352 | const text = await (await fetch(page(), { headers: sign("u1", "trevato", "admin") })).text(); | |
| 353 | const at = (s: string) => text.indexOf(s); | |
| 354 | expect(at('class="flow-in"')).toBeGreaterThan(-1); | |
| 355 | expect(at('class="flow-in"')).toBeLessThan(at('class="flow-hub"')); | |
| 356 | expect(at('class="flow-hub"')).toBeLessThan(at('class="flow-out"')); | |
| 357 | // Stacked order for a reader: Flows in, Packages, Flows out. | |
| 358 | expect(at("Flows in")).toBeLessThan(at("Packages")); | |
| 359 | expect(at("Packages")).toBeLessThan(at("Flows out")); | |
| 360 | }); | |
| 361 | ||
| 362 | test("200 lines cost the page no more queries than none: no N+1, no script", async () => { | |
| 363 | const init = { headers: sign("u1", "trevato", "admin") }; | |
| 364 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 365 | const before = await requestStatements(app.db, page(), init); | |
| 366 | for (let i = 0; i < 200; i++) | |
outdated
This bounds output length, not time or work. Any slow implementation passes it, so it would not fail without the behaviour; the issue's 'under 50 ms warm' has no test. Replace it with a test that exercises 200 lines through the view or page and asserts bounded work in the way the repo permits. |
||
| 367 | app.db.query("INSERT OR IGNORE INTO repo_source (id, repo_id, url, branch, by_id, enabled, created_at) VALUES (?1, ?2, ?3, 'main', 'u1', 1, '2026-11-03T00:00:00Z')").run(`w${i}`, repo.id, `https://example.com/w${i}.git`); | |
| 368 | expectWork(await requestStatements(app.db, page(), init), { max: before }); | |
| 369 | const text = await (await fetch(page(), init)).text(); | |
| 370 | const map = text.slice(text.indexOf('class="flow-map"')); | |
| 371 | expect(map.slice(0, map.indexOf("</main>") < 0 ? undefined : map.indexOf("</main>"))).not.toContain("<script"); | |
| 372 | expect(text).toContain("example.com/w199.git"); | |
| 373 | app.db.query("DELETE FROM repo_source WHERE id LIKE 'w%'").run(); | |
| 374 | }); | |
| 375 | ||
| 376 | test("a line is a row with its state and no script", () => { | |
| 377 | const line = { kind: "fork", label: "o/r", href: "/o/r", state: "up to date", at: null, problem: false, health: "ok" } as const; | |
| 378 | const out = flowMap("demo", [lineRow(line)], [], []).toString(); | |
| 379 | expect(out).toContain("o/r"); | |
| 380 | expect(out).toContain("never moved"); | |
| 381 | }); | |
| 382 | }); |
Add which tokens the hub uses (--muted, --fg) and that no accent is used, so the one-accent rule is checkable.