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

Merged opened this

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

Merged

torque/forge:torque/9595-the-flows-view-is-a-picture-first-source → main 4 commits fork 6 threads · 6 unresolved

  • Merged. torque/9595-the-flows-view-is-a-picture-first-source was merged into main as 2869472.

Review

Sign in to review this change.
5 files changed +82 −14
docs/design/areas/flows.md +4
@@ -1,3 +1,7 @@
11 # Flows
22
33 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.

Add which tokens the hub uses (--muted, --fg) and that no accent is used, so the one-accent rule is checkable.

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 @@
11 # Flows
22
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.
44
55 - **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.
66 - **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 @@
22 * Flows (#9434): `/:owner/:name/flows` is one picture per repository — what
33 * flows in (origin, followed sources, pinned packages with their newest
44 * 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
67 * read upstream now. Server-rendered, no script; the read is from state the
78 * forge keeps, so it answers without the network or a model.
89 */
@@ -22,7 +23,7 @@ import { html, type Html } from "../html.ts";
2223 import { flashTo, page } from "../layout.ts";
2324 import { badge, btn, type Tone, card, listCard, row, time } from "../ui.ts";
2425
25 const lineRow = (l: FlowLine): Html =>
26 export const lineRow = (l: FlowLine): Html =>
2627 row({
2728 ...(l.action ? { after: actionForm(l.action) } : {}),
2829 ...(l.href === "" ? {} : { href: l.href }),
@@ -45,6 +46,22 @@ function side(title: string, lines: readonly Html[], empty: string): Html {
4546 return card(lines.length === 0 ? html`<p class="muted">${empty}</p>` : listCard([...lines]), { head: html`${title}`, pad: lines.length === 0 });
4647 }
4748
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
4865 // 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.

4966 export async function flowsPage(ctx: Ctx): Promise<Response> {
5067 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -67,13 +84,7 @@ export async function flowsPage(ctx: Ctx): Promise<Response> {
6784 );
6885 const body = html`${repoHeader(ctx, repo, "flows")}
6986 ${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))}
7788 ${admin
7889 ? html`<form method="post" action="${repoBase(repo)}/flows/probe" class="mt">${btn("Compare heads now", { variant: "secondary" })}</form>
7990 <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; }
341341 .split > aside { position: sticky; top: calc(var(--topbar-h) + 16px); }
342342 }
343343
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
344359 /* The studio's wide rail (home, an app): a column of cards that runs taller
345360 than a screen, so it scrolls with the page instead of sticking, sits flush
346361 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";
1515 import { MAX_SOURCES, probeLinks, type Reads } from "../src/flows/probe.ts";
1616 import { startRadar } from "../src/flows/start.ts";
1717 import { followHealth, plainUrl, readFlows } from "../src/flows/view.ts";
18 import { flowMap, lineRow } from "../src/web/pages/flows.ts";
1819 import { serve, type Servers } from "../src/http/server.ts";
1920 import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts";
2021 import { seedPreview } from "../src/spawn/preview-seed.ts";
22 import { expectWork, requestStatements } from "./helpers/work.ts";
2123
2224 const v = (s: string) => parseSemver(s)!;
2325
@@ -52,6 +54,10 @@ let dir: string;
5254 let app: App;
5355 let servers: Servers;
5456 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 };
5561 beforeAll(async () => {
5662 dir = await mkdtemp(join(tmpdir(), "forge-flows-"));
5763 app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY });
@@ -148,10 +154,6 @@ describe("starting the radar", () => {
148154 });
149155
150156 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 };
155157 const url = () => `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`;
156158
157159 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)", () => {
342344 });
343345 });
344346 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs