Public: anyone can read this; the crew can change it.
Calm pass: the signed-in home page answers 'what needs me, what moved' at first sight #10375
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10298-calm-pass-the-signed-in-home-page-answer → main 6 commits fork 2 threads · 2 unresolved
10 files
changed +89
−29
docs/design/areas/home.md +2
| @@ -10,3 +10,5 @@ | ||
| 10 | 10 | The signed-in home's door line (Notes, Talk) is `.home-doors`: accent underlined links, each a 44px tap target (#9584). |
| 11 | 11 | |
| 12 | 12 | `/explore` (#9645) speaks the same language: the kinds (All, Apps, People, Forges) are `tabs()` as on home; sort and time stay one `seg` each in a small line (they stay boxed: they are two-to-three-way switches whose current choice must read at a glance, and a plain link row would blur it from the tabs above), with "Show archived" a link (`aria-pressed`) wearing `.home-doors`' 44px hit area, no box; the rows are a `.card.bare` list, rows and space on the page's one background, no box. Row words, ranking, "Make it yours" and the URL line are unchanged, so the terminal's explore search keeps the same words. No new class. |
| 13 | ||
| 14 | The signed-in home (#10298) is three compartments in this order: **Needs you** (`#needs-you`: what waits on the owner, the held gate, fired alarms; absent when empty), **Since you were here** (the digest, absent when nothing moved), and **Live** (`#live-moving`: the pipeline line and what is being built). What just shipped and the admin's figures (`#home-figures`, with the week's pace) open in place in a `disclosure`, closed. No new class; `.moving` is no longer drawn by home. |
docs/design/attention-budget.json +4 −4
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "home": { |
| 3 | 3 | "390": { |
| 4 | "words": 182, | |
| 4 | "words": 95, | |
| 5 | 5 | "blocks": 20, |
| 6 | "colours": 3 | |
| 6 | "colours": 2 | |
| 7 | 7 | }, |
| 8 | 8 | "1280": { |
| 9 | "words": 182, | |
| 9 | "words": 95, | |
| 10 | 10 | "blocks": 20, |
| 11 | "colours": 3 | |
| 11 | "colours": 2 | |
| 12 | 12 | } |
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
docs/features/attention.md +1 −1
| @@ -21,7 +21,7 @@ reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and | ||
| 21 | 21 | |
| 22 | 22 | | Page | 390 px | 1280 px | |
| 23 | 23 | |---|---|---| |
| 24 | | home | 182 / 20 / 3 | 182 / 20 / 3 | | |
| 24 | | home | 95 / 20 / 2 | 95 / 20 / 2 | | |
| 25 | 25 | | repository | 189 / 28 / 2 | 189 / 28 / 2 | |
| 26 | 26 | | app | 189 / 28 / 2 | 189 / 28 / 2 | |
| 27 | 27 | | pull | 133 / 22 / 4 | 133 / 22 / 4 | |
src/web/studio/held.ts +1 −2
| @@ -43,8 +43,7 @@ export function heldLine(ctx: Ctx, user: User): Html | null { | ||
| 43 | 43 | >${hold.held} ${hold.held === 1 ? "issue is" : "issues are"} held |
| 44 | 44 | behind the Later gate</a |
| 45 | 45 | > |
| 46 | in ${repo.name}, set by ${hold.by} ${time(hold.since)}. Nothing there | |
| 47 | starts until you lift it. | |
| 46 | in ${repo.name}, set by ${hold.by} ${time(hold.since)} | |
| 48 | 47 | </p>`, |
| 49 | 48 | )} |
| 50 | 49 | </section>`; |
src/web/studio/home.ts +25 −6
| @@ -41,6 +41,7 @@ import { | ||
| 41 | 41 | btn, |
| 42 | 42 | card, |
| 43 | 43 | dot, |
| 44 | disclosure, | |
| 44 | 45 | empty, |
| 45 | 46 | envStateBadge, |
| 46 | 47 | icon, |
| @@ -246,9 +247,13 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | | ||
| 246 | 247 | const ceilingUsd = forgeCeiling(ctx.app.config); |
| 247 | 248 | const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn); |
| 248 | 249 | const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; |
| 249 | return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} | |
| 250 | ${homeWeek(ctx.app, user)} | |
| 251 | ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`; | |
| 250 | // The figures and the week's pace open in place; alarms need the owner, so they sit under "Needs you". | |
| 251 | return html`${disclosure( | |
| 252 | "Figures", | |
| 253 | html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} | |
| 254 | ${homeWeek(ctx.app, user)}`, | |
| 255 | { small: true, id: "home-figures" }, | |
| 256 | )}`; | |
| 252 | 257 | }, |
| 253 | 258 | waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)), |
| 254 | 259 | panels: async () => (await timePhaseAsync(request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(db))) ?? html``, |
| @@ -333,13 +338,27 @@ export async function studioHome( | ||
| 333 | 338 | const kpis = figures |
| 334 | 339 | ? await homeRegion(ctx.app.db, ctx.request, user.id, "kpis", homeVariant(ctx.url), access, homeDraws(ctx, user).kpis) |
| 335 | 340 | : liveLine(ctx); |
| 341 | // Three compartments (#10298): what needs the owner, what moved since the last look, what is live. | |
| 342 | const draws = homeDraws(ctx, user); | |
| 343 | const waiting = await homeRegion(ctx.app.db, ctx.request, user.id, "waiting", homeVariant(ctx.url), access, draws.waiting); | |
| 344 | const held = timePhase(ctx.request, "held", () => heldLine(ctx, user)); | |
| 345 | const alarms = user.isAdmin | |
| 346 | ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config))) | |
| 347 | : null; | |
| 348 | const visitedAt = Date.now(); | |
| 349 | const before = timePhase(ctx.request, "visit", () => | |
| 350 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), | |
| 351 | ); | |
| 352 | const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, user, before, visitedAt)); | |
| 353 | const needsYou = html`${waiting}${held}${alarms}`; | |
| 336 | 354 | const body = html`<h1 class="sr-only">Home</h1> |
| 337 | 355 | ${tellBox(ctx, user)} |
| 338 | 356 | ${ask} ${address} |
| 339 | ${timePhase(ctx.request, "held", () => heldLine(ctx, user))} | |
| 357 | ${when(String(needsYou).trim() !== "", () => html`<section class="stack stack-tight" id="needs-you" aria-label="Needs you"><h2 class="small muted">Needs you</h2>${needsYou}</section>`)} | |
Principle 1 and consistency: Needs you gets an h2 and an aria-label, Since and Live get none from here. Name all three compartments or none. Also no test covers this section being absent when empty. |
||
| 358 | ${when(String(since).trim() !== "", () => html`<section aria-label="Since you were here">${since}</section>`)} | |
| 359 | <section aria-label="Live">${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}</section> | |
| 340 | 360 | <section class="stack" aria-label="The forge">${kpis}</section> |
| 341 | 361 | ${pinnedSlot(ctx.app.db, user)} |
| 342 | ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))} | |
| 343 | 362 | ${appChips(ctx, user)} |
| 344 | 363 | ${when( |
| 345 | 364 | steps, |
| @@ -352,7 +371,7 @@ export async function studioHome( | ||
| 352 | 371 | body, |
| 353 | 372 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`, |
| 354 | 373 | }); |
| 355 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access"]); | |
| 374 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access", "waiting", "held", "visit", "since"]); | |
| 356 | 375 | return res; |
| 357 | 376 | } |
| 358 | 377 |
src/web/studio/living.ts +12 −13
| @@ -14,7 +14,7 @@ import type { User } from "../../core/users.ts"; | ||
| 14 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { fragment } from "../layout.ts"; |
| 17 | import { btn, row, timeAgo } from "../ui.ts"; | |
| 17 | import { btn, disclosure, row, timeAgo } from "../ui.ts"; | |
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 19 | import type { NowStage } from "../../flow/now_rows.ts"; |
| 20 | 20 | import { nowHref } from "../pages/now.ts"; |
| @@ -53,7 +53,7 @@ export function nowLine(now: Now, boards = true): Html { | ||
| 53 | 53 | </section>`; |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | function listOf(label: string, rows: readonly MovingRow[], empty: string): Html { | |
| 56 | function listOf(label: string, rows: readonly MovingRow[], empty: string, heading = true): Html { | |
| 57 | 57 | const shown = rows.map((r) => |
| 58 | 58 | row({ |
| 59 | 59 | href: r.href, |
| @@ -65,17 +65,18 @@ function listOf(label: string, rows: readonly MovingRow[], empty: string): Html | ||
| 65 | 65 | // Always MOVING_ROWS rows: a quiet slot is as tall as a busy one. |
| 66 | 66 | for (let i = shown.length; i < MOVING_ROWS; i++) |
| 67 | 67 | shown.push(row({ title: html`<span class="muted small">${i === 0 ? empty : " "}</span>` })); |
| 68 | return html`<section aria-label="${label}"> | |
| 69 | <h2 class="small muted">${label}</h2> | |
| 70 | <ul class="list"> | |
| 68 | const list = html`<ul class="list"> | |
| 71 | 69 | ${shown} |
| 72 | </ul> | |
| 73 | </section>`; | |
| 70 | </ul>`; | |
| 71 | return heading ? html`<section aria-label="${label}"> | |
| 72 | <h2 class="small muted">${label}</h2> | |
| 73 | ${list} | |
| 74 | </section>` : list; | |
| 74 | 75 | } |
| 75 | 76 | |
| 76 | 77 | /** |
| 77 | * The pipeline, then being built and just shipped side by side on a desk and | |
| 78 | * stacked on a phone; one live region. The pipeline counts every repository, | |
| 78 | * The pipeline, then what is being built; what just shipped is one tap away, | |
| 79 | * open in place (calm pass, #10298). One live region. The pipeline counts every repository, | |
| 79 | 80 | * private ones too, so only a site admin, who reads them all, sees the line. |
| 80 | 81 | */ |
| 81 | 82 | export function movingRegion(db: Database, viewer: User): Html { |
| @@ -83,10 +84,8 @@ export function movingRegion(db: Database, viewer: User): Html { | ||
| 83 | 84 | const shipped = readShipped(db, viewer); |
| 84 | 85 | return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving"> |
| 85 | 86 | ${viewer.isAdmin ? nowLine(pipelineNow(db), viewer.kind !== "agent") : null} |
| 86 | <div class="moving"> | |
| 87 | ${listOf("Being built", building, "Nothing is being built right now")} | |
| 88 | ${listOf("Just shipped", shipped, "Nothing has shipped yet")} | |
| 89 | </div> | |
| 87 | ${listOf("Being built", building, "Nothing is being built right now")} | |
| 88 | ${disclosure("Just shipped", listOf("Just shipped", shipped, "Nothing has shipped yet", false), { small: true })} | |
| 90 | 89 | </div>`; |
| 91 | 90 | } |
| 92 | 91 |
test/board-pins.test.ts −1
| @@ -118,7 +118,6 @@ describe("pinned panels", () => { | ||
| 118 | 118 | test("home's first paint holds a slot after the KPIs only when something is pinned", async () => { |
| 119 | 119 | const first = await get("/?all=1", "alice"); |
| 120 | 120 | expect(first.indexOf('aria-label="The forge"')).toBeLessThan(first.indexOf('id="home-panels"')); |
| 121 | expect(first.indexOf('id="home-panels"')).toBeLessThan(first.indexOf("live-moving")); | |
| 122 | 121 | expect(first).not.toContain("Pinned panels"); |
| 123 | 122 | expect(first).toContain('class="home-panels-slot"'); |
| 124 | 123 | expect(first).toContain("Open your pinned panels"); // no-script door |
test/home-200.test.ts +1 −1
| @@ -40,7 +40,7 @@ test("the second look at home as the owner costs a bounded number of statements, | ||
| 40 | 40 | expectWork(await statements(f.app.db, async () => void (await look())), { max: 70 }); |
| 41 | 41 | // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone. |
| 42 | 42 | // Every phase home.ts and the feed time: one missing here fails as a plain expect, which the gate's budget retry never re-runs. |
| 43 | const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "moving", "render"]); | |
| 43 | const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "held", "panels", "feed", "settled", "visit", "since", "moving", "render"]); | |
| 44 | 44 | for (const p of slow) expect(known.has(p.name)).toBe(true); |
| 45 | 45 | }); |
| 46 | 46 |
test/home-living.test.ts +16 −1
| @@ -190,8 +190,9 @@ describe("home", () => { | ||
| 190 | 190 | |
| 191 | 191 | test("a warm home costs a bounded number of statements, the two scans read once per write", async () => { |
| 192 | 192 | await home(aliceCookie); |
| 193 | // Calm pass (#10298): home now also reads what waits on the owner, the last visit (a write) and the digest since: 3 more statements, bound raised 40 to 46. | |
| 193 | 194 | const warm = await requestStatements(app.db, `${servers.url}/`, { headers: { cookie: aliceCookie } }); |
| 194 | expectWork(warm, { max: 40 }); | |
| 195 | expectWork(warm, { max: 46 }); | |
| 195 | 196 | const scans = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text()))); |
| 196 | 197 | const again = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text()))); |
| 197 | 198 | expect(again).toBeLessThanOrEqual(scans); |
| @@ -232,3 +233,17 @@ describe("the feed page", () => { | ||
| 232 | 233 | expect(moving).not.toContain('class="card'); |
| 233 | 234 | }); |
| 234 | 235 | }); |
| 236 | ||
| 237 | describe("home, calm pass (#10298)", () => { | |
| 238 | test("what just shipped opens in place; being built stays in view", async () => { | |
| 239 | const page = await home(aliceCookie); | |
| 240 | expect(page).toMatch(/<details[^>]*class="disclosure[^>]*>\s*<summary[^>]*>Just shipped<\/summary>/); | |
| 241 | expect(page).toMatch(/<h2 class="small muted">Being built<\/h2>/); | |
| 242 | }); | |
| 243 | ||
| 244 | test("the figures open in place for the owner, and the held line no longer restates itself", async () => { | |
| 245 | const page = await home(rootCookie); | |
| 246 | expect(page).toMatch(/id="home-figures"[^>]*class="disclosure/); | |
| 247 | expect(page).not.toContain("until you lift it"); | |
| 248 | }); | |
| 249 | }); |
test/home-since.test.ts +27
| @@ -127,6 +127,33 @@ test("a prefetch, a preview or a non-document fetch leaves home_visit unchanged; | ||
| 127 | 127 | expect(lastVisit(db, f.owner.id)).toBeGreaterThan(at); |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | test("home (not only the feed) leaves the visit mark alone on a prefetch and moves it on a navigation, and names its three compartments", async () => { | |
| 131 | const db = f.app.db; | |
| 132 | const at = Date.now() - 3 * H; | |
| 133 | const set = () => db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(f.owner.id, at); | |
| 134 | for (const headers of [{ "sec-purpose": "prefetch" }, { purpose: "prefetch" }, { "sec-fetch-dest": "empty" }]) { | |
| 135 | set(); | |
| 136 | const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, ...headers } })).text(); | |
| 137 | expect(text).toContain("Since you were here · 3 hours"); | |
| 138 | expect(lastVisit(db, f.owner.id)).toBe(at); | |
| 139 | } | |
| 140 | set(); | |
| 141 | const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, "sec-fetch-dest": "document" } })).text(); | |
| 142 | expect(lastVisit(db, f.owner.id)).toBeGreaterThan(at); | |
| 143 | expect(text).toContain('aria-label="Since you were here"'); | |
| 144 | expect(text).toContain('aria-label="Live"'); | |
| 145 | }); | |
| 146 | ||
| 147 | test("home leaves out the Since compartment when nothing moved", async () => { | |
| 148 | const db = f.app.db; | |
| 149 | db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(f.peer.id, Date.now()); | |
| 150 | db.query("DELETE FROM pull").run(); | |
| 151 | const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.peerCookie, "sec-fetch-dest": "empty" } })).text(); | |
| 152 | expect(text).not.toContain('aria-label="Since you were here"'); | |
| 153 | expect(text).not.toContain('id="since-you-were-here"'); | |
| 154 | expect(text).toContain('aria-label="Live"'); | |
| 155 | }); | |
| 156 | ||
| 130 | 157 | test("the digest query reads pull through the existing pull_merged index, and the home-visit migration adds no second one", () => { |
| 131 | 158 | const plan = f.app.db |
| 132 | 159 | .query<{ detail: string }, [string, string]>( |
Home GET now writes (noteVisit) on navigation. Test that a non-navigation request does not move the last-visit mark, or the digest silently empties.