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

The next page at /:owner/:name/next speaks the home page's language #9783

Merged opened this

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

Merged

mason/forge:mason/9764-the-next-page-at-owner-name-next-speaks → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/9764-the-next-page-at-owner-name-next-speaks was merged into main as ac8cbb4.

Review

Sign in to review this change.
3 files changed +107 −29
docs/design/areas/next-up.md +4
@@ -3,3 +3,7 @@
33 - `.next-week`: a row dimmed (opacity only, no layout change) while it is dragged below the cut line, so it reads as next week. Added and removed by the page's script during a drag; absent at rest.
44 - Dragging is mouse-only (HTML5 drag does not fire on touch); on a phone a row moves by its Top/Up/Down buttons. A pointer path is a later change.
55 - Fit at 390 px (#9324): no new class. `[data-next]` (an attribute on the page's wrapper) breaks long words anywhere; a row asks for 14rem so its Top/Up/Down/Not now buttons wrap under the text instead of squeezing the title, and the buttons wrap in `.row-after`.
6
7 ## Rows under headings, not boxes (#9764)
8
9 The head is `pageHead("Next up")` with one sentence: the crew builds strictly from the top. The ranked list is a `<ul class="list setting-list">` straight under `<h2>Ready, in order</h2>`, and Not now (`?all=1`) the same under its own `<h2>`: no card, not even a bare one, since a list is rows (principle 7). Inside `[data-next]` the rows are spaced by a 4px gap, not ruled by hairlines (principle 3); the cut line keeps its dashed rule, as it marks where this week ends. The week's projection, its link, and for a writer the drag and j/k hint sit in one muted line after the list, beside the buttons that show their own keys. Blocker or Enabler is muted text in the row's meta (no badge). Top/Up/Down/Not now stay plain same-origin POST forms; each row still names its issue number, who is on it and what it waits on. No new class.
src/web/pages/next.ts +17 −28
@@ -14,19 +14,24 @@ import { sealedScope } from "../../core/sealed.ts";
1414 import { notStartable } from "../../crew/not_startable.ts";
1515 import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts";
1616 import type { Ctx } from "../../http/context.ts";
17 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
17 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
1818 import { backTo, field, readForm } from "../form.ts";
1919 import { html, type Html } from "../html.ts";
2020 import { flashOrJson, page } from "../layout.ts";
21 import { badge, btn, card, kbd, row } from "../ui.ts";
21 import { btn, kbd, row } from "../ui.ts";
2222
2323 /**
2424 * The owner's issue titles are their own words, so inside `[data-next]` a long
2525 * word breaks anywhere, and a row's actions drop under its text (the row asks
2626 * for 14rem, not 0) in place of squeezing the title to a sliver at 390 px.
27 * The lists sit straight on the page and are spaced, not ruled (DESIGN.md §1,
28 * principles 3 and 7): no hairline between rows, a small gap instead.
2729 * No new class.
2830 */
2931 export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; }
32 [data-next] .list { display: flex; flex-direction: column; gap: 4px; padding: 0; margin: 0; }
33 [data-next] .list > li + li { border-top: 0; }
34 [data-next] .list .row { border-radius: var(--radius); }
3035 [data-next] .list > li.has-after > .row { flex: 1 1 14rem; }
3136 [data-next] .row-after { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 14px 8px; }
3237 [data-next] .cut-line { overflow-wrap: anywhere; }`;
@@ -159,8 +164,7 @@ function line(
159164 title: r.issue.title,
160165 titleAttr: r.issue.title,
161166 lead: html`${r.place === null ? "·" : String(r.place)}`,
162 meta: html`${badge(r.role === "blocker" ? "Blocker" : "Enabler", { variant: r.role === "blocker" ? "secondary" : "outline" })}
163 #${n} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`,
167 meta: html`${r.role === "blocker" ? "Blocker" : "Enabler"} · #${n} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`,
164168 nav: true,
165169 data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) },
166170 ...(controls === null ? {} : { after: controls }),
@@ -199,26 +203,16 @@ export function nextPage(ctx: Ctx): Response {
199203 };
200204 const body = html`${repoHeader(ctx, repo, "issues")}
201205 <div data-next>
202 <div class="page-head mt">
203 <h1>Next up</h1>
204 <p class="muted">
205 The crew builds strictly from the top.
206 ${writer
207 ? "Drag a row, or press the key beside an action on the row you are on (j and k move between rows): each saves at once."
208 : "Only someone who writes here can change the order."}
209 Dependencies order work within the list; they are shown, not edited.
210 </p>
211 </div>
212 <p class="muted" id="next-week">${sentence} <a href="${base}/week">The week</a></p>
206 ${pageHead("Next up", { sub: "The crew builds strictly from the top." })}
213207 <p class="error" id="next-flash" role="alert" hidden></p>
214208 ${next.rows.length === 0
215209 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
216 : card(
217 html`<ul class="list setting-list" id="next-list"${costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
210 : html`<section class="stack-tight mt"><h2>Ready, in order</h2><ul class="list setting-list" id="next-list"${costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
218211 ${rowsHtml()}
219 </ul>`,
220 { pad: false },
221 )}
212 </ul></section>`}
213 <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer
214 ? " · Drag a row, or press the key on its button; j and k move between rows."
215 : null}</p>
outdated

Principle 7: a list is rows, not a card. Drop the card() wrapper here and put the <ul class="list"> straight under the h2. Principle 1: the h2 plus the long intro above say the same thing twice.

222216 ${all
223217 ? null
224218 : rest > 0 || next.notNowTotal > 0
@@ -233,14 +227,9 @@ export function nextPage(ctx: Ctx): Response {
233227 : null}
234228 ${all && next.notNow.length > 0
235229 ? html`<section class="stack-tight mt">
236 <h2>Not now</h2>
237 <p class="muted small">Behind the Later gate. Top lifts one past it in one action.</p>
238 ${card(
239 html`<ul class="list setting-list">
240 ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))}
241 </ul>`,
242 { pad: false },
243 )}
230 <h2>Not now</h2><ul class="list setting-list">
231 ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))}
232 </ul>
244233 </section>`
245234 : null}
246235 </div>`;
test/next-up.test.ts +86 −1
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { openIssue, setIssueState } from "../src/actions/issues.ts";
12 import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts";
1313 import { NEXT_ALREADY_AWAY, NEXT_FULL, placeOf, rankIssue } from "../src/actions/next.ts";
1414 import { createRepo } from "../src/actions/repos.ts";
1515 import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts";
@@ -17,6 +17,7 @@ import { getIssue, getIssueById } from "../src/core/issues.ts";
1717 import { MAX_RANKED, writeOrder } from "../src/core/rank.ts";
1818 import { nextUp } from "../src/core/next_up.ts";
1919 import type { Repo } from "../src/core/repos.ts";
20 import { createSession } from "../src/core/sessions.ts";
2021 import { createUser, type User } from "../src/core/users.ts";
2122 import { serve, type Servers } from "../src/http/server.ts";
2223
@@ -314,3 +315,87 @@ describe("the picker follows the page (#8826)", () => {
314315 }
315316 });
316317 });
318
319 describe("Next up page shape (#9764)", () => {
320 test("renders rows straight under headings: one-sentence head, no card, no badge, plain-text role, forms still POST", async () => {
outdated

Reads the source text with regexes, so it passes on any file containing those strings and says nothing about the rendered page. Render nextPage and assert the rows-not-boxes shape on the HTML (h2 present, list inside .card.bare, no badge, forms still POST). Also expect(typeof nextPage) proves nothing.

321 const dir = await mkdtemp(join(tmpdir(), "forge-next-shape-"));
322 const app = createApp({ dataDir: dir, port: 0 });
323 const servers = await serve(app);
324 try {
325 const mk = async (username: string, first: boolean) => {
326 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
327 if (!made.ok) throw new Error(made.error);
328 return made.value;
329 };
330 const alice = await mk("alice", true);
331 const bob = await mk("bob", false);
332 const made = await createRepo(app, alice, { name: "shape", visibility: "public" });
333 if (!made.ok) throw new Error(String(made.error));
334 const repo = made.value;
335 const open = (title: string) => {
336 const o = openIssue(app, alice, repo, { title });
337 if (!o.ok) throw new Error(String(o.error));
338 return o.value;
339 };
340 const first = open("First job");
341 const second = open("Second job");
342 const waits = open("Waits on first");
343 addDependency(app.db, waits, first, alice.id);
344 expect(rankIssue(app, alice, repo, waits.number, placeOf("top")!).ok).toBe(true);
345 const given = assignIssue(app, alice, repo, second, bob);
346 if (!given.ok) throw new Error(String(given.error));
347 const cookie = `forge_session=${createSession(app.db, alice.id).token}`;
348 const res = await fetch(`${servers.url}/alice/shape/next`, { headers: { cookie } });
349 expect(res.status).toBe(200);
350 const body = (await res.text()).replace(/\s+/g, " ");
351
352 expect(body).toContain("<h2>Ready, in order</h2>");
353 // The list is rows straight under its heading: no card, bare or boxed.
354 expect(body).toMatch(/<h2>Ready, in order<\/h2> ?<ul class="list setting-list" id="next-list"/);
355 const main = body.slice(body.indexOf("<div data-next>"));
outdated

This pins card bare as the shape; if the card wrapper goes (principle 7), assert the <ul> directly follows the h2 instead. Add the ?all=1 Not now case, which this pull also changed.

356 expect(main).not.toMatch(/class="card\b/);
357 // One sentence in the head, from pageHead; the key hint sits after the list, not in the lead.
358 expect(main).toMatch(/<div class="page-head"> ?<h1>Next up<\/h1> ?<span class="page-head-sub muted small">The crew builds strictly from the top\.<\/span>/);
359 expect(main.indexOf("j and k move between rows")).toBeGreaterThan(main.indexOf("</ul>"));
360 expect(body).not.toMatch(/class="[^"]*\bbadge\b/);
361 expect(body).not.toMatch(/<(?:span|em|strong)[^>]*badge/);
362
363 const rows = body.match(/<li[^>]*data-number="\d+"[\s\S]*?<\/li>/g) ?? [];
364 expect(rows.length).toBe(3);
365 const rowOf = (n: number) => rows.find((r) => r.includes(`data-number="${n}"`))!;
366 for (const [issue, role] of [[first, "Blocker"], [second, "Enabler"]] as const) {
367 const meta = /<span class="row-meta">([\s\S]*?)<\/span>/.exec(rowOf(issue.number))![1]!;
368 expect(meta).toContain(`${role} · #${issue.number}`);
369 expect(meta.split("<span")[0]).not.toContain("<");
370 }
371 expect(rowOf(second.number)).toContain("on it: bob");
372 expect(rowOf(waits.number)).toContain(`waits for #${first.number}`);
373
374 // Every action is still a form that posts.
375 for (const label of ["Top", "Up", "Down", "Not now"]) {
376 expect(body).toMatch(new RegExp(`<form method="post" action="[^"]*/next"[^>]*>(?:(?!</form>).)*${label} <kbd>`));
377 }
378 expect(body).not.toMatch(/<form(?![^>]*method="post")[^>]*action="[^"]*\/next"/);
379
380 // Not now (?all=1) is rows under its own heading too, and Top is the one move it offers.
381 const gate = open("Later gate: not now");
382 const far = open("Far off");
383 addDependency(app.db, far, gate, alice.id);
384 const all = (await (await fetch(`${servers.url}/alice/shape/next?all=1`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
385 const notNow = all.slice(all.indexOf("<h2>Not now</h2>"));
386 expect(notNow).toMatch(/^<h2>Not now<\/h2> ?<ul class="list setting-list">/);
387 expect(notNow).not.toMatch(/class="card\b/);
388 expect(notNow).not.toContain("Behind the Later gate. Top lifts");
389 const farRow = /<li[^>]*data-number="(\d+)"[\s\S]*?<\/li>/.exec(notNow)!;
390 expect(farRow[1]).toBe(String(far.number));
391 expect(farRow[0]).toContain("behind the Later gate");
392 expect(farRow[0]).toMatch(/<form method="post" action="[^"]*\/next"[^>]*>(?:(?!<\/form>).)*value="top"/);
393 expect(farRow[0]).not.toMatch(/\bUp <kbd>/);
394 expect(farRow[0]).not.toMatch(/\bDown <kbd>/);
395 } finally {
396 await servers.stop();
397 app.close();
398 await rm(dir, { recursive: true, force: true });
399 }
400 });
401 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs