oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1{{/* show — GET /d/{sqid}, the document read view (spec §6.1/§6.2).
2 handlers.go's show builds the view data: Title is the document's
3 own title, printed as-is the same way list.html's row titles are
4 (data, not authored copy — not resolved through rastrillo.T);
5 HTML is Store.HTML's server-rendered replay of the CRDT's
6 ProseMirror fragment through wazero, never a database column, and
7 is empty for a document with no updates yet — an ordinary empty
8 canvas below, not an error. It is template.HTML, so {{.HTML}}
9 below is written out unescaped on purpose — see Store.HTML's own
10 doc comment for where that trust decision is made and why.
11
12 [docs-canvas]/[docs-page]/[docs-text] are this app's own
13 vocabulary (SKILL.md §7: "ui is rastrillo's vocabulary, not
14 yours"), not rastrillo's, so they are not spelled rst-*, same
15 reasoning as list.html's [docs-thumb].
16
17 The canvas opts out of the surrounding theme on purpose (spec
18 §10.2, the brief's canvas constraint): a document is a piece of
19 paper, and paper does not follow the reader's dark-mode
20 preference the way the chrome around it does. color-scheme:
21 light plus literal colours below — never var(--rst-surface) or
22 var(--rst-text), which would make the "page" repaint itself dark
23 along with everything else on screen.
24
25 [docs-page] is one page-shaped container, and it stays one in both
26 view modes. Paged draws desk-coloured gaps BETWEEN pages as widget
27 decorations inside the text flow (fixtures/pages.mjs) rather than
28 repeating this element.
29
30 This comment used to say a later version would repeat [docs-page]
31 once per page. It cannot: ProseMirror owns everything inside the
32 editable element ({mount: el}), so its children cannot be wrapped
33 in sibling page boxes without taking the document away from it.
34 Repeating the box is a layout-engine shape; a decoration is a
35 pagination-as-a-view shape, and a view is all pagination can be
36 while page breaks are computed from measured heights and never
37 persisted.
38
39 Writes the bare rst-* attribute grammar throughout — see
40 list.html's own comment on why, and
41 render_test.go's TestOurTemplatesUseTheAttributeGrammar, which
42 holds it against this file too. */}}
43{{/* The bar is the layout's (views/layout.html, chrome.go). On this
44 screen it carries the document's title, editable in place for anyone
45 who may write (estilo's title slot, POST /d/{sqid}/title), and the
46 owner's "Share…" action. Sign out is in the account menu and nowhere
47 else. */}}
48{{define "content"}}<link rel="stylesheet" href="{{asset "static/estilo-save-status.css"}}"><style>
49 /* This screen is the one that is not a column of content. It is a
50 document on a desk, and in both of the applications this layout
51 language comes from the desk runs to the window's edges.
52
53 [rst-page] — rastrillo's own <main>, written by the topbar shell
54 outside any block this app can override — is a 64rem centred
55 column with --rst-sp-5/--rst-sp-4/--rst-sp-6 of padding. That is
56 right for the list and wrong here, in two separate ways: it
57 leaves the grey tray as a rectangle floating in white with about
58 85px of tray showing either side of the sheet, and it holds the
59 formatting bar to 64rem when the bar belongs to the window the
60 way the shell's own bar above it does.
61
62 Cancelling the column here rather than reaching for
63 `width: 100vw` is the same choice, for the same reason, that the
64 narrow-screen block below already records: 100vw measures the
65 viewport INCLUDING a classic scrollbar, so it overhangs by the
66 scrollbar's width and starts exactly the horizontal scroll that
67 SC 1.4.10 forbids. A container told to stop padding itself
68 cannot overhang.
69
70 Scoped by delivery rather than by selector: this stylesheet is
71 inside show.html's own "content" and ships only with this page,
72 so the rule reaches neither the list nor sign-in. It is written
73 bare — no `[docs-canvas] ~` dance — because a page-wide rule
74 that pretends to be scoped is worse than one that says what it
75 does. */
76 [rst-page] {
77 max-inline-size: none;
78 padding: 0;
79 }
80 /* The document's title is in the estilo bar now (chrome.go), weighted
81 550 as the page's subject and editable in place for a writer. It
82 used to be [docs-shell-title], a 12.5px regular span that could not
83 be renamed. */
84 /* [docs-canvas] is the tray the page sits in — it is chrome around
85 paper, so it stays close to the surrounding theme's tone, but it
86 still opts out of the scheme (color-scheme: light) because its
87 own background below is a literal, not a token: a scheme without
88 a matching literal is what makes dark-mode form controls repaint
89 themselves onto a light strip. */
90 [docs-canvas] {
91 color-scheme: light;
92 display: flex;
93 justify-content: center;
94 /* Less air above the sheet than below it. The tray used to carry
95 --rst-sp-6 on both block edges and sat under a page header that
96 had already spent --rst-sp-5 of its own below the rule; with the
97 header gone and the bar sitting directly on the tray, the same
98 --rst-sp-6 read as a gap rather than as a margin. --rst-sp-5
99 above puts the top of the sheet where the eye expects it from
100 the bar; --rst-sp-6 below is scroll run-out, which wants to stay
101 generous. */
102 padding: var(--rst-sp-5) var(--rst-sp-4) var(--rst-sp-6);
103 background: #d8d8dc;
104 }
105 /* [docs-page]: white, centred, with margins — literal colours per
106 spec §10.2, not var(--rst-surface)/var(--rst-text): a document
107 stored as dark paper by its author must still read as dark paper
108 to a reader in light mode, and vice versa, so nothing here may
109 depend on which theme is currently active. Sized to a US Letter
110 page's proportions at 96dpi; a real page-geometry constant
111 arrives with the layout engine, not this task. */
112 [docs-page] {
113 box-sizing: border-box;
114 display: flex;
115 flex-direction: column;
116 inline-size: min(100%, 8.5in);
117 min-block-size: 11in;
118 margin-block-end: var(--rst-sp-6);
119 padding: 1in;
120 background: #ffffff;
121 color: #1a1a1a;
122 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 12px rgba(0, 0, 0, 0.12);
123 }
124 /* Narrow screens stop simulating paper, because at phone widths the
125 simulation costs more than it says.
126
127 Above this breakpoint a reader sees TWO nested insets:
128 [docs-canvas]'s own 1rem tray padding, then [docs-page]'s 1in.
129 That inner inch is a real measurement — it stands for the
130 document's page margin, which page setup and the layout engine
131 will own properly later — and on a desktop it is worth every
132 pixel. On a 375px phone the two together (375 - 32 - 192) leave a
133 151px column, and at 320px, which this app supports without
134 exemption (WCAG 2.2 AA SC 1.4.10), 96px: about twelve characters
135 of 12pt body text per line. A page margin that squeezes the page
136 is no longer describing the page.
137
138 (Every character count in this comment divides by 8px, the
139 average advance of 12pt Georgia — the fallback for the serif this
140 canvas used to default to. Stated because the counts here were
141 for a while derived from two different divisors at once: "five
142 characters" for 64px is 12.8px each, "40 characters" for 320px is
143 8px each, and both sat in the same paragraph claiming to be
144 measured. The default is Docs Sans now, whose characters run a
145 little wider at the same size, so these are if anything
146 generous — the breakpoint the arithmetic argues for does not
147 move.)
148
149 (The count and the numbers in this paragraph have now been wrong
150 in both directions, which is worth leaving on the record. They
151 first said two insets and 151px/96px, forgetting that [rst-page]
152 pads --rst-sp-4 either side; that was corrected to three and
153 119px/64px. Both were true of the tree they were written against.
154 [rst-page] no longer pads this screen at all — the rule at the top
155 of this stylesheet zeroes it, so that the tray can reach the
156 window's edges — which puts the arithmetic back on two insets and
157 back on the original figures. Re-measure rather than trusting any
158 of the three the next time something above this changes.)
159
160 So below 36rem the tray's side padding goes to nothing, the sheet
161 runs edge to edge, its block padding drops from --rst-sp-5/-6 to
162 --rst-sp-4, and ONE margin survives inside it. Text goes to 343px
163 at 375px wide and 288px at 320px. The 1in is untouched at every
164 width above the breakpoint, so where the print margin can be shown
165 honestly it still is.
166
167 36rem is a judgement rather than a formula, and the honest version
168 of the arithmetic says so: at 576px the two insets leave a 352px
169 column, about 44 characters of this face — a hair under the 45
170 that make a comfortable measure, where before the shell's padding
171 came off it was 320px and about 40. The breakpoint has not been
172 moved to follow that, deliberately: 36rem was already the
173 conservative end of its own argument, the change only ever gives
174 the sheet more room, and moving a breakpoint because a number
175 improved is how a breakpoint stops meaning anything. Phones in
176 portrait are all well below it; a tablet or a small window keeps
177 the paper.
178
179 The shadow goes with the tray — it exists to lift the sheet off the
180 grey, and once there is no grey beside it, it is a dark smear on the
181 screen edge. What deliberately does NOT change: the flex column and
182 min-block-size, because the editable node fills the page's text area
183 so that a click in the empty space below the last paragraph still
184 lands in the document (see fixtures/editor-entry.js's
185 placeCaretFromPage), and the literal colours, which must not repaint
186 under anyone's theme at any width.
187
188 `screen and`, not a bare width query, and it is not decoration. A
189 media query with no type matches print too, and a print box is
190 measured in CSS pixels like any other: US Letter and A4 come out
191 around 700-800px and stay above this breakpoint, but A5 portrait is
192 about 559px and crosses it. Printing a document on A5 would then
193 drop the 1in for the 1rem meant for a phone screen — an
194 edge-bleeding sheet, in the one medium where the page margin is
195 not a metaphor at all. `screen` is what keeps the paragraph above
196 true. */
197 @media screen and (max-width: 36rem) {
198 [docs-canvas] {
199 /* Zeroing the tray's own padding is now all it takes to reach
200 the screen edge. It used to take a second rule as well —
201 `margin-inline: calc(var(--rst-sp-4) * -1)`, cancelling the
202 --rst-sp-4 that [rst-page] pads on every side — and that rule
203 is gone because the inset it cancelled is gone: the [rst-page]
204 override at the top of this stylesheet zeroes that padding at
205 every width, so there is nothing left here to pull back.
206 The reasoning it carried has not been lost, only moved: the
207 argument for cancelling a known padding instead of reaching
208 for `width: 100vw`, which overhangs by a classic scrollbar's
209 width and starts the horizontal scroll SC 1.4.10 forbids, is
210 written out where that override lives. */
211 padding-inline: 0;
212 padding-block: var(--rst-sp-4);
213 }
214 [docs-page] {
215 inline-size: 100%;
216 margin-block-end: 0;
217 padding: var(--rst-sp-4);
218 box-shadow: none;
219 }
220 /* The page gap's negative margin has to match the padding it is
221 pulling back out of, and that padding is different here.
222
223 [docs-page-gap]'s rule below uses margin-inline: -1in against the
224 wide layout's 1in padding. At this width the padding is
225 --rst-sp-4 (1rem), so a -1in margin overhangs the page by about
226 80px on each side — with nothing clipping it, that is a
227 horizontal scrollbar on every phone in paged mode. SC 1.4.10,
228 failed, in the one check this branch promised to keep green.
229
230 Missed because the narrow block was written before the gaps
231 existed and nothing went back to look at it, and not caught
232 because Chromium will not start in this sandbox. Derived, not
233 measured — the arithmetic is 1in = 96px against 1rem = 16px. */
234 [docs-page-gap] {
235 margin-inline: calc(var(--rst-sp-4) * -1);
236 padding-inline-end: var(--rst-sp-4);
237 }
238 }
239 /* [docs-text] holds Store.HTML's rendered markup (core/src/fragment.rs
240 — <p>, <h1>-<h6>, <ul>/<ol>/<li>, <blockquote>, and the marks
241 <strong>/<em>/<code>; the only attribute it ever emits is class,
242 and every class it can emit is a literal from one of that file's
243 own closed tables, so no byte of a document ever reaches attribute
244 position — which is what makes Store.HTML's template.HTML return
245 honest). white-space is the browser default (normal),
246 not pre-wrap: that was for the old plain-text read view, and real
247 block markup carries its own line breaks now — pre-wrap on real
248 HTML would double up on the blank lines <p> already produces.
249 Colours below are literal, same reasoning as [docs-page] above:
250 a document's own marks must not repaint under the reader's
251 colour scheme. */
252 /* The core font set (internal/fonts), served from this instance
253 rather than matched against whatever the reader's machine calls
254 Georgia. Spec §11.1: the PDF exporter measures with these exact
255 font programs (internal/export/pdf.go), so a document LINE-breaks
256 the same way on screen and in its own export only while the screen
257 is using them too. The names are private ("Docs Serif", not
258 "Liberation Serif") precisely so a locally-installed copy of a
259 different version cannot win the match and quietly change the
260 measurements. Paths are content-hashed and rendered from Go, never
261 written by hand, so they can never name a face this binary does
262 not serve.
263
264 LINE-breaks, not paginates, and the distinction is new. This
265 comment used to say the document "paginates the same way on screen
266 and in its own export", which was true only while the screen had no
267 pages at all. It has them now (fixtures/pages.mjs), and the two
268 paginate differently on purpose: internal/export/pdf.go breaks
269 between LINES, and the screen breaks between BLOCKS, so any page
270 whose last paragraph straddles the boundary comes out differently.
271 Shared font programs are what make the two agree about where a LINE
272 ends, which is all this paragraph was ever entitled to claim.
273 Screen page numbers are an orientation aid; the PDF's are the ones
274 to quote to somebody. */
275 {{range .FontFaces}}@font-face {
276 font-family: "{{.CSSName}}";
277 src: url("{{.Path}}") format("truetype");
278 font-weight: {{.Weight}};
279 font-style: {{.Style}};
280 font-display: swap;
281 }
282 {{end}}
283 /* The default face. Sans, and NOT because sans is nicer: this is the
284 face every paragraph nobody has marked is laid out in, on screen
285 and in the PDF, so it is a measurement before it is a taste. It has
286 to name the same font program internal/export/pdf.go's styleFor
287 falls back to (internal/fonts' Default) or a document breaks its
288 lines in one place on screen and another on paper, and
289 TestPageGeometryMatchesTheStylesheet is what holds the two equal.
290
291 Georgia and Times New Roman are gone from this stack with the serif
292 that named them; Arial and Helvetica are the fallbacks now, for the
293 seconds before "Docs Sans" loads. */
294 [docs-text] {
295 overflow-wrap: break-word;
296 font-family: "Docs Sans", Arial, Helvetica, sans-serif;
297 font-size: 12pt;
298 line-height: 1.4;
299 }
300 /* The font and size marks (core/src/fragment.rs's MARKS table, and
301 the same class names in fixtures/editor-entry.js's schema). These
302 three definitions — the Rust renderer's, the editor schema's and
303 this stylesheet's — are one vocabulary in three places, and
304 internal/export/pdf.go's sizeScale carries the same multipliers
305 into the PDF. A mark carries no value of its own: the set is
306 closed, which is what keeps a document's bytes out of attribute
307 position in the server-rendered read view.
308
309 All three faces have a class, including the default one: a run can
310 be marked with the face it already has, and until serif got a mark
311 of its own there was no way to say "serif" at all inside a document
312 whose default had moved. The face stack after each private name is
313 the fallback for the seconds before the @font-face above loads (or
314 forever, if it fails) — the same face by a name the reader's
315 machine might know. */
316 [docs-text] .docs-font-serif {
317 font-family: "Docs Serif", Georgia, "Times New Roman", serif;
318 }
319 [docs-text] .docs-font-sans {
320 font-family: "Docs Sans", Arial, Helvetica, sans-serif;
321 }
322 [docs-text] .docs-font-mono {
323 font-family: "Docs Mono", "SFMono-Regular", Consolas, monospace;
324 }
325 /* The point ladder (fixtures/sizes.mjs, fragment.rs's MARKS, and
326 internal/export/pdf.go's SizePt — one ladder in four places).
327
328 ABSOLUTE pt, not em, and that is a change from the four-step scale
329 these replaced. A multiplier compounds: 14pt inside a heading that
330 is itself 1.6em would come out at 1.6 x 14, which is not what
331 anybody means by setting a run to 14pt. Every word processor treats
332 a chosen size as absolute, and so does this now — the size a person
333 picks is the size the text is, wherever it sits.
334
335 12pt has no rule because it has no mark: it is [docs-text]'s own
336 font-size above, and the default carries no mark anywhere in this
337 system. */
338 [docs-text] .docs-size-8 { font-size: 8pt; }
339 [docs-text] .docs-size-9 { font-size: 9pt; }
340 [docs-text] .docs-size-10 { font-size: 10pt; }
341 [docs-text] .docs-size-11 { font-size: 11pt; }
342 [docs-text] .docs-size-14 { font-size: 14pt; }
343 [docs-text] .docs-size-18 { font-size: 18pt; }
344 [docs-text] .docs-size-24 { font-size: 24pt; }
345 [docs-text] .docs-size-36 { font-size: 36pt; }
346 [docs-text] .docs-size-48 { font-size: 48pt; }
347 [docs-text] .docs-size-72 { font-size: 72pt; }
348 /* The four-step scale the ladder replaced: Small, Large and Extra
349 large, which were 0.85em, 1.25em and 1.5em of a 12pt body — 10.2,
350 15 and 18pt. They are no longer offered by the control and they are
351 styled here forever, because documents in the wild carry these
352 marks and a mark that stops being styled is text that silently
353 loses its size.
354
355 Mapped onto the nearest ladder step rather than kept as their old
356 multipliers, so the two systems cannot disagree about one document:
357 10 and 18 are exact, and 15 rounds to 14. A document set in "Large"
358 is therefore one point smaller than it was. That is a real change
359 and it is the honest price of a closed ladder — the alternative is
360 a fifteenth mark preserving a number nobody chose, since "Large"
361 never told anyone it meant 15pt. */
362 [docs-text] .docs-size-sm { font-size: 10pt; }
363 [docs-text] .docs-size-lg { font-size: 14pt; }
364 [docs-text] .docs-size-xl { font-size: 18pt; }
365 /* Indent (fragment.rs's indent_class, fixtures/indent.mjs's
366 indentClass, and internal/export/pdf.go's indentOf — one step in
367 four places). Half an inch per level, which is what Docs, Word and
368 Pages all use, and eight levels, past which a paragraph on US
369 Letter has no measure left.
370
371 margin-inline-start rather than padding: the indent has to move the
372 text away from the page margin, and padding would leave a
373 blockquote's own border rule sitting at the old position while its
374 text moved.
375
376 Written out as eight rules rather than generated from a custom
377 property, because this stylesheet is read by
378 TestPageGeometryMatchesTheStylesheet, which parses these numbers out
379 of the file and compares them with the Go exporter's. A calc() over
380 a variable would be tidier here and unparseable there, and a
381 geometry check that cannot see the numbers is one that reports
382 agreement without checking any. */
383 /* Paged mode: the desk showing between two sheets.
384 (fixtures/pages.mjs draws these as widget decorations; nothing
385 about them is in the document — see that module's head comment for
386 why a persisted page break would be an edit two people could fight
387 over.)
388
389 The negative inline margins are the trick that makes one
390 [docs-page] look like several. The gap lives inside the page's own
391 text flow, so it is inset by [docs-page]'s 1in padding; pulling it
392 back out by that inch lets it paint the desk colour edge to edge,
393 and the box-shadow above and below reproduces the lift a sheet's
394 own edge has.
395
396 The colour is a literal, matching [docs-canvas]'s own: this is the
397 desk, and the desk does not follow the reader's dark-mode
398 preference any more than the paper does (spec §10.2).
399
400 Only in paged mode, and only under a screen. In print the browser
401 does its own pagination and a drawn gap would be a grey band across
402 somebody's page. */
403 [docs-canvas][data-view="infinite"] [docs-page-gap] {
404 display: none;
405 }
406 /* Print.
407 (File → Print, which is window.print() — fixtures/menu.mjs's
408 LOCAL_COMMANDS. Deliberately not a second PDF exporter: this is the
409 browser printing the page a person is looking at, and the PDF
410 export is a different thing with its own File item.)
411
412 The browser paginates here, which makes a THIRD paginator after the
413 screen's and pdf.go's. They will not always agree, and that is
414 stated rather than papered over — see the @font-face comment above.
415 What this stylesheet has to do is get the chrome out of the way and
416 hand the browser a page-shaped page.
417
418 @page carries the geometry, so [docs-page] gives up its own
419 padding: keeping both would inset the text by two inches. The size
420 and margin match internal/export/pdf.go's PageWidthPt/PageHeightPt
421 and MarginPt, so a printed page and an exported one have the same
422 measure even where they break in different places.
423
424 The screen's page gaps go: the browser is making real pages now,
425 and a drawn gap would be a grey band across somebody's paper. */
426 @page {
427 size: letter;
428 margin: 1in;
429 }
430 @media print {
431 [docs-page-gap] {
432 display: none;
433 }
434 /* Every piece of chrome. The bar is estilo's and is named by its
435 vocabulary, not ours. */
436 [estilo-bar],
437 [docs-chrome],
438 [docs-jumpto] {
439 display: none !important;
440 }
441 /* The desk is not paper. Both of these carry literal colours on
442 screen (spec §10.2) and both must simply stop existing here —
443 printing a grey background wastes a reader's ink to reproduce a
444 metaphor. */
445 [docs-canvas] {
446 display: block;
447 padding: 0;
448 background: none;
449 }
450 [docs-page] {
451 inline-size: auto;
452 min-block-size: 0;
453 margin: 0;
454 padding: 0;
455 box-shadow: none;
456 }
457 /* Widows and orphans are the one typographic courtesy print gives
458 away for free, and a heading stranded at the foot of a page is
459 the thing people notice. */
460 [docs-text] p {
461 orphans: 2;
462 widows: 2;
463 }
464 [docs-text] h1,
465 [docs-text] h2,
466 [docs-text] h3,
467 [docs-text] h4,
468 [docs-text] h5,
469 [docs-text] h6 {
470 break-after: avoid;
471 }
472 }
473 [docs-page-gap] {
474 display: flex;
475 align-items: center;
476 justify-content: flex-end;
477 block-size: var(--rst-sp-6);
478 margin-block: 1em;
479 margin-inline: -1in;
480 padding-inline-end: 1in;
481 background: #d8d8dc;
482 box-shadow:
483 inset 0 1px 0 rgba(0, 0, 0, 0.16),
484 inset 0 -1px 0 rgba(0, 0, 0, 0.16);
485 user-select: none;
486 }
487 /* The number of the page starting BELOW the gap. Quiet, and a literal
488 colour for the same reason the gap's own is: it sits on the desk,
489 not on the paper. */
490 [docs-page-number] {
491 color: #5a5a60;
492 font-family: "Docs Sans", Arial, Helvetica, sans-serif;
493 font-size: 9pt;
494 }
495 /* The Tools menu's two dialogs. Chrome, so they take the app's theme
496 tokens and repaint with the reader's light/dark preference — unlike
497 the paper, which does not.
498
499 The backdrop is the browser's own; ::backdrop is the only way to
500 colour it and it needs no JavaScript. */
501 [docs-dialog] {
502 max-inline-size: min(30rem, calc(100vw - var(--rst-sp-6)));
503 padding: var(--rst-sp-4);
504 border: 1px solid var(--rst-line);
505 /* A float: the suite's one floating radius (estilo menu.css). */
506 border-radius: var(--estilo-float-radius);
507 background: var(--rst-surface);
508 color: var(--rst-text);
509 box-shadow: var(--rst-shadow-pop);
510 }
511 [docs-dialog]::backdrop {
512 background: var(--rst-overlay);
513 }
514 [docs-dialog] h2 {
515 margin-block: 0 var(--rst-sp-3);
516 font-size: var(--rst-fs-lg, 1.1rem);
517 }
518 [docs-dialog] form {
519 display: flex;
520 justify-content: flex-end;
521 margin-block-start: var(--rst-sp-4);
522 }
523 /* Label on the left, number on the right, on one row each — the
524 shape every word processor's word count has, and the one that lets
525 a reader compare two numbers by scanning down a column. */
526 [docs-dialog-rows] {
527 margin: 0;
528 }
529 [docs-dialog-rows] > div {
530 display: flex;
531 gap: var(--rst-sp-4);
532 justify-content: space-between;
533 padding-block: var(--rst-sp-1);
534 }
535 [docs-dialog-rows] > div + div {
536 border-block-start: 1px solid var(--rst-line);
537 }
538 [docs-dialog-rows] dt {
539 color: var(--rst-text-muted);
540 }
541 [docs-dialog-rows] dd {
542 margin: 0;
543 font-variant-numeric: tabular-nums;
544 }
545 [docs-shortcuts-group] {
546 margin-block: var(--rst-sp-3) var(--rst-sp-1);
547 color: var(--rst-text-muted);
548 font-size: var(--rst-fs-sm);
549 font-weight: 600;
550 }
551 [docs-shortcuts-row] {
552 display: flex;
553 align-items: baseline;
554 gap: var(--rst-sp-4);
555 justify-content: space-between;
556 padding-block: var(--rst-sp-1);
557 }
558 [docs-shortcuts-row] kbd {
559 color: var(--rst-text-muted);
560 font-family: "Docs Mono", "SFMono-Regular", Consolas, monospace;
561 font-size: var(--rst-fs-sm);
562 white-space: nowrap;
563 }
564 [docs-text] .docs-indent-1 { margin-inline-start: 0.5in; }
565 [docs-text] .docs-indent-2 { margin-inline-start: 1in; }
566 [docs-text] .docs-indent-3 { margin-inline-start: 1.5in; }
567 [docs-text] .docs-indent-4 { margin-inline-start: 2in; }
568 [docs-text] .docs-indent-5 { margin-inline-start: 2.5in; }
569 [docs-text] .docs-indent-6 { margin-inline-start: 3in; }
570 [docs-text] .docs-indent-7 { margin-inline-start: 3.5in; }
571 [docs-text] .docs-indent-8 { margin-inline-start: 4in; }
572 /* Paragraph alignment (fragment.rs's ALIGNMENTS table, and the same
573 class names in fixtures/editor-entry.js's schema and
574 fixtures/toolbar.mjs's controls). The first block-level formatting,
575 and the first class here that lands on a <p> rather than on a span
576 around a run — so unlike the marks above it is on the same element
577 as the margin rules below rather than inside them.
578
579 Two classes, not three: left is what a paragraph already is, so it
580 spends no class and no attribute at all, which is what keeps a
581 document written before alignment existed rendering byte-identically
582 (core/tests/fragment.rs pins that). A value the renderer does not
583 know never reaches this stylesheet — it is dropped in Rust, before
584 any of it could reach attribute position.
585
586 text-align, not margin arithmetic: the browser has the measured
587 line and CSS is how you ask it for this. internal/export/pdf.go has
588 to do the arithmetic by hand instead, offsetting each line within
589 the column it wrapped in, and internal/docs's own
590 TestTheAlignmentClassesSetTextAlign is what holds the two
591 descriptions of "centred" to each other. */
592 [docs-text] .docs-align-center {
593 text-align: center;
594 }
595 [docs-text] .docs-align-right {
596 text-align: right;
597 }
598 /* First and last block lose their outer margin so [docs-page]'s own
599 1in padding is the only air above the first line and below the
600 last — otherwise a leading <p> or <h1> would stack its own
601 margin-block-start on top of the page padding. */
602 [docs-text] > :first-child {
603 margin-block-start: 0;
604 }
605 [docs-text] > :last-child {
606 margin-block-end: 0;
607 }
608 /* Paragraphs stack like lines rather than like blocks: nothing
609 before, nothing after. That is how Google Docs defines its own
610 "Normal text" (0pt before, 0pt after, 1.15 line spacing), and a
611 document pasted out of Docs is the ordinary case here. The 1em
612 that used to sit under every paragraph turned a list of short
613 paragraphs into a widely spaced document nobody asked for, and
614 put a double gap wherever the author had left a blank line of
615 their own.
616
617 p:empty is what keeps that blank line, and it is load-bearing
618 rather than tidying. An empty paragraph reaches the read view as
619 `<p></p>` — core/src/fragment.rs's fragment_html emits exactly
620 that, and core/tests/fragment.rs pins it — and it was visible on
621 screen ONLY because it contributed the margin above. With that
622 margin gone it collapses to zero height, so a blank line would
623 still appear while editing, where ProseMirror puts a
624 `<br class="ProseMirror-trailingBreak">` inside an empty
625 textblock and :empty therefore does not match, and would then
626 silently vanish for a reader with no JavaScript. The other two
627 renderers already keep it: internal/export/pdf.go's paragraph
628 gives an empty paragraph a whole line box, and in Markdown the
629 paragraph break IS a blank line. This stylesheet was the one
630 that needed telling. */
631 [docs-text] p {
632 margin-block: 0;
633 }
634 [docs-text] p:empty {
635 min-block-size: 1lh;
636 }
637 /* Headings clamp to level 1-6 (fragment.rs), but every seed document
638 today only ever produces <h2> (generate-seed.mjs's block
639 classifier) — sized so a heading reads as a heading against 12pt
640 body text without shouting the way a bare browser default h1
641 would. */
642 [docs-text] h1,
643 [docs-text] h2,
644 [docs-text] h3,
645 [docs-text] h4,
646 [docs-text] h5,
647 [docs-text] h6 {
648 font-family: inherit;
649 font-weight: 700;
650 line-height: 1.3;
651 margin-block: 1.5em 0.5em;
652 }
653 [docs-text] h1 {
654 font-size: 1.6em;
655 }
656 [docs-text] h2 {
657 font-size: 1.3em;
658 }
659 [docs-text] h3 {
660 font-size: 1.1em;
661 }
662 [docs-text] h4,
663 [docs-text] h5,
664 [docs-text] h6 {
665 font-size: 1em;
666 font-style: italic;
667 }
668 /* A list stacks like the paragraphs around it, and its items stack
669 like each other — both follow from the rule above rather than
670 being a second opinion about density. A list keeping 1em below it
671 and nothing above would carry a gap on one side of itself only,
672 because the other side used to come from the paragraph before it;
673 and an `li` keeping 0.35em would leave items further apart than
674 two paragraphs are, inverting the one relationship a reader can
675 actually see (a list is tighter than the prose around it, not
676 looser). Headings and blockquotes keep their own margins: those
677 were never supplied by a neighbouring paragraph, so nothing here
678 changes what they need.
679
680 `li > :last-child` still earns its place with `p` at zero — a
681 nested list, or a blockquote inside an item, has margins of its
682 own for it to cancel. */
683 [docs-text] ul,
684 [docs-text] ol {
685 margin-block: 0;
686 padding-inline-start: 1.4em;
687 }
688 [docs-text] li > :last-child {
689 margin-block-end: 0;
690 }
691 [docs-text] blockquote {
692 margin-block: 1em;
693 margin-inline: 0;
694 padding-inline-start: 1em;
695 border-inline-start: 3px solid #c8c8c8;
696 color: #4a4a4a;
697 font-style: italic;
698 }
699 [docs-text] strong {
700 font-weight: 700;
701 }
702 [docs-text] em {
703 font-style: italic;
704 }
705 [docs-text] code {
706 font-family: "Docs Mono", "SFMono-Regular", Consolas, Menlo, monospace;
707 font-size: 0.85em;
708 background: #f0f0f0;
709 padding: 0.15em 0.35em;
710 border-radius: 3px;
711 }
712
713 /* [docs-editor] is the same element as [docs-text] above — the
714 mount point, which ProseMirror turns into the contenteditable
715 surface itself ({mount: el}, editor-entry.js). Two rules it needs
716 that the scriptless read view does not:
717
718 flex: 1 0 auto (with [docs-page]'s own column flex above) grows
719 the editable node down to the bottom of the page's text area
720 instead of stopping at the last line of prose. Without it a click
721 in the empty space below the final paragraph lands on [docs-page]
722 — not on anything editable — and nothing happens, which reads as
723 a broken document rather than a short one. Basis auto and
724 flex-shrink 0 so a document longer than one page still lays out
725 at its own height and pushes the page taller, rather than being
726 squeezed back into 11in.
727
728 outline: none drops the browser's default focus ring. Every
729 browser draws one around a focused contenteditable, which here
730 meant a blue box tracing the text area of the paper — paper does
731 not have a focus ring, and the caret already says where typing
732 will go. This is the one case where removing an outline costs no
733 accessibility: the element's own text cursor is a stronger,
734 always-visible focus indicator than the ring it replaces, unlike
735 a button or a link where the ring is the only signal. Buttons in
736 the chrome above keep rastrillo's own focus styling untouched. */
737 [docs-editor] {
738 flex: 1 0 auto;
739 }
740 [docs-editor]:focus,
741 [docs-editor]:focus-visible {
742 outline: none;
743 }
744
745 /* [estilo-menubar] and [docs-bar] are two strips of one slab of
746 chrome, between the estilo bar and the paper: menus on top,
747 formatting under them. They read as one object rather than two
748 because only the lower one carries a bottom rule — the seam
749 between them is a hairline, not a gap, and the paper below is the
750 figure.
751
752 Both are sticky, and they stack: the menu bar pins directly under
753 the estilo bar and the formatting bar directly beneath it. A formatting bar that scrolls
754 away with the first page is not there when a person is writing on
755 the third one, and the same is true of File.
756
757 The document's own actions used to live at [docs-bar]'s trailing
758 end — two export links reached by an auto margin, with a long
759 comment here about why an auto margin rather than
760 space-between survives a wrap. Both are gone: the exports are File
761 menu items now, which is where a document-level verb belongs, and
762 with nothing at the trailing end there is no wrap behaviour left to
763 get right. The bar still wraps at 320px; it just has one child to
764 wrap.
765
766 Chrome, not paper: unlike [docs-page] and [docs-text] these take
767 the app's theme tokens and repaint with the reader's light/dark
768 preference. */
769 /* ONE sticky element, wrapping both strips — not two.
770 The menu bar and [docs-bar] were each `position: sticky;
771 inset-block-start: 0` for about an hour, which does not stack: two
772 sticky siblings pinned to the same offset occupy the same band, so
773 on the first scroll the formatting bar slid underneath the menu bar
774 and was gone. Found in review before it was pushed, and worth the
775 comment because the broken version looks obviously right — each bar
776 "sticks to the top" is exactly what you would write.
777 A container that sticks, holding two children that do not, is the
778 shape that actually keeps them in order. */
779 /* Below the estilo bar, which is sticky too: the published height,
780 never a copied 52px (spec §5.3). z-index 3 stays under the bar's 45,
781 so the account menu paints over the menubar and the toolbar. */
782 [docs-chrome] {
783 position: sticky;
784 inset-block-start: var(--estilo-bar-block-size);
785 z-index: 3;
786 background: var(--rst-surface);
787 border-block-end: 1px solid var(--rst-line);
788 }
789 [docs-chrome] > [estilo-menubar] {
790 padding: 0 var(--rst-sp-3);
791 }
792 [docs-bar] {
793 display: flex;
794 flex-wrap: wrap;
795 align-items: center;
796 gap: var(--rst-sp-2);
797 padding: var(--rst-sp-2) var(--rst-sp-4);
798 }
799 /* The menu bar's titles and panels are estilo's ([estilo-menubar],
800 [estilo-menu], estilo menu.css): the one menu style the suite shares,
801 square titles in the page flow and a 9px floating panel with inset
802 rows, a check column and a key column. The [docs-menu-*] rules that
803 used to be here drew a second, different menu (6px panels, full-bleed
804 rows, 6px rounded titles) and are gone. */
805 /* [docs-toolbar] is a sibling of [docs-canvas], never inside
806 [docs-editor]: ProseMirror's {mount: el} takes over everything
807 inside that element, so a toolbar rendered in there would be
808 replaced by the document on the first mount. */
809 /* flex: 1 1 auto is the CLS fix, measured. As a content-sized flex item
810 the toolbar was laid out in the first paint at 645px wide and three
811 rows tall at 1440px: that paint happens while the parser is still
812 running, before ui/select.js has swapped the three native selects for
813 their comboboxes, and a wrapping flex container sized to its content
814 wraps. Then it settled to one 28px row, and the paper below jumped
815 60px: a layout shift of 0.137 on six or seven document loads in
816 twelve, on main as well as here (polish-audit/shots r2 notes). Taking
817 the bar's width from the start, it has room for the native selects
818 too; with the group rule below, the first paint is the last. */
819 [docs-toolbar] {
820 display: flex;
821 flex: 1 1 auto;
822 min-inline-size: 0;
823 flex-wrap: wrap;
824 align-items: center;
825 gap: var(--rst-sp-1);
826 }
827 /* A group wraps inside itself, and that is not the same claim as
828 the gap/rule one below. The three <select>s — Style, Font, Size —
829 are one group, and a <select>'s automatic minimum size is its
830 widest option, so at 320px (§10.3's reflow width, which this app
831 supports without exemption) the three of them measure wider than
832 the bar and the page grows a horizontal scrollbar: SC 1.4.10,
833 failed. Letting the group wrap and letting each control shrink
834 below its content — min-inline-size: 0, which is what actually
835 lifts that automatic minimum — is what keeps the row inside the
836 viewport. The rule between groups survives a wrap, so the
837 grouping still reads once it has happened.
838
839 This predates the bar being restructured around it; it was not
840 introduced by that work and is, if anything, slightly less bad
841 than it was. The toolbar used to sit inside [rst-page] and get
842 320 - 32 (the shell's padding) - 32 (its own) = 256px at this
843 width; it now gets 320 - 32 = 288px, because the shell no longer
844 pads this screen. Both overflow. Measured at 320px in Chromium,
845 before and after. */
846 [docs-toolbar-group] {
847 display: flex;
848 flex-wrap: wrap;
849 align-items: center;
850 gap: 2px;
851 }
852 /* ...but only where it has to. Chrome sizes a wrapping flex container
853 by its widest item, not by the sum of them, so with wrap on, the Style,
854 Font and Size group was laid out one select per row in the first paint
855 (88px tall, before ui/select.js has run) and then collapsed to one row:
856 the 0.137 layout shift the toolbar rule above also answers. At 40rem
857 and wider the three always fit on a line, so they are held on one. */
858 @media (min-width: 40rem) {
859 [docs-toolbar-group] {
860 flex-wrap: nowrap;
861 }
862 }
863 /* A rule between groups rather than a wider gap: at 320px (§10.3's
864 reflow width) the bar wraps, and a gap that has wrapped tells a
865 reader nothing about which controls belong together. */
866 [docs-toolbar-group] + [docs-toolbar-group] {
867 margin-inline-start: var(--rst-sp-2);
868 padding-inline-start: var(--rst-sp-2);
869 border-inline-start: 1px solid var(--rst-line);
870 }
871 /* 28px, comfortably over WCAG 2.2 AA's 24x24 minimum target
872 (AGENTS.md: no exemptions). Nothing here touches :focus-visible —
873 the editable surface drops its focus ring on the argument that a
874 caret says more, and that argument does not extend to a button,
875 which has no caret. */
876 [docs-tool] {
877 display: inline-flex;
878 align-items: center;
879 justify-content: center;
880 min-inline-size: 28px;
881 min-block-size: 28px;
882 padding: 0 4px;
883 border: 0;
884 /* Square: a borderless toolbar button's hover and pressed backgrounds
885 are in-flow highlights (the suite's square rule, spec §11 and Paul's
886 §13 Q4 answer). The bordered selects beside them keep day's radius. */
887 border-radius: 0;
888 background: transparent;
889 color: var(--rst-text);
890 font: inherit;
891 line-height: 1;
892 cursor: pointer;
893 }
894 [docs-tool]:hover:not(:disabled) {
895 background: var(--rst-accent-soft);
896 }
897 /* aria-pressed is the state, and colour is not allowed to be the only
898 thing that shows it (§10.3, and AGENTS.md's "no exemptions").
899
900 This rule used to lean on `font-weight: 700` for that, and the
901 comment used to say so. Weight does nothing to an SVG icon, and six
902 of the nine controls in this bar are icon-only. Worse, the pressed
903 background is the SAME `--rst-accent-soft` as :hover above, so a
904 hovered unpressed button and a pressed one differed by icon hue
905 alone — which is exactly the thing §10.3 forbids, asserted by a
906 comment to be handled while it was not.
907
908 A complete 1px ring is what carries it: present or absent,
909 independent of hue, and equally legible on the icon buttons and on
910 B/I/<>. The weight stays because it still helps the three lettered
911 buttons; it is no longer what this rule depends on.
912
913 It was a 2px rule under the glyph until Paul looked at it
914 (2026-09-02) and asked for "simple borders, not the weird rounded
915 one-side". He was reading a real defect, not a preference: a
916 one-sided rule inside a box with `border-radius` does not stop at
917 the corner, it follows the curve, so the underline turned up at
918 both ends and the pressed button read as a tab or a tooltip stem
919 rather than a selected control. Either the rule or the radius had
920 to go, and the radius is the house style every other control on
921 this screen is drawn with.
922
923 Still box-shadow rather than `border`, and for the reason the
924 previous comment gave: it costs no layout, so it cannot shrink the
925 28px target and cannot widen the bar. That last part is not
926 housekeeping — the toolbar has to fit 320px (AGENTS.md's reflow
927 rule, no exemptions), and a real 1px border on nine buttons would
928 have spent 18px of that budget to say the same thing. `inset 0 0 0
929 1px` draws the ring on the inside of the same box, following the
930 same radius the rest of the control already has. */
931 [docs-tool][aria-pressed="true"] {
932 background: var(--rst-accent-soft);
933 color: var(--rst-accent);
934 font-weight: 700;
935 box-shadow: inset 0 0 0 1px currentColor;
936 }
937 [docs-tool]:disabled {
938 color: var(--rst-text-faint);
939 cursor: default;
940 }
941 [docs-tool] svg {
942 inline-size: 20px;
943 block-size: 20px;
944 fill: none;
945 stroke: currentColor;
946 stroke-width: 1.6;
947 stroke-linecap: round;
948 stroke-linejoin: round;
949 }
950 /* The letterforms are the icon for bold, italic and code — every
951 editor a person has used draws them this way, and a B that is
952 actually bold says more than a glyph of one would. */
953 [docs-tool-glyph] {
954 font-size: 15px;
955 }
956 [docs-tool-glyph="bold"] {
957 font-family: "Docs Serif", Georgia, serif;
958 font-weight: 700;
959 }
960 [docs-tool-glyph="italic"] {
961 font-family: "Docs Serif", Georgia, serif;
962 font-style: italic;
963 }
964 [docs-tool-glyph="code"] {
965 font-family: "Docs Mono", Consolas, monospace;
966 font-size: 13px;
967 }
968 [docs-tool-select] {
969 min-block-size: 28px;
970 /* min-inline-size: 0 lifts the automatic minimum size, which for
971 a <select> is its widest option — see [docs-toolbar-group]'s
972 own comment for what that overflows and where. Wrapping is what
973 actually resolves the 320px case, and it is the better of the
974 two outcomes: these three controls carry words, and a control
975 shrunk under its widest word is a control you cannot read. This
976 only takes over if a viewport is narrow enough that even one of
977 them will not fit on a line of its own. */
978 min-inline-size: 0;
979 max-inline-size: 11rem;
980 padding: 0 var(--rst-sp-1);
981 border: 1px solid var(--rst-line);
982 border-radius: var(--rst-radius-sm);
983 background: var(--rst-surface);
984 color: var(--rst-text);
985 font-size: var(--rst-fs-sm);
986 }
987 /* Once ui/select.js has run, the native <select> is visually hidden
988 and the control a person actually uses is an [rst-combo] wrapper
989 inserted just before it. Every constraint that was on the select
990 has to move onto the wrapper, or it stops applying at exactly the
991 moment the enhancement lands.
992
993 The width cap is the one that matters. [docs-toolbar-group]'s own
994 comment explains at length why these three controls carry
995 min-inline-size: 0 and a max — a control whose automatic minimum is
996 its widest option overflows the bar at 320px, and this app supports
997 that width without exemption. select.js's input is width: 100% of
998 its wrapper, so an uncapped wrapper reintroduces exactly the reflow
999 failure that comment was written about. */
1000 [docs-toolbar] [rst-combo] {
1001 position: relative;
1002 min-inline-size: 0;
1003 max-inline-size: 11rem;
1004 }
1005 [docs-toolbar] [rst-combo] [rst-input] {
1006 inline-size: 100%;
1007 min-block-size: 28px;
1008 padding: 0 var(--rst-sp-1);
1009 border: 1px solid var(--rst-line);
1010 border-radius: var(--rst-radius-sm);
1011 background: var(--rst-surface);
1012 color: var(--rst-text);
1013 font: inherit;
1014 font-size: var(--rst-fs-sm);
1015 }
1016 /* A phone: one row that scrolls sideways, not three rows of wrapped
1017 controls with separators stranded at the starts of lines (the audit's
1018 "crowded at 390px"). The toolbar is its own horizontal scroller, so
1019 the page never overflows, and nothing wraps, so the first paint is
1020 already the final one (CLS stays 0).
1021
1022 The catch is the three comboboxes: their lists are absolutely
1023 positioned under the input, and inside a scroller they would be
1024 clipped. Anchor positioning takes them out: each list is fixed to the
1025 viewport and anchored to its own combobox. Where a browser has no
1026 anchor positioning (Firefox, today) the whole rule is skipped and the
1027 bar wraps as before, which is crowded but every list still opens. */
1028 @media (max-width: 39.99rem) {
1029 @supports (anchor-name: --docs-probe) {
1030 [docs-toolbar] {
1031 flex-wrap: nowrap;
1032 overflow-x: auto;
1033 overscroll-behavior-x: contain;
1034 scrollbar-width: thin;
1035 padding-block-end: 2px;
1036 }
1037 [docs-toolbar-group] {
1038 flex: none;
1039 flex-wrap: nowrap;
1040 }
1041 [docs-toolbar] [rst-combo],
1042 [docs-tool-select] {
1043 flex: none;
1044 inline-size: 7.5rem;
1045 }
1046 [docs-toolbar] [rst-combo]:has(#docs-select-size-combo),
1047 #docs-select-size {
1048 inline-size: 4.5rem;
1049 }
1050 [docs-toolbar] [rst-combo]:has(#docs-select-block-combo) { anchor-name: --docs-select-block; }
1051 [docs-toolbar] [rst-combo]:has(#docs-select-font-combo) { anchor-name: --docs-select-font; }
1052 [docs-toolbar] [rst-combo]:has(#docs-select-size-combo) { anchor-name: --docs-select-size; }
1053 [docs-toolbar] [rst-combo]:has(#docs-select-block-combo) [rst-combo-list] { position-anchor: --docs-select-block; }
1054 [docs-toolbar] [rst-combo]:has(#docs-select-font-combo) [rst-combo-list] { position-anchor: --docs-select-font; }
1055 [docs-toolbar] [rst-combo]:has(#docs-select-size-combo) [rst-combo-list] { position-anchor: --docs-select-size; }
1056 [docs-toolbar] [rst-combo-list] {
1057 position: fixed;
1058 inset: auto;
1059 top: anchor(bottom);
1060 left: anchor(left);
1061 min-inline-size: anchor-size(width);
1062 inline-size: max-content;
1063 }
1064 }
1065 }
1066 /* Disabled is mirrored onto the input by fixtures/toolbar.mjs —
1067 select.js does not do it — and has to LOOK disabled too, or the
1068 control reads as usable while refusing to be used. Same token the
1069 buttons already use. */
1070 [docs-toolbar] [rst-combo] [rst-input]:disabled {
1071 color: var(--rst-text-faint);
1072 cursor: default;
1073 }
1074 /* iOS zooms the page into any text control under 16px, and tokens.css
1075 brings controls up to 16px where the pointer is coarse. The three
1076 comboboxes (and their selects, before select.js runs) set
1077 --rst-fs-sm with selectors that outweigh its bare `input`, so they
1078 stayed at 12.5px. Same rule, same max(), restated at their weight.
1079 TestNoZoomOnTap (browser tag) measures them. */
1080 @media (pointer: coarse) {
1081 [docs-toolbar] [rst-combo] [rst-input],
1082 [docs-tool-select] {
1083 font-size: max(1rem, 1em);
1084 }
1085 }
1086</style>
1087{{/* This page has no [rst-page-header]. It used to, and what it held
1088 is not gone — every piece of it moved to where its equivalent
1089 lives in the two applications this layout language comes from.
1090 What follows is only the map.
1091
1092 The route back to the list was [docs-back], a breadcrumb above
1093 the title, and is now the bar's brand link, "Docs" — the same
1094 control on every screen rather than one this page draws for
1095 itself. Until [docs-back] landed this page had no in-app
1096 navigation at all, so arriving by a bookmark or a mailed link left
1097 a person with no route to their own list.
1098
1099 The title is in the bar beside the brand, editable in place.
1100
1101 Sign-out (POST /signout, auth.go) is in the bar's account menu and
1102 nowhere else: it used to be the last item of File as well.
1103
1104 The exports are items in the File menu below. They used to be two
1105 ghost buttons pinned to the formatting bar's trailing end, which is
1106 what Paul's brief called the "random buttons": export is a
1107 document-level verb, and the formatting bar is a group of run-level
1108 controls, so it was the one place a person would not look. They are
1109 still ordinary links to ordinary URLs, so both still work with no
1110 JavaScript at all — spec §6.2 — and both are GETs of a document the
1111 signed-in person may already read, so neither needs a form or a CSRF
1112 token. download tells the browser to save rather than navigate; the
1113 filename it uses comes from the server's Content-Disposition, not
1114 from this attribute, because a title is arbitrary text a person
1115 typed and sanitising it belongs server-side (handlers.go's
1116 exportFilename). */}}
1117{{/* The menu bar (internal/docs/menu.go for the model and the copy,
1118 fixtures/menu.mjs for the keyboard behaviour).
1119
1120 Each menu is a <details>, and that is the load-bearing decision on
1121 this screen rather than a convenience. The export links work today
1122 with scripts disabled and the whole read view is built on that
1123 guarantee (editor_view_test.go's TestShowRendersContentWithoutScript);
1124 a JS-driven role="menubar" would have moved the only route to a
1125 person's own document behind a control that never opens for them.
1126 <details> opens with no script at all, and name="estilo-menus"
1127 makes the five an exclusive group — opening one closes the rest —
1128 with no state for anything to keep. It is the SAME group as the
1129 bar's account menu, switcher and Menu sheet, so opening File closes
1130 the account menu and the other way round (spec §7.1).
1131
1132 It stays a disclosure rather than being relabelled role="menubar"
1133 with role="menuitem" children. <summary> is a real disclosure
1134 control that browsers and assistive technology already handle
1135 correctly; declaring the menu pattern promises typeahead and
1136 submenu semantics, and delivering less of it than that is worse for
1137 a screen reader user than the honest disclosure it replaced.
1138 fixtures/menu.mjs only ADDS to this: arrow keys between and within
1139 menus, Escape to close, and a roving tabindex so the bar is one tab
1140 stop. With no JavaScript every one of these still opens, and every
1141 item that is a link or a form still works.
1142
1143 There are no submenus anywhere, and there will not be: a nested
1144 <details> inside a name= group collapses its parent (rastrillo's
1145 rst-menus trap, which the Sheets session hit first). So "Download
1146 as PDF" is a sibling of "New document" rather than a child of a
1147 "Download" submenu. */}}
1148{{/* [docs-chrome] is the single sticky element wrapping both bars.
1149
1150 It was styled and never rendered for one review cycle, which cost
1151 two things at once: [docs-bar] had been `position: sticky` before
1152 this branch and stopped being, so the formatting bar scrolled away
1153 — a regression against main sitting under a 30-line comment
1154 describing the fix that replaced it; and File → Print printed the
1155 menu bar and the toolbar across the top of page one, because the
1156 print rule hides [docs-chrome] and nothing carried it.
1157
1158 Both rules were correct. Neither had an element. */}}
1159<div docs-chrome>
1160<nav estilo-menubar aria-label="{{.Menu.Label}}">
1161 {{range .Menu.Menus}}{{$checks := .Checks}}<details estilo-menubar-menu name="estilo-menus" id="docs-menu-{{.ID}}">
1162 <summary estilo-menubar-title>{{.Title}}</summary>
1163 <div estilo-menu>
1164 {{range .Items}}
1165 {{- if eq .Kind "divider"}}<hr>
1166 {{- else if eq .Kind "link"}}<a estilo-menu-item href="{{.Href}}"{{if .Download}} download{{end}}{{if .JumpTo}} data-docs-jumpto{{end}}{{with .Shortcut}} data-docs-shortcut="{{.}}"{{end}}>{{if $checks}}<span estilo-menu-check aria-hidden="true"></span>{{end}}<span estilo-menu-label>{{.Label}}</span>{{with .Shortcut}}<kbd estilo-menu-key aria-hidden="true">{{.}}</kbd>{{end}}</a>
1167 {{- else if eq .Kind "form"}}<form method="post" action="{{.Action}}"><button estilo-menu-item type="submit">{{if $checks}}<span estilo-menu-check aria-hidden="true"></span>{{end}}<span estilo-menu-label>{{.Label}}</span></button></form>
1168 {{- else}}{{/* Every command item ships disabled, because without
1169 the bundle there is no editor to act on; fixtures/menu.mjs
1170 enables each as soon as the editor mounts, and afterwards
1171 exactly when its command would do something. A checkable
1172 item carries aria-pressed on every row of its group, and
1173 the check glyph, not colour, shows the current one. */}}<button estilo-menu-item type="button" data-docs-command="{{.Command}}"{{with .Shortcut}} data-docs-shortcut="{{.}}"{{end}}{{if .Checkable}} aria-pressed="{{if .Pressed}}true{{else}}false{{end}}"{{end}} disabled>{{if $checks}}<span estilo-menu-check aria-hidden="true">{{if .Checkable}}{{template "estilo-icon-check"}}{{end}}</span>{{end}}<span estilo-menu-label>{{.Label}}</span>{{with .Shortcut}}<kbd estilo-menu-key aria-hidden="true">{{.}}</kbd>{{end}}</button>
1174 {{- end}}
1175 {{end}}
1176 </div>
1177 </details>{{end}}
1178</nav>
1179<div docs-bar>
1180{{/* The formatting bar (fixtures/toolbar.mjs). Rendered server-side,
1181 with every label resolved from the catalog (render.go's
1182 docs.editor.toolbar.*) — the bundle carries no copy of its own,
1183 the same rule [estilo-save-status] below already follows.
1184
1185 It is rendered even for a reader with no JavaScript, where it
1186 does nothing at all. That is a deliberate choice and the weaker
1187 of the two: the alternative is rendering it only when the editor
1188 mounts, which means the page reflows under a person the moment
1189 the bundle runs, and means the server template has to know
1190 something it cannot know. A visible-but-inert bar is the smaller
1191 harm, and the controls are all disabled until the toolbar's own
1192 plugin enables them, so nothing here invites a click that would
1193 silently do nothing.
1194
1195 role="toolbar" with an accessible name, and each control's name
1196 from the catalog rather than from its glyph: a button whose only
1197 content is a "B" announces as "B" to a screen reader without
1198 one. */}}
1199<div docs-toolbar role="toolbar" aria-orientation="horizontal" aria-label="{{.Toolbar.Label}}">
1200 <div docs-toolbar-group>
1201 <button docs-tool tabindex="-1" type="button" data-docs-command="undo" title="{{.Toolbar.Undo}}" aria-label="{{.Toolbar.Undo}}" disabled>
1202 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M6 6 3 9l3 3"/><path d="M3 9h8a4.5 4.5 0 0 1 0 9H7"/></svg>
1203 </button>
1204 <button docs-tool tabindex="-1" type="button" data-docs-command="redo" title="{{.Toolbar.Redo}}" aria-label="{{.Toolbar.Redo}}" disabled>
1205 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="m14 6 3 3-3 3"/><path d="M17 9H9a4.5 4.5 0 0 0 0 9h4"/></svg>
1206 </button>
1207 </div>
1208 {{/* The three selects opt into rastrillo's searchable-select
1209 enhancement (ui/select.js, already served by render.go's
1210 vendoredStaticFS and linked by the shell's own layout). Paul's
1211 brief: "selects should be Rastrillo design system searchable
1212 selects".
1213
1214 data-rst-select is the opt-in for a HAND-WRITTEN select —
1215 ui/partials/field-select.html emits it only past ten options, on
1216 the framework's judgement that search over a handful of items is
1217 furniture rather than help. All three take it here anyway. Two
1218 reasons: they sit side by side in one group, and one combobox
1219 flanked by two native selects looks worse than either extreme;
1220 and all three are growth surfaces — Size has eleven options
1221 already, Style grows with named styles, and the face list is
1222 built to grow. If the filter over three fonts reads as silly in
1223 the morning it is one attribute to take off, which is why the
1224 tension is recorded rather than resolved silently.
1225
1226 The three data-rst-select-* strings are the framework's own
1227 catalog keys, resolved server-side (export.go) because select.js
1228 builds its markup in the browser where the catalog is out of
1229 reach.
1230
1231 Still `disabled` until the editor mounts, like every other
1232 control in this bar — and select.js does NOT mirror that, so
1233 toolbar.mjs mirrors it onto the combobox by hand. Left alone,
1234 the enhancement ships a control that looks usable, accepts focus
1235 and typing, and drives a disabled select behind it. */}}
1236 <div docs-toolbar-group>
1237 <select docs-tool-select rst-input id="docs-select-block" tabindex="-1" disabled data-docs-select="block" aria-label="{{.Toolbar.BlockLabel}}"
1238 data-rst-select data-rst-select-filter="{{.Toolbar.SelectFilter}}" data-rst-select-results="{{.Toolbar.SelectResults}}" data-rst-select-result-one="{{.Toolbar.SelectResultOne}}">
1239 {{range .Toolbar.Blocks}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
1240 </select>
1241 <select docs-tool-select rst-input id="docs-select-font" tabindex="-1" disabled data-docs-select="font" aria-label="{{.Toolbar.FontLabel}}"
1242 data-rst-select data-rst-select-filter="{{.Toolbar.SelectFilter}}" data-rst-select-results="{{.Toolbar.SelectResults}}" data-rst-select-result-one="{{.Toolbar.SelectResultOne}}">
1243 {{range .Toolbar.Fonts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
1244 </select>
1245 <select docs-tool-select rst-input id="docs-select-size" tabindex="-1" disabled data-docs-select="size" aria-label="{{.Toolbar.SizeLabel}}"
1246 data-rst-select data-rst-select-filter="{{.Toolbar.SelectFilter}}" data-rst-select-results="{{.Toolbar.SelectResults}}" data-rst-select-result-one="{{.Toolbar.SelectResultOne}}">
1247 {{range .Toolbar.Sizes}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
1248 </select>
1249 </div>
1250 <div docs-toolbar-group>
1251 <button docs-tool tabindex="-1" type="button" data-docs-command="strong" title="{{.Toolbar.Bold}}" aria-label="{{.Toolbar.Bold}}" aria-pressed="false" disabled>
1252 <span docs-tool-glyph="bold" aria-hidden="true">B</span>
1253 </button>
1254 <button docs-tool tabindex="-1" type="button" data-docs-command="em" title="{{.Toolbar.Italic}}" aria-label="{{.Toolbar.Italic}}" aria-pressed="false" disabled>
1255 <span docs-tool-glyph="italic" aria-hidden="true">I</span>
1256 </button>
1257 <button docs-tool tabindex="-1" type="button" data-docs-command="code" title="{{.Toolbar.Code}}" aria-label="{{.Toolbar.Code}}" aria-pressed="false" disabled>
1258 <span docs-tool-glyph="code" aria-hidden="true">&lt;&gt;</span>
1259 </button>
1260 </div>
1261 {{/* Alignment: the first block-level formatting, so it gets a group
1262 of its own between the inline marks and the list controls, which
1263 is also where the block it acts on sits in the reading order.
1264
1265 aria-pressed rather than a select, and exactly one of the three
1266 is pressed at any moment — toolbar.mjs's sync sets it from the
1267 block the cursor is in, the same way Bold reads the marks there.
1268 [docs-tool]'s 28px box already clears WCAG 2.2 AA's 24x24
1269 minimum. These three are icon-only, so the pressed state is shown
1270 by the inset bar under the glyph rather than by the weight change
1271 that serves B/I/<> — see that rule's own comment, which used to
1272 claim the weight covered every button and did not (§10.3: colour
1273 never carries meaning alone). */}}
1274 <div docs-toolbar-group>
1275 <button docs-tool tabindex="-1" type="button" data-docs-command="align_left" title="{{.Toolbar.AlignLeft}}" aria-label="{{.Toolbar.AlignLeft}}" aria-pressed="false" disabled>
1276 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3 5h14M3 9h9M3 13h14M3 17h9"/></svg>
1277 </button>
1278 <button docs-tool tabindex="-1" type="button" data-docs-command="align_center" title="{{.Toolbar.AlignCenter}}" aria-label="{{.Toolbar.AlignCenter}}" aria-pressed="false" disabled>
1279 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3 5h14M5.5 9h9M3 13h14M5.5 17h9"/></svg>
1280 </button>
1281 <button docs-tool tabindex="-1" type="button" data-docs-command="align_right" title="{{.Toolbar.AlignRight}}" aria-label="{{.Toolbar.AlignRight}}" aria-pressed="false" disabled>
1282 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M3 5h14M8 9h9M3 13h14M8 17h9"/></svg>
1283 </button>
1284 </div>
1285 <div docs-toolbar-group>
1286 <button docs-tool tabindex="-1" type="button" data-docs-command="bullet_list" title="{{.Toolbar.BulletList}}" aria-label="{{.Toolbar.BulletList}}" aria-pressed="false" disabled>
1287 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="4" cy="6" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="10" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="14" r="1.1" fill="currentColor" stroke="none"/><path d="M8 6h9M8 10h9M8 14h9"/></svg>
1288 </button>
1289 <button docs-tool tabindex="-1" type="button" data-docs-command="ordered_list" title="{{.Toolbar.OrderedList}}" aria-label="{{.Toolbar.OrderedList}}" aria-pressed="false" disabled>
1290 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M8 6h9M8 10h9M8 14h9"/><text x="1.5" y="7.6" font-size="5.5" fill="currentColor" stroke="none">1</text><text x="1.5" y="11.6" font-size="5.5" fill="currentColor" stroke="none">2</text><text x="1.5" y="15.6" font-size="5.5" fill="currentColor" stroke="none">3</text></svg>
1291 </button>
1292 <button docs-tool tabindex="-1" type="button" data-docs-command="blockquote" title="{{.Toolbar.Quote}}" aria-label="{{.Toolbar.Quote}}" aria-pressed="false" disabled>
1293 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M4 5v10"/><path d="M8 7h9M8 11h9M8 15h6"/></svg>
1294 </button>
1295 </div>
1296</div>
1297</div>
1298</div>{{/* closes [docs-chrome] */}}
1299{{/* data-view is the person's paged/infinite preference, read from the
1300 docs-view cookie server-side (menu.go's viewMode) so the FIRST
1301 paint is already right. Doing it in the bundle instead would mean
1302 anyone who prefers paged watches an infinite document reflow into
1303 pages a moment after it appears.
1304
1305 data-page-label is the page-number template
1306 ("Page {n}") for fixtures/pages.mjs to stamp into each gap. Copy,
1307 so it comes from the catalog here rather than being a literal in
1308 the bundle — the rule the toolbar and the save-status indicator
1309 already follow. */}}
1310<div docs-canvas data-view="{{.ViewMode}}" data-page-label="{{.PageLabel}}">
1311 <div docs-page>
1312 {{/* docs-editor is the ProseMirror mount point (fixtures/editor-entry.js,
1313 Task 6): the same [docs-text] container the scriptless read view
1314 already renders into, not a second element beside it. ProseMirror
1315 replaces this container's contents only once it mounts, so the
1316 server-rendered {{.HTML}} below is what a scriptless reader sees
1317 and what a scripted reader sees until the editor takes over — spec
1318 §6.2, and editor_view_test.go's own
1319 TestShowRendersContentWithoutScript pins it. data-sqid is the
1320 bundle's only way to learn which document's update log
1321 (/d/{{.Sqid}}/updates) to load and post to. */}}
1322 {{/* A reader gets the same container without the mount point, the
1323 data-sqid or (below) the script — absent, not disabled, because a
1324 disabled editor is one CSS rule from being an editor again. The
1325 server-rendered HTML is identical either way, so this is the
1326 scriptless read view the whole page already falls back to. */}}
1327 {{if .CanWrite}}<div docs-text docs-editor data-sqid="{{.Sqid}}" data-save-status-module="{{asset "static/estilo-save-status.mjs"}}">{{.HTML}}</div>
1328 {{else}}<div docs-text>{{.HTML}}</div>{{end}}
1329 </div>
1330</div>
1331{{/* The Tools menu's two dialogs.
1332
1333 Native <dialog>, so the browser provides modality, Escape, focus
1334 trapping and the backdrop — four things that are easy to get subtly
1335 wrong by hand and none of which is the interesting part of either
1336 feature.
1337
1338 Rendered on every document page and empty until opened: the copy is
1339 here because every user-visible string is a catalog key, and the
1340 bundle fills in numbers (fixtures/wordcount.mjs) and rows
1341 (fixtures/shortcuts.mjs) only. With no JavaScript neither can open,
1342 which is honest — there is no editor to count or to send keys to.
1343
1344 Outside [docs-editor], like every other piece of chrome on this
1345 screen: ProseMirror's {mount: el} takes over everything inside that
1346 element. */}}
1347<dialog docs-dialog docs-wordcount>
1348 <h2>{{.Dialogs.WordCountTitle}}</h2>
1349 <dl docs-dialog-rows>
1350 <div><dt>{{.Dialogs.WordCountWords}}</dt><dd docs-count-words>0</dd></div>
1351 <div><dt>{{.Dialogs.WordCountCharacters}}</dt><dd docs-count-characters>0</dd></div>
1352 <div><dt>{{.Dialogs.WordCountCharactersNoSpaces}}</dt><dd docs-count-characters-nospaces>0</dd></div>
1353 <div><dt>{{.Dialogs.WordCountParagraphs}}</dt><dd docs-count-paragraphs>0</dd></div>
1354 {{/* Hidden until something is selected — a row reading "0" invites
1355 a reader to work out whether that is a count or an absence. */}}
1356 <div docs-count-selection hidden><dt>{{.Dialogs.WordCountSelection}}</dt><dd docs-count-selection-words>0</dd></div>
1357 </dl>
1358 <form method="dialog"><button rst-btn="ghost" type="submit">{{.Dialogs.WordCountClose}}</button></form>
1359</dialog>
1360<dialog docs-dialog docs-shortcuts>
1361 <h2>{{.Dialogs.ShortcutsTitle}}</h2>
1362 <div docs-shortcuts-body></div>
1363 {{/* The bindings no menu item carries, so shortcuts.mjs cannot read
1364 them off the bar. Rendered here as data for it to pick up rather
1365 than as literals in the bundle. */}}
1366 <div docs-shortcuts-extra hidden data-group="{{.Dialogs.ShortcutsGroup}}">
1367 {{range .Dialogs.ShortcutsExtra}}<span data-label="{{.Label}}" data-keys="{{.Keys}}"></span>{{end}}
1368 </div>
1369 <form method="dialog"><button rst-btn="ghost" type="submit">{{.Dialogs.ShortcutsClose}}</button></form>
1370</dialog>
1371{{template "estilo-save-status" .SaveStatus}}
1372{{/* type="module" defer: modules are deferred by default, but stated
1373 explicitly here so load order (after the DOM, before DOMContentLoaded)
1374 doesn't depend on that default going unnoticed by a future edit. Served
1375 without a session (assets.go's staticHandler, ahead of the
1376 RequireSession group) and, if it throws, editor-entry.js's own catch
1377 marks [docs-editor] data-editor-failed rather than clearing it — the
1378 read view above stays exactly as rendered. */}}
1379{{if .CanWrite}}<script type="module" defer src="{{.EditorScript}}"></script>{{end}}
1380{{template "docs-jumpto" .}}
1381{{end}}
1382