Public: anyone can read this; the crew can change it.
The next page at /:owner/:name/next speaks the home page's language #9783
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9764-the-next-page-at-owner-name-next-speaks → main 4 commits fork 4 threads · 4 unresolved
docs/design/areas/next-up.md +4
| @@ -3,3 +3,7 @@ | ||
| 3 | 3 | - `.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. |
| 4 | 4 | - 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. |
| 5 | 5 | - 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"; | ||
| 14 | 14 | import { notStartable } from "../../crew/not_startable.ts"; |
| 15 | 15 | import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { backTo, field, readForm } from "../form.ts"; |
| 19 | 19 | import { html, type Html } from "../html.ts"; |
| 20 | 20 | import { flashOrJson, page } from "../layout.ts"; |
| 21 | import { badge, btn, card, kbd, row } from "../ui.ts"; | |
| 21 | import { btn, kbd, row } from "../ui.ts"; | |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | 24 | * The owner's issue titles are their own words, so inside `[data-next]` a long |
| 25 | 25 | * word breaks anywhere, and a row's actions drop under its text (the row asks |
| 26 | 26 | * 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. | |
| 27 | 29 | * No new class. |
| 28 | 30 | */ |
| 29 | 31 | 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); } | |
| 30 | 35 | [data-next] .list > li.has-after > .row { flex: 1 1 14rem; } |
| 31 | 36 | [data-next] .row-after { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 14px 8px; } |
| 32 | 37 | [data-next] .cut-line { overflow-wrap: anywhere; }`; |
| @@ -159,8 +164,7 @@ function line( | ||
| 159 | 164 | title: r.issue.title, |
| 160 | 165 | titleAttr: r.issue.title, |
| 161 | 166 | 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}`}`, | |
| 164 | 168 | nav: true, |
| 165 | 169 | data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) }, |
| 166 | 170 | ...(controls === null ? {} : { after: controls }), |
| @@ -199,26 +203,16 @@ export function nextPage(ctx: Ctx): Response { | ||
| 199 | 203 | }; |
| 200 | 204 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 201 | 205 | <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." })} | |
| 213 | 207 | <p class="error" id="next-flash" role="alert" hidden></p> |
| 214 | 208 | ${next.rows.length === 0 |
| 215 | 209 | ? 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}> | |
| 218 | 211 | ${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> | |
| 222 | 216 | ${all |
| 223 | 217 | ? null |
| 224 | 218 | : rest > 0 || next.notNowTotal > 0 |
| @@ -233,14 +227,9 @@ export function nextPage(ctx: Ctx): Response { | ||
| 233 | 227 | : null} |
| 234 | 228 | ${all && next.notNow.length > 0 |
| 235 | 229 | ? 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> | |
| 244 | 233 | </section>` |
| 245 | 234 | : null} |
| 246 | 235 | </div>`; |
test/next-up.test.ts +86 −1
| @@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { NEXT_ALREADY_AWAY, NEXT_FULL, placeOf, rankIssue } from "../src/actions/next.ts"; |
| 14 | 14 | import { createRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts"; |
| @@ -17,6 +17,7 @@ import { getIssue, getIssueById } from "../src/core/issues.ts"; | ||
| 17 | 17 | import { MAX_RANKED, writeOrder } from "../src/core/rank.ts"; |
| 18 | 18 | import { nextUp } from "../src/core/next_up.ts"; |
| 19 | 19 | import type { Repo } from "../src/core/repos.ts"; |
| 20 | import { createSession } from "../src/core/sessions.ts"; | |
| 20 | 21 | import { createUser, type User } from "../src/core/users.ts"; |
| 21 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 23 | |
| @@ -314,3 +315,87 @@ describe("the picker follows the page (#8826)", () => { | ||
| 314 | 315 | } |
| 315 | 316 | }); |
| 316 | 317 | }); |
| 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 |
||
| 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 |
||
| 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 | }); |
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.