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

The signed-in home speaks the public home page's language, with looks before and after #9635

Merged opened this

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

Merged

scout/forge:scout/9584-the-signed-in-home-speaks-the-public-hom → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/9584-the-signed-in-home-speaks-the-public-hom was merged into main as 21c47e6.

Review

Sign in to review this change.
5 files changed +46 −15
docs/design/areas/home.md +2
@@ -4,3 +4,5 @@
44 - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone.
55 - `.kpi-row` — a modifier on `.stats` for the home's figure tiles, not a second layout: it only changes the phone case. `.stats` alone (`minmax(160px, 1fr)`) falls to one column at 360px (two 160px tiles and the gap need 332px of 328px), stacking nine 104px tiles to ~1,100px; under 480px `.kpi-row` is two equal `minmax(0, 1fr)` columns instead. Tile height stays fixed (`.stat`), so nothing shifts.
66 - `.home-panels-slot` — home's place for the pinned panels, right after the KPIs (`pinnedSlot` in `src/web/studio/pinned.ts`). Only while it still waits for `/live/home/panels` (`data-refresh`) it holds 240px, about one panel row, with the 16px below it that `.bgrid` keeps (the system has no spacing tokens, only the 4px scale in §4, and both sit on it), so the charts arriving after first paint shift nothing; the refresh script drops the attribute when the fragment lands, is empty, or fails, and the slot collapses to its content.
7
8 The signed-in home's door line (Notes, Talk) is `.home-doors`: accent underlined links, each a 44px tap target (#9584).
src/web/studio/living.ts +14 −10
@@ -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, listCard, row, timeAgo } from "../ui.ts";
17 import { btn, 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";
@@ -67,7 +67,9 @@ function listOf(label: string, rows: readonly MovingRow[], empty: string): Html
6767 shown.push(row({ title: html`<span class="muted small">${i === 0 ? empty : " "}</span>` }));
6868 return html`<section aria-label="${label}">
6969 <h2 class="small muted">${label}</h2>
70 ${listCard(shown)}
70 <ul class="list">
71 ${shown}
72 </ul>
7173 </section>`;
7274 }
7375
@@ -114,9 +116,11 @@ export function tellBox(ctx: Ctx, user: User): Html {
114116 <option value="${FORGE_TARGET}">the forge</option>
115117 </select>`}
116118 ${btn("Tell", { variant: "primary" })}
117 ${btn("Notes", { href: "/notes", icon: "comment", title: "Your notes and where they stand" })}
118 ${btn("Talk", { href: "/talk", icon: "comment", title: "Talk to the forge" })}
119 </form>`;
119 </form>
120 <p class="small muted home-doors">
121 <a href="/notes" title="Your notes and where they stand">Notes</a>
outdated

Notes and Talk are now small muted text links with no affordance or tap size; they are primary doors to the home. Make them visibly links with a 44px target.

122 <a href="/talk" title="Talk to the forge">Talk</a>
123 </p>`;
120124 }
121125
122126 /** Your apps as chips, each a link to the app, and a plus for a new one. */
@@ -124,11 +128,11 @@ export function appChips(ctx: Ctx, user: User): Html {
124128 const { repos } = writableAppsOf(ctx, user);
125129 return html`<section aria-label="Your apps" id="live-chips" data-live="app.serving,app.stopped,app.created,app.deleted" data-live-src="/live/chips">
126130 <h2 class="small muted">Your apps</h2>
127 <div class="bar">
128 ${repos.slice(0, 12).map((r) => btn(r.name, { href: repoBase(r), size: "sm" }))}
129 ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}
130 ${repos.length > 12 ? btn(`All ${repos.length}`, { href: "/apps", size: "sm" }) : null}
131 </div>
131 <p class="bar small">
132 ${repos.slice(0, 12).map((r) => html`<a href="${repoBase(r)}">${r.name}</a>`)}
133 <a href="/apps/new">New app</a>
134 ${repos.length > 12 ? html`<a href="/apps">All ${repos.length}</a>` : null}

Principle 8/usability: app links are small, unpadded, undecorated text (global a inherits colour, no underline) so they do not read as links and are well under a 44px tap target. Add a link affordance and min-height 44px.

135 </p>
132136 </section>`;
133137 }
134138
src/web/style/content.ts +4 −1
@@ -80,7 +80,10 @@ a.where-place:hover, button.where-place:hover {
8080 long app name, which wraps inside its chip, and the tell box's input and
8181 select, which may shrink. The chip row is server-rendered, so a wrapped
8282 chip is its final height on first paint. */
83 #live-chips .btn { max-width: 100%; height: auto; white-space: normal; overflow-wrap: anywhere; text-align: left; }
83 #live-chips a, .home-doors a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; display: inline-flex; align-items: center; min-height: 44px; padding: 0 6px; box-sizing: border-box; }
outdated

The rule only handles wrapping. Add the tap-target size and link affordance here, scoped to the home, and assert it in home-fit.

84 #live-chips a:hover, #live-chips a:focus-visible, .home-doors a:hover, .home-doors a:focus-visible { text-decoration-thickness: 2px; }
85 #live-chips a { max-width: 100%; overflow-wrap: anywhere; }
86 .home-doors { display: flex; flex-wrap: wrap; gap: 4px 12px; }
8487 .tell-box .input { min-width: 0; max-width: 100%; }
8588 .row-trail form { display: inline-flex; }
8689
test/home-fit.test.ts +10 −4
@@ -85,7 +85,8 @@ describe("the signed-in home at 390 px", () => {
8585 });
8686
8787 test("the chip wraps and the tell box shrinks: the rules exist, scoped to the home", () => {
88 expect(content).toMatch(/#live-chips \.btn\s*\{[^}]*height:\s*auto[^}]*white-space:\s*normal[^}]*overflow-wrap:\s*anywhere/);
88 expect(content).toMatch(/#live-chips a\s*\{[^}]*max-width:\s*100%[^}]*overflow-wrap:\s*anywhere/);
89 expect(content).toMatch(/#live-chips a, \.home-doors a\s*\{[^}]*underline[^}]*min-height:\s*44px/);
8990 expect(content).toMatch(/\.tell-box \.input\s*\{[^}]*min-width:\s*0/);
9091 });
9192 });
@@ -101,14 +102,19 @@ describe.skipIf(chromium === undefined)("the home, measured at 390x844", () => {
101102 // Only a one-line ellipsis title inside main is excused: it clips by design.
102103 const clipped = (el) => { for (let p = el.parentElement; p && p.tagName !== "MAIN"; p = p.parentElement) { const c = getComputedStyle(p); if (c.textOverflow === "ellipsis" && c.overflowX === "hidden") return true; } return false; };
103104 const overflowing = [...document.querySelectorAll("main *")].filter((el) => el.getBoundingClientRect().right > vw + 1 && !clipped(el)).slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + el.className);
104 const chip = document.querySelector("#live-chips .btn");
105 const chip = document.querySelector("#live-chips a");
105106 const chipRight = chip ? Math.round(chip.getBoundingClientRect().right) : -1;
107 const tall = [...document.querySelectorAll("#live-chips a, .home-doors a")].map((a) => Math.round(a.getBoundingClientRect().height));
108 const underlined = [...document.querySelectorAll("#live-chips a, .home-doors a")].every((a) => getComputedStyle(a).textDecorationLine.includes("underline"));
106109 const sel = document.querySelector("#tell-box select");
107110 const selRight = sel ? Math.round(sel.getBoundingClientRect().right) : -1;
108 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing, chipRight, selRight });
111 return JSON.stringify({ tall, underlined, scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing, chipRight, selRight });
109112 })()`;
110 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; overflowing: string[]; chipRight: number; selRight: number };
113 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/`, { width: 390, height: 844 }, [session], measure)) as { tall: number[]; underlined: boolean; scrollWidth: number; viewportWidth: number; overflowing: string[]; chipRight: number; selRight: number };
111114 console.log(`home 390x844: ${JSON.stringify(m)}`);
115 expect(m.tall.length).toBeGreaterThan(2);
116 for (const h of m.tall) expect(h).toBeGreaterThanOrEqual(44);
117 expect(m.underlined).toBe(true);
112118 expect(m.chipRight).toBeGreaterThan(0);
113119 expect(m.selRight).toBeGreaterThan(0);
114120 expect(m.chipRight).toBeLessThanOrEqual(m.viewportWidth);
test/home-living.test.ts +16
@@ -215,4 +215,20 @@ describe("the feed page", () => {
215215 const stranger = await get("/feed");
216216 expect(stranger.status).not.toBe(200);
217217 });
218
219 test("the home speaks in rows and plain links, not boxes and button strips (#9584)", async () => {
220 const page = await home(aliceCookie);
221 // The tell box has one button; Notes and Talk are muted links.
222 const form = /<form id="tell-box"[\s\S]*?<\/form>/.exec(page)![0];
223 expect(form).not.toContain('href="/notes"');
224 expect(page).toMatch(/<p class="small muted home-doors">\s*<a href="\/notes"[^>]*>Notes<\/a>/);
225 // Your apps are links, not chips with buttons.
226 const apps = /<section aria-label="Your apps"[\s\S]*?<\/section>/.exec(page)![0];
227 expect(apps).not.toContain('class="btn');
228 expect(apps).toContain('href="/apps/new"');
229 // The moving lists are rows, not a card.
230 const moving = /<section aria-label="Being built">[\s\S]*?<\/section>/.exec(page)![0];
231 expect(moving).toContain('<ul class="list">');
232 expect(moving).not.toContain('class="card');
233 });
218234 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs