Public: anyone can read this; the crew can change it.
The studio's stylesheet belongs in the stylesheet, not in every page #166
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/155-the-studio-s-stylesheet-belongs-in-the-s → main 2 commits fork
15 files
changed +85
−37
src/web/layout.ts +5 −1
| @@ -47,7 +47,11 @@ export interface PageOptions { | ||
| 47 | 47 | crumbs?: { label: string; href: string }[]; |
| 48 | 48 | body: Html; |
| 49 | 49 | /** |
| 50 | * Page-specific CSS (e.g. dynamic label colors). Emitted in a nonce'd | |
| 50 | * CSS this request computed: label colors mixed from the labels actually | |
| 51 | * on the page (`labelCss`) are the case this exists for. A rule that is | |
| 52 | * the same for every visitor belongs in the served stylesheet | |
| 53 | * (`web/style.ts`), which the browser already has — the studio's and the | |
| 54 | * board's used to be sent from here and now live there. Emitted in a nonce'd | |
| 51 | 55 | * <style> the strict CSP allows. Must be trusted CSS the page generated — |
| 52 | 56 | * never unescaped user text. |
| 53 | 57 | */ |
src/web/pages/apps.ts +1 −2
| @@ -55,7 +55,6 @@ import { html, when, type Html } from "../html.ts"; | ||
| 55 | 55 | import { flashTo, page, redirect } from "../layout.ts"; |
| 56 | 56 | import { liveClientJs, liveMount } from "../live.ts"; |
| 57 | 57 | import { shelfRows } from "../studio/shelf.ts"; |
| 58 | import { studioCss } from "../studio/style.ts"; | |
| 59 | 58 | import { |
| 60 | 59 | badge, |
| 61 | 60 | btn, |
| @@ -282,7 +281,7 @@ async function studioAppsList(ctx: Ctx): Promise<Response> { | ||
| 282 | 281 | action: create, |
| 283 | 282 | }) |
| 284 | 283 | : listCard(rows)}`; |
| 285 | return page({ ctx, title: "Apps", body, styleCss: studioCss }); | |
| 284 | return page({ ctx, title: "Apps", body }); | |
| 286 | 285 | } |
| 287 | 286 | |
| 288 | 287 | // ---- new app ---- |
src/web/pages/board.ts +8 −1
| @@ -154,7 +154,6 @@ export function boardPage(ctx: Ctx): Response { | ||
| 154 | 154 | body, |
| 155 | 155 | bare: true, |
| 156 | 156 | theme: "dark", |
| 157 | styleCss: boardCss, | |
| 158 | 157 | scriptJs: `${liveRegionsJs}\n${boardJs}`, |
| 159 | 158 | }); |
| 160 | 159 | } |
| @@ -452,7 +451,14 @@ const widths = Array.from( | ||
| 452 | 451 | (_, n) => `.board-fill[data-w="${n}"] { width: ${n}%; }`, |
| 453 | 452 | ).join("\n"); |
| 454 | 453 | |
| 454 | /** | |
| 455 | * The board's own components, assembled into the served stylesheet by | |
| 456 | * `web/style.ts` rather than sent inline with the page. Every rule here is | |
| 457 | * a `.board-*` selector nothing else in the sheet mentions, so the | |
| 458 | * `components` layer is where they read the same as they always did. | |
| 459 | */ | |
| 455 | 460 | export const boardCss = /* css */ ` |
| 461 | @layer components { | |
| 456 | 462 | .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; font-size: 16px; } |
| 457 | 463 | .board-top { display: flex; align-items: center; gap: 14px; padding: 14px var(--pad) 0; color: var(--faint); font-size: 13px; } |
| 458 | 464 | .board-brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 650; letter-spacing: -0.03em; font-size: 15px; color: var(--muted-fg); } |
| @@ -569,4 +575,5 @@ ${widths} | ||
| 569 | 575 | .board[data-focus] .board-region { display: none; } |
| 570 | 576 | .board[data-focus="working"] #working, .board[data-focus="landed"] #landed, |
| 571 | 577 | .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; } |
| 578 | } | |
| 572 | 579 | `; |
src/web/studio/app.ts −2
| @@ -45,7 +45,6 @@ import { withLikely } from "./leans.ts"; | ||
| 45 | 45 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 46 | 46 | import { appPanels } from "./previews.ts"; |
| 47 | 47 | import { stopForm } from "./run.ts"; |
| 48 | import { studioCss } from "./style.ts"; | |
| 49 | 48 | import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 50 | 49 | |
| 51 | 50 | /** |
| @@ -500,7 +499,6 @@ export async function studioApp( | ||
| 500 | 499 | crumbs: crumbsFor(repo), |
| 501 | 500 | palette: repoPalette(ctx, repo), |
| 502 | 501 | body, |
| 503 | styleCss: studioCss, | |
| 504 | 502 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}\n${appTailJs}`, |
| 505 | 503 | // The no-script half of the tail: while the crew's run goes, the page |
| 506 | 504 | // refreshes itself, the way the run's own page does; the script above |
src/web/studio/change.ts −2
| @@ -32,7 +32,6 @@ import { lookCard } from "../pages/looks.ts"; | ||
| 32 | 32 | import { pullState } from "../pages/pulls.ts"; |
| 33 | 33 | import { composer, conversation, originGlyph } from "../pages/thread.ts"; |
| 34 | 34 | import { decisionActions, decisionForm, decisionsFor } from "./asks.ts"; |
| 35 | import { studioCss } from "./style.ts"; | |
| 36 | 35 | import { pullWord, wordBadge, type WordCtx } from "./words.ts"; |
| 37 | 36 | |
| 38 | 37 | /** What a conflicted change's own page says: the base it no longer merges |
| @@ -199,7 +198,6 @@ export async function studioPull( | ||
| 199 | 198 | crumbs: crumbsFor(repo), |
| 200 | 199 | palette: repoPalette(ctx, repo), |
| 201 | 200 | body, |
| 202 | styleCss: studioCss, | |
| 203 | 201 | scriptJs: liveClientJs, |
| 204 | 202 | }); |
| 205 | 203 | } |
src/web/studio/home.ts −2
| @@ -22,7 +22,6 @@ import { appsOf, asksPanel, decisionsFor } from "./asks.ts"; | ||
| 22 | 22 | import { withLikely } from "./leans.ts"; |
| 23 | 23 | import { stopForm } from "./run.ts"; |
| 24 | 24 | import { shelfRows } from "./shelf.ts"; |
| 25 | import { studioCss } from "./style.ts"; | |
| 26 | 25 | |
| 27 | 26 | /** |
| 28 | 27 | * The home in the studio: what asks you first, then your apps as a shelf of |
| @@ -148,7 +147,6 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 148 | 147 | ctx, |
| 149 | 148 | title: "Studio", |
| 150 | 149 | body, |
| 151 | styleCss: studioCss, | |
| 152 | 150 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`, |
| 153 | 151 | }); |
| 154 | 152 | } |
src/web/studio/lists.ts −4
| @@ -37,7 +37,6 @@ import { | ||
| 37 | 37 | time, |
| 38 | 38 | } from "../ui.ts"; |
| 39 | 39 | import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts"; |
| 40 | import { studioCss } from "./style.ts"; | |
| 41 | 40 | import { tellWord } from "./tell.ts"; |
| 42 | 41 | import { |
| 43 | 42 | pullWord, |
| @@ -211,7 +210,6 @@ export async function studioTells( | ||
| 211 | 210 | crumbs: crumbsFor(repo), |
| 212 | 211 | palette: repoPalette(ctx, repo), |
| 213 | 212 | body, |
| 214 | styleCss: studioCss, | |
| 215 | 213 | scriptJs: liveClientJs, |
| 216 | 214 | }); |
| 217 | 215 | } |
| @@ -300,7 +298,6 @@ export async function studioChanges( | ||
| 300 | 298 | crumbs: crumbsFor(repo), |
| 301 | 299 | palette: repoPalette(ctx, repo), |
| 302 | 300 | body, |
| 303 | styleCss: studioCss, | |
| 304 | 301 | scriptJs: liveClientJs, |
| 305 | 302 | }); |
| 306 | 303 | } |
| @@ -415,7 +412,6 @@ export async function studioRuns( | ||
| 415 | 412 | crumbs: crumbsFor(repo), |
| 416 | 413 | palette: repoPalette(ctx, repo), |
| 417 | 414 | body, |
| 418 | styleCss: studioCss, | |
| 419 | 415 | scriptJs: liveClientJs, |
| 420 | 416 | }); |
| 421 | 417 | } |
src/web/studio/run.ts −2
| @@ -29,7 +29,6 @@ import { btn, icon } from "../ui.ts"; | ||
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; |
| 31 | 31 | import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 32 | import { studioCss } from "./style.ts"; | |
| 33 | 32 | import { runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 34 | 33 | |
| 35 | 34 | /** |
| @@ -263,7 +262,6 @@ export async function studioRun( | ||
| 263 | 262 | crumbs: crumbsFor(repo), |
| 264 | 263 | palette: repoPalette(ctx, repo), |
| 265 | 264 | body, |
| 266 | styleCss: studioCss, | |
| 267 | 265 | scriptJs: studioRunJs, |
| 268 | 266 | // The no-script half of a live log: while the run is going, the page |
| 269 | 267 | // refreshes itself; the script above removes this the moment it runs. |
src/web/studio/search.ts −3
| @@ -33,7 +33,6 @@ import { | ||
| 33 | 33 | type Tone, |
| 34 | 34 | } from "../ui.ts"; |
| 35 | 35 | import { appsOf } from "./asks.ts"; |
| 36 | import { studioCss } from "./style.ts"; | |
| 37 | 36 | |
| 38 | 37 | const APP_LIMIT = 20; |
| 39 | 38 | const CHANGE_LIMIT = 20; |
| @@ -146,7 +145,6 @@ export function studioSearchPage(ctx: Ctx): Response { | ||
| 146 | 145 | return page({ |
| 147 | 146 | ctx, |
| 148 | 147 | title: "Studio search", |
| 149 | styleCss: studioCss, | |
| 150 | 148 | body: html`${pageHead("Search")} ${form} |
| 151 | 149 | ${empty({ |
| 152 | 150 | icon: "search", |
| @@ -202,7 +200,6 @@ export function studioSearchPage(ctx: Ctx): Response { | ||
| 202 | 200 | return page({ |
| 203 | 201 | ctx, |
| 204 | 202 | title: `Studio search · ${query}`, |
| 205 | styleCss: studioCss, | |
| 206 | 203 | body, |
| 207 | 204 | }); |
| 208 | 205 | } |
src/web/studio/style.ts +16 −14
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | * The studio's page stylesheet: the one `styleCss` block every studio page | |
| 3 | * hands `page()`, assembled from named parts the way `web/style.ts` assembles | |
| 4 | * the served sheet from `style/*`. Each part is named for the concept whose | |
| 5 | * markup it styles, and they are joined in the order they were served when | |
| 6 | * this was one block at the bottom of `pages/studio.ts`, so the sheet is | |
| 7 | * byte-for-byte the string it has always been. `bulkCss` is not written here | |
| 8 | * at all: it is derived from the same table the bulk route enforces | |
| 9 | * (`asks.ts`). | |
| 2 | * The studio's slice of the served stylesheet (`web/style.ts` assembles it | |
| 3 | * in, beside `style/*`). These rules are the studio's design, identical for | |
| 4 | * every visitor and every request, so they belong in the file the browser | |
| 5 | * caches — not in a `<style>` block on every studio page. | |
| 6 | * | |
| 7 | * Each part is named for the concept whose markup it styles. They land in the | |
| 8 | * `components` layer, last, which is where an inline block's rules used to | |
| 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. `bulkCss` is not written here at | |
| 12 | * all: it is derived from the same table the bulk route enforces (`asks.ts`). | |
| 10 | 13 | * |
| 11 | 14 | * `shell` and `rail` hold rules nothing in this directory draws any more, |
| 12 | 15 | * and `rows` all but two of them. They are kept exactly as they were because |
| @@ -16,9 +19,8 @@ | ||
| 16 | 19 | |
| 17 | 20 | import { bulkCss } from "./asks.ts"; |
| 18 | 21 | |
| 19 | /** A part as written: the newline that opens the template belongs to the | |
| 20 | * sheet, the one that closes it does not. */ | |
| 21 | const part = (css: string): string => css.slice(0, -1); | |
| 22 | /** A part as written, without the newlines the template literal adds. */ | |
| 23 | const part = (css: string): string => css.trim(); | |
| 22 | 24 | |
| 23 | 25 | /** The first studio's own two-column shell, and the preview box it drew. */ |
| 24 | 26 | const shell = part(/* css */ ` |
| @@ -102,7 +104,7 @@ const rail = part(/* css */ ` | ||
| 102 | 104 | .studio-crew li span { opacity: .85; } |
| 103 | 105 | `); |
| 104 | 106 | |
| 105 | export const studioCss = `${[ | |
| 107 | export const studioCss = `@layer components {\n${[ | |
| 106 | 108 | shell, |
| 107 | 109 | tellBox, |
| 108 | 110 | appHead, |
| @@ -110,6 +112,6 @@ export const studioCss = `${[ | ||
| 110 | 112 | wordBadges, |
| 111 | 113 | rows, |
| 112 | 114 | asks, |
| 113 | `\n${bulkCss}`, | |
| 115 | bulkCss, | |
| 114 | 116 | rail, |
| 115 | ].join("")}\n`; | |
| 117 | ].join("\n\n")}\n}`; |
src/web/studio/tell.ts −2
| @@ -37,7 +37,6 @@ import { | ||
| 37 | 37 | import { previewCard } from "../pages/apps.ts"; |
| 38 | 38 | import { costText, durationText, reasonWords } from "../pages/runs.ts"; |
| 39 | 39 | import { composer, conversation, originGlyph } from "../pages/thread.ts"; |
| 40 | import { studioCss } from "./style.ts"; | |
| 41 | 40 | import { |
| 42 | 41 | pullWord, |
| 43 | 42 | runWord, |
| @@ -301,7 +300,6 @@ export async function studioTell( | ||
| 301 | 300 | crumbs: crumbsFor(repo), |
| 302 | 301 | palette: repoPalette(ctx, repo), |
| 303 | 302 | body, |
| 304 | styleCss: studioCss, | |
| 305 | 303 | scriptJs: liveClientJs, |
| 306 | 304 | }); |
| 307 | 305 | } |
src/web/style.ts +13 −1
| @@ -3,7 +3,9 @@ | ||
| 3 | 3 | * |
| 4 | 4 | * reset → the handful of UA defaults we disagree with |
| 5 | 5 | * base → constants, type scale, focus, motion, the page shell |
| 6 | * components → the component vocabulary (docs/DESIGN.md is its contract) | |
| 6 | * components → the component vocabulary (docs/DESIGN.md is its contract), | |
| 7 | * plus two surfaces with a vocabulary of their own: the | |
| 8 | * studio (`studio/style.ts`) and the board (`pages/board.ts`) | |
| 7 | 9 | * content → prose, code, diffs, and the furniture that frames them |
| 8 | 10 | * utilities → the few one-liners pages are allowed to reach for |
| 9 | 11 | * |
| @@ -12,12 +14,20 @@ | ||
| 12 | 14 | * the stylesheet is the artifact, served as written, and meant to be read. |
| 13 | 15 | * Every color is a token from `design/themes.ts`, so all six themes get every |
| 14 | 16 | * refinement for free. |
| 17 | * | |
| 18 | * The studio's and the board's rules live beside the markup they style but | |
| 19 | * are served from here, at the end of `components`: they are static, so a | |
| 20 | * page has no business carrying them inline, and last in the layer is where | |
| 21 | * an inline block's rules used to win from. The import goes one way only: | |
| 22 | * this file reads those two, so neither may ever read `css` back. | |
| 15 | 23 | */ |
| 16 | 24 | |
| 17 | 25 | import { swatchCss, themeCss } from "../design/themes.ts"; |
| 26 | import { boardCss } from "./pages/board.ts"; | |
| 18 | 27 | import { base, layers, reset, utilities } from "./style/base.ts"; |
| 19 | 28 | import { components } from "./style/components.ts"; |
| 20 | 29 | import { content } from "./style/content.ts"; |
| 30 | import { studioCss } from "./studio/style.ts"; | |
| 21 | 31 | |
| 22 | 32 | /** |
| 23 | 33 | * Twelve avatar hues as static rules. A name hashes to one of these buckets |
| @@ -36,6 +46,8 @@ export const css = [ | ||
| 36 | 46 | base, |
| 37 | 47 | components, |
| 38 | 48 | avatarHues, |
| 49 | studioCss, | |
| 50 | boardCss, | |
| 39 | 51 | content, |
| 40 | 52 | utilities, |
| 41 | 53 | swatchCss, |
test/board.test.ts +4
| @@ -136,6 +136,10 @@ describe("the board", () => { | ||
| 136 | 136 | expect(html).toContain('id="board-clock"'); |
| 137 | 137 | expect(html).toContain("new EventSource('/events')"); |
| 138 | 138 | expect(html).toContain("requestFullscreen"); |
| 139 | // A wall left open all day fetches its 13 KB of rules once, with the rest | |
| 140 | // of the design, instead of carrying them inline on every reload. | |
| 141 | expect(html).not.toContain("<style"); | |
| 142 | expect(html).toContain('<link rel="stylesheet" href="/assets/forge.css"'); | |
| 139 | 143 | // A viewer who chose a theme keeps it. |
| 140 | 144 | const paper = await ( |
| 141 | 145 | await get("/board", { cookie: `${cookie}; forge_theme=paper` }) |
test/studio.test.ts +35 −1
| @@ -222,6 +222,32 @@ describe("the studio", () => { | ||
| 222 | 222 | expect(dev).toContain(">Commits<"); |
| 223 | 223 | }); |
| 224 | 224 | |
| 225 | test("the studio's design is in the stylesheet, not in every page", async () => { | |
| 226 | // The rules are static, so they belong in the file the browser caches. | |
| 227 | // A studio page carries no <style> at all — only a page that computes CSS | |
| 228 | // for this request (label colors) is allowed one. | |
| 229 | for (const path of ["/", "/alice/notes", "/apps"]) { | |
| 230 | const html = await (await get(path)).text(); | |
| 231 | expect(html).toContain('<link rel="stylesheet" href="/assets/forge.css"'); | |
| 232 | expect(html).not.toContain("<style"); | |
| 233 | } | |
| 234 | ||
| 235 | const sheet = await fetch(`${base}/assets/forge.css`); | |
| 236 | expect(sheet.headers.get("content-type")).toContain("text/css"); | |
| 237 | const css = await sheet.text(); | |
| 238 | expect(css).toContain(".studio-tell textarea"); | |
| 239 | expect(css).toContain(".studio-bulk-count"); | |
| 240 | expect(css).toContain("@keyframes studio-flash"); | |
| 241 | expect(css).toContain('.board-fill[data-w="100"]'); | |
| 242 | // In the `components` layer, last: an inline block used to win over every | |
| 243 | // layer, and last in `components` is the nearest place that still reads | |
| 244 | // the same — a `content` rule or a utility must keep out-specifying them. | |
| 245 | for (const rule of [".studio-tell textarea", ".board-h"]) { | |
| 246 | expect(css.indexOf(rule)).toBeGreaterThan(css.indexOf("@layer components {")); | |
| 247 | expect(css.indexOf(rule)).toBeLessThan(css.indexOf("@layer content {")); | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 225 | 251 | test("telling the crew opens an issue and hands it to a builder", async () => { |
| 226 | 252 | const told = await post("/alice/notes/tell", { |
| 227 | 253 | text: "Make the notes page load in under a second on my phone.\n\nIt takes three seconds now.", |
| @@ -2977,7 +3003,15 @@ describe("working: a run's own page in the studio", () => { | ||
| 2977 | 3003 | const page = await ( |
| 2978 | 3004 | await get(`/alice/engine/runs/${run.id}`, writerCookie) |
| 2979 | 3005 | ).text(); |
| 2980 | expect(page).toContain('data-word="look"'); | |
| 3006 | // The badge itself, not a stylesheet that happens to mention the word: | |
| 3007 | // this read `data-word="look"` until the studio's rules moved out of the | |
| 3008 | // page, and matched `.studio-list li[data-word="look"]` in the inline | |
| 3009 | // <style>, never the badge. The pull has no deploy of this head, so its | |
| 3010 | // word is `waiting` — and the point stands either way: the word came | |
| 3011 | // from the pull found by branch, not from the summary (`planned` is what | |
| 3012 | // a run with nothing to show would say). | |
| 3013 | expect(page).toMatch(/data-word="waiting"\s+id="run-word"/); | |
| 3014 | expect(page).not.toContain('data-word="planned"'); | |
| 2981 | 3015 | expect(page).not.toContain('id="run-stop"'); |
| 2982 | 3016 | expect(page).not.toContain(">Stop<"); |
| 2983 | 3017 | expect(page).not.toContain(">Cancel run<"); |
test/web-ui.test.ts +3
| @@ -192,6 +192,9 @@ describe("web ui", () => { | ||
| 192 | 192 | expect(page).toContain("lc-d73a4a"); |
| 193 | 193 | expect(page).toContain(".lc-d73a4a{--label:#d73a4a}"); |
| 194 | 194 | expect(page).toContain(">bug</span>"); |
| 195 | // A colour mixed from the labels on *this* page is what `styleCss` is | |
| 196 | // for, and the only thing left using it: nothing static rides along. | |
| 197 | expect(page).toMatch(/<style nonce="[^"]+">\.lc-d73a4a\{--label:#d73a4a\}<\/style>/); | |
| 195 | 198 | |
| 196 | 199 | // And the list can filter by it. |
| 197 | 200 | const filtered = await ( |
new above
offline