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

Merged opened this in answer to

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

Merged

scout/forge:scout/10298-calm-pass-the-signed-in-home-page-answer → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/10298-calm-pass-the-signed-in-home-page-answer was merged into main as d4ee51b.

Review

Sign in to review this change.
10 files changed +89 −29
docs/design/areas/home.md +2
@@ -10,3 +10,5 @@
1010 The signed-in home's door line (Notes, Talk) is `.home-doors`: accent underlined links, each a 44px tap target (#9584).
1111
1212 `/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 @@
11 {
22 "home": {
33 "390": {
4 "words": 182,
4 "words": 95,
55 "blocks": 20,
6 "colours": 3
6 "colours": 2
77 },
88 "1280": {
9 "words": 182,
9 "words": 95,
1010 "blocks": 20,
11 "colours": 3
11 "colours": 2
1212 }
1313 },
1414 "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
2121
2222 | Page | 390 px | 1280 px |
2323 |---|---|---|
24 | home | 182 / 20 / 3 | 182 / 20 / 3 |
24 | home | 95 / 20 / 2 | 95 / 20 / 2 |
2525 | repository | 189 / 28 / 2 | 189 / 28 / 2 |
2626 | app | 189 / 28 / 2 | 189 / 28 / 2 |
2727 | 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 {
4343 >${hold.held} ${hold.held === 1 ? "issue is" : "issues are"} held
4444 behind the Later gate</a
4545 >
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)}
4847 </p>`,
4948 )}
5049 </section>`;
src/web/studio/home.ts +25 −6
@@ -41,6 +41,7 @@ import {
4141 btn,
4242 card,
4343 dot,
44 disclosure,
4445 empty,
4546 envStateBadge,
4647 icon,
@@ -246,9 +247,13 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html |
246247 const ceilingUsd = forgeCeiling(ctx.app.config);
247248 const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn);
248249 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 )}`;
252257 },
253258 waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)),
254259 panels: async () => (await timePhaseAsync(request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(db))) ?? html``,
@@ -333,13 +338,27 @@ export async function studioHome(
333338 const kpis = figures
334339 ? await homeRegion(ctx.app.db, ctx.request, user.id, "kpis", homeVariant(ctx.url), access, homeDraws(ctx, user).kpis)
335340 : 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", () =>

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.

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}`;
336354 const body = html`<h1 class="sr-only">Home</h1>
337355 ${tellBox(ctx, user)}
338356 ${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>
340360 <section class="stack" aria-label="The forge">${kpis}</section>
341361 ${pinnedSlot(ctx.app.db, user)}
342 ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}
343362 ${appChips(ctx, user)}
344363 ${when(
345364 steps,
@@ -352,7 +371,7 @@ export async function studioHome(
352371 body,
353372 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`,
354373 });
355 notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access"]);
374 notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access", "waiting", "held", "visit", "since"]);
356375 return res;
357376 }
358377
src/web/studio/living.ts +12 −13
@@ -14,7 +14,7 @@ import type { User } from "../../core/users.ts";
1414 import type { Ctx } from "../../http/context.ts";
1515 import { html, type Html } from "../html.ts";
1616 import { fragment } from "../layout.ts";
17 import { btn, row, timeAgo } from "../ui.ts";
17 import { btn, disclosure, row, timeAgo } from "../ui.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1919 import type { NowStage } from "../../flow/now_rows.ts";
2020 import { nowHref } from "../pages/now.ts";
@@ -53,7 +53,7 @@ export function nowLine(now: Now, boards = true): Html {
5353 </section>`;
5454 }
5555
56 function listOf(label: string, rows: readonly MovingRow[], empty: string): Html {
56 function listOf(label: string, rows: readonly MovingRow[], empty: string, heading = true): Html {
5757 const shown = rows.map((r) =>
5858 row({
5959 href: r.href,
@@ -65,17 +65,18 @@ function listOf(label: string, rows: readonly MovingRow[], empty: string): Html
6565 // Always MOVING_ROWS rows: a quiet slot is as tall as a busy one.
6666 for (let i = shown.length; i < MOVING_ROWS; i++)
6767 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">
7169 ${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;
7475 }
7576
7677 /**
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,
7980 * private ones too, so only a site admin, who reads them all, sees the line.
8081 */
8182 export function movingRegion(db: Database, viewer: User): Html {
@@ -83,10 +84,8 @@ export function movingRegion(db: Database, viewer: User): Html {
8384 const shipped = readShipped(db, viewer);
8485 return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving">
8586 ${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 })}
9089 </div>`;
9190 }
9291
test/board-pins.test.ts −1
@@ -118,7 +118,6 @@ describe("pinned panels", () => {
118118 test("home's first paint holds a slot after the KPIs only when something is pinned", async () => {
119119 const first = await get("/?all=1", "alice");
120120 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"));
122121 expect(first).not.toContain("Pinned panels");
123122 expect(first).toContain('class="home-panels-slot"');
124123 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,
4040 expectWork(await statements(f.app.db, async () => void (await look())), { max: 70 });
4141 // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone.
4242 // 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"]);
4444 for (const p of slow) expect(known.has(p.name)).toBe(true);
4545 });
4646
test/home-living.test.ts +16 −1
@@ -190,8 +190,9 @@ describe("home", () => {
190190
191191 test("a warm home costs a bounded number of statements, the two scans read once per write", async () => {
192192 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.
193194 const warm = await requestStatements(app.db, `${servers.url}/`, { headers: { cookie: aliceCookie } });
194 expectWork(warm, { max: 40 });
195 expectWork(warm, { max: 46 });
195196 const scans = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text())));
196197 const again = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text())));
197198 expect(again).toBeLessThanOrEqual(scans);
@@ -232,3 +233,17 @@ describe("the feed page", () => {
232233 expect(moving).not.toContain('class="card');
233234 });
234235 });
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;
127127 expect(lastVisit(db, f.owner.id)).toBeGreaterThan(at);
128128 });
129129
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
130157 test("the digest query reads pull through the existing pull_merged index, and the home-visit migration adds no second one", () => {
131158 const plan = f.app.db
132159 .query<{ detail: string }, [string, string]>(

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs