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

Merged opened this

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

Merged

mason/forge:mason/3616-design-the-studio-s-head-tell-box-and-sh → main 2 commits fork

  • Merged. mason/3616-design-the-studio-s-head-tell-box-and-sh was merged into main as 59c7a98.

Review

Sign in to review this change.
15 files changed +41 −92
docs/DESIGN.md −1
@@ -399,7 +399,6 @@ gone, so this list only shrinks.
399399 - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out`
400400 - **`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`
401401 - **`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`
403402
404403 ---
405404
docs/design/baseline.json +1 −1
@@ -69,7 +69,7 @@
6969 "motion": 13,
7070 "forced": 1,
7171 "focus": 1,
72 "input": 2,
72 "input": 1,
7373 "target": 2
7474 }
7575 }
src/web/live.ts +2 −2
@@ -147,12 +147,12 @@ export const liveClientJs = /* js */ `
147147 }
148148 // The head card's own word, whatever envPhrase drew into its badge.
149149 function headWord(root) {
150 return textOf(root && root.querySelector('.studio-head .badge'));
150 return textOf(root && root.querySelector('.page-head h1 .badge'));
151151 }
152152 // When the word is "live", the timing phrase envFacts already printed
153153 // under it — never a second way of saying how fast.
154154 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') : [];
156156 for (var i = 0; i < spans.length; i++) {
157157 var t = textOf(spans[i]);
158158 if (t.indexOf('live ') === 0) return t;
src/web/studio/app.ts +1 −1
@@ -287,7 +287,7 @@ export async function studioApp(
287287 const tell = !ownerFacts
288288 ? null
289289 : html`<form
290 class="card comment-form studio-tell"
290 class="card comment-form"
291291 method="post"
292292 action="${base}/tell"
293293 >
src/web/studio/feed.ts +2 −2
@@ -68,7 +68,7 @@ export function feedCard(
6868 const head = html`${avatar(item.actor ?? item.app)}
6969 <strong class="fcard-who">${item.actor ?? item.app}</strong
7070 >${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>
7272 <span class="spacer"></span>
7373 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
7474 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;
107107 export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html {
108108 const head = html`${avatar(who)}
109109 <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>
111111 <span class="spacer"></span>
112112 <span class="badge">Received</span>`;
113113 return card(
src/web/studio/flow_card.ts +1 −1
@@ -19,7 +19,7 @@ const BUTTON: Record<StalledStep["action"], string> = { run: "Run check", rerun:
1919 function stalledCard(s: StalledStep): Html {
2020 const base = `/${s.ownerName}/${s.repoName}/pulls/${s.pull}`;
2121 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>`;
2323 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>
2424 <form method="post" action="${base}/checks" class="fcard-unblock">
2525 <input type="hidden" name="next" value="/">
src/web/studio/now.ts +1 −3
@@ -203,9 +203,7 @@ export async function nowCard(
203203 ? null
204204 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
205205 trail: stop
206 ? html`<span class="studio-ask-actions"
207 >${wordBadge(word)} ${stop}</span
208 >`
206 ? html`${wordBadge(word)} ${stop}`
209207 : wordBadge(word),
210208 }),
211209 );
src/web/studio/previews.ts +2 −2
@@ -303,9 +303,9 @@ export function appPanels(
303303 const audience = audienceOf(
304304 audienceFacts(db, repo, ctx.auth.user),
305305 ).sentence;
306 const head = html`<div class="page-head studio-head">
306 const head = html`<div class="page-head">
307307 <div class="stack stack-tight">
308 <h1>
308 <h1 class="bar">
309309 ${repo.name}
310310 ${badge(
311311 live.word,
src/web/studio/shelf.ts +9 −19
@@ -113,15 +113,9 @@ function sealedRow(repo: Repo, user: User | undefined): Html {
113113 ? repo.name
114114 : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`,
115115 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}`,
125119 trail: raw(`\n <span class="badge" data-tone="default">private</span>`),
126120 });
127121 }
@@ -282,9 +276,7 @@ export async function shelfRows(
282276 : canTell
283277 ? html`<span class="muted">quiet — tell the crew something</span>`
284278 : 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>
288280 ${who ? html`· ${who}` : null}
289281 ${word.word === "working"
290282 ? html`· now`
@@ -305,13 +297,11 @@ export async function shelfRows(
305297 lead: dot(tone),
306298 title,
307299 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}`,
315305 trail: raw(
316306 `\n <span class="badge" data-tone="${tone}">${escapeHtml(live.word)}</span>`,
317307 ),
src/web/studio/style.ts +3 −48
@@ -7,28 +7,14 @@
77 * Each part is named for the concept whose markup it styles. They land in the
88 * `components` layer, last, which is where an inline block's rules used to
99 * 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.
1213 */
1314
1415 /** A part as written, without the newlines the template literal adds. */
1516 const part = (css: string): string => css.trim();
1617
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
3218 /** A card the live client just swapped in place (`live.ts`): a background
3319 * flash, never a resize, so the page never shifts under a reader. */
3420 const justChanged = part(/* css */ `
@@ -42,51 +28,20 @@ const wordBadges = part(/* css */ `
4228 .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; }
4329 `);
4430
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
6031 /** The phone: one document, narrower. Each rule belongs to a component above
6132 * and only ever applies at 600px and under, so nothing changes on a desktop. */
6233 const phone = part(/* css */ `
6334 @media (max-width: 600px) {
64 .studio-head { flex-wrap: wrap; }
6535 .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; }
7236 .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; }
7837 }
7938 `);
8039
8140 import { feedCss } from "./feed_style.ts";
8241
8342 export const studioCss = `@layer components {\n${[
84 tellBox,
85 appHead,
8643 justChanged,
8744 wordBadges,
88 rows,
89 rowActions,
9045 part(feedCss),
9146 phone,
9247 ].join("\n\n")}\n}`;
src/web/style/content.ts +1 −1
@@ -817,7 +817,7 @@ form.inline { display: inline; margin-left: auto; }
817817 are shown — a pull's preview, the studio head — so they are not scoped to
818818 the card. */
819819 .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; }
821821 .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; }
822822 /* The checks line inside the merge box, in the reviews chip's voice. */
823823 .checks-line {
test/studio-door.test.ts +3 −3
@@ -1414,13 +1414,13 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
14141414 });
14151415
14161416 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
14181418 // .badge` and, for a preview row, the pull's label at the start of
14191419 // `.row-meta` beside its word at `.row-trail .badge`. If this markup
14201420 // ever moves, the announcement goes silent without a test failing here.
14211421 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</);
14241424
14251425 const alice = getUserByName(app.db, "alice")!;
14261426 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";
33
44 /** The studio's phone rules: mechanical checks; the look is judged by eye. */
55 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) {"));
77
88 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"]) {
1010 expect(phone).toContain(sel);
1111 }
1212 });
1313
14 test("decision buttons stack and taps are 44px", () => {
15 expect(phone).toContain("flex-direction: column");
14 test("taps are 44px", () => {
1615 expect(phone).toContain("min-height: 44px");
1716 });
1817
test/studio.test.ts +11 −3
@@ -249,14 +249,12 @@ describe("the studio", () => {
249249 const sheet = await fetch(`${base}${stylesheetAsset().path}`);
250250 expect(sheet.headers.get("content-type")).toContain("text/css");
251251 const css = await sheet.text();
252 expect(css).toContain(".studio-tell textarea");
253 expect(css).toContain(".studio-ask-actions");
254252 expect(css).toContain("@keyframes studio-flash");
255253 expect(css).toContain('.board[data-focus="working"] #working');
256254 // In the `components` layer, last: an inline block used to win over every
257255 // layer, and last in `components` is the nearest place that still reads
258256 // 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"]) {
260258 expect(css.indexOf(rule)).toBeGreaterThan(
261259 css.indexOf("@layer components {"),
262260 );
@@ -264,6 +262,16 @@ describe("the studio", () => {
264262 }
265263 });
266264
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
267275 test("telling the crew opens an issue and hands it to a builder", async () => {
268276 const told = await post("/alice/notes/tell", {
269277 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", () => {
240240 // and Open live; production's state is the row's own phrase.
241241 const list = await bodyOf(alice, "/apps");
242242 expect(list).toContain('href="/alice/hello"');
243 expect(list).toContain('class="shelf-');
243 expect(list).toContain('class="row"');
244244 // Signed out, a public app is still listed; the button says sign in.
245245 const anonymous = await bodyOf("", "/apps");
246246 expect(anonymous).toContain('href="/alice/hello"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs