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

The studio's stylesheet belongs in the stylesheet, not in every page #166

Merged opened this

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

Merged

mason/forge:mason/155-the-studio-s-stylesheet-belongs-in-the-s → main 2 commits fork

  • Merged. mason/155-the-studio-s-stylesheet-belongs-in-the-s was merged into main as 2164e06.

Review

Sign in to review this change.
15 files changed +85 −37
src/web/layout.ts +5 −1
@@ -47,7 +47,11 @@ export interface PageOptions {
4747 crumbs?: { label: string; href: string }[];
4848 body: Html;
4949 /**
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
5155 * <style> the strict CSP allows. Must be trusted CSS the page generated —
5256 * never unescaped user text.
5357 */
src/web/pages/apps.ts +1 −2
@@ -55,7 +55,6 @@ import { html, when, type Html } from "../html.ts";
5555 import { flashTo, page, redirect } from "../layout.ts";
5656 import { liveClientJs, liveMount } from "../live.ts";
5757 import { shelfRows } from "../studio/shelf.ts";
58 import { studioCss } from "../studio/style.ts";
5958 import {
6059 badge,
6160 btn,
@@ -282,7 +281,7 @@ async function studioAppsList(ctx: Ctx): Promise<Response> {
282281 action: create,
283282 })
284283 : listCard(rows)}`;
285 return page({ ctx, title: "Apps", body, styleCss: studioCss });
284 return page({ ctx, title: "Apps", body });
286285 }
287286
288287 // ---- new app ----
src/web/pages/board.ts +8 −1
@@ -154,7 +154,6 @@ export function boardPage(ctx: Ctx): Response {
154154 body,
155155 bare: true,
156156 theme: "dark",
157 styleCss: boardCss,
158157 scriptJs: `${liveRegionsJs}\n${boardJs}`,
159158 });
160159 }
@@ -452,7 +451,14 @@ const widths = Array.from(
452451 (_, n) => `.board-fill[data-w="${n}"] { width: ${n}%; }`,
453452 ).join("\n");
454453
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 */
455460 export const boardCss = /* css */ `
461 @layer components {
456462 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; font-size: 16px; }
457463 .board-top { display: flex; align-items: center; gap: 14px; padding: 14px var(--pad) 0; color: var(--faint); font-size: 13px; }
458464 .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}
569575 .board[data-focus] .board-region { display: none; }
570576 .board[data-focus="working"] #working, .board[data-focus="landed"] #landed,
571577 .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; }
578 }
572579 `;
src/web/studio/app.ts −2
@@ -45,7 +45,6 @@ import { withLikely } from "./leans.ts";
4545 import { LOG_TAIL_CAP, logBox } from "./log.ts";
4646 import { appPanels } from "./previews.ts";
4747 import { stopForm } from "./run.ts";
48 import { studioCss } from "./style.ts";
4948 import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts";
5049
5150 /**
@@ -500,7 +499,6 @@ export async function studioApp(
500499 crumbs: crumbsFor(repo),
501500 palette: repoPalette(ctx, repo),
502501 body,
503 styleCss: studioCss,
504502 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}\n${appTailJs}`,
505503 // The no-script half of the tail: while the crew's run goes, the page
506504 // 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";
3232 import { pullState } from "../pages/pulls.ts";
3333 import { composer, conversation, originGlyph } from "../pages/thread.ts";
3434 import { decisionActions, decisionForm, decisionsFor } from "./asks.ts";
35 import { studioCss } from "./style.ts";
3635 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
3736
3837 /** What a conflicted change's own page says: the base it no longer merges
@@ -199,7 +198,6 @@ export async function studioPull(
199198 crumbs: crumbsFor(repo),
200199 palette: repoPalette(ctx, repo),
201200 body,
202 styleCss: studioCss,
203201 scriptJs: liveClientJs,
204202 });
205203 }
src/web/studio/home.ts −2
@@ -22,7 +22,6 @@ import { appsOf, asksPanel, decisionsFor } from "./asks.ts";
2222 import { withLikely } from "./leans.ts";
2323 import { stopForm } from "./run.ts";
2424 import { shelfRows } from "./shelf.ts";
25 import { studioCss } from "./style.ts";
2625
2726 /**
2827 * 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> {
148147 ctx,
149148 title: "Studio",
150149 body,
151 styleCss: studioCss,
152150 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
153151 });
154152 }
src/web/studio/lists.ts −4
@@ -37,7 +37,6 @@ import {
3737 time,
3838 } from "../ui.ts";
3939 import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts";
40 import { studioCss } from "./style.ts";
4140 import { tellWord } from "./tell.ts";
4241 import {
4342 pullWord,
@@ -211,7 +210,6 @@ export async function studioTells(
211210 crumbs: crumbsFor(repo),
212211 palette: repoPalette(ctx, repo),
213212 body,
214 styleCss: studioCss,
215213 scriptJs: liveClientJs,
216214 });
217215 }
@@ -300,7 +298,6 @@ export async function studioChanges(
300298 crumbs: crumbsFor(repo),
301299 palette: repoPalette(ctx, repo),
302300 body,
303 styleCss: studioCss,
304301 scriptJs: liveClientJs,
305302 });
306303 }
@@ -415,7 +412,6 @@ export async function studioRuns(
415412 crumbs: crumbsFor(repo),
416413 palette: repoPalette(ctx, repo),
417414 body,
418 styleCss: studioCss,
419415 scriptJs: liveClientJs,
420416 });
421417 }
src/web/studio/run.ts −2
@@ -29,7 +29,6 @@ import { btn, icon } from "../ui.ts";
2929 import { previewCard } from "../pages/apps.ts";
3030 import { costText, durationText, runTitle } from "../pages/runs.ts";
3131 import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts";
32 import { studioCss } from "./style.ts";
3332 import { runWord, wordBadge, type WordCtx } from "./words.ts";
3433
3534 /**
@@ -263,7 +262,6 @@ export async function studioRun(
263262 crumbs: crumbsFor(repo),
264263 palette: repoPalette(ctx, repo),
265264 body,
266 styleCss: studioCss,
267265 scriptJs: studioRunJs,
268266 // The no-script half of a live log: while the run is going, the page
269267 // refreshes itself; the script above removes this the moment it runs.
src/web/studio/search.ts −3
@@ -33,7 +33,6 @@ import {
3333 type Tone,
3434 } from "../ui.ts";
3535 import { appsOf } from "./asks.ts";
36 import { studioCss } from "./style.ts";
3736
3837 const APP_LIMIT = 20;
3938 const CHANGE_LIMIT = 20;
@@ -146,7 +145,6 @@ export function studioSearchPage(ctx: Ctx): Response {
146145 return page({
147146 ctx,
148147 title: "Studio search",
149 styleCss: studioCss,
150148 body: html`${pageHead("Search")} ${form}
151149 ${empty({
152150 icon: "search",
@@ -202,7 +200,6 @@ export function studioSearchPage(ctx: Ctx): Response {
202200 return page({
203201 ctx,
204202 title: `Studio search · ${query}`,
205 styleCss: studioCss,
206203 body,
207204 });
208205 }
src/web/studio/style.ts +16 −14
@@ -1,12 +1,15 @@
11 /**
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`).
1013 *
1114 * `shell` and `rail` hold rules nothing in this directory draws any more,
1215 * and `rows` all but two of them. They are kept exactly as they were because
@@ -16,9 +19,8 @@
1619
1720 import { bulkCss } from "./asks.ts";
1821
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();
2224
2325 /** The first studio's own two-column shell, and the preview box it drew. */
2426 const shell = part(/* css */ `
@@ -102,7 +104,7 @@ const rail = part(/* css */ `
102104 .studio-crew li span { opacity: .85; }
103105 `);
104106
105 export const studioCss = `${[
107 export const studioCss = `@layer components {\n${[
106108 shell,
107109 tellBox,
108110 appHead,
@@ -110,6 +112,6 @@ export const studioCss = `${[
110112 wordBadges,
111113 rows,
112114 asks,
113 `\n${bulkCss}`,
115 bulkCss,
114116 rail,
115 ].join("")}\n`;
117 ].join("\n\n")}\n}`;
src/web/studio/tell.ts −2
@@ -37,7 +37,6 @@ import {
3737 import { previewCard } from "../pages/apps.ts";
3838 import { costText, durationText, reasonWords } from "../pages/runs.ts";
3939 import { composer, conversation, originGlyph } from "../pages/thread.ts";
40 import { studioCss } from "./style.ts";
4140 import {
4241 pullWord,
4342 runWord,
@@ -301,7 +300,6 @@ export async function studioTell(
301300 crumbs: crumbsFor(repo),
302301 palette: repoPalette(ctx, repo),
303302 body,
304 styleCss: studioCss,
305303 scriptJs: liveClientJs,
306304 });
307305 }
src/web/style.ts +13 −1
@@ -3,7 +3,9 @@
33 *
44 * reset → the handful of UA defaults we disagree with
55 * 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`)
79 * content → prose, code, diffs, and the furniture that frames them
810 * utilities → the few one-liners pages are allowed to reach for
911 *
@@ -12,12 +14,20 @@
1214 * the stylesheet is the artifact, served as written, and meant to be read.
1315 * Every color is a token from `design/themes.ts`, so all six themes get every
1416 * 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.
1523 */
1624
1725 import { swatchCss, themeCss } from "../design/themes.ts";
26 import { boardCss } from "./pages/board.ts";
1827 import { base, layers, reset, utilities } from "./style/base.ts";
1928 import { components } from "./style/components.ts";
2029 import { content } from "./style/content.ts";
30 import { studioCss } from "./studio/style.ts";
2131
2232 /**
2333 * Twelve avatar hues as static rules. A name hashes to one of these buckets
@@ -36,6 +46,8 @@ export const css = [
3646 base,
3747 components,
3848 avatarHues,
49 studioCss,
50 boardCss,
3951 content,
4052 utilities,
4153 swatchCss,
test/board.test.ts +4
@@ -136,6 +136,10 @@ describe("the board", () => {
136136 expect(html).toContain('id="board-clock"');
137137 expect(html).toContain("new EventSource('/events')");
138138 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"');
139143 // A viewer who chose a theme keeps it.
140144 const paper = await (
141145 await get("/board", { cookie: `${cookie}; forge_theme=paper` })
test/studio.test.ts +35 −1
@@ -222,6 +222,32 @@ describe("the studio", () => {
222222 expect(dev).toContain(">Commits<");
223223 });
224224
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
225251 test("telling the crew opens an issue and hands it to a builder", async () => {
226252 const told = await post("/alice/notes/tell", {
227253 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", () => {
29773003 const page = await (
29783004 await get(`/alice/engine/runs/${run.id}`, writerCookie)
29793005 ).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"');
29813015 expect(page).not.toContain('id="run-stop"');
29823016 expect(page).not.toContain(">Stop<");
29833017 expect(page).not.toContain(">Cancel run<");
test/web-ui.test.ts +3
@@ -192,6 +192,9 @@ describe("web ui", () => {
192192 expect(page).toContain("lc-d73a4a");
193193 expect(page).toContain(".lc-d73a4a{--label:#d73a4a}");
194194 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>/);
195198
196199 // And the list can filter by it.
197200 const filtered = await (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs