oficina / sheets Public

build: catch the design system up to rastrillo v0.23.0

The rake line — .rst-page-header::after, a 2.5rem accent stroke laid over
the page header's rule — was still being drawn on every screen. rastrillo
retired it in v0.23.0 because, built from the same parts in the same
order, it is indistinguishable from a determinate progress bar stuck at
12%. AGENTS.md already said not to build on it. The pin had not moved, so
we were drawing it anyway.

tokens.css is re-copied by `rastrillo doctor --fix` at v0.23.0. theme.css
is copied by hand, and that is the part worth reading twice: --fix
reported theme.css as drifted, printed the diff, and then did not write
it. The two files are coupled — v0.23.0's tokens.css asks for
var(--rst-header-rule) and v0.22.0's day declares no such token, which
makes the whole border-bottom shorthand invalid at computed-value time
and resolves it to border-style: none. Left half-upgraded, the header
rule does not go wrong; it disappears. AGENTS.md now carries that as a
rule rather than as a thing somebody remembers.

The doctor binary is upgraded alongside the module for the same reason
doctor itself refuses to compare across a mismatch: one release behind,
it calls the upgrade you just did the drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtQKC2QWg1cP9RQ5zAL72J
Paul Campbell pushed by paul@keymail.dev 25aac1954aa05c2811fd9c7381871e2cff13472f parent b54989d
5 files changed, +174 −22
  • AGENTS.md +32 −9
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/sheets/static/theme.css +13 −0
  • internal/sheets/static/tokens.css +126 −12
diff --git a/AGENTS.md b/AGENTS.md
index 2af266a..0b48e98 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -146,8 +146,8 @@ relying on remembering one, make it a test instead.
`class="rst-btn rst-btn--primary"`.** This reverses the earlier rule in
this file, which was written against v0.20.0 where the attribute
spelling genuinely did not exist. It landed in v0.21.0 and is complete
- in the pinned v0.22.0: measured against
- `$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.22.0/ui/tokens.css`,
+ in the pinned v0.23.0: measured against
+ `$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.23.0/ui/tokens.css`,
every class selector has an attribute twin, and rastrillo enforces that
itself with `TestEveryClassSelectorHasAnAttributeTwin`. Both spellings
are specificity `(0,1,0)`, so pairing changes no cascade. The class
@@ -167,14 +167,37 @@ relying on remembering one, make it a test instead.
`rastrillo doctor --fix` to re-copy what drifted. `static/tokens.css` is
frozen at scaffold time while the partials it styles keep upgrading with
the module, so an app can run new markup against old CSS and see nothing
- worse than a slightly wrong screen. `doctor` shipped in the pinned
- v0.22.0 (verified: `rastrillo doctor --help` offers `-fix`, `-force`,
- `-theme`). It reports a version mismatch as a mismatch rather than as
- drift and refuses `--fix` across one, since copying newer assets into an
- app built against an older framework manufactures the fault it is
- looking for.
+ worse than a slightly wrong screen. `doctor` shipped in v0.22.0 (verified:
+ `rastrillo doctor --help` offers `-fix`, `-force`, `-theme`). It reports a
+ version mismatch as a mismatch rather than as drift and refuses `--fix`
+ across one, since copying newer assets into an app built against an older
+ framework manufactures the fault it is looking for. **So upgrade the
+ `doctor` binary alongside the module** — a `doctor` one release behind
+ compares against its own library copy and calls the upgrade you just did
+ the drift.
+- **`doctor --fix` does not re-copy `static/theme.css`. Copy it by hand.**
+ Measured on the v0.22.0 → v0.23.0 upgrade: `doctor --fix -theme day`
+ printed `drift theme.css` with the diff, rewrote `tokens.css`, and left
+ `theme.css` untouched without saying it had. Unnamed, the theme is not
+ compared at all (`custom or drifted — not compared`), which reads like
+ a clean bill.
+ This is the worst shape the frozen-theme problem takes, because the two
+ files are coupled: v0.23.0's `tokens.css` writes
+ `border-bottom: 1px solid var(--rst-header-rule)` and v0.22.0's `day`
+ declares no such token. An undefined custom property makes the whole
+ shorthand invalid at computed-value time, so it resolves to `initial` —
+ `border-style: none`. The page header's rule does not go grey or go
+ wrong; **it disappears**, silently, on every screen. So after every
+ upgrade: `cp "$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@<ver>/ui/themes/day.css"
+ internal/sheets/static/theme.css`, then re-run `doctor -theme day` and
+ require `all matching the library`.
- Don't build on `.rst-page-header::after` — the rake line is retired in
- favour of `--rst-header-rule`.
+ favour of `--rst-header-rule` (rastrillo v0.23.0, design doc §6-v2.2).
+ It was retired because, built from the same parts in the same order, it
+ was indistinguishable from a determinate progress bar at 12%. The rule
+ is decorative and carries **no** contrast floor: it is deliberately
+ absent from rastrillo's contrast pair table, and a later pass must not
+ argue it up to 1.4.11's 3:1.
- 300-line JS module cap, test-enforced, ratchet-down only. The vendored
Jspreadsheet blob is exempt and named as such; nothing we write is.
- Every drag-only affordance has a menu equivalent.
diff --git a/go.mod b/go.mod
index 1c8fc5a..8c1af05 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module amadan.net/oficina/sheets
go 1.25.0
require (
- github.com/carlosframework/rastrillo v0.22.0
+ github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.55.0
diff --git a/go.sum b/go.sum
index eea4eba..a075856 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
+github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
diff --git a/internal/sheets/static/theme.css b/internal/sheets/static/theme.css
index c3ab91f..5da4384 100644
--- a/internal/sheets/static/theme.css
+++ b/internal/sheets/static/theme.css
@@ -104,6 +104,12 @@
a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
borders use --rst-line-strong, which does clear 3:1.
+ --rst-header-rule is not in the table either, and deliberately so
+ (design doc §6-v2.2): the page header's rule is decoration, not
+ structure — the heading's size, weight and spacing carry the
+ structure — so no contrast floor applies to it and it is not in
+ ui/contrast_test.go's pair table. Do not argue it up to 3:1.
+
Pagination chips are the one deliberate exception: they are bordered
with --rst-line rather than --rst-line-strong, because a page strip
ringed in hard outlines shouts louder than the list it belongs to.
@@ -139,6 +145,13 @@
--rst-line: light-dark(#e5e7eb, #2a3038);
--rst-line-strong: light-dark(#7f8792, #79828e);
+ /* The page header's rule, derived rather than authored: the accent
+ folded into the line at 18%, so a theme that changes its accent
+ gets a matching rule with no second value to keep in step. day's
+ is barely there — a hairline you would call grey until you put it
+ beside plain's. Decorative: it carries no contrast floor. */
+ --rst-header-rule: color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line));
+
/* Text */
--rst-text: light-dark(#1a1d21, #e8eaed);
--rst-text-muted: light-dark(#4b5563, #a8b0ba);
diff --git a/internal/sheets/static/tokens.css b/internal/sheets/static/tokens.css
index e855ec7..16a8809 100644
--- a/internal/sheets/static/tokens.css
+++ b/internal/sheets/static/tokens.css
@@ -281,7 +281,7 @@ body {
/* ── page-header ──────────────────────────────────────────────────── */
.rst-page-header, [rst-page-header] {
align-items: baseline;
- border-bottom: 1px solid var(--rst-line);
+ border-bottom: 1px solid var(--rst-header-rule);
display: flex;
flex-wrap: wrap;
gap: var(--rst-sp-3);
@@ -290,18 +290,33 @@ body {
padding-bottom: var(--rst-sp-3);
position: relative;
}
-/* The rake line: one short accent stroke laid over the header's rule.
- The library's one flourish — the tool it is named for, drawn once per
- screen and nowhere else. Absolutely positioned, so it is not a flex
- item. */
+/* The rake line is retired (design doc §6-v2.2). It drew a 2.5rem
+ accent stroke over this rule, flush to the inline start — the
+ library's one flourish, and, built from the same parts in the same
+ order, indistinguishable from a determinate progress bar at 12%. The
+ rule itself carries the theme now: --rst-header-rule is derived from
+ the theme's accent and line, so plain draws grey, day draws a barely
+ tinted hairline and signal a visibly tinted one, and an app that
+ wants its own sets one custom property instead of fighting an
+ ::after it cannot remove.
+
+ The declaration stays, rather than the rule being deleted, because a
+ stylesheet that merely stops mentioning the stroke says nothing to
+ the next reader; this says the stroke is gone on purpose. It also
+ neutralises a vendored theme or app sheet that still carries the old
+ flourish at a lower specificity.
+
+ THE RULE IS DECORATIVE AND CARRIES NO CONTRAST FLOOR. The heading's
+ size, weight and spacing carry the structure; the rule does not. It
+ is deliberately absent from ui/contrast_test.go's pair table, and a
+ later pass must not argue it up to 1.4.11's 3:1 — that floor was
+ never its. See ui/contrast_test.go's colorMixSkip comment.
+
+ position: relative stays: it is the containing block anything an app
+ absolutely positions inside its own header expects to find, and
+ removing it would move those elements rather than tidy the file. */
.rst-page-header::after, [rst-page-header]::after {
- background: var(--rst-accent);
- block-size: 2px;
- content: "";
- inline-size: 2.5rem;
- inset-block-end: -1px;
- inset-inline-start: 0;
- position: absolute;
+ content: none;
}
.rst-page-header__titles, [rst-page-header-titles] {
flex: 1 1 auto;
@@ -1492,6 +1507,105 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
}
}
+
+/* console shell — a brand-and-account bar across the top AND a
+ navigation rail down the side. topbar without the rail is topbar;
+ sidebar without the bar is sidebar; this is the admin shape both of
+ them were half of, and the one an app used to hand-write.
+
+ TWO CHROMES, ONE CONTROL. Below 800px the bar's tail and the rail
+ both have to go away, and two disclosures would be two things to
+ learn. So one <details rst-shell-menu> gates both: its own next
+ sibling with +, and the rail from the shell root with :has(). One
+ [open], two reveals, one summary on screen at 390px.
+
+ Both reveals are written as HIDE-WHEN-CLOSED rather than
+ show-when-open. In an engine without :has() the rail's hide rule
+ never matches and the rail renders as a column of links under the
+ bar — longer page, navigation intact. Spelled the other way round,
+ the same missing selector is a phone that cannot navigate. Choose
+ the failure. */
+.rst-shell-console .rst-shell__bar, [rst-shell-console] [rst-shell-bar] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-4); padding-block: 0.5rem; padding-inline: var(--rst-sp-4); }
+.rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { margin-inline-start: auto; }
+.rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { align-items: center; border: 1px solid var(--rst-line); border-radius: var(--rst-radius-sm); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.35rem; list-style: none; min-block-size: 24px; padding-block: 0.3rem; padding-inline: 0.55rem; }
+.rst-shell-console .rst-shell__menu > summary::-webkit-details-marker, [rst-shell-console] [rst-shell-menu] > summary::-webkit-details-marker { display: none; }
+/* The rail, narrow: a full-width column under the bar, disclosed. It
+ holds navigation and nothing else — the language switcher and the
+ person are on the bar — so there is no rail-foot here and nothing
+ pinned to a bottom edge. box-sizing all the same, because the wide
+ query below sizes something in this subtree against the viewport and
+ a viewport promise has to be kept by the border box. */
+.rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rst-sp-3); padding: var(--rst-sp-4); }
+.rst-shell-console .rst-shell__nav, [rst-shell-console] [rst-shell-nav] { flex-direction: column; gap: 2px; }
+.rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); display: block; padding-block: 0.35rem; padding-inline: 0.55rem; }
+.rst-shell-console .rst-shell__nav a:hover, [rst-shell-console] [rst-shell-nav] a:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
+.rst-shell-console .rst-shell__nav a[aria-current], [rst-shell-console] [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
+/* The two hide rules. Same [open], two combinators. */
+.rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail, [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail] { display: none; }
+.rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
+/* Disclosed, the tail is a column across the full width of the wrapped
+ bar. The account's margin-inline-start: auto has to be undone by
+ hand and stretch does not do it: in a column flex box the inline
+ axis is the CROSS axis, and an item with an auto cross-axis margin
+ is exempt from stretching — the same trap the topbar documents. */
+.rst-shell-console .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console] [rst-shell-menu][open] + [rst-shell-tail] { display: flex; flex-basis: 100%; flex-direction: column; gap: var(--rst-sp-3); }
+.rst-shell-console .rst-shell__tail > .rst-shell__account, [rst-shell-console] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: 0; }
+@media (min-width: 800px) {
+ /* The frame: bar across the top, rail beside the page, footer under
+ the page and not under the rail. Named areas rather than DOM
+ order, because the DOM order is the reading order and must not be
+ spent on layout — and areas map to the block and inline axes, so
+ an RTL locale gets the rail on the other side with no second rule.
+ min-block-size, never block-size: the rail reaches the foot of a
+ short page, and a long one still scrolls the document. */
+ .rst-shell-console, [rst-shell-console] { display: grid; grid-template-areas: "bar bar" "rail main" "rail foot"; grid-template-columns: 15rem 1fr; grid-template-rows: auto 1fr auto; min-block-size: 100dvh; }
+ .rst-shell-console > .rst-shell__bar, [rst-shell-console] > [rst-shell-bar] { grid-area: bar; }
+ .rst-shell-console > .rst-shell__main, [rst-shell-console] > [rst-shell-main] { grid-area: main; }
+ .rst-shell-console > .rst-shell__foot, [rst-shell-console] > [rst-shell-foot] { grid-area: foot; }
+ .rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { display: none; }
+ /* Every undo below repeats the narrow rule's own selector, because a
+ media query adds no specificity: without the :has() spelling here
+ a reader who opened the menu and then widened the window would
+ keep a hidden rail. */
+ .rst-shell-console .rst-shell__tail,
+ [rst-shell-console] [rst-shell-tail],
+ .rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail,
+ [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail],
+ .rst-shell-console .rst-shell__menu[open] + .rst-shell__tail,
+ [rst-shell-console] [rst-shell-menu][open] + [rst-shell-tail] { display: contents; }
+ .rst-shell-console .rst-shell__tail > .rst-shell__account, [rst-shell-console] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: auto; }
+ .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; }
+ /* The undo is a rule of ITS OWN, and this is the whole reason: a
+ selector list is invalid as a whole if any selector in it is
+ invalid, so an engine that cannot parse :has() drops every
+ declaration in the list — including grid-area: rail, which has
+ nothing to do with :has() and which this shell's wide layout is
+ built on. Co-listed, the four selectors above cost the legacy
+ engine its declared frame and left the rail in the right cell only
+ because grid auto-placement happened to put it there. Split, the
+ plain pair always applies and only the fight over `display` — the
+ one declaration the narrow hide rule contests — rides on :has().
+ That is the failure direction this shell chose, actually chosen
+ rather than nearly chosen. */
+ .rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail,
+ [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
+ /* THE VIEWPORT. The rail itself is a grid item stretched to the row,
+ which can be taller than the window — that is what makes the
+ background reach the foot of a long page. So the thing that has to
+ fit the window is the nav, and it is the nav that is sized against
+ it: sticky, capped at the viewport MINUS the two gaps it is inset
+ by, measured as a border box, scrolling itself when an app's
+ navigation is longer than that.
+ This is the shipped regression, avoided by construction rather
+ than by care: block-size: 100dvh on a content box that also
+ carried padding gave a border box 32px taller than the window, and
+ the gate that missed it asked where the person sat inside the rail
+ — a question that stayed true while the rail hung off the screen.
+ TestTheConsoleRailFitsTheViewport asks the other question, and
+ carries a control page that must answer it the other way. */
+ .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; }
+}
+
/* error page — one centred column, whatever shell it lands in: the
status as a quiet label above the sentence, one or two ways out, and
the reference last, where an operator asked to quote it will find it.