Public: anyone can read this; the crew can change it.
Design: the studio's head, tell box and shelf rows use the design system, and their studio-* and shelf-* classes leave §5d #3630
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3616-design-the-studio-s-head-tell-box-and-sh → main 2 commits fork
15 files
changed +41
−92
docs/DESIGN.md −1
| @@ -399,7 +399,6 @@ gone, so this list only shrinks. | ||
| 399 | 399 | - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out` |
| 400 | 400 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 401 | 401 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` |
| 402 | - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word` | |
| 403 | 402 | |
| 404 | 403 | --- |
| 405 | 404 |
docs/design/baseline.json +1 −1
| @@ -69,7 +69,7 @@ | ||
| 69 | 69 | "motion": 13, |
| 70 | 70 | "forced": 1, |
| 71 | 71 | "focus": 1, |
| 72 | "input": 2, | |
| 72 | "input": 1, | |
| 73 | 73 | "target": 2 |
| 74 | 74 | } |
| 75 | 75 | } |
src/web/live.ts +2 −2
| @@ -147,12 +147,12 @@ export const liveClientJs = /* js */ ` | ||
| 147 | 147 | } |
| 148 | 148 | // The head card's own word, whatever envPhrase drew into its badge. |
| 149 | 149 | function headWord(root) { |
| 150 | return textOf(root && root.querySelector('.studio-head .badge')); | |
| 150 | return textOf(root && root.querySelector('.page-head h1 .badge')); | |
| 151 | 151 | } |
| 152 | 152 | // When the word is "live", the timing phrase envFacts already printed |
| 153 | 153 | // under it — never a second way of saying how fast. |
| 154 | 154 | function headPhrase(root) { |
| 155 | var spans = root ? root.querySelectorAll('.studio-head .env-facts span') : []; | |
| 155 | var spans = root ? root.querySelectorAll('.page-head .env-facts span') : []; | |
| 156 | 156 | for (var i = 0; i < spans.length; i++) { |
| 157 | 157 | var t = textOf(spans[i]); |
| 158 | 158 | if (t.indexOf('live ') === 0) return t; |
src/web/studio/app.ts +1 −1
| @@ -287,7 +287,7 @@ export async function studioApp( | ||
| 287 | 287 | const tell = !ownerFacts |
| 288 | 288 | ? null |
| 289 | 289 | : html`<form |
| 290 | class="card comment-form studio-tell" | |
| 290 | class="card comment-form" | |
| 291 | 291 | method="post" |
| 292 | 292 | action="${base}/tell" |
| 293 | 293 | > |
src/web/studio/feed.ts +2 −2
| @@ -68,7 +68,7 @@ export function feedCard( | ||
| 68 | 68 | const head = html`${avatar(item.actor ?? item.app)} |
| 69 | 69 | <strong class="fcard-who">${item.actor ?? item.app}</strong |
| 70 | 70 | >${nameMark(item.actor === null ? undefined : marks.get(item.actor))} |
| 71 | <span class="fcard-app muted small">${item.app} · ${timeAgo(item.at)}</span> | |
| 71 | <span class="muted small">${item.app} · ${timeAgo(item.at)}</span> | |
| 72 | 72 | <span class="spacer"></span> |
| 73 | 73 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 74 | 74 | const body = html`<p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p> |
| @@ -107,7 +107,7 @@ const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000; | ||
| 107 | 107 | export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html { |
| 108 | 108 | const head = html`${avatar(who)} |
| 109 | 109 | <strong class="fcard-who">${who}</strong> |
| 110 | <span class="fcard-app muted small">${timeAgo(thought.receivedAt)}</span> | |
| 110 | <span class="muted small">${timeAgo(thought.receivedAt)}</span> | |
| 111 | 111 | <span class="spacer"></span> |
| 112 | 112 | <span class="badge">Received</span>`; |
| 113 | 113 | return card( |
src/web/studio/flow_card.ts +1 −1
| @@ -19,7 +19,7 @@ const BUTTON: Record<StalledStep["action"], string> = { run: "Run check", rerun: | ||
| 19 | 19 | function stalledCard(s: StalledStep): Html { |
| 20 | 20 | const base = `/${s.ownerName}/${s.repoName}/pulls/${s.pull}`; |
| 21 | 21 | const head = html`<strong class="fcard-who">${s.stage}</strong> |
| 22 | <span class="fcard-app muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span>`; | |
| 22 | <span class="muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span>`; | |
| 23 | 23 | const body = html`<p class="fcard-reason"><a href="${base}">${s.title}</a>${s.issue === null ? null : html` <span class="muted small">for #${String(s.issue)}</span>`}</p> |
| 24 | 24 | <form method="post" action="${base}/checks" class="fcard-unblock"> |
| 25 | 25 | <input type="hidden" name="next" value="/"> |
src/web/studio/now.ts +1 −3
| @@ -203,9 +203,7 @@ export async function nowCard( | ||
| 203 | 203 | ? null |
| 204 | 204 | : html` · ${time(r.finishedAt ?? r.createdAt)}`}`, |
| 205 | 205 | trail: stop |
| 206 | ? html`<span class="studio-ask-actions" | |
| 207 | >${wordBadge(word)} ${stop}</span | |
| 208 | >` | |
| 206 | ? html`${wordBadge(word)} ${stop}` | |
| 209 | 207 | : wordBadge(word), |
| 210 | 208 | }), |
| 211 | 209 | ); |
src/web/studio/previews.ts +2 −2
| @@ -303,9 +303,9 @@ export function appPanels( | ||
| 303 | 303 | const audience = audienceOf( |
| 304 | 304 | audienceFacts(db, repo, ctx.auth.user), |
| 305 | 305 | ).sentence; |
| 306 | const head = html`<div class="page-head studio-head"> | |
| 306 | const head = html`<div class="page-head"> | |
| 307 | 307 | <div class="stack stack-tight"> |
| 308 | <h1> | |
| 308 | <h1 class="bar"> | |
| 309 | 309 | ${repo.name} |
| 310 | 310 | ${badge( |
| 311 | 311 | live.word, |
src/web/studio/shelf.ts +9 −19
| @@ -113,15 +113,9 @@ function sealedRow(repo: Repo, user: User | undefined): Html { | ||
| 113 | 113 | ? repo.name |
| 114 | 114 | : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`, |
| 115 | 115 | titleAttr: repo.description || repo.name, |
| 116 | meta: html`<span class="shelf-meta" | |
| 117 | >${repo.description | |
| 118 | ? html`<span class="shelf-desc">${repo.description}</span>` | |
| 119 | : null}<span class="shelf-facts" | |
| 120 | >${repo.description ? "· " : null}<span class="muted" | |
| 121 | >private app</span | |
| 122 | ></span | |
| 123 | ></span | |
| 124 | >`, | |
| 116 | meta: html`<span class="muted">private app</span>${repo.description | |
| 117 | ? html` · ${repo.description}` | |
| 118 | : null}`, | |
| 125 | 119 | trail: raw(`\n <span class="badge" data-tone="default">private</span>`), |
| 126 | 120 | }); |
| 127 | 121 | } |
| @@ -282,9 +276,7 @@ export async function shelfRows( | ||
| 282 | 276 | : canTell |
| 283 | 277 | ? html`<span class="muted">quiet — tell the crew something</span>` |
| 284 | 278 | : html`<span class="muted">quiet</span>` |
| 285 | : html`<span class="studio-word" data-word="${word.word}" | |
| 286 | >${word.word}</span | |
| 287 | > | |
| 279 | : html`<strong data-word="${word.word}">${word.word}</strong> | |
| 288 | 280 | ${who ? html`· ${who}` : null} |
| 289 | 281 | ${word.word === "working" |
| 290 | 282 | ? html`· now` |
| @@ -305,13 +297,11 @@ export async function shelfRows( | ||
| 305 | 297 | lead: dot(tone), |
| 306 | 298 | title, |
| 307 | 299 | titleAttr: repo.description || repo.name, |
| 308 | meta: html`<span class="shelf-meta" | |
| 309 | >${repo.description | |
| 310 | ? html`<span class="shelf-desc">${repo.description}</span>` | |
| 311 | : null}<span class="shelf-facts" | |
| 312 | >${repo.description ? "· " : null}${happening}</span | |
| 313 | ></span | |
| 314 | >`, | |
| 300 | // The word leads and the description follows, so the meta line's one | |
| 301 | // ellipsis falls on the description (whole in the title), never the word. | |
| 302 | meta: html`${happening}${repo.description | |
| 303 | ? html` · ${repo.description}` | |
| 304 | : null}`, | |
| 315 | 305 | trail: raw( |
| 316 | 306 | `\n <span class="badge" data-tone="${tone}">${escapeHtml(live.word)}</span>`, |
| 317 | 307 | ), |
src/web/studio/style.ts +3 −48
| @@ -7,28 +7,14 @@ | ||
| 7 | 7 | * Each part is named for the concept whose markup it styles. They land in the |
| 8 | 8 | * `components` layer, last, which is where an inline block's rules used to |
| 9 | 9 | * win from: nothing else in the sheet styles a `.studio-*` selector, and the |
| 10 | * few rules that reach a shared class (`.badge`, `.row-lead`, `.env-facts`) | |
| 11 | * set properties those components do not. | |
| 10 | * few rules that reach a shared class (`.badge`, `.card-head`, `.bar`) set | |
| 11 | * properties those components do not. The tell box, the app head, the word | |
| 12 | * and the shelf rows are §5's own card, page head, bar and row, unstyled here. | |
| 12 | 13 | */ |
| 13 | 14 | |
| 14 | 15 | /** A part as written, without the newlines the template literal adds. */ |
| 15 | 16 | const part = (css: string): string => css.trim(); |
| 16 | 17 | |
| 17 | /** The box a person tells the crew from (`app.ts`). */ | |
| 18 | const tellBox = part(/* css */ ` | |
| 19 | .studio-tell textarea { min-height: 72px; resize: vertical; } | |
| 20 | .studio-tell .bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; } | |
| 21 | `); | |
| 22 | ||
| 23 | /** The app's head card: what production is doing (`previews.ts`). */ | |
| 24 | const appHead = part(/* css */ ` | |
| 25 | .studio-head h1 { display: flex; align-items: center; gap: 10px; } | |
| 26 | /* The facts under the title take the whole line; the two buttons that act | |
| 27 | on production sit with the rest, each in its own POST form. */ | |
| 28 | .studio-head .env-facts { margin-top: 2px; } | |
| 29 | .studio-head form { display: inline-flex; } | |
| 30 | `); | |
| 31 | ||
| 32 | 18 | /** A card the live client just swapped in place (`live.ts`): a background |
| 33 | 19 | * flash, never a resize, so the page never shifts under a reader. */ |
| 34 | 20 | const justChanged = part(/* css */ ` |
| @@ -42,51 +28,20 @@ const wordBadges = part(/* css */ ` | ||
| 42 | 28 | .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; } |
| 43 | 29 | `); |
| 44 | 30 | |
| 45 | /** The word itself where a row's meta line still carries one, and the | |
| 46 | * shelf row's meta line (`shelf.ts`). */ | |
| 47 | const rows = part(/* css */ ` | |
| 48 | .studio-word { font-family: var(--mono); font-size: 11.5px; opacity: .8; white-space: nowrap; } | |
| 49 | .shelf-meta { display: flex; gap: 0 4px; min-width: 0; } | |
| 50 | .shelf-desc { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 51 | .shelf-facts { flex: 0 0 auto; max-width: 100%; } | |
| 52 | .row-meta .studio-word { font-family: inherit; font-size: inherit; opacity: 1; font-weight: 560; } | |
| 53 | `); | |
| 54 | ||
| 55 | /** The row of controls beside a list row: a stop, a change's buttons. */ | |
| 56 | const rowActions = part(/* css */ ` | |
| 57 | .studio-ask-actions { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } | |
| 58 | `); | |
| 59 | ||
| 60 | 31 | /** The phone: one document, narrower. Each rule belongs to a component above |
| 61 | 32 | * and only ever applies at 600px and under, so nothing changes on a desktop. */ |
| 62 | 33 | const phone = part(/* css */ ` |
| 63 | 34 | @media (max-width: 600px) { |
| 64 | .studio-head { flex-wrap: wrap; } | |
| 65 | 35 | .feed .card-head { row-gap: 2px; } |
| 66 | .feed .card-head .fcard-app { flex: 1 1 100%; } | |
| 67 | .studio-head h1 { flex: 1 1 100%; flex-wrap: wrap; overflow-wrap: anywhere; } | |
| 68 | .studio-head .env-facts { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; } | |
| 69 | .studio-head form, .studio-head form .btn { flex: 1 1 auto; } | |
| 70 | .studio-tell .bar { flex-wrap: wrap; } | |
| 71 | .studio-tell .bar .btn, .studio-ask-actions .btn { min-height: 44px; } | |
| 72 | 36 | .feed .bar .btn, .fcard-form .btn { min-height: 44px; } |
| 73 | .studio-ask-actions { display: flex; flex-direction: column; align-items: stretch; white-space: normal; width: 100%; } | |
| 74 | .studio-word { white-space: normal; } | |
| 75 | .shelf-meta { flex-wrap: wrap; } | |
| 76 | .shelf-desc { flex: 1 1 100%; } | |
| 77 | .studio-tell textarea, .studio-tell .input { font-size: 16px; } | |
| 78 | 37 | } |
| 79 | 38 | `); |
| 80 | 39 | |
| 81 | 40 | import { feedCss } from "./feed_style.ts"; |
| 82 | 41 | |
| 83 | 42 | export const studioCss = `@layer components {\n${[ |
| 84 | tellBox, | |
| 85 | appHead, | |
| 86 | 43 | justChanged, |
| 87 | 44 | wordBadges, |
| 88 | rows, | |
| 89 | rowActions, | |
| 90 | 45 | part(feedCss), |
| 91 | 46 | phone, |
| 92 | 47 | ].join("\n\n")}\n}`; |
src/web/style/content.ts +1 −1
| @@ -817,7 +817,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 817 | 817 | are shown — a pull's preview, the studio head — so they are not scoped to |
| 818 | 818 | the card. */ |
| 819 | 819 | .env-card .card-head { gap: 10px; } |
| 820 | .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } | |
| 820 | .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); overflow-wrap: anywhere; } | |
| 821 | 821 | .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; } |
| 822 | 822 | /* The checks line inside the merge box, in the reviews chip's voice. */ |
| 823 | 823 | .checks-line { |
test/studio-door.test.ts +3 −3
| @@ -1414,13 +1414,13 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1414 | 1414 | }); |
| 1415 | 1415 | |
| 1416 | 1416 | test("the fragment's markup is what the live client's status announcement reads: the head's word in a badge, a preview row's word beside its `#N title`", async () => { |
| 1417 | // `live.ts`'s `announceSwap` finds the head's word at `.studio-head | |
| 1417 | // `live.ts`'s `announceSwap` finds the head's word at `.page-head h1 | |
| 1418 | 1418 | // .badge` and, for a preview row, the pull's label at the start of |
| 1419 | 1419 | // `.row-meta` beside its word at `.row-trail .badge`. If this markup |
| 1420 | 1420 | // ever moves, the announcement goes silent without a test failing here. |
| 1421 | 1421 | const text = await (await get("/alice/vault/app/state", owner)).text(); |
| 1422 | expect(text).toContain("studio-head"); | |
| 1423 | expect(text).toMatch(/studio-head[\s\S]*?class="badge"[^>]*>not running</); | |
| 1422 | expect(text).toContain('class="page-head"'); | |
| 1423 | expect(text).toMatch(/class="page-head"[\s\S]*?<h1 class="bar">[\s\S]*?class="badge"[^>]*>not running</); | |
| 1424 | 1424 | |
| 1425 | 1425 | const alice = getUserByName(app.db, "alice")!; |
| 1426 | 1426 | const branch = await commitToBranch(app, alice, repo, { |
test/studio-phone.test.ts +3 −4
| @@ -3,16 +3,15 @@ import { studioCss } from "../src/web/studio/style.ts"; | ||
| 3 | 3 | |
| 4 | 4 | /** The studio's phone rules: mechanical checks; the look is judged by eye. */ |
| 5 | 5 | describe("the studio at 360px", () => { |
| 6 | const phone = studioCss.slice(studioCss.indexOf("@media (max-width: 600px) {\n .studio-head")); | |
| 6 | const phone = studioCss.slice(studioCss.lastIndexOf("@media (max-width: 600px) {")); | |
| 7 | 7 | |
| 8 | 8 | test("every component the issue names has a phone rule", () => { |
| 9 | for (const sel of [".studio-head", ".studio-tell", ".feed .card-head", ".studio-ask-actions", ".studio-word"]) { | |
| 9 | for (const sel of [".feed .card-head", ".feed .bar .btn"]) { | |
| 10 | 10 | expect(phone).toContain(sel); |
| 11 | 11 | } |
| 12 | 12 | }); |
| 13 | 13 | |
| 14 | test("decision buttons stack and taps are 44px", () => { | |
| 15 | expect(phone).toContain("flex-direction: column"); | |
| 14 | test("taps are 44px", () => { | |
| 16 | 15 | expect(phone).toContain("min-height: 44px"); |
| 17 | 16 | }); |
| 18 | 17 |
test/studio.test.ts +11 −3
| @@ -249,14 +249,12 @@ describe("the studio", () => { | ||
| 249 | 249 | const sheet = await fetch(`${base}${stylesheetAsset().path}`); |
| 250 | 250 | expect(sheet.headers.get("content-type")).toContain("text/css"); |
| 251 | 251 | const css = await sheet.text(); |
| 252 | expect(css).toContain(".studio-tell textarea"); | |
| 253 | expect(css).toContain(".studio-ask-actions"); | |
| 254 | 252 | expect(css).toContain("@keyframes studio-flash"); |
| 255 | 253 | expect(css).toContain('.board[data-focus="working"] #working'); |
| 256 | 254 | // In the `components` layer, last: an inline block used to win over every |
| 257 | 255 | // layer, and last in `components` is the nearest place that still reads |
| 258 | 256 | // the same — a `content` rule or a utility must keep out-specifying them. |
| 259 | for (const rule of [".studio-tell textarea", ".board-region > h2"]) { | |
| 257 | for (const rule of ["@keyframes studio-flash", ".board-region > h2"]) { | |
| 260 | 258 | expect(css.indexOf(rule)).toBeGreaterThan( |
| 261 | 259 | css.indexOf("@layer components {"), |
| 262 | 260 | ); |
| @@ -264,6 +262,16 @@ describe("the studio", () => { | ||
| 264 | 262 | } |
| 265 | 263 | }); |
| 266 | 264 | |
| 265 | test("the head, tell box, word and shelf rows are §5's own parts: no studio-*, shelf-* or fcard-app class is left on a page (#3616)", async () => { | |
| 266 | const old = /class="[^"]*\b(?:fcard-app|shelf-desc|shelf-facts|shelf-meta|studio-ask-actions|studio-head|studio-tell|studio-word)\b/; | |
| 267 | for (const path of ["/", "/alice/notes", "/apps"]) { | |
| 268 | expect(await (await get(path)).text()).not.toMatch(old); | |
| 269 | } | |
| 270 | const studio = await (await get("/alice/notes")).text(); | |
| 271 | expect(studio).toMatch(/<div class="page-head">\s*<div class="stack stack-tight">\s*<h1 class="bar">/); | |
| 272 | expect(studio).toContain('class="card comment-form"'); | |
| 273 | }); | |
| 274 | ||
| 267 | 275 | test("telling the crew opens an issue and hands it to a builder", async () => { |
| 268 | 276 | const told = await post("/alice/notes/tell", { |
| 269 | 277 | text: "Make the notes page load in under a second on my phone.\n\nIt takes three seconds now.", |
test/web-apps.test.ts +1 −1
| @@ -240,7 +240,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 240 | 240 | // and Open live; production's state is the row's own phrase. |
| 241 | 241 | const list = await bodyOf(alice, "/apps"); |
| 242 | 242 | expect(list).toContain('href="/alice/hello"'); |
| 243 | expect(list).toContain('class="shelf-'); | |
| 243 | expect(list).toContain('class="row"'); | |
| 244 | 244 | // Signed out, a public app is still listed; the button says sign in. |
| 245 | 245 | const anonymous = await bodyOf("", "/apps"); |
| 246 | 246 | expect(anonymous).toContain('href="/alice/hello"'); |
new above
offline