oficina / docs Public

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 the two applications this layout language comes
from. 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 references 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.

Two things found by looking at the result rather than by reasoning
about it:

  - The first version of signin.html's rule to hide the bar did
    nothing. tokens.css lays the bar 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, SC 1.4.10 with it. 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: the group wraps, and min-inline-size lifts a
    select's automatic minimum.

No new catalog strings: the brand reuses docs.list.title, and a
document's title is its own data.

TestDocumentPageLinksBackToTheList moved to the link's new home
rather than being deleted. What it holds is the route, not the
element that carried it, and the route is exactly as easy to lose
now — a shell block a page never defines falls back to the layout's
default, silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvAMFZXaJSaXRqGVn11WXj
Paul Campbell pushed by paul@keymail.dev 20515c230d221692909b8cd08d6e7108186d2c4e parent 84c906c
5 files changed, +381 −177
  • internal/docs/list_test.go +19 −9
  • internal/docs/render.go +29 −4
  • internal/docs/views/list.html +34 −13
  • internal/docs/views/show.html +263 −151
  • internal/docs/views/signin.html +36 −0

CI passed — run details

Intent

Attributed to this commit: 241 g CO₂e (25937690 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-tighten-chrome, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 91c7a7e..ea06a77 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -198,12 +198,22 @@ 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 — and
+// the route is exactly as easy to lose in the new home as in the old
+// one, since a shell block a page never defines silently falls back to
+// the layout's default.
//
// 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 +239,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 +264,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..b8db7e5 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -14,12 +14,37 @@ 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 the shell renders [rst-shell-account]
+// itself, outside any block an app can override, so a screen that
+// must not offer an account menu hides it in its own stylesheet.
+// signin.html is the one that does, and its comment says why.
const (
dayTheme = "day"
- shellLayout = "column"
+ shellLayout = "topbar"
)
// Catalog is CARLOS Docs' own English strings. Every user-visible
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..acfcec0 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -35,35 +35,86 @@
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. Truncation over wrapping — flex: 0 1 auto with a zero
+ min-inline-size, which is what lets a flex item shrink below its
+ content at all — so a long title gives way to the account
+ control rather than pushing it off the bar. */
+ [docs-shell-title] {
+ flex: 0 1 auto;
+ 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 +126,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 +159,51 @@
/* 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 seven 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.)
+
+ (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 +219,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 +503,88 @@
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, and the order matters when it does: at 320px the
+ formatting groups take the first line or two and the exports drop
+ to their own, which is why [docs-doc-actions] uses an auto margin
+ rather than `justify-content: space-between` — an auto margin
+ collapses on a wrapped line and leaves the exports flush left
+ under the toolbar, where space-between would strand them apart
+ from each other at opposite screen edges. */
+ [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 +675,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 +692,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 +744,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 +863,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..6a23b21 100644
--- a/internal/docs/views/signin.html
+++ b/internal/docs/views/signin.html
@@ -27,6 +27,42 @@
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.
+ Every control that bar carries is for somebody with a session:
+ an account menu for a person who has no account yet, and a brand
+ link to "/" that RequireSession would bounce straight back to
+ this page. An empty bar would be 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