@runbooks/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -0
  2. package/dist/color.d.ts +17 -0
  3. package/dist/color.js +24 -0
  4. package/dist/geometry.test.d.ts +1 -0
  5. package/dist/geometry.test.js +155 -0
  6. package/dist/icons.d.ts +69 -0
  7. package/dist/icons.js +103 -0
  8. package/dist/icons.test.d.ts +1 -0
  9. package/dist/icons.test.js +140 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.js +9 -0
  12. package/dist/mark.d.ts +42 -0
  13. package/dist/mark.js +102 -0
  14. package/dist/primitives.d.ts +75 -0
  15. package/dist/primitives.js +126 -0
  16. package/dist/primitives.test.d.ts +1 -0
  17. package/dist/primitives.test.js +134 -0
  18. package/dist/render.d.ts +97 -0
  19. package/dist/render.js +1085 -0
  20. package/dist/render.test.d.ts +1 -0
  21. package/dist/render.test.js +179 -0
  22. package/dist/specimen.d.ts +2 -0
  23. package/dist/specimen.gen.d.ts +1 -0
  24. package/dist/specimen.gen.js +9 -0
  25. package/dist/specimen.js +81 -0
  26. package/dist/stylesheet.d.ts +95 -0
  27. package/dist/stylesheet.js +987 -0
  28. package/dist/stylesheet.test.d.ts +1 -0
  29. package/dist/stylesheet.test.js +265 -0
  30. package/dist/text.d.ts +28 -0
  31. package/dist/text.js +89 -0
  32. package/dist/tokens.d.ts +104 -0
  33. package/dist/tokens.js +142 -0
  34. package/dist/tokens.test.d.ts +1 -0
  35. package/dist/tokens.test.js +125 -0
  36. package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
  37. package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
  38. package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
  39. package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
  40. package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
  41. package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
  42. package/fonts/LICENSE.txt +93 -0
  43. package/package.json +40 -0
@@ -0,0 +1,987 @@
1
+ /**
2
+ * The tokens, as the stylesheet the pages actually get.
3
+ *
4
+ * D-01 produced `PALETTES`, `TYPE_SCALE`, `FONT_STACKS` and `NUMERIC_FEATURES`, and its
5
+ * contrast tests measured them against each other. Nothing consumed them. The site
6
+ * shipped with no stylesheet at all — default serif, no measure, no tabular figures — so
7
+ * every token in this package was a value the product had agreed on and never applied,
8
+ * and the tests passed because they were checking the agreement rather than the page.
9
+ *
10
+ * Emitted rather than written beside the tokens, for the reason the rest of this
11
+ * repository keeps arriving at: a hand-kept CSS file with `#141A1F` in it is a second
12
+ * copy of a decision, and the copy is the one that goes stale. Every colour, size and
13
+ * stack below is read from the token objects; a test asserts the output contains no
14
+ * literal that did not come from one.
15
+ *
16
+ * §18.2's prohibitions are kept the way that file keeps them — by having nothing to
17
+ * reach for. There is no shadow token, no gradient except the risk legend's, and no
18
+ * uppercase transform, so none of the three can be spelled here.
19
+ */
20
+ import { PALETTES, RISK_COLORS, TYPE_SCALE, FONT_STACKS, NUMERIC_FEATURES, NODE_RADIUS } from "./tokens.js";
21
+ import { RISK_ORDER } from "@runbooks/schema";
22
+ /** Where the self-hosted faces are served from. */
23
+ export const FONT_DIR = "/fonts";
24
+ /**
25
+ * Where they are kept, relative to this package.
26
+ *
27
+ * Source, not build output: `apps/web/public` is emitted in its entirety and gitignored
28
+ * as such, so a font left there would vanish on a fresh clone and the site would render
29
+ * in the fallback stack with nothing failing. The package that declares the faces owns
30
+ * the files, and the catalog build copies them out with the rest of what it serves.
31
+ */
32
+ export const FONT_SOURCE_DIR = "fonts";
33
+ /**
34
+ * The faces, and only the ones the type scale asks for.
35
+ *
36
+ * 400 and 500 are the scale's two weights; 600 is what `strong` needs and italic is what
37
+ * `em` needs, and synthesising either from a regular face is how a careful type choice
38
+ * ends up looking careless. Six files, Latin1 subsets, ~124 kB in total.
39
+ */
40
+ export const FACES = [
41
+ { family: "IBM Plex Sans", file: "IBMPlexSans-Regular-Latin1.woff2", weight: 400, style: "normal" },
42
+ { family: "IBM Plex Sans", file: "IBMPlexSans-Italic-Latin1.woff2", weight: 400, style: "italic" },
43
+ { family: "IBM Plex Sans", file: "IBMPlexSans-Medium-Latin1.woff2", weight: 500, style: "normal" },
44
+ { family: "IBM Plex Sans", file: "IBMPlexSans-SemiBold-Latin1.woff2", weight: 600, style: "normal" },
45
+ { family: "IBM Plex Mono", file: "IBMPlexMono-Regular-Latin1.woff2", weight: 400, style: "normal" },
46
+ { family: "IBM Plex Mono", file: "IBMPlexMono-SemiBold-Latin1.woff2", weight: 600, style: "normal" },
47
+ ];
48
+ /**
49
+ * Upstream's own range for the Latin1 split, copied rather than narrowed.
50
+ *
51
+ * It carries U+2013-2014 and U+2018-201E and U+2026 — the dashes, the curly quotes and
52
+ * the ellipsis this catalog's prose is full of. A range invented here would be a
53
+ * subsetting decision made twice, and the half that is wrong shows up as one character in
54
+ * a different typeface in the middle of a sentence.
55
+ */
56
+ export const LATIN1_RANGE = "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, " +
57
+ "U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, " +
58
+ "U+20AC, U+2122, U+2212, U+FB01-FB02";
59
+ function fontFaces() {
60
+ return FACES.map((face) => `@font-face{
61
+ font-family:"${face.family}";
62
+ font-style:${face.style};
63
+ font-weight:${face.weight};
64
+ font-display:swap;
65
+ src:url("${FONT_DIR}/${face.file}") format("woff2");
66
+ unicode-range:${LATIN1_RANGE};
67
+ }`).join("\n");
68
+ }
69
+ /** One theme's palette as custom properties. */
70
+ function palette(theme) {
71
+ const values = PALETTES[theme];
72
+ return [
73
+ `--rb-background:${values.background}`,
74
+ `--rb-surface:${values.surface}`,
75
+ `--rb-border:${values.border}`,
76
+ `--rb-text:${values.text}`,
77
+ `--rb-text-secondary:${values.textSecondary}`,
78
+ `--rb-trust:${values.trust}`,
79
+ /**
80
+ * The risk scale as properties too, so a graph follows the theme.
81
+ *
82
+ * The SVG is server-rendered — §18.4 puts it above the fold and a picture that waits
83
+ * for script is invisible during the incident it is opened in — so it is drawn once,
84
+ * before anyone has chosen anything. Emitting `var(--rb-risk-…)` with the drawn
85
+ * theme's value as the fallback is what lets one drawing serve both: in a page it
86
+ * takes the theme in force, and exported on its own it keeps the colours it was
87
+ * drawn with.
88
+ */
89
+ ...RISK_ORDER.map((risk) => `--rb-risk-${risk}:${RISK_COLORS[theme][risk]}`),
90
+ ].join(";");
91
+ }
92
+ /** The type scale as custom properties, in the units the scale is stated in. */
93
+ function typeScale() {
94
+ return Object.entries(TYPE_SCALE)
95
+ .flatMap(([name, step]) => {
96
+ const kebab = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
97
+ const parts = [
98
+ `--rb-${kebab}-size:${step.size}px`,
99
+ `--rb-${kebab}-leading:${step.leading}px`,
100
+ `--rb-${kebab}-weight:${step.weight}`,
101
+ ];
102
+ if ("maxLineLength" in step)
103
+ parts.push(`--rb-measure:${step.maxLineLength}ch`);
104
+ return parts;
105
+ })
106
+ .join(";");
107
+ }
108
+ /**
109
+ * The whole stylesheet.
110
+ *
111
+ * Deliberately one string rather than a file Next imports. A generated file on disk is a
112
+ * file somebody edits, and then the tokens and the page disagree again with nothing to
113
+ * catch it. Inlined in the document head it also costs no second request and cannot
114
+ * flash unstyled, which on a static catalog of this size is the better trade anyway.
115
+ */
116
+ export function stylesheet(options) {
117
+ return `${fontFaces()}
118
+
119
+ :root{
120
+ ${palette(options.defaultTheme)};
121
+ ${typeScale()};
122
+ --rb-sans:${FONT_STACKS.sans};
123
+ --rb-mono:${FONT_STACKS.mono};
124
+ --rb-radius:${NODE_RADIUS}px;
125
+ --rb-step:8px;
126
+ /* The page, which is not the measure: prose is capped, a diagram is not. */
127
+ color-scheme:${options.defaultTheme === "dark" ? "dark light" : "light dark"};
128
+ }
129
+
130
+ /**
131
+ * The other theme is reached by choosing it, not by an operating-system setting.
132
+ *
133
+ * Following \`prefers-color-scheme\` made the catalog's default whatever the desktop
134
+ * happened to be set to, which is not a default at all — the site had one look on this
135
+ * machine and another on the next, and neither was the one it was designed in.
136
+ *
137
+ * Q10 refused to *lock* a theme, and that still holds: the toggle in the header sets
138
+ * \`data-theme\` and it wins, in either direction, and both palettes are measured against
139
+ * the same contrast floors. What changed is only which one you get before you choose.
140
+ */
141
+ :root[data-theme="light"]{ ${palette("light")}; color-scheme:light; }
142
+ :root[data-theme="dark"]{ ${palette("dark")}; color-scheme:dark; }
143
+
144
+ *,*::before,*::after{box-sizing:border-box;}
145
+
146
+ body{
147
+ margin:0;
148
+ background:var(--rb-background);
149
+ color:var(--rb-text);
150
+ font-family:var(--rb-sans);
151
+ font-size:var(--rb-body-size);
152
+ line-height:var(--rb-body-leading);
153
+ font-weight:var(--rb-body-weight);
154
+ /* Numbers appear in sequence in every table, every trust level and every step id. */
155
+ font-variant-numeric:${NUMERIC_FEATURES};
156
+ -webkit-text-size-adjust:100%;
157
+ }
158
+
159
+ /**
160
+ * The page is as wide as the screen, and the padding is the only thing that scales.
161
+ *
162
+ * Two different limits, and conflating them cost the site most of its display twice over.
163
+ * First a single column at the measure made the graph, the table and the step list all as
164
+ * narrow as a paragraph. Then a 1180px shell fixed that for a laptop and spent two thirds
165
+ * of a 4K display on margins.
166
+ *
167
+ * So width belongs to the page, and each kind of content is held by the rule that suits
168
+ * it: prose by --rb-measure, cards by the width one card needs before it stops being
169
+ * readable, the graph by its column. The header is inside this rule so the navigation
170
+ * runs edge to edge — its underline is a line across the screen rather than a line across
171
+ * a column floating in the middle of one.
172
+ *
173
+ * A clamp rather than breakpoints: 16px of padding on a phone, growing with the viewport,
174
+ * and stopping at 80px so a very large screen does not push the content into a frame.
175
+ */
176
+ header,main{
177
+ max-width:none;
178
+ margin:0;
179
+ padding:0 clamp(calc(var(--rb-step) * 2), 3vw, calc(var(--rb-step) * 10));
180
+ }
181
+
182
+ header{
183
+ display:flex;
184
+ flex-wrap:wrap;
185
+ gap:var(--rb-step) calc(var(--rb-step) * 2);
186
+ align-items:baseline;
187
+ justify-content:space-between;
188
+ padding-top:calc(var(--rb-step) * 3);
189
+ padding-bottom:calc(var(--rb-step) * 2);
190
+ border-bottom:1px solid var(--rb-border);
191
+ margin-bottom:calc(var(--rb-step) * 4);
192
+ }
193
+ /* The nav ran together as one word without this: "runbooks.directoryCatalogTrust…". */
194
+ header nav{ display:flex; flex-wrap:wrap; gap:calc(var(--rb-step) * 2); }
195
+ header nav[aria-label="For machines"]{
196
+ gap:var(--rb-step);
197
+ font-family:var(--rb-mono);
198
+ font-size:var(--rb-meta-size);
199
+ }
200
+ header nav[aria-label="For machines"] a{ color:var(--rb-text-secondary); }
201
+ header [data-slot="theme"]{
202
+ padding:calc(var(--rb-step) * 0.3) calc(var(--rb-step) * 0.9);
203
+ font-size:var(--rb-meta-size);
204
+ font-family:var(--rb-sans);
205
+ color:var(--rb-text-secondary);
206
+ }
207
+
208
+ main{ padding-bottom:calc(var(--rb-step) * 8); }
209
+
210
+ /*
211
+ * A long title has to break somewhere rather than push the page sideways — but only where
212
+ * it must.
213
+ *
214
+ * This was anywhere on every piece of prose, and that value does more than permit a
215
+ * break: it makes the narrowest letter the element's minimum width, so a table column
216
+ * collapses to nothing and takes the ordinary words with it. The status page printed
217
+ * "not meas / ured" in a column that had a whole screen beside it.
218
+ */
219
+ h1,h2,h3,p,li,dt,dd,td,th{ overflow-wrap:break-word; }
220
+
221
+ /*
222
+ * A ref, a capability URN and a content hash carry no break of their own and are each
223
+ * longer than a phone is wide, so these may break anywhere. They are also the only things
224
+ * on the page that need to.
225
+ */
226
+ code,kbd,samp{ overflow-wrap:anywhere; }
227
+
228
+ h1{
229
+ font-size:var(--rb-page-title-size);
230
+ line-height:var(--rb-page-title-leading);
231
+ font-weight:var(--rb-page-title-weight);
232
+ margin:0 0 calc(var(--rb-step) * 2);
233
+ letter-spacing:-0.01em;
234
+ }
235
+ h2{
236
+ font-size:var(--rb-section-title-size);
237
+ line-height:var(--rb-section-title-leading);
238
+ font-weight:var(--rb-section-title-weight);
239
+ margin:calc(var(--rb-step) * 5) 0 calc(var(--rb-step) * 1.5);
240
+ }
241
+ h3{
242
+ font-size:var(--rb-body-size);
243
+ line-height:var(--rb-body-leading);
244
+ font-weight:600;
245
+ margin:calc(var(--rb-step) * 3) 0 var(--rb-step);
246
+ }
247
+
248
+ p,li{ max-width:var(--rb-measure); }
249
+ p{ margin:0 0 calc(var(--rb-step) * 1.5); }
250
+ ul,ol{ margin:0 0 calc(var(--rb-step) * 2); padding-left:calc(var(--rb-step) * 3); }
251
+ li{ margin-bottom:calc(var(--rb-step) * 0.5); }
252
+
253
+ a{ color:var(--rb-trust); text-underline-offset:2px; }
254
+ a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{
255
+ outline:2px solid var(--rb-trust);
256
+ outline-offset:2px;
257
+ }
258
+
259
+ strong{ font-weight:600; }
260
+
261
+ code,kbd,pre,samp{
262
+ font-family:var(--rb-mono);
263
+ font-size:var(--rb-code-size);
264
+ line-height:var(--rb-code-leading);
265
+ font-variant-numeric:${NUMERIC_FEATURES};
266
+ }
267
+ code{
268
+ background:var(--rb-surface);
269
+ border:1px solid var(--rb-border);
270
+ border-radius:var(--rb-radius);
271
+ padding:0.1em 0.35em;
272
+ }
273
+ pre{
274
+ background:var(--rb-surface);
275
+ border:1px solid var(--rb-border);
276
+ border-radius:var(--rb-radius);
277
+ padding:calc(var(--rb-step) * 2);
278
+ overflow-x:auto;
279
+ }
280
+ pre code{ background:none; border:0; padding:0; }
281
+
282
+ /* Meta text: provenance, trust lines, counts. Secondary, never smaller than the scale. */
283
+ dl,[data-slot="fidelity"],[data-slot="pointer"],[data-slot="graph-note"],[data-section="trust"] p{
284
+ font-size:var(--rb-meta-size);
285
+ line-height:var(--rb-meta-leading);
286
+ color:var(--rb-text-secondary);
287
+ }
288
+ /*
289
+ * The value column may shrink below what is in it.
290
+ *
291
+ * A bare 1fr track never goes under its content's min-content width, and provenance holds
292
+ * source URLs, which have no break in them. At 390px the track sized itself to the longest
293
+ * URL - 471px inside a 263px list - and the record page scrolled sideways to 604px.
294
+ * Every other grid in this stylesheet was already written minmax(0,...); this was the one
295
+ * that was not, and it is the only page-level overflow the phone pass found.
296
+ */
297
+ dl{ display:grid; grid-template-columns:auto minmax(0,1fr); gap:var(--rb-step) calc(var(--rb-step) * 2); }
298
+ dt{ color:var(--rb-text); font-weight:500; }
299
+ dd{ margin:0; }
300
+
301
+ /* A wide table scrolls inside its own box; it never widens the page. */
302
+ table{ border-collapse:collapse; font-variant-numeric:${NUMERIC_FEATURES}; width:100%; }
303
+ main :where(table){ display:block; overflow-x:auto; }
304
+ th,td{ border-bottom:1px solid var(--rb-border); padding:var(--rb-step) calc(var(--rb-step) * 2) var(--rb-step) 0; text-align:left; vertical-align:top; }
305
+ th{ font-weight:500; }
306
+ /*
307
+ * Prose is prose in a cell too.
308
+ *
309
+ * The measure above is set on p and li, which is where prose lives on every page but one.
310
+ * The status table holds its sentences in the cell directly, so its definition column ran
311
+ * at about 120 characters against the 75 the design holds everywhere else - the widest
312
+ * line on the site, on the page whose subject is honesty about itself. The table gets
313
+ * taller and every line stays readable, which is the trade the measure exists to make.
314
+ */
315
+ td{ max-width:var(--rb-measure); }
316
+
317
+ /* Anything the build hands over as markup: the graph, a card, a specimen. */
318
+ svg{ max-width:100%; height:auto; }
319
+
320
+ /**
321
+ * The step being read, marked in both places at once.
322
+ *
323
+ * Set by a script and by nothing else: absent, every node keeps its own weight and the
324
+ * page is what it was. The graph already draws a hovered node heavier, so this is the same
325
+ * emphasis reaching the same element from the prose — weight, never hue, because hue is
326
+ * the risk channel and nothing else may borrow it (§18.2).
327
+ */
328
+ [data-slot="graph-column"][data-reading] [data-node]>path:first-of-type{ stroke-opacity:.45; }
329
+ [data-slot="graph-column"][data-reading] [data-node][data-reading="true"]>path:first-of-type{
330
+ stroke-opacity:1;
331
+ stroke-width:2.6;
332
+ }
333
+ /* A rule, not a shadow: §18.2 has no shadows to reach for, and this is the trust accent
334
+ doing what it does everywhere else — marking the thing the reader is on. */
335
+ /* Drawn as a 2px rule plus 14px of padding, pulled back by the 16px that adds up to, so
336
+ marking a step moves nothing on the page. */
337
+ [data-step]{ border-left:2px solid transparent; padding-left:calc(var(--rb-step) * 2 - 2px); margin-left:calc(var(--rb-step) * -2); }
338
+ [data-step][data-reading="true"]{ border-left-color:var(--rb-trust); }
339
+
340
+ /**
341
+ * A tool page: the thing you are working on, and everything about it beside it.
342
+ *
343
+ * The submit page put a document editor down the left and its gate list, its promise and
344
+ * its instructions underneath, one after another, with two thirds of the screen empty
345
+ * next to them — so a reader fixing a failing gate scrolled away from the document to read
346
+ * which gate had failed. Drawn in the canvas as two columns for that reason.
347
+ *
348
+ * 420px is the canvas's number. The rule shipped 32rem — 512px — from the commit that made
349
+ * these two columns, half an hour after the artboard landed and without a word about the
350
+ * width, and the parity check for this page compared that there were two columns without
351
+ * comparing how wide. The record page's check does compare the number, which is why its
352
+ * 600px never drifted. Owner's call, 2026-09-05: the drawing is right.
353
+ */
354
+ [data-layout="tool"]{
355
+ display:grid;
356
+ grid-template-columns:minmax(0,1fr) minmax(0,420px);
357
+ column-gap:calc(var(--rb-step) * 5);
358
+ align-items:start;
359
+ }
360
+ /*
361
+ * Placement through :where(), so the narrow-screen rule below can take it back.
362
+ *
363
+ * It could not before: a selector naming the slot outranks one naming every child, so
364
+ * grid-column:2 survived the media query, column 2 became an implicit auto track, and the
365
+ * 1fr had nothing left to distribute. Measured in a 390px viewport, the work column - the
366
+ * matcher on /stack, the paste field on /import, the gate runner on /submit - was 0 pixels
367
+ * wide. Not narrow: gone.
368
+ *
369
+ * :where() contributes no specificity, so these place the panels and the media query wins
370
+ * on source order, which is what a media query is for.
371
+ */
372
+ [data-layout="tool"]>:where(h1){ grid-column:1 / -1; grid-row:1; }
373
+ [data-layout="tool"]>:where(p){ grid-column:1 / -1; grid-row:2; }
374
+ /*
375
+ * The wide column is named by where it sits, not by what one page happens to put in it.
376
+ *
377
+ * It was [data-section="run"], which is the gate runner on /submit and nothing at all on
378
+ * the other three routes this layout covers. /stack and /import shipped one column for
379
+ * that reason: the drawing had always shown two and there was no name for the left one.
380
+ */
381
+ [data-layout="tool"]>:where([data-slot="work"]){ grid-column:1; grid-row:3; }
382
+ [data-layout="tool"]>:where([data-slot="beside"]){ grid-column:2; grid-row:3; min-width:0; }
383
+
384
+ @media (max-width:1100px){
385
+ [data-layout="tool"]{ grid-template-columns:minmax(0,1fr); }
386
+ [data-layout="tool"]>*{ grid-column:1; grid-row:auto; position:static; }
387
+ }
388
+
389
+ /**
390
+ * The constructor is three panels, and the page already says so.
391
+ *
392
+ * Library, canvas, inspector: the markup names them, and stacking them put a step palette
393
+ * above an editor above an inspector, each as wide as a paragraph, on a screen with room
394
+ * for all three. The page itself warns that editing needs a wider window — this is what a
395
+ * wider window is for.
396
+ */
397
+ [data-page="constructor"]{
398
+ display:grid;
399
+ grid-template-columns:minmax(0,16rem) minmax(0,1fr) minmax(0,22rem);
400
+ column-gap:calc(var(--rb-step) * 4);
401
+ align-items:start;
402
+ }
403
+ /* Through :where() for the reason the tool layout gives above. Without it the inspector
404
+ stayed in column 3 on a phone, three tracks were computed at 390px - 0px, 114px, 219px -
405
+ and the heading, the notice and the step library were all 0 pixels wide. */
406
+ [data-page="constructor"]>:where(h1, [data-section="mobile-notice"], [data-section="draft-notice"]){ grid-column:1 / -1; }
407
+ [data-page="constructor"]>:where([data-panel="library"]){ grid-column:1; }
408
+ [data-page="constructor"]>:where([data-panel="canvas"]){ grid-column:2; }
409
+ [data-page="constructor"]>:where([data-panel="inspector"]){ grid-column:3; }
410
+
411
+ /* Below this the three do not fit, and the page is honest about being read-only there. */
412
+ @media (max-width:1100px){
413
+ [data-page="constructor"]{ grid-template-columns:minmax(0,1fr); }
414
+ [data-page="constructor"]>*{ grid-column:1; position:static; }
415
+ }
416
+
417
+ /**
418
+ * A record page is two things read together, so it is two columns.
419
+ *
420
+ * The graph and the prose about it were stacked, which meant that reading step five put
421
+ * the picture of the procedure off the top of the screen — exactly when a reader wants to
422
+ * see where step five sits. The graph column is sticky, and that is the whole reason for
423
+ * the layout: it stays while the steps scroll past it.
424
+ *
425
+ * Width goes to the graph, not the prose. Prose is capped by its measure whatever the
426
+ * column allows, so surplus width does nothing for it and everything for a diagram.
427
+ *
428
+ * The heading spans both columns and comes first in the markup, so a reader with no CSS
429
+ * meets the record's name, then its picture, then its prose — the order the page had
430
+ * before this rule existed. The grid is what arranges them; without it they stack.
431
+ */
432
+ [data-layout="record"]{
433
+ display:grid;
434
+ grid-template-columns:minmax(0,1fr) minmax(0,600px);
435
+ align-items:start;
436
+ }
437
+ [data-layout="record"]>:where([data-slot="head"]){ grid-column:1 / -1; }
438
+
439
+ /**
440
+ * The graph column is a panel the height of the screen, and the rule between the columns
441
+ * is what separates them — not a gap. Drawn this way in the canvas, and the height is what
442
+ * lets the picture be fitted rather than panned: a graph that fills the column needs no
443
+ * scrollbar of its own, and the page keeps the only one on the screen.
444
+ */
445
+ [data-layout="record"]>[data-slot="graph-column"]{
446
+ min-width:0;
447
+ display:flex;
448
+ flex-direction:column;
449
+ border-right:1px solid var(--rb-border);
450
+ }
451
+ /*
452
+ * The prose is what stays put.
453
+ *
454
+ * The graph is the long thing now that it takes the width it is given, so sticking it
455
+ * would pin the tall half and scroll the short one. The steps are the reference a reader
456
+ * keeps glancing at, so they are the half that stays while the picture goes past. Nothing
457
+ * here scrolls on its own: the page has the only scrollbar, and a column taller than the
458
+ * screen simply travels with it.
459
+ */
460
+ [data-layout="record"]>[data-slot="prose-column"]{
461
+ min-width:0;
462
+ padding:calc(var(--rb-step) * 5) calc(var(--rb-step) * 5) calc(var(--rb-step) * 12);
463
+ }
464
+
465
+ /**
466
+ * The right column stays put, on every page that has one.
467
+ *
468
+ * The record page had this rule and no other page did, so the same two-column reading —
469
+ * picture on the left, what is said about it on the right — behaved one way on a record
470
+ * and another way on the dry pass, the home page and the submit page. The reason given
471
+ * for the record is not about records: the left half is the long one, so sticking it
472
+ * would pin the tall column and scroll the short one.
473
+ *
474
+ * One declaration rather than five, because it is one rule. Each layout below only says
475
+ * which of its children is the right column; what happens to it is decided here.
476
+ *
477
+ * Every one of these selectors names a SINGLE element. A sticky column has to be one
478
+ * element: a grid whose right-hand children are flat, each sticky on its own, pins them
479
+ * all to the same top edge and stacks them on top of each other. That is why the markup
480
+ * of the pages that had flat children now wraps them.
481
+ *
482
+ * Nothing scrolls on its own: the page keeps the only scrollbar, and a column taller than
483
+ * its own row simply travels with the page — sticky gives it no room to move, which is
484
+ * the correct behaviour rather than a special case.
485
+ */
486
+ [data-layout="record"]>[data-slot="prose-column"],
487
+ [data-section="simulation"]>[data-slot="beside"],
488
+ [data-section="hero"]>[data-slot="beside"],
489
+ [data-layout="tool"]>[data-slot="beside"],
490
+ [data-page="constructor"]>[data-panel="inspector"]{
491
+ position:sticky;
492
+ top:0;
493
+ align-self:start;
494
+ }
495
+
496
+ /*
497
+ * And it stops staying put where there is no second column to stay beside.
498
+ *
499
+ * One declaration takes back the one above, with the same selectors so it wins on source
500
+ * order rather than on a specificity trick. Each of these layouts already collapses to one
501
+ * column at this width; the sticky rule did not follow, because the position:static in
502
+ * those media queries names every child and lost to the five selectors above.
503
+ *
504
+ * What that looked like: on a phone the record page stacked its two halves and then pinned
505
+ * the prose to the top edge, so scrolling drew the rest of the page underneath it.
506
+ */
507
+ @media (max-width:1100px){
508
+ [data-layout="record"]>[data-slot="prose-column"],
509
+ [data-section="simulation"]>[data-slot="beside"],
510
+ [data-section="hero"]>[data-slot="beside"],
511
+ [data-layout="tool"]>[data-slot="beside"],
512
+ [data-page="constructor"]>[data-panel="inspector"]{
513
+ position:static;
514
+ }
515
+ }
516
+
517
+ /*
518
+ * The picture first and the controls under it, which is the order the canvas draws.
519
+ *
520
+ * A grid rather than a flex column, and the reason is the fitting below: a percentage
521
+ * height resolves against a grid row that was given a size, and does not resolve against
522
+ * a flex item that merely ended up one. Measured in the browser both ways — flexed, the
523
+ * picture kept its own 1064px inside a 664px frame and was clipped.
524
+ */
525
+ [data-layout="record"] [data-slot="graph-column"] [data-section="graph-viewer"]{
526
+ min-height:0;
527
+ display:grid;
528
+ grid-template-rows:auto auto;
529
+ }
530
+ /* The step being read, named at the end of the bar the way the canvas names it. */
531
+ [data-layout="record"] [data-slot="graph-column"] [data-slot="reading"]{
532
+ margin-left:auto;
533
+ color:var(--rb-trust);
534
+ font-family:var(--rb-mono);
535
+ font-size:var(--rb-meta-size);
536
+ }
537
+ [data-layout="record"] [data-slot="graph-column"] [data-slot="controls"]{
538
+ order:1;
539
+ margin:0;
540
+ border-top:1px solid var(--rb-border);
541
+ padding:calc(var(--rb-step) * 1.5) calc(var(--rb-step) * 3);
542
+ }
543
+ [data-layout="record"] [data-slot="graph-column"] [data-slot="frame"]{
544
+ min-height:0;
545
+ max-height:none;
546
+ border:0;
547
+ border-radius:0;
548
+ background:transparent;
549
+ display:flex;
550
+ align-items:center;
551
+ justify-content:center;
552
+ padding:calc(var(--rb-step) * 3);
553
+ }
554
+ /*
555
+ * A graph takes the width it is given, and its container takes its height. Everywhere.
556
+ *
557
+ * This was written inside the record layout and left every other graph — the dry pass, a
558
+ * full-screen view — drawn at its own size in the left third of a frame that ran the width
559
+ * of the screen. The default is the rule; the record page only adds the border and the
560
+ * order of the controls.
561
+ *
562
+ * Nothing drags and nothing scrolls until somebody zooms in: at rest the whole picture is
563
+ * there, so the page keeps the only scrollbar on the screen.
564
+ *
565
+ * And the page keeps the wheel too. Hidden overflow leaves the frame a scroll container
566
+ * that cannot scroll, and contained overscroll stops the chaining out of it, so a wheel
567
+ * over the graph did nothing at all: not the picture, not the page. Clipping is the same
568
+ * visual result and is not a scroll container, and touch is handed back for the state that
569
+ * has no gesture to protect.
570
+ *
571
+ * Containment is gone from the frame entirely, and that is the fix rather than a detail of
572
+ * it. It was written for a pan that runs past its end, and it also fires wherever the
573
+ * frame happens not to be scrollable — which the record layout guarantees, because there
574
+ * the frame grows instead of scrolling. Measured in a browser: zoomed in, the wheel moved
575
+ * neither the picture nor the page. Chaining at the end of a scroll is what a reader
576
+ * expects; a wheel that does nothing is not.
577
+ */
578
+ [data-section="graph-viewer"] [data-slot="frame"][data-zoomed="false"]{
579
+ overflow:clip;
580
+ cursor:default;
581
+ max-height:none;
582
+ touch-action:auto;
583
+ }
584
+ [data-section="graph-viewer"] [data-slot="frame"][data-zoomed="false"] [data-slot="canvas"],
585
+ [data-section="graph-viewer"] [data-slot="frame"][data-zoomed="false"] [data-slot="scaled"]{
586
+ display:block;
587
+ min-width:0;
588
+ width:100%;
589
+ height:auto;
590
+ }
591
+ /*
592
+ * Width leads and height follows: a graph is drawn to be read, and a picture that fits by
593
+ * height leaves half its column empty beside a tall procedure. It still may not exceed the
594
+ * screen, so the height is capped rather than fixed — below that cap the width is what
595
+ * decides.
596
+ */
597
+ /*
598
+ * The picture fills the column it was given.
599
+ *
600
+ * This said down-to-fit-never-up for a while, and the reason was real: the renderer picks
601
+ * the drawing size for legibility — node widths, and type at 14 — so scaling the picture
602
+ * up scales its labels with it, and a five-node dry pass across a wide column comes out
603
+ * with type larger than the page's own headings. The trade was made the other way on
604
+ * purpose: a graph that stops halfway across its column reads as a picture that failed to
605
+ * load, and half an empty column is a worse answer than large labels. The viewBox does the
606
+ * scaling, so the drawing keeps its proportions and nothing is cropped.
607
+ */
608
+ [data-section="graph-viewer"] [data-slot="frame"][data-zoomed="false"] svg{
609
+ width:100%;
610
+ height:auto;
611
+ }
612
+
613
+ /**
614
+ * One column below the breakpoint, and nothing sticky.
615
+ *
616
+ * A sticky graph on a phone is a graph occupying the screen the prose needs. The two
617
+ * columns become the order they are written in, which is the order they read in.
618
+ */
619
+ @media (max-width:1100px){
620
+ [data-layout="record"]{ grid-template-columns:minmax(0,1fr); }
621
+ [data-layout="record"]>*{ grid-column:1; position:static; }
622
+ /*
623
+ * One column has nothing to separate, and nothing to indent away from.
624
+ *
625
+ * The rule down the graph column is what stands between the two columns. Stacked, it
626
+ * became a vertical line down the right edge of a full-width block, separating the page
627
+ * from the window. The prose column's own 40px sat on top of the page padding, which
628
+ * already tightens with the viewport: measured at 390px, 56 pixels a side went to
629
+ * margin, leaving 259 for the text.
630
+ */
631
+ [data-layout="record"]>[data-slot="graph-column"]{ border-right:0; }
632
+ [data-layout="record"]>[data-slot="prose-column"]{
633
+ padding:calc(var(--rb-step) * 3) 0 calc(var(--rb-step) * 6);
634
+ }
635
+ }
636
+
637
+ /*
638
+ * The composition, drawn member by member.
639
+ *
640
+ * The playbook borrows the record's grid, so the column and the sticky prose beside it
641
+ * come for free; what is its own is the stack inside. Each member is a figure with its
642
+ * caption above the picture, because the caption says whose procedure this is and whose
643
+ * trust level it carries — read after the drawing it names, it is a footnote to something
644
+ * the reader has already taken on faith.
645
+ *
646
+ * The pictures take the column's width the way every other graph on the site does.
647
+ */
648
+ [data-page="playbook"] [data-slot="graph-column"] [data-section="drawn"]{
649
+ padding:calc(var(--rb-step) * 3);
650
+ }
651
+ [data-page="playbook"] figure[data-member-picture]{
652
+ margin:0 0 calc(var(--rb-step) * 4);
653
+ }
654
+ [data-page="playbook"] figure[data-member-picture] figcaption{
655
+ margin-bottom:var(--rb-step);
656
+ font-family:var(--rb-mono);
657
+ font-size:var(--rb-meta-size);
658
+ color:var(--rb-text-secondary);
659
+ }
660
+ [data-page="playbook"] figure[data-member-picture] svg{ width:100%; height:auto; }
661
+
662
+ /**
663
+ * The dry pass reads in two columns, because it was drawn in two.
664
+ *
665
+ * The picture and the questions about it are the record page's shape again: width belongs
666
+ * to the graph, and what a reader answers stays beside it rather than under it. The
667
+ * artboard has had these two columns since the page was designed; the page shipped as one,
668
+ * and nothing compared them, so a reader answered a fork with the graph scrolled off the
669
+ * top — which is the moment the picture matters most.
670
+ *
671
+ * Placed rather than wrapped: the runner's children are flat, so the picture takes the
672
+ * first column and everything else the second, the way the hero does. Neither half is
673
+ * sticky: the graph is bounded and the answers are short, so there is nothing long enough
674
+ * to travel past the other.
675
+ */
676
+ [data-section="simulation"]{
677
+ display:grid;
678
+ grid-template-columns:minmax(0,1fr) minmax(0,380px);
679
+ column-gap:calc(var(--rb-step) * 5);
680
+ align-items:start;
681
+ }
682
+ [data-section="simulation"]>[data-slot="picture"]{ grid-column:1; grid-row:1; }
683
+ [data-section="simulation"]>[data-slot="beside"]{ grid-column:2; grid-row:1; }
684
+
685
+ @media (max-width:1100px){
686
+ [data-section="simulation"]{ grid-template-columns:minmax(0,1fr); }
687
+ [data-section="simulation"]>*{ grid-column:1; grid-row:auto; position:static; }
688
+ }
689
+
690
+ /**
691
+ * The home page's hero graph, bounded by height.
692
+ *
693
+ * §18.4 puts the graph above the fold, and a width cap alone does not keep it there: a
694
+ * procedure laid out top to bottom is a thousand pixels tall, so scaling it to the column
695
+ * width still leaves a picture that runs off the first screen and pushes everything the
696
+ * page has to say below it. The hero had no rule of its own at all — it was drawn at
697
+ * whatever size the renderer produced, and the day the layout turned vertical it stopped
698
+ * being a poster and became a wall.
699
+ *
700
+ * Height is the constraint, width follows it, and the viewBox does the scaling. It is a
701
+ * poster rather than a document: the reader is meant to see that a procedure is a graph
702
+ * with a branch and a dangerous step in it, and to open the record for the rest.
703
+ */
704
+ /**
705
+ * The hero is the graph and the words about it, side by side.
706
+ *
707
+ * Centred in a full-width page the picture became an island: the prose sat at its measure
708
+ * on the left, the graph floated in the middle, and the space between them was the widest
709
+ * thing on the screen. Drawn in the canvas as two columns, and that is what makes the
710
+ * width worth having — the half that gains from it takes it, and the half that would be
711
+ * harmed by it keeps the measure.
712
+ */
713
+ [data-section="hero"]{
714
+ display:grid;
715
+ grid-template-columns:minmax(0,1fr) minmax(0,420px);
716
+ column-gap:calc(var(--rb-step) * 5);
717
+ align-items:start;
718
+ }
719
+ [data-section="hero"]>h2{ grid-column:1 / -1; grid-row:1; }
720
+ [data-section="hero"]>.hero-graph{ grid-column:1; grid-row:2; align-self:start; }
721
+ [data-section="hero"]>[data-slot="beside"]{ grid-column:2; grid-row:2; min-width:0; }
722
+
723
+ @media (max-width:1100px){
724
+ [data-section="hero"]{ grid-template-columns:minmax(0,1fr); }
725
+ [data-section="hero"]>*{ grid-column:1; grid-row:auto; position:static; }
726
+ }
727
+
728
+ .hero-graph{ display:flex; justify-content:center; margin:calc(var(--rb-step) * 2) 0; }
729
+ .hero-graph svg{ width:100%; height:auto; max-width:100%; max-height:min(58vh, 540px); }
730
+
731
+ /**
732
+ * The graph's frame.
733
+ *
734
+ * A bounded, scrolling box rather than a picture that sets the page's width: a procedure
735
+ * is as wide as it is, and letting it push the column out moves every paragraph on the
736
+ * page to accommodate a diagram. Panning is a pointer gesture, so text selection inside
737
+ * the frame is off — a drag that selects the labels instead of moving the graph reads as
738
+ * the page being broken.
739
+ */
740
+ [data-section="graph-viewer"]{ margin:calc(var(--rb-step) * 2) 0 calc(var(--rb-step) * 3); }
741
+ [data-section="graph-viewer"] [data-slot="frame"]{
742
+ overflow:auto;
743
+ max-height:70vh;
744
+ border:1px solid var(--rb-border);
745
+ border-radius:var(--rb-radius);
746
+ background:var(--rb-surface);
747
+ cursor:grab;
748
+ touch-action:none;
749
+ user-select:none;
750
+ -webkit-user-select:none;
751
+ }
752
+ [data-section="graph-viewer"] [data-slot="frame"]:active{ cursor:grabbing; }
753
+
754
+ /**
755
+ * On a phone the graph is a card, not the screen.
756
+ *
757
+ * Seventy per cent of the viewport is most of a phone, and a reader who came for the steps
758
+ * meets a picture they then have to scroll past. What the card has to do here is what a
759
+ * silhouette does on a catalog page: show that the procedure branches and that there is red
760
+ * in it. Reading it is the full-screen view's job, and the control that opens it is the one
761
+ * thing on the card that has to be easy to hit.
762
+ *
763
+ * Full screen keeps the whole viewport, because that is the view that exists to be read.
764
+ */
765
+ @media (max-width:700px){
766
+ [data-section="graph-viewer"] [data-slot="frame"]{ max-height:34vh; }
767
+ [data-section="graph-viewer"]:fullscreen [data-slot="frame"]{ max-height:none; }
768
+ [data-section="graph-viewer"] [data-slot="controls"]{ flex-wrap:wrap; row-gap:var(--rb-step); }
769
+ [data-section="graph-viewer"] [data-slot="hint"]{ display:none; }
770
+ }
771
+ [data-section="graph-viewer"] [data-slot="canvas"]{ width:max-content; }
772
+ [data-section="graph-viewer"] [data-slot="scaled"]{ width:max-content; }
773
+ [data-section="graph-viewer"] [data-slot="scaled"]>svg{ display:block; max-width:none; }
774
+ [data-section="graph-viewer"] [data-slot="controls"]{
775
+ display:flex;
776
+ align-items:center;
777
+ gap:var(--rb-step);
778
+ margin-bottom:var(--rb-step);
779
+ font-size:var(--rb-meta-size);
780
+ color:var(--rb-text-secondary);
781
+ }
782
+ [data-section="graph-viewer"] [data-slot="controls"] button{
783
+ min-width:2.2em;
784
+ padding:calc(var(--rb-step) * 0.4) calc(var(--rb-step) * 0.8);
785
+ }
786
+ [data-section="graph-viewer"] [data-slot="zoom"]{
787
+ font-family:var(--rb-mono);
788
+ font-variant-numeric:tabular-nums;
789
+ min-width:4.5ch;
790
+ text-align:center;
791
+ }
792
+ [data-section="graph-viewer"] [data-slot="hint"]{ margin-left:auto; }
793
+
794
+ /**
795
+ * Full screen. The browser's own mode where it exists, and a fixed cover where it does
796
+ * not — an iPhone has no element fullscreen, and a control that silently does nothing is
797
+ * worse than one that does the useful part.
798
+ */
799
+ [data-section="graph-viewer"]:fullscreen{
800
+ background:var(--rb-background);
801
+ padding:calc(var(--rb-step) * 2);
802
+ display:flex;
803
+ flex-direction:column;
804
+ }
805
+ [data-section="graph-viewer"]:fullscreen [data-slot="frame"]{ flex:1; max-height:none; }
806
+ [data-section="graph-viewer"][data-full="true"]:not(:fullscreen){
807
+ position:fixed;
808
+ inset:0;
809
+ z-index:50;
810
+ margin:0;
811
+ padding:calc(var(--rb-step) * 2);
812
+ background:var(--rb-background);
813
+ display:flex;
814
+ flex-direction:column;
815
+ }
816
+ [data-section="graph-viewer"][data-full="true"]:not(:fullscreen) [data-slot="frame"]{
817
+ flex:1;
818
+ max-height:none;
819
+ }
820
+
821
+ @media (max-width:600px){
822
+ [data-section="graph-viewer"] [data-slot="frame"]{ max-height:60vh; }
823
+ /* The hint describes a pointer nobody on a phone has. */
824
+ [data-section="graph-viewer"] [data-slot="hint"]{ display:none; }
825
+ }
826
+
827
+ /**
828
+ * A target a finger can hit.
829
+ *
830
+ * 44px is the smallest square most people can reliably tap, and the controls on this site
831
+ * are laid out for a pointer: a zoom button was 22px tall and a facet chip barely more.
832
+ * Applied where there is no fine pointer rather than at a width, because the question is
833
+ * what is doing the pointing, not how wide the screen is — a touch laptop needs this and
834
+ * a narrow desktop window does not.
835
+ */
836
+ @media (pointer:coarse){
837
+ button,
838
+ [data-chip="facet"],
839
+ summary,
840
+ header nav a,
841
+ input,
842
+ select{
843
+ min-height:44px;
844
+ }
845
+ button,[data-chip="facet"]{ min-width:44px; justify-content:center; }
846
+ /* A link inside a paragraph is read, not tapped as a control; only lists of links get
847
+ the spacing, or every article would be double-spaced. */
848
+ [data-slot="chips"]{ gap:calc(var(--rb-step) * 1.25); }
849
+ }
850
+
851
+ /**
852
+ * The catalog: facets beside the results, not above them.
853
+ *
854
+ * A sidebar at a width that fits the longest facet value and no more, and the rest of the
855
+ * shell to the records. Below the breakpoint the columns stack and the facets become a
856
+ * row of chips that wraps — which is the same markup, read the other way.
857
+ */
858
+ [data-layout="catalog"]{
859
+ display:grid;
860
+ grid-template-columns:minmax(0,15rem) minmax(0,1fr);
861
+ gap:calc(var(--rb-step) * 5);
862
+ align-items:start;
863
+ }
864
+ [data-layout="catalog"] [data-section="facets"] h2{ margin-top:0; }
865
+ [data-layout="catalog"] [data-section="results"] h2{ margin-top:0; }
866
+ [data-facet]{ margin-bottom:calc(var(--rb-step) * 2.5); }
867
+ [data-facet] h3{ margin:0 0 var(--rb-step); color:var(--rb-text-secondary); font-size:var(--rb-meta-size); font-weight:500; }
868
+
869
+ /* A navigation control is not prose, so it carries no list marker. */
870
+ [data-slot="chips"]{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap; gap:calc(var(--rb-step) * 0.75); }
871
+ [data-slot="chips"]>li{ margin:0; max-width:none; }
872
+ [data-chip="facet"]{
873
+ display:inline-flex;
874
+ align-items:baseline;
875
+ gap:calc(var(--rb-step) * 0.6);
876
+ padding:calc(var(--rb-step) * 0.35) calc(var(--rb-step) * 0.9);
877
+ border:1px solid var(--rb-border);
878
+ border-radius:999px;
879
+ background:var(--rb-surface);
880
+ text-decoration:none;
881
+ font-size:var(--rb-meta-size);
882
+ }
883
+ [data-chip="facet"]:hover{ border-color:var(--rb-trust); }
884
+ [data-chip="facet"] [data-slot="count"]{ color:var(--rb-text-secondary); font-variant-numeric:${NUMERIC_FEATURES}; }
885
+ [data-slot="not-offered"],[data-slot="reading"]{ font-size:var(--rb-meta-size); color:var(--rb-text-secondary); }
886
+ /**
887
+ * Cards reflow by how many fit, not by how wide the window was said to be.
888
+ *
889
+ * One column at any width meant a 4K display showed three cards where it has room for
890
+ * eight, and a breakpoint list would answer only for the widths somebody thought of.
891
+ * auto-fill asks the one question that matters — how many cards of a readable width fit —
892
+ * and the inner min() keeps the answer at one when the screen is narrower than a card.
893
+ */
894
+ /**
895
+ * The card leads with a silhouette, not a diagram.
896
+ *
897
+ * Its job is to say that the procedure branches and that there is red in it, at a glance
898
+ * and before a word is read. Drawn at its own size it came out as specks of different
899
+ * heights, so the cards were ragged and the shape was the one thing you could not see. A
900
+ * band of one height with the picture fitted inside gives every card the same thing to
901
+ * compare, which is what makes a row of them scannable.
902
+ */
903
+ [data-slot="mini-graph"]{
904
+ display:flex;
905
+ align-items:center;
906
+ justify-content:center;
907
+ height:calc(var(--rb-step) * 14);
908
+ overflow:hidden;
909
+ }
910
+ [data-slot="mini-graph"] svg{ max-width:100%; max-height:100%; width:auto; height:auto; }
911
+
912
+ [data-slot="cards"]{
913
+ display:grid;
914
+ gap:calc(var(--rb-step) * 2);
915
+ grid-template-columns:repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
916
+ align-items:start;
917
+ }
918
+
919
+ @media (max-width:820px){
920
+ [data-layout="catalog"]{ grid-template-columns:minmax(0,1fr); gap:calc(var(--rb-step) * 3); }
921
+ }
922
+
923
+ /*
924
+ * Two pictures over one coordinate system, side by side (W-17, §18.4).
925
+ *
926
+ * The diff page carried this attribute and the stylesheet had no rule for it, so the two
927
+ * graphs - laid out over one coordinate system precisely so they can be compared - would
928
+ * have stacked a full graph apart. Nobody saw it because the route generated no page: it
929
+ * asked for pairs from a list of one version, and the catalog kept one version. Both of
930
+ * those are fixed, so the rule has to exist before the first republication rather than
931
+ * after somebody opens the first diff.
932
+ *
933
+ * Placement through :where() and a stack below the breakpoint, for the reason the tool
934
+ * layout gives: a comparison is two columns or it is not a comparison, and on a phone it
935
+ * is one column read in order.
936
+ */
937
+ [data-section="side-by-side"]{
938
+ display:grid;
939
+ grid-template-columns:minmax(0,1fr) minmax(0,1fr);
940
+ gap:calc(var(--rb-step) * 3);
941
+ align-items:start;
942
+ }
943
+ [data-section="side-by-side"]>:where(*){ min-width:0; }
944
+
945
+ @media (max-width:1100px){
946
+ [data-section="side-by-side"]{ grid-template-columns:minmax(0,1fr); }
947
+ }
948
+
949
+ /* A pasted document, a command line, a step id: none of them may widen the page. */
950
+ pre{ max-width:100%; }
951
+ [data-slot="text"]{ overflow-x:auto; }
952
+
953
+ input,select,textarea,button{
954
+ font:inherit;
955
+ color:inherit;
956
+ background:var(--rb-surface);
957
+ border:1px solid var(--rb-border);
958
+ border-radius:var(--rb-radius);
959
+ padding:calc(var(--rb-step) * 0.75) var(--rb-step);
960
+ }
961
+ /*
962
+ * A field somebody pastes a document into is not prose.
963
+ *
964
+ * The measure keeps a paragraph readable; a document has structure, and seeing the shape
965
+ * of it is the point of pasting it here. Capped at the measure the import page showed a
966
+ * 700px field with half its column empty beside it and the button hanging off the edge.
967
+ */
968
+ textarea{ font-family:var(--rb-mono); width:100%; max-width:100%; }
969
+ button{ cursor:pointer; }
970
+ button[aria-pressed="true"]{ background:var(--rb-text); color:var(--rb-background); }
971
+ button[disabled]{ cursor:not-allowed; color:var(--rb-text-secondary); }
972
+ label{ display:block; margin:calc(var(--rb-step) * 2) 0 calc(var(--rb-step) * 0.5); font-weight:500; }
973
+
974
+ form,fieldset{ border:0; margin:0; padding:0; }
975
+ fieldset{ margin:calc(var(--rb-step) * 2) 0; }
976
+
977
+ /* §18.3 rule 1's toggle, and every other run of controls. */
978
+ [role="group"]{ display:flex; gap:var(--rb-step); margin-bottom:calc(var(--rb-step) * 2); }
979
+
980
+ details{ margin:calc(var(--rb-step) * 2) 0; }
981
+ summary{ cursor:pointer; font-weight:500; }
982
+
983
+ @media (prefers-reduced-motion:reduce){
984
+ *,*::before,*::after{ animation-duration:0.01ms !important; animation-iteration-count:1 !important; transition-duration:0.01ms !important; scroll-behavior:auto !important; }
985
+ }
986
+ `;
987
+ }