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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9584-the-signed-in-home-speaks-the-public-hom → main 6 commits fork 3 threads · 3 unresolved
5 files
changed +46
−15
docs/design/areas/home.md +2
| @@ -4,3 +4,5 @@ | ||
| 4 | 4 | - `.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. |
| 5 | 5 | - `.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. |
| 6 | 6 | - `.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"; | ||
| 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, listCard, row, timeAgo } from "../ui.ts"; | |
| 17 | import { btn, 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"; |
| @@ -67,7 +67,9 @@ function listOf(label: string, rows: readonly MovingRow[], empty: string): Html | ||
| 67 | 67 | shown.push(row({ title: html`<span class="muted small">${i === 0 ? empty : " "}</span>` })); |
| 68 | 68 | return html`<section aria-label="${label}"> |
| 69 | 69 | <h2 class="small muted">${label}</h2> |
| 70 | ${listCard(shown)} | |
| 70 | <ul class="list"> | |
| 71 | ${shown} | |
| 72 | </ul> | |
| 71 | 73 | </section>`; |
| 72 | 74 | } |
| 73 | 75 | |
| @@ -114,9 +116,11 @@ export function tellBox(ctx: Ctx, user: User): Html { | ||
| 114 | 116 | <option value="${FORGE_TARGET}">the forge</option> |
| 115 | 117 | </select>`} |
| 116 | 118 | ${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> | |
| 122 | <a href="/talk" title="Talk to the forge">Talk</a> | |
| 123 | </p>`; | |
| 120 | 124 | } |
| 121 | 125 | |
| 122 | 126 | /** 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 { | ||
| 124 | 128 | const { repos } = writableAppsOf(ctx, user); |
| 125 | 129 | 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"> |
| 126 | 130 | <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> | |
| 132 | 136 | </section>`; |
| 133 | 137 | } |
| 134 | 138 |
src/web/style/content.ts +4 −1
| @@ -80,7 +80,10 @@ a.where-place:hover, button.where-place:hover { | ||
| 80 | 80 | long app name, which wraps inside its chip, and the tell box's input and |
| 81 | 81 | select, which may shrink. The chip row is server-rendered, so a wrapped |
| 82 | 82 | 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; } | |
| 84 | 87 | .tell-box .input { min-width: 0; max-width: 100%; } |
| 85 | 88 | .row-trail form { display: inline-flex; } |
| 86 | 89 |
test/home-fit.test.ts +10 −4
| @@ -85,7 +85,8 @@ describe("the signed-in home at 390 px", () => { | ||
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | 87 | 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/); | |
| 89 | 90 | expect(content).toMatch(/\.tell-box \.input\s*\{[^}]*min-width:\s*0/); |
| 90 | 91 | }); |
| 91 | 92 | }); |
| @@ -101,14 +102,19 @@ describe.skipIf(chromium === undefined)("the home, measured at 390x844", () => { | ||
| 101 | 102 | // Only a one-line ellipsis title inside main is excused: it clips by design. |
| 102 | 103 | 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; }; |
| 103 | 104 | 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"); | |
| 105 | 106 | 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")); | |
| 106 | 109 | const sel = document.querySelector("#tell-box select"); |
| 107 | 110 | 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 }); | |
| 109 | 112 | })()`; |
| 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 }; | |
| 111 | 114 | 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); | |
| 112 | 118 | expect(m.chipRight).toBeGreaterThan(0); |
| 113 | 119 | expect(m.selRight).toBeGreaterThan(0); |
| 114 | 120 | expect(m.chipRight).toBeLessThanOrEqual(m.viewportWidth); |
test/home-living.test.ts +16
| @@ -215,4 +215,20 @@ describe("the feed page", () => { | ||
| 215 | 215 | const stranger = await get("/feed"); |
| 216 | 216 | expect(stranger.status).not.toBe(200); |
| 217 | 217 | }); |
| 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 | }); | |
| 218 | 234 | }); |
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.