The chrome is a bar, and the paper has the whole desk
A document screen carries three things that are not the document: its
own name, the account, and the document-level actions. With no shell
bar to hold any of them they had all ended up in [rst-page-header],
which is a screen heading — "Documents", "Posts" — and not an app bar.
So a document's title rendered at 22px under the design system's rake
stroke, with PDF, Markdown and Sign out beside it as three
identically-weighted ghost buttons, and the grey canvas sat as a
rectangle floating in white with about 85px of tray either side of the
sheet.
Switch the shell from column to topbar and let each piece go where its
equivalent lives in Google Docs and Word, which agree with each other
about this. The brand link is the route back to the list (it was
[docs-back], a breadcrumb this page drew for itself); the title sits
beside it; sign-out is in the bar's account menu, the same control on
every signed-in screen; the exports are at the formatting bar's
trailing end, which is where both put a document's own controls and
which was empty white space before. The list header loses sign-out
with them and is back to two controls.
The canvas then takes the window: [rst-page]'s 64rem column and its
padding are cancelled on this one screen, by delivery rather than by
selector, since the stylesheet ships only with show.html. Not with
`width: 100vw` — that measures the viewport including a classic
scrollbar and starts the horizontal scroll SC 1.4.10 forbids, which
the narrow-screen block below it had already worked out once. That
block gets simpler as a result: the negative margin it used to need to
cancel the shell's padding is gone, because the padding is.
No new catalog strings: the brand reuses docs.list.title, and a
document's title is its own data.
Three bugs found by looking at the rendered result rather than by
reasoning about it, none of them the layout change itself:
- signin.html's first rule to hide the bar did nothing. tokens.css
lays it out at (0,2,0) and a bare [rst-shell-bar] is (0,1,0), so
the front door kept a "Home" link to a route its own guard
bounces straight back.
- At 320px the three toolbar selects measured wider than the bar and
the page grew a horizontal scrollbar. That one predates this work
— the toolbar had 256px inside [rst-page] and has 288px now — and
is fixed here because this is the row being restructured.
- fixtures/browser-drive.mjs was red and had not been run. It is not
in the gate (Chromium cannot start in the agent sandbox) and it is
the only check of the keyboard path. It anchored on the sign-out
button, which is now inside a closed <details>: found by
querySelector, not rendered, so .focus() silently did nothing and
the loop counted Tab presses from wherever focus already was. It
reported 8 — a true measurement of nothing. Now anchored on
[rst-shell-brand], asserting the anchor took before counting, with
the bound re-derived from the real path (account, toolbar as one
roving stop, PDF, Markdown, editor = 5).
Found by the branch review and fixed with them: [docs-shell-title]
claimed truncation and wrapped the bar instead (flex line-breaking
uses the hypothetical main size, so flex: 1 1 0 is what never forces a
break); [docs-bar]'s auto-margin note was backwards in both halves;
[rst-shell-brand] is a 21px target and [docs-back] had already found
that 24px floor before this branch deleted it, so the rule is back in
head.html; the narrow-screen character counts used two divisors in one
paragraph while claiming to be measured.
Verified on the merged tree, not just on the branch: .amadan/ci.d/
10-gate exits 0 and the browser drive is 23/23 against main's newer
editor bundle and paste pipeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvAMFZXaJSaXRqGVn11WXj
11 files changed,
+526
−206
AGENTS.md+2 −2fixtures/browser-drive.mjs+50 −12fixtures/toolbar-roving.mjs+1 −1internal/docs/handlers.go+8 −6internal/docs/list_test.go+26 −9internal/docs/render.go+41 −9internal/docs/views/head.html+25 −1internal/docs/views/list.html+34 −13internal/docs/views/show.html+295 −151internal/docs/views/signin.html+40 −0internal/export/pdf.go+4 −2
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index bf1bf8b..b2580de 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -26,8 +26,8 @@ Every document route — `GET /`, `POST /documents`, `GET /d/{sqid}` — sits |
| behind rastrillo/auth's `RequireSession` and is unreachable without a real |
| session (`internal/docs/auth.go`). Sign-in is a magic link: `POST /signin` |
| emails (or, in development, logs) a single-use link; following it |
| -(`GET /auth/verify`) sets a real session cookie; `POST /signout` (linked |
| -from both `list.html` and `show.html`'s page headers) really revokes it, |
| +(`GET /auth/verify`) sets a real session cookie; `POST /signout` (in the shell |
| +bar's account menu on both signed-in screens) really revokes it, |
| not just clearing the cookie client-side. No passkey second factor is |
| wired, on purpose — spec §8.1's ruling: `passkey.Config.Origin` derives |
| its relying party from this instance's own host, so a person in several |
diff --git a/fixtures/browser-drive.mjs b/fixtures/browser-drive.mjs| index 4ea64d2..f9d1cf3 100644 |
| --- a/fixtures/browser-drive.mjs |
| +++ b/fixtures/browser-drive.mjs |
| @@ -525,13 +525,34 @@ async function caretChecks() { |
| // see it".) |
| // |
| // Bounded rather than exact: the bar is one tab stop today |
| - // (toolbar.mjs's roving tabindex) so two presses suffice, but |
| - // asserting "two" would break the week someone adds a control |
| - // outside the bar for a reason that has nothing to do with this. |
| - // Twelve is generous and still fails loudly the day focus cannot |
| - // get out of the toolbar at all. |
| - await evaluate( |
| - `(()=>{const b=document.querySelector('form[action="/signout"] button');if(b)b.focus();return !!b})()`, |
| + // (toolbar.mjs's roving tabindex), but asserting an exact number |
| + // would break the week someone adds a control outside the bar for |
| + // a reason that has nothing to do with this. Twelve is generous |
| + // and still fails loudly the day focus cannot get out of the |
| + // toolbar at all. |
| + // |
| + // Starts from the shell bar's brand link, and that is not a free |
| + // choice. It used to start from the sign-out button, which was in |
| + // this page's own header; sign-out is now inside the shell's |
| + // account dropdown, a CLOSED <details>, whose content is in the |
| + // DOM but is not rendered — so querySelector still finds the |
| + // button, .focus() is a silent no-op, and the loop below counted |
| + // Tab presses from wherever focus happened to be already (the |
| + // editor, from check 6 above). It reported 8, which was a true |
| + // measurement of nothing. That is the failure shape AGENTS.md |
| + // names twice, so: the anchor must be something always rendered. |
| + // [rst-shell-brand] is — it is the first focusable thing in the |
| + // bar at every width — and the assertion below fails if it ever |
| + // stops being focusable, rather than quietly measuring from |
| + // somewhere else. |
| + const anchored = await evaluate( |
| + `(()=>{const b=document.querySelector('[rst-shell-brand]');if(!b)return false;b.focus();` + |
| + `return document.activeElement===b})()`, |
| + ) |
| + check( |
| + 'the tab-order check starts where it thinks it does', |
| + anchored === true, |
| + 'could not focus [rst-shell-brand] — the count below would measure from somewhere else', |
| ) |
| await sleep(200) |
| let reachedAfter = -1 |
| @@ -545,19 +566,36 @@ async function caretChecks() { |
| } |
| } |
| check( |
| - 'the document is reachable by Tab from the page header', |
| + 'the document is reachable by Tab from the shell bar', |
| reachedAfter > 0, |
| reachedAfter > 0 ? '' : 'twelve presses of Tab never reached the editor', |
| ) |
| // 8. And the bar is ONE stop on the way, not one per control. |
| // role="toolbar" is a promise that the arrow keys move within it; |
| - // a bar that spends eleven tab presses has made that promise and |
| - // broken it. |
| + // a bar that spends fourteen tab presses has made that promise |
| + // and broken it. |
| + // |
| + // Five, derived rather than chosen, at this drive's 1280px (above |
| + // the shell's own 800px breakpoint, so the bar is laid out wide: |
| + // the hamburger is hidden and the tail is display: contents). |
| + // From [rst-shell-brand], in DOM order: the account summary (1), |
| + // the formatting bar, entire, as one roving-tabindex stop (2), |
| + // the PDF export (3), the Markdown export (4), the editor (5). |
| + // |
| + // It was three when the path was sign-out -> bar -> editor and |
| + // this page drew its own header. The two exports moved into the |
| + // bar's trailing end and the account control moved into the shell, |
| + // which is +2 for reasons that have nothing to do with the roving |
| + // tabindex — exactly the drift the original comment predicted and |
| + // left slack for. The slack is spent now, so the number is |
| + // re-derived above rather than nudged: if the toolbar ever stops |
| + // being one stop this becomes 18 and fails, which is the whole |
| + // point of the check. |
| check( |
| 'the formatting bar is a single tab stop', |
| - reachedAfter > 0 && reachedAfter <= 3, |
| - `the editor was ${reachedAfter} tab presses from the header`, |
| + reachedAfter > 0 && reachedAfter <= 5, |
| + `the editor was ${reachedAfter} tab presses from the shell bar`, |
| ) |
| // 9. ...and the arrow keys are what move inside it. |
diff --git a/fixtures/toolbar-roving.mjs b/fixtures/toolbar-roving.mjs| index 3f0bf97..7bd5857 100644 |
| --- a/fixtures/toolbar-roving.mjs |
| +++ b/fixtures/toolbar-roving.mjs |
| @@ -12,7 +12,7 @@ |
| // pattern, which `role="toolbar"` in views/show.html is a promise to |
| // implement. |
| // |
| -// Without it the bar is eleven tab stops between the page header and |
| +// Without it the bar is fourteen tab stops between the shell bar and |
| // the first word of the document, on every page load, for someone who |
| // only wanted to type. It is also what a word processor does, so it is |
| // what people's hands already expect. (Raised by the caret-placement |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 4a2a995..f626cad 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -65,8 +65,8 @@ type handlers struct { |
| // everyone else (fix round 1: the original version of this screen |
| // had no path to POST /documents at all outside a hand-typed curl). |
| // |
| -// SignOutLabel backs the sign-out control list.html's header also |
| -// carries — added in the fix round that deleted the development-only |
| +// SignOutLabel backs the sign-out control in the shell bar's account |
| +// menu, which this screen fills with an "account" block — added in the fix round that deleted the development-only |
| // stand-in identity: POST /signout (auth.go) always existed and |
| // worked, but nothing on any page linked to it, which matters once |
| // this is reachable at a real URL rather than only ever behind a |
| @@ -269,10 +269,12 @@ type showPage struct { |
| HTML template.HTML |
| SignOutLabel string // see listPage.SignOutLabel's own doc comment |
| - // BackLabel is the document page's one route back to the list |
| - // (views/show.html's [docs-back]). Until this landed, GET |
| - // /d/{sqid} offered no in-app navigation at all — its header held |
| - // sign-out and nothing else — so a person who arrived by a |
| + // BackLabel is the document page's one route back to the list — |
| + // the label on the shell bar's brand link, which views/show.html |
| + // fills with a "brand" block (it was [docs-back], a breadcrumb in |
| + // this page's own header, until the chrome moved to the topbar |
| + // shell). Until it landed, GET /d/{sqid} offered no in-app |
| + // navigation at all — its header held sign-out and nothing else — so a person who arrived by a |
| // bookmark or a mailed link, rather than by clicking through from |
| // GET /, had no way back to their own documents except the |
| // browser's back button, which that arrival gives them nothing to |
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go| index 91c7a7e..61e25ea 100644 |
| --- a/internal/docs/list_test.go |
| +++ b/internal/docs/list_test.go |
| @@ -198,12 +198,29 @@ func TestUnknownDocument404s(t *testing.T) { |
| } |
| } |
| -// A document page must offer a way back to the list. Before |
| -// [docs-back] landed, GET /d/{sqid} rendered sign-out and no link at |
| -// all, so arriving by a bookmark or a mailed link — the two ways of |
| -// reaching a document that do not pass through GET / on the way — |
| -// stranded a person on it with nothing but the browser's own back |
| -// button, which such an arrival gives nothing to go back to. |
| +// A document page must offer a way back to the list. Before that link |
| +// landed, GET /d/{sqid} rendered sign-out and no link at all, so |
| +// arriving by a bookmark or a mailed link — the two ways of reaching a |
| +// document that do not pass through GET / on the way — stranded a |
| +// person on it with nothing but the browser's own back button, which |
| +// such an arrival gives nothing to go back to. |
| +// |
| +// The link has moved once since. It was [docs-back], a breadcrumb this |
| +// page drew above its own title; it is [rst-shell-brand] now, the |
| +// topbar shell's brand slot, which show.html fills with the same href |
| +// and the same label (render.go's shellLayout comment has the why). |
| +// This test moved with it rather than being deleted, because what it |
| +// holds is the route, not the element that happened to carry it. |
| +// |
| +// Note what that means for each half below, because they are not |
| +// equally load-bearing any more. The layout's own default brand is |
| +// already <a rst-shell-brand href="/">, so a page that stopped |
| +// defining "brand" would keep the href and lose only the label: the |
| +// first assertion would still pass and the second is what would |
| +// catch it. The first is not useless — it catches the shell being |
| +// swapped for one with no brand at all, which is exactly the kind of |
| +// whole-app change this branch itself was — but the label comparison |
| +// is the one doing the work. |
| // |
| // Asserted on the rendered page rather than the template's bytes: the |
| // href has to survive the handler populating it, not merely be |
| @@ -229,8 +246,8 @@ func TestDocumentPageLinksBackToTheList(t *testing.T) { |
| } |
| body := rec.Body.String() |
| - if !strings.Contains(body, `<a docs-back href="/">`) { |
| - t.Error("no [docs-back] link to / on the document page — a document reached directly has no route to the list") |
| + if !strings.Contains(body, `<a rst-shell-brand href="/">`) { |
| + t.Error("no brand link to / on the document page — a document reached directly has no route to the list") |
| } |
| // The label is docs.list.title, deliberately shared with the screen |
| @@ -254,7 +271,7 @@ func TestDocumentPageLinksBackToTheList(t *testing.T) { |
| t.Fatalf("list status %d", listRec.Code) |
| } |
| listTitle := betweenTags(t, listRec.Body.String(), "<h1>", "</h1>") |
| - backLabel := betweenTags(t, body, `<a docs-back href="/">`, "</a>") |
| + backLabel := betweenTags(t, body, `<a rst-shell-brand href="/">`, "</a>") |
| if backLabel == "" || backLabel != listTitle { |
| t.Errorf("back link label = %q, want the list's own <h1> (%q)", backLabel, listTitle) |
| } |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 460174c..e412baf 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -14,12 +14,42 @@ import ( |
| ) |
| // dayTheme and shellLayout pick the design system's day theme and |
| -// column shell — rastrillo new's own defaults, and what the design |
| -// spec (docs/superpowers/specs/2026-08-30-docs-design.md, §10) asks |
| -// for. |
| +// topbar shell. |
| +// |
| +// The shell was "column" from Task 1 until the formatting bar |
| +// landed — rastrillo new's own default, and the right shape while |
| +// every screen really was one centred column of content. The editor |
| +// is what made it wrong. A document screen carries three things that |
| +// are not the document: its own identity, the account, and the |
| +// document-level actions. With no shell bar to hold any of them they |
| +// all ended up in [rst-page-header], which is a *screen heading* |
| +// ("Documents", "Posts") and not an app bar. What that looked like: |
| +// a document's title set at 22px with the design system's rake |
| +// stroke under it, and PDF / Markdown / Sign out as three |
| +// identically-weighted ghost buttons beside it — chrome competing |
| +// with the document for the top of the screen. |
| +// |
| +// topbar gives both signed-in screens one bar: brand, the screen's |
| +// own identity, the account menu. That is the layout language Google |
| +// Docs and Word share, and the reason a document and a document list |
| +// read as one app in either of them. It is the design system's own |
| +// shell rather than a bar this app drew for itself, which is what |
| +// spec §10's "the thing to watch is drift in the chrome" asks for. |
| +// |
| +// Two things to know before changing this again. It is a whole-app |
| +// choice — newRenderer parses exactly one layout, so there is no |
| +// per-screen opt-out. And a page's only lever on the bar is the |
| +// blocks the layout exposes (brand, nav, account, locale, foot): |
| +// the bar element itself, the account disclosure and the narrow-width |
| +// hamburger are markup the shell emits outside all of them. Leaving |
| +// "account" undefined does remove the account menu — tokens.css hides |
| +// a disclosure whose menu is empty — but nothing removes the default |
| +// brand, so a screen that must offer no bar at all hides it in its |
| +// own stylesheet. signin.html is the one that does, and its comment |
| +// says why, including why the obvious selector for it silently fails. |
| const ( |
| dayTheme = "day" |
| - shellLayout = "column" |
| + shellLayout = "topbar" |
| ) |
| // Catalog is CARLOS Docs' own English strings. Every user-visible |
| @@ -145,9 +175,11 @@ var Catalog = rastrillo.Catalog{ |
| "docs.signin.error.expired": "That link has expired or was already used. Enter your email below for a new one.", |
| "docs.signin.error.unknown": "We couldn't sign you in. Enter your email below for a new link.", |
| - // docs.signout.label is the sign-out control's text (list.html's |
| - // and show.html's page headers) — both its button label and its |
| - // accessible name. |
| + // docs.signout.label is the sign-out control's text — both its |
| + // button label and its accessible name. It lives in the shell |
| + // bar's account menu, which list.html and show.html each fill with |
| + // an "account" block; it was in their two page headers until the |
| + // chrome moved to the topbar shell (see shellLayout above). |
| "docs.signout.label": "Sign out", |
| // docs.editor.status.* is the save-status indicator fix round 1's |
| @@ -222,8 +254,8 @@ var Catalog = rastrillo.Catalog{ |
| "docs.editor.toolbar.align.center": "Align centre", |
| "docs.editor.toolbar.align.right": "Align right", |
| - // docs.export.* — the two download links in show.html's page |
| - // header. The short forms are what the links say; the long forms |
| + // docs.export.* — the two download links at the trailing end of |
| + // show.html's formatting bar ([docs-doc-actions]). The short forms are what the links say; the long forms |
| // are their accessible names, because "PDF" on its own does not |
| // say what will happen when it is followed. |
| "docs.export.pdf.short": "PDF", |
diff --git a/internal/docs/views/head.html b/internal/docs/views/head.html| index feaffe5..125e95d 100644 |
| --- a/internal/docs/views/head.html |
| +++ b/internal/docs/views/head.html |
| @@ -22,4 +22,28 @@ |
| not out of the rastrillo.Assets tree {{asset}} fingerprints. |
| assets_test.go asserts this markup and that constant agree, so |
| the pair cannot drift into a silent 404. */}} |
| -{{define "head"}}<link rel="icon" type="image/svg+xml" href="/static/icon.svg">{{end}} |
| +{{/* The one app-wide style rule, and it is here for the same reason |
| + the icon is: it belongs to every page and must be declared once. |
| + |
| + [rst-shell-brand] is now a real target on the signed-in screens — |
| + it is the route back to the list, which used to be [docs-back] in |
| + show.html's own header. tokens.css gives the brand colour, weight |
| + and no padding at all, so it measures 21px tall against 14px body |
| + text at line-height 1.5. AGENTS.md commits to WCAG 2.2 AA with no |
| + exemptions, and SC 2.5.8's 24x24 floor is one of the three it |
| + names. 21px clears that only through the spacing exception, which |
| + is a different argument that stops holding the moment anything |
| + lands beside the brand. |
| + |
| + [docs-back] had already learned this and stated the floor outright |
| + rather than hoping padding added up to it — measured, its first |
| + version came to 22px because --rst-fs-sm is 12.5px and not the |
| + 14px it was assumed to be. Deleting [docs-back] deleted that |
| + finding with it; this is the same rule at the link's new address. |
| + inline-flex is what makes min-block-size apply to a link at all. |
| + |
| + Written against the framework's own element rather than proposed |
| + upstream yet: spec §10 says a component we keep editing belongs |
| + upstream, and if the sibling apps want the same floor this is the |
| + rule to send. It is inert on sign-in, which hides the bar. */}} |
| +{{define "head"}}<link rel="icon" type="image/svg+xml" href="/static/icon.svg"><style>[rst-shell-brand]{display:inline-flex;align-items:center;min-block-size:24px}</style>{{end}} |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index e755010..5c997ae 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -10,17 +10,22 @@ |
| documents yet exactly the same way it reaches everyone else, |
| rather than only appearing once there is something to list. |
| - [docs-header-actions] wraps that create form and the sign-out |
| - form (POST /signout, auth.go) as one group — added in a later fix |
| - round once real sign-in existed and nothing on the page could end |
| - one: [rst-page-header]'s flex/space-between rule wants at most two |
| - top-level children (titles, one action area), so both controls |
| + [docs-header-actions] wraps the search form and that create form |
| + as one group: [rst-page-header]'s flex/space-between rule wants at |
| + most two top-level children (titles, one action area), so both |
| live inside this one wrapper rather than becoming a third |
| - top-level child of the header itself. Sign-out is a second, |
| - separate <form>, not a second button in the create form, because |
| - they are two unrelated actions with two different targets |
| - (/documents vs /signout) and must never be merged into one |
| - submit. |
| + top-level child of the header itself. They stay two separate |
| + <form>s, never one, because they are unrelated actions with |
| + different methods and targets (GET / vs POST /documents) and must |
| + never be merged into one submit. |
| + |
| + Sign-out (POST /signout, auth.go) used to be a third form in that |
| + wrapper. It is in the shell bar's account menu now — the same |
| + control, in the same place, on every signed-in screen. It was |
| + added to this header in a fix round once real sign-in existed and |
| + nothing on the page could end a session; that is still the reason |
| + it must exist somewhere, and a header holding search, create and |
| + sign-out at once had become five controls on one line. |
| Writes the bare rst-* attribute grammar throughout — the rst- |
| vocabulary always as a bare attribute, never as a class — which is |
| @@ -35,6 +40,25 @@ |
| own tiny stylesheet here rather than a static/ file this |
| hand-built app does not otherwise have one of. */}} |
| {{define "title"}}{{.Title}}{{end}} |
| +{{/* brand and account fill two of the topbar shell's own slots |
| + (render.go's shellLayout). Defined per page rather than once in |
| + views/head.html because they differ per screen — show.html's |
| + brand carries a document's name beside the link home, and |
| + sign-in offers no account menu at all. |
| + |
| + This screen's brand is just the app's own name, which is |
| + listPage.Title, the same string the heading below uses. That |
| + repetition is deliberate and is what both reference applications |
| + do: the bar names the app, the heading names the screen, and on |
| + the one screen where those are the same thing they read as the |
| + same thing. */}} |
| +{{define "brand"}}<a rst-shell-brand href="/">{{.Title}}</a>{{end}} |
| +{{/* account is the dropdown's MENU BODY, not the whole menu — see |
| + show.html's own copy of this block for the reasoning; the two are |
| + identical on purpose, and are two definitions rather than one |
| + shared partial because a page template is where a shell block |
| + override has to live for the clone that page is parsed into. */}} |
| +{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}} |
| {{define "content"}}<style> |
| /* [docs-thumb] is reserved space, not a placeholder image: a later |
| plan's layout engine draws a real rendering of the document's |
| @@ -207,9 +231,6 @@ |
| <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off"> |
| <button rst-btn="primary" type="submit">{{.CreateLabel}}</button> |
| </form> |
| - <form method="post" action="/signout"> |
| - <button rst-btn="ghost" type="submit">{{.SignOutLabel}}</button> |
| - </form> |
| </div> |
| </header> |
| {{/* Searching renders a ranked, UNGROUPED result list. Recency |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index 53c6055..5cb89c0 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -35,35 +35,104 @@ |
| render_test.go's TestOurTemplatesUseTheAttributeGrammar, which |
| holds it against this file too. */}} |
| {{define "title"}}{{.Title}}{{end}} |
| +{{/* brand and account fill two of the topbar shell's own slots |
| + (render.go's shellLayout, rastrillo ui/layouts/topbar.html). |
| + Defined here rather than in views/head.html — which is the one |
| + definition all three pages share — because these two differ per |
| + screen: this page's brand carries a document's name beside the |
| + link home, and sign-in must offer no account menu at all. |
| + |
| + brand emits two elements, not one. The block sits directly inside |
| + [rst-shell-bar], which is a flex row, so the link and the title |
| + become two flex items with the bar's own --rst-sp-4 gap between |
| + them; there is no wrapper because a wrapper would need a layout |
| + rule of its own to reproduce what the bar already does. |
| + |
| + The title is printed as-is, the same way list.html's row titles |
| + are — it is the document's own data, not authored copy, so it |
| + never goes through rastrillo.T. */}} |
| +{{define "brand"}}<a rst-shell-brand href="/">{{.BackLabel}}</a><span docs-shell-title>{{.Title}}</span>{{end}} |
| +{{/* account is the dropdown's MENU BODY, not the whole menu: the |
| + shell owns the <details>/<summary> and this block supplies only |
| + what sits inside [rst-dropdown-menu]. A <form> rather than a |
| + link, because ending a session is a POST (auth.go), and |
| + [rst-dropdown-menu] button already styles a bare button as a |
| + full-width menu row, so it needs no rst-btn of its own — which |
| + is the point of putting it here instead of leaving it as a ghost |
| + button in a page header. */}} |
| +{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}} |
| {{define "content"}}<style> |
| - /* [docs-back]: the route to the document list, above the title. |
| - Chrome, so it takes the app's theme tokens rather than the |
| - canvas's literals. Muted at rest and full-strength on hover and |
| - focus, so it reads as secondary to the document's own title |
| - without dropping under the 4.5:1 body-text floor at rest. |
| - min-block-size states the 24×24 floor (WCAG 2.2 AA SC 2.5.8) |
| - outright rather than hoping padding adds up to it: measured, the |
| - first version of this rule — --rst-fs-sm plus a --rst-sp-1 |
| - padding either side — came to 22px, because --rst-fs-sm is |
| - 12.5px here and not the 14px it was assumed to be. A target size |
| - derived from two tokens is a target size that changes when either |
| - token does, silently. inline-flex is what makes min-block-size |
| - apply to a link at all and centres the label inside it. */ |
| - [docs-back] { |
| - display: inline-flex; |
| - align-items: center; |
| - min-block-size: 24px; |
| - padding-block: var(--rst-sp-1); |
| - font-size: var(--rst-fs-sm); |
| - line-height: 1.15; |
| - color: var(--rst-text-muted); |
| - text-decoration: none; |
| - } |
| - [docs-back]:hover, |
| - [docs-back]:focus-visible { |
| + /* This screen is the one that is not a column of content. It is a |
| + document on a desk, and in both of the applications this layout |
| + language comes from the desk runs to the window's edges. |
| + |
| + [rst-page] — rastrillo's own <main>, written by the topbar shell |
| + outside any block this app can override — is a 64rem centred |
| + column with --rst-sp-5/--rst-sp-4/--rst-sp-6 of padding. That is |
| + right for the list and wrong here, in two separate ways: it |
| + leaves the grey tray as a rectangle floating in white with about |
| + 85px of tray showing either side of the sheet, and it holds the |
| + formatting bar to 64rem when the bar belongs to the window the |
| + way the shell's own bar above it does. |
| + |
| + Cancelling the column here rather than reaching for |
| + `width: 100vw` is the same choice, for the same reason, that the |
| + narrow-screen block below already records: 100vw measures the |
| + viewport INCLUDING a classic scrollbar, so it overhangs by the |
| + scrollbar's width and starts exactly the horizontal scroll that |
| + SC 1.4.10 forbids. A container told to stop padding itself |
| + cannot overhang. |
| + |
| + Scoped by delivery rather than by selector: this stylesheet is |
| + inside show.html's own "content" and ships only with this page, |
| + so the rule reaches neither the list nor sign-in. It is written |
| + bare — no `[docs-canvas] ~` dance — because a page-wide rule |
| + that pretends to be scoped is worse than one that says what it |
| + does. */ |
| + [rst-page] { |
| + max-inline-size: none; |
| + padding: 0; |
| + } |
| + /* [docs-shell-title] is the document's own name in the shell bar, |
| + beside the brand link back to the list — the pattern both |
| + reference applications use, and the reason this page no longer |
| + carries a [rst-page-header] of its own (see render.go's |
| + shellLayout comment for the whole argument). |
| + |
| + It is a <span>, not a heading: the document's title already IS a |
| + heading in the only place that counts, <title>, and a visible h1 |
| + that is not the document's first line was the thing making the |
| + old header read as a screen heading rather than a document's |
| + name. |
| + |
| + flex: 1 1 0, and the zero basis is the whole point rather than a |
| + tidy default. [rst-shell-bar] wraps (tokens.css), and a flex |
| + container decides its line breaks from each item's HYPOTHETICAL |
| + main size — for `flex-basis: auto` on a nowrap span, its full |
| + untruncated width — before shrinking or min-inline-size get a |
| + say. So the obvious spelling, flex: 0 1 auto with |
| + min-inline-size: 0, does not truncate a long title at all: it |
| + drops the title onto a second row of the bar and the account |
| + control onto a third, and only ellipsises once the title alone |
| + is wider than the whole bar. A zero basis never forces a break, |
| + so the item grows into whatever space is free and shrinks with |
| + an ellipsis when there is none — which is the behaviour this |
| + paragraph used to claim and did not have. |
| + |
| + What that costs, knowingly: a truncated title is then readable |
| + nowhere on this screen — the span is not focusable and the |
| + document's own first line is not its title. It is still in the |
| + tab title, and in full on the list. A wrapping bar would keep it |
| + legible at the price of the chrome changing height as you open |
| + documents, which is the thing this whole branch is undoing. */ |
| + [docs-shell-title] { |
| + flex: 1 1 0; |
| + min-inline-size: 0; |
| + overflow: hidden; |
| + white-space: nowrap; |
| + text-overflow: ellipsis; |
| color: var(--rst-text); |
| - text-decoration: underline; |
| - text-underline-offset: 0.2em; |
| + font-size: var(--rst-fs-sm); |
| } |
| /* [docs-canvas] is the tray the page sits in — it is chrome around |
| paper, so it stays close to the surrounding theme's tone, but it |
| @@ -75,7 +144,15 @@ |
| color-scheme: light; |
| display: flex; |
| justify-content: center; |
| - padding: var(--rst-sp-6) var(--rst-sp-4); |
| + /* Less air above the sheet than below it. The tray used to carry |
| + --rst-sp-6 on both block edges and sat under a page header that |
| + had already spent --rst-sp-5 of its own below the rule; with the |
| + header gone and the bar sitting directly on the tray, the same |
| + --rst-sp-6 read as a gap rather than as a margin. --rst-sp-5 |
| + above puts the top of the sheet where the eye expects it from |
| + the bar; --rst-sp-6 below is scroll run-out, which wants to stay |
| + generous. */ |
| + padding: var(--rst-sp-5) var(--rst-sp-4) var(--rst-sp-6); |
| background: #d8d8dc; |
| } |
| /* [docs-page]: white, centred, with margins — literal colours per |
| @@ -100,46 +177,57 @@ |
| /* Narrow screens stop simulating paper, because at phone widths the |
| simulation costs more than it says. |
| - Above this breakpoint a reader sees THREE nested insets, and the |
| - first one is easy to miss because it belongs to the framework: the |
| - shell this template renders into (rastrillo's [rst-page]) pads |
| - --rst-sp-4 either side, then [docs-canvas] adds its own 1rem tray |
| - padding, then [docs-page] adds 1in. That inner inch is a real |
| - measurement — it stands for the document's page margin, which page |
| - setup and the layout engine will own properly later — and on a |
| - desktop it is worth every pixel. On a 375px phone the three together |
| - (375 - 32 - 32 - 192) left a 119px column, and at 320px, which this |
| - app supports without exemption (WCAG 2.2 AA SC 1.4.10), 64px: about |
| - five characters of 12pt body text per line. A page margin that |
| - squeezes the page is no longer describing the page. (The character |
| - counts here and below were measured against 12pt Georgia, the |
| - fallback for the serif this canvas used to default to. The default |
| - is Docs Sans now, whose characters run a little wider at the same |
| - size, so the counts are if anything generous — the breakpoint the |
| - arithmetic argues for does not move.) |
| - |
| - (An earlier version of this comment counted two insets and said |
| - 151px and 96px. It was wrong in the direction that flatters the |
| - code, and it was wrong while the paragraph below it described |
| - cancelling the very inset it had forgotten. The numbers here are |
| - measured, not derived.) |
| - |
| - So below 36rem the tray's side padding goes to nothing, its block |
| - padding drops from --rst-sp-6 to --rst-sp-4, the sheet runs edge to |
| - edge, and ONE margin survives inside it. Text goes to 343px at 375px |
| - wide and 288px at 320px. The 1in is untouched at every width above |
| - the breakpoint, so where the print margin can be shown honestly it |
| - still is. |
| + Above this breakpoint a reader sees TWO nested insets: |
| + [docs-canvas]'s own 1rem tray padding, then [docs-page]'s 1in. |
| + That inner inch is a real measurement — it stands for the |
| + document's page margin, which page setup and the layout engine |
| + will own properly later — and on a desktop it is worth every |
| + pixel. On a 375px phone the two together (375 - 32 - 192) leave a |
| + 151px column, and at 320px, which this app supports without |
| + exemption (WCAG 2.2 AA SC 1.4.10), 96px: about twelve characters |
| + of 12pt body text per line. A page margin that squeezes the page |
| + is no longer describing the page. |
| + |
| + (Every character count in this comment divides by 8px, the |
| + average advance of 12pt Georgia — the fallback for the serif this |
| + canvas used to default to. Stated because the counts here were |
| + for a while derived from two different divisors at once: "five |
| + characters" for 64px is 12.8px each, "40 characters" for 320px is |
| + 8px each, and both sat in the same paragraph claiming to be |
| + measured. The default is Docs Sans now, whose characters run a |
| + little wider at the same size, so these are if anything |
| + generous — the breakpoint the arithmetic argues for does not |
| + move.) |
| + |
| + (The count and the numbers in this paragraph have now been wrong |
| + in both directions, which is worth leaving on the record. They |
| + first said two insets and 151px/96px, forgetting that [rst-page] |
| + pads --rst-sp-4 either side; that was corrected to three and |
| + 119px/64px. Both were true of the tree they were written against. |
| + [rst-page] no longer pads this screen at all — the rule at the top |
| + of this stylesheet zeroes it, so that the tray can reach the |
| + window's edges — which puts the arithmetic back on two insets and |
| + back on the original figures. Re-measure rather than trusting any |
| + of the three the next time something above this changes.) |
| + |
| + So below 36rem the tray's side padding goes to nothing, the sheet |
| + runs edge to edge, its block padding drops from --rst-sp-5/-6 to |
| + --rst-sp-4, and ONE margin survives inside it. Text goes to 343px |
| + at 375px wide and 288px at 320px. The 1in is untouched at every |
| + width above the breakpoint, so where the print margin can be shown |
| + honestly it still is. |
| 36rem is a judgement rather than a formula, and the honest version |
| - of the arithmetic says so: at 576px the three insets leave a 320px |
| - column, about 40 characters of this face, and holding out for the |
| - 45 characters that make a comfortable measure would put the |
| - breakpoint nearer 38rem. 36rem is the choice because reflow only |
| - promises 320px viewports, and between 577 and 600px the paper still |
| - yields 306-330px — cramped, readable, and still recognisably a |
| - page. Phones in portrait are all well below it; a tablet or a small |
| - window keeps the paper. |
| + of the arithmetic says so: at 576px the two insets leave a 352px |
| + column, about 44 characters of this face — a hair under the 45 |
| + that make a comfortable measure, where before the shell's padding |
| + came off it was 320px and about 40. The breakpoint has not been |
| + moved to follow that, deliberately: 36rem was already the |
| + conservative end of its own argument, the change only ever gives |
| + the sheet more room, and moving a breakpoint because a number |
| + improved is how a breakpoint stops meaning anything. Phones in |
| + portrait are all well below it; a tablet or a small window keeps |
| + the paper. |
| The shadow goes with the tray — it exists to lift the sheet off the |
| grey, and once there is no grey beside it, it is a dark smear on the |
| @@ -155,34 +243,26 @@ |
| measured in CSS pixels like any other: US Letter and A4 come out |
| around 700-800px and stay above this breakpoint, but A5 portrait is |
| about 559px and crosses it. Printing a document on A5 would then |
| - drop the 1in and apply a negative margin meant for a phone screen — |
| - an edge-bleeding sheet with 1rem margins, in the one medium where |
| - the page margin is not a metaphor at all. `screen` is what keeps |
| - the paragraph above true. */ |
| + drop the 1in for the 1rem meant for a phone screen — an |
| + edge-bleeding sheet, in the one medium where the page margin is |
| + not a metaphor at all. `screen` is what keeps the paragraph above |
| + true. */ |
| @media screen and (max-width: 36rem) { |
| [docs-canvas] { |
| + /* Zeroing the tray's own padding is now all it takes to reach |
| + the screen edge. It used to take a second rule as well — |
| + `margin-inline: calc(var(--rst-sp-4) * -1)`, cancelling the |
| + --rst-sp-4 that [rst-page] pads on every side — and that rule |
| + is gone because the inset it cancelled is gone: the [rst-page] |
| + override at the top of this stylesheet zeroes that padding at |
| + every width, so there is nothing left here to pull back. |
| + The reasoning it carried has not been lost, only moved: the |
| + argument for cancelling a known padding instead of reaching |
| + for `width: 100vw`, which overhangs by a classic scrollbar's |
| + width and starts the horizontal scroll SC 1.4.10 forbids, is |
| + written out where that override lives. */ |
| padding-inline: 0; |
| padding-block: var(--rst-sp-4); |
| - /* The tray's own padding is not the only inset in the way: the |
| - shell this template renders into ([rst-page], rastrillo's own |
| - chrome) carries --rst-sp-4 either side, measured. Cancelling it |
| - here is what actually reaches the screen edge, and it buys more |
| - than the 32px: with the sheet at 0 and its own 1rem inside it, |
| - the document's first character lines up exactly with the page |
| - header's title above it, which an inset sheet does not do. |
| - |
| - A negative margin in the shell's own token rather than the |
| - 100vw trick on purpose. `width: 100vw` measures the viewport |
| - INCLUDING a classic scrollbar, so on a desktop browser narrowed |
| - under this breakpoint — which is exactly how anyone tests the |
| - 320px reflow this app promises — it overhangs by the scrollbar's |
| - width and starts a horizontal scroll: the same WCAG 1.4.10 |
| - failure this whole block exists to fix, reintroduced by the fix. |
| - Cancelling a known padding cannot overhang. It does couple this |
| - rule to [rst-page]'s padding staying --rst-sp-4; if that ever |
| - changes, the sheet sits slightly inset or slightly proud, which |
| - is visible and harmless rather than silent. */ |
| - margin-inline: calc(var(--rst-sp-4) * -1); |
| } |
| [docs-page] { |
| inline-size: 100%; |
| @@ -447,29 +527,96 @@ |
| outline: none; |
| } |
| - /* [docs-toolbar] is chrome, not paper, so — unlike [docs-page] and |
| - [docs-text] above — it uses the app's own theme tokens and |
| - repaints with the reader's light/dark preference, exactly as the |
| - page header does. It is a sibling of [docs-canvas], never inside |
| - [docs-editor]: ProseMirror's {mount: el} takes over everything |
| - inside that element, so a toolbar rendered in there would be |
| - replaced by the document on the first mount. |
| - Sticky, because a formatting bar that scrolls away with the first |
| - page is not there when a person is writing on the third one. */ |
| - [docs-toolbar] { |
| + /* [docs-bar] is the strip between the shell's own bar and the |
| + paper: formatting on the left, the document's own actions on the |
| + right. Both reference applications put exactly this row in |
| + exactly this place, and the right-hand end is why it exists as a |
| + container at all — the formatting controls fill about half the |
| + width, and before the exports moved down here the other half was |
| + empty while three ghost buttons crowded the header above. |
| + |
| + It carries what [docs-toolbar] used to carry itself: chrome, not |
| + paper, so unlike [docs-page] and [docs-text] it takes the app's |
| + theme tokens and repaints with the reader's light/dark |
| + preference. Sticky, because a formatting bar that scrolls away |
| + with the first page is not there when a person is writing on the |
| + third one — and sticky on the container rather than on the |
| + toolbar so the exports stay put with it instead of scrolling out |
| + from beside it. |
| + |
| + It wraps: at 320px the formatting groups take the first lines and |
| + the exports drop to one of their own. |
| + |
| + [docs-doc-actions] gets there by an auto margin rather than |
| + `justify-content: space-between`, and the difference is only |
| + visible once the bar has wrapped. On one line the two are |
| + identical — two items, all the free space between them. On a |
| + wrapped line they are opposites: flexbox distributes free space |
| + to auto margins per line, so the auto margin keeps the exports at |
| + the trailing end, where they were before the wrap, while |
| + space-between gives a lone item flex-start and jumps them to the |
| + leading edge. Staying put is the point. (An earlier version of |
| + this comment had this exactly backwards, and also claimed |
| + space-between would separate the two links from each other, which |
| + it cannot — both are inside this one flex item.) */ |
| + [docs-bar] { |
| position: sticky; |
| inset-block-start: 0; |
| z-index: 2; |
| display: flex; |
| flex-wrap: wrap; |
| align-items: center; |
| - gap: var(--rst-sp-1); |
| + gap: var(--rst-sp-2); |
| padding: var(--rst-sp-2) var(--rst-sp-4); |
| background: var(--rst-surface); |
| border-block-end: 1px solid var(--rst-line); |
| } |
| + /* [docs-toolbar] is a sibling of [docs-canvas], never inside |
| + [docs-editor]: ProseMirror's {mount: el} takes over everything |
| + inside that element, so a toolbar rendered in there would be |
| + replaced by the document on the first mount. */ |
| + [docs-toolbar] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + align-items: center; |
| + gap: var(--rst-sp-1); |
| + } |
| + /* The export links, at the bar's trailing end. A sibling of |
| + [docs-toolbar] and deliberately outside it: [docs-toolbar] is a |
| + real role="toolbar" whose controls are one roving-tabindex widget |
| + (fixtures/toolbar.mjs, which reads [data-docs-command] within its |
| + own root), and a link that is not a formatting control has no |
| + business joining that widget's arrow-key order. Outside it, the |
| + exports are two ordinary tab stops, which is what they should be. */ |
| + [docs-doc-actions] { |
| + display: flex; |
| + flex: none; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| + margin-inline-start: auto; |
| + } |
| + /* A group wraps inside itself, and that is not the same claim as |
| + the gap/rule one below. The three <select>s — Style, Font, Size — |
| + are one group, and a <select>'s automatic minimum size is its |
| + widest option, so at 320px (§10.3's reflow width, which this app |
| + supports without exemption) the three of them measure wider than |
| + the bar and the page grows a horizontal scrollbar: SC 1.4.10, |
| + failed. Letting the group wrap and letting each control shrink |
| + below its content — min-inline-size: 0, which is what actually |
| + lifts that automatic minimum — is what keeps the row inside the |
| + viewport. The rule between groups survives a wrap, so the |
| + grouping still reads once it has happened. |
| + |
| + This predates the bar being restructured around it; it was not |
| + introduced by that work and is, if anything, slightly less bad |
| + than it was. The toolbar used to sit inside [rst-page] and get |
| + 320 - 32 (the shell's padding) - 32 (its own) = 256px at this |
| + width; it now gets 320 - 32 = 288px, because the shell no longer |
| + pads this screen. Both overflow. Measured at 320px in Chromium, |
| + before and after. */ |
| [docs-toolbar-group] { |
| display: flex; |
| + flex-wrap: wrap; |
| align-items: center; |
| gap: 2px; |
| } |
| @@ -560,6 +707,15 @@ |
| } |
| [docs-tool-select] { |
| min-block-size: 28px; |
| + /* min-inline-size: 0 lifts the automatic minimum size, which for |
| + a <select> is its widest option — see [docs-toolbar-group]'s |
| + own comment for what that overflows and where. Wrapping is what |
| + actually resolves the 320px case, and it is the better of the |
| + two outcomes: these three controls carry words, and a control |
| + shrunk under its widest word is a control you cannot read. This |
| + only takes over if a viewport is narrow enough that even one of |
| + them will not fit on a line of its own. */ |
| + min-inline-size: 0; |
| max-inline-size: 11rem; |
| padding: 0 var(--rst-sp-1); |
| border: 1px solid var(--rst-line); |
| @@ -568,13 +724,6 @@ |
| color: var(--rst-text); |
| font-size: var(--rst-fs-sm); |
| } |
| - /* The export links, in the page header beside sign-out. */ |
| - [docs-header-actions] { |
| - display: flex; |
| - align-items: center; |
| - gap: var(--rst-sp-2); |
| - } |
| - |
| /* [docs-save-status] (fix round 1, F2) is chrome, not paper — it |
| reports on the save itself, not the document's own content — so |
| unlike [docs-page]/[docs-text] above it uses the app's own theme |
| @@ -627,49 +776,39 @@ |
| display: inline; |
| } |
| </style> |
| -<header rst-page-header> |
| - <div rst-page-header-titles> |
| - {{/* [docs-back] is this page's only in-app navigation, and until |
| - it landed there was none: the header below held sign-out and |
| - nothing else, so arriving here by a bookmark or a mailed link |
| - left a person with no route to their own list at all. It sits |
| - inside [rst-page-header-titles] rather than beside it because |
| - [rst-page-header]'s space-between rule wants at most two |
| - top-level children (titles, one action area) — the same |
| - constraint list.html's [docs-header-actions] comment records |
| - from the other direction. |
| - |
| - A breadcrumb, not an arrow: the label is the destination |
| - screen's own name (showPage.BackLabel, which *is* |
| - docs.list.title), so it needs no glyph to be understood and |
| - nothing about it has to be mirrored for the RTL locales in |
| - this app's twelve. */}} |
| - <a docs-back href="/">{{.BackLabel}}</a> |
| - <h1>{{.Title}}</h1> |
| - </div> |
| - {{/* Sign-out control (POST /signout, auth.go) — same reasoning as |
| - list.html's own [docs-header-actions] comment: a session lasts |
| - 30 days and this page needs a working way to end one on |
| - purpose, not just list.html. |
| - |
| - The two export links sit beside it, in a wrapper this header |
| - now needs (it used to have a single form as its one flex |
| - child). Ordinary links to ordinary URLs, so both work with no |
| - JavaScript at all — spec §6.2 — and both are GETs of a document |
| - the signed-in person already owns, so neither needs a form or a |
| - CSRF token. download tells the browser to save rather than |
| - navigate; the filename it uses comes from the server's |
| - Content-Disposition, not from this attribute, because a title |
| - is arbitrary text a person typed and sanitising it belongs |
| - server-side (handlers.go's exportFilename). */}} |
| - <div docs-header-actions> |
| - <a rst-btn="ghost" href="/d/{{.Sqid}}/export.pdf" aria-label="{{.ExportPDFLabel}}" download>{{.ExportPDFShort}}</a> |
| - <a rst-btn="ghost" href="/d/{{.Sqid}}/export.md" aria-label="{{.ExportMarkdownLabel}}" download>{{.ExportMarkdownShort}}</a> |
| - <form method="post" action="/signout"> |
| - <button rst-btn="ghost" type="submit">{{.SignOutLabel}}</button> |
| - </form> |
| - </div> |
| -</header> |
| +{{/* This page has no [rst-page-header]. It used to, and what it held |
| + is not gone — every piece of it moved to where its equivalent |
| + lives in the two applications this layout language comes from. |
| + The argument for the move is in render.go's shellLayout comment; |
| + what follows is only the map. |
| + |
| + The route back to the list was [docs-back], a breadcrumb above |
| + the title, and is now the shell bar's brand link — same |
| + destination, same label (showPage.BackLabel, which *is* |
| + docs.list.title, so it needs no glyph to be understood and |
| + nothing about it has to be mirrored for the RTL locales in this |
| + app's twelve), one row up. Until [docs-back] landed this page had |
| + no in-app navigation at all, so arriving by a bookmark or a mailed |
| + link left a person with no route to their own list; promoting it |
| + to the brand slot keeps that fixed and makes it the same control |
| + on every screen rather than one this page draws for itself. |
| + |
| + The title is [docs-shell-title], beside that brand link. |
| + |
| + Sign-out (POST /signout, auth.go) is in the bar's account menu, |
| + which is where a 30-day session belongs and is now the one place |
| + both screens end one. |
| + |
| + The exports are in [docs-doc-actions] below, at the formatting |
| + bar's trailing end. Ordinary links to ordinary URLs, so both work |
| + with no JavaScript at all — spec §6.2 — and both are GETs of a |
| + document the signed-in person already owns, so neither needs a |
| + form or a CSRF token. download tells the browser to save rather |
| + than navigate; the filename it uses comes from the server's |
| + Content-Disposition, not from this attribute, because a title is |
| + arbitrary text a person typed and sanitising it belongs |
| + server-side (handlers.go's exportFilename). */}} |
| +<div docs-bar> |
| {{/* The formatting bar (fixtures/toolbar.mjs). Rendered server-side, |
| with every label resolved from the catalog (render.go's |
| docs.editor.toolbar.*) — the bundle carries no copy of its own, |
| @@ -756,6 +895,11 @@ |
| </button> |
| </div> |
| </div> |
| +<div docs-doc-actions> |
| + <a rst-btn="ghost" href="/d/{{.Sqid}}/export.pdf" aria-label="{{.ExportPDFLabel}}" download>{{.ExportPDFShort}}</a> |
| + <a rst-btn="ghost" href="/d/{{.Sqid}}/export.md" aria-label="{{.ExportMarkdownLabel}}" download>{{.ExportMarkdownShort}}</a> |
| +</div> |
| +</div> |
| <div docs-canvas> |
| <div docs-page> |
| {{/* docs-editor is the ProseMirror mount point (fixtures/editor-entry.js, |
diff --git a/internal/docs/views/signin.html b/internal/docs/views/signin.html| index 80bc0a9..725cb39 100644 |
| --- a/internal/docs/views/signin.html |
| +++ b/internal/docs/views/signin.html |
| @@ -27,6 +27,46 @@ |
| what that test walks. */}} |
| {{define "title"}}{{.Title}}{{end}} |
| {{define "content"}}<style> |
| + /* No shell bar on the front door. The topbar shell (render.go's |
| + shellLayout) writes [rst-shell-bar] on every page, and unlike |
| + brand/nav/account — which are blocks a page can define its way |
| + out of — the bar and the account disclosure are markup the |
| + layout emits outside any block. So this screen hides them in CSS |
| + rather than by overriding a block, which is the only lever it |
| + has. |
| + |
| + It hides them because there is nothing true to put in them. The |
| + account menu is not actually the problem: tokens.css already |
| + carries `[rst-shell-account]:has(> [rst-dropdown-menu]:empty) |
| + { display: none }`, so a page that defines no "account" block — |
| + which this one does not — loses it without being asked. What is |
| + left over is the part this page really does have to deal with: |
| + the shell's DEFAULT brand, a link to "/" that RequireSession |
| + bounces straight back here, and below 800px the hamburger, which |
| + would open an empty tail. An empty bar is worse than none — a |
| + strip of chrome that looks like it should do something. |
| + |
| + Hiding the bar rather than the two controls inside it, so the |
| + rule cannot go stale the day the shell grows a third one. |
| + display: none and not visibility/opacity: this must leave the |
| + accessibility tree too, or a screen-reader user tabs into an |
| + "Account" disclosure that sighted people cannot see. The shell's |
| + own skip link is a sibling of the bar, not inside it, so it |
| + survives — this page keeps a working skip-to-content. |
| + |
| + Written as a descendant of [rst-shell-topbar] to match the |
| + framework's own selector, and that is load-bearing rather than |
| + stylistic. tokens.css lays the bar out with |
| + `[rst-shell-topbar] [rst-shell-bar]`, specificity (0,2,0); a bare |
| + `[rst-shell-bar]` here is (0,1,0) and loses to it, so the first |
| + version of this rule did nothing at all and the bar stayed on |
| + screen showing rastrillo's default brand — a "Home" link to a |
| + route this page's own guard bounces straight back here. Matching |
| + the framework's shape means matching its specificity, and the |
| + later origin then decides it. */ |
| + [rst-shell-topbar] [rst-shell-bar] { |
| + display: none; |
| + } |
| /* [docs-signin] gives this screen one measure, shared by the |
| callouts and the box below them. |
| Measured before it existed: the box filled the shell's whole |
diff --git a/internal/export/pdf.go b/internal/export/pdf.go| index 79dc1eb..83f7772 100644 |
| --- a/internal/export/pdf.go |
| +++ b/internal/export/pdf.go |
| @@ -274,8 +274,10 @@ func PDF(doc Document) ([]byte, Report, error) { |
| // The title is set as a level-1 heading at the top of the first |
| // page. It is not part of the CRDT and so not part of the box tree |
| - // the screen renders — the screen puts it in the page header, |
| - // which is chrome around the paper rather than on it. A PDF has no |
| + // the screen renders — the screen puts it in the shell bar beside |
| + // the brand link, which is chrome around the paper rather than on |
| + // it (it was a page header until the chrome moved to the topbar |
| + // shell; either way it is not on the paper). A PDF has no |
| // chrome: a file that does not say which document it is, is worse |
| // than one that occasionally repeats a heading its author also |
| // typed. Markdown does the same thing for the same reason. |