| 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"><></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 | |