oficina / sheets Public

merge: the design system's catch-up to rastrillo v0.23.0

Two branches were both editing the design system — core carrying plan
2's grid and its dark-mode fix, design-catchup carrying the v0.23.0
bump, PRODUCT.md and the first design review. Divergence on tokens.css
compounds, and the sharing UI due next should be built on the current
tokens rather than stale ones, so they meet here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev 841e1ce5ce07a621c080c7b4ac72aa0980b5913a parent 1dd05dbparent bedfdd6
7 files changed, +607 −22
  • AGENTS.md +32 −9
  • PRODUCT.md +171 −0
  • docs/design/2026-09-01-design-review.md +262 −0
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/sheets/static/theme.css +13 −0
  • internal/sheets/static/tokens.css +126 −12

CI passed — run details

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/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..348d560
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,171 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+## Platform
+
+web
+
+## Users
+
+**Confirmed by Paul, 2026-09-01: Paul and his collaborators, dogfooding.**
+Real use, small N. Design for the people actually in it rather than a
+hypothesised market — a feature nobody here uses is not evidence of
+anything.
+
+They are running the CARLOS suite already, so Sheets is not entered cold:
+a person arrives from the suite home with a session, and the spreadsheet
+is one of several apps on one instance. The job is the ordinary one —
+put numbers in a shared grid, have the other person see them — under a
+storage rule that is stricter than the ordinary one.
+
+Two roles exist beyond the team: a **guest** holds no team-wide rights
+and sees exactly the sheets granted to them; a **link viewer** is
+anonymous, read-only, and mints no membership at all. Both are real
+audiences for the read surfaces even though neither is the primary user.
+
+## Product Purpose
+
+A spreadsheet for one team, on that team's own instance, whose contents
+are sealed at rest.
+
+Success is that a team can do its actual spreadsheet work — grid editing,
+formulas, live collaboration, import and export — without the storage
+layer ever holding a plaintext cell, and without the product lying to
+them about what that does and does not protect.
+
+## Positioning
+
+Three claims a neighbouring product could not truthfully copy, all
+confirmed binding by Paul on 2026-09-01:
+
+1. **Sealed at rest, honestly scoped.** The bucket never holds a
+ plaintext cell. The instance holds the key while it runs, so a
+ compromised live host *can* read an open sheet — and the README says
+ so. **We never claim "we can't read your data."** The UI must never
+ imply otherwise, in copy, in iconography, or by omission.
+2. **One instance per team.** Sharing is membership: the sheet never
+ moves, and an outside person becomes a scoped member of the instance
+ that owns it. There is no federation story and there will not be one.
+3. **No affordance is script-required.** Sign-in, the sheet list, sharing
+ and membership, settings, version history, comments, import and export
+ all work scriptless, as does a paginated table view of any sheet with
+ per-cell edit forms. The grid editor is an enhancement on top of a
+ floor, not the floor.
+
+## Operating Context
+
+- **One binary per instance**, `CGO_ENABLED=0`, cgo-free SQLite,
+ deployed to CARLOS. Review happens on a deployed canary, never
+ localhost.
+- **The suite home is a separate product in a separate repo.** Sheets
+ depends on it for identity and consumes a five-call contract; it does
+ not define it. Passkeys enroll at the home's origin only — instances
+ never enroll them.
+- **Sheets sits beside Docs and the home** under the Oficina suite, which
+ shares a design system (`estilo`). Chrome that appears in more than one
+ app is the suite's, not Sheets'.
+- Live collaboration is cell-level last-write-wins over SSE. Version
+ history replays inputs and recomputes; derived values are never logged.
+
+## Capabilities and Constraints
+
+**Built and working as of 2026-09-01:** sign-in, sheet list and creation,
+a grid backed by a Go calc engine, live sync across tabs over SSE, a
+scriptless paginated table view with per-cell editing, XLSX/CSV import
+and export, and a contrast notice for imported fills.
+
+**Constraints that bind design:**
+
+- **No build step.** Jspreadsheet CE v5 is vendored as a static asset —
+ no CDN, no bundler. Its own formula engine is disabled; Go owns calc.
+- **300-line cap on any JS module we write**, test-enforced, ratchet-down
+ only. The vendored grid blob is exempt and named as such.
+- **Light/dark is per person, not per document** (confirmed binding,
+ 2026-09-01), and must resolve before first paint from a cookie read
+ server-side. Cell fills resolve against the theme, so a late preference
+ repaints every coloured cell. Never a fetch.
+- **Every drag-only affordance has a menu equivalent.**
+- **No cell content, sheet title or tab name in any log line, error
+ string, job status or metric label.** Errors carry coordinates and
+ codes. This constrains error copy directly.
+- Cell values are IEEE doubles end to end; currency is a number format,
+ not a type.
+- Cell fill is stored as OKLCH intent; an imported hex is retained as
+ authored, even when it fails contrast (see Accessibility).
+- Link sharing is viewer-or-commenter, **never editor** — an anonymous
+ editor would write unattributed rows into version history.
+
+**Explicitly undecided:** the suite's own domain (the passkey relying
+party is provisionally an `oncarlos.com` host). Sheets' own visual world
+beyond the suite theme — see Brand Commitments.
+
+## Brand Commitments
+
+- **Name:** Sheets, within the **Oficina** suite (CARLOS).
+- **Design system:** `rastrillo` (the framework) with **`estilo`** as the
+ suite's shared variant. estilo's theme *is* rastrillo's `day` — not
+ derived from it, identical — until a **named need** says a token has to
+ differ. "It would look better" is explicitly not such a need; the bar
+ is "Sheets' grid requires a denser row token that day does not
+ declare". Any divergence is recorded as a decision, not applied.
+- **Markup grammar:** the attribute spelling (`rst-btn="primary compact"`),
+ never the class spelling. Utilities stay classes. Adopt per screen,
+ never per element.
+- **Voice:** plain, exact, and never reassuring beyond the facts. The
+ repo's own prose is the reference — it names its residual risks in the
+ same breath as its guarantees.
+- **Authorship is disclosed.** 🤖 / 👨 markers, published prompt and
+ carbon ledgers. Every path works with AI switched off.
+
+## Evidence on Hand
+
+- `docs/superpowers/specs/2026-08-30-sheets-design.md` — the design spec.
+ §1 is a dated, attributed settled-decisions table with the rejected
+ alternative beside each ruling. §2 is the leak list. §14 is the
+ deferred table, each row carrying a written trigger.
+- `AGENTS.md` — binding engineering rules, several of them design-facing.
+- A working deployment at `https://sheets.bds.oncarlos.com`.
+- `tools/drive/drive.mjs` — drives the real binary in a real browser.
+
+**Absences future work must not fabricate:** there are no customers, no
+testimonials, no benchmarks, no pricing and no usage numbers. There is no
+market claim of any kind. Sheets is being dogfooded by its author and his
+collaborators, and nothing in the product may suggest more than that.
+
+## Product Principles
+
+1. **Say the true thing, including the unflattering half.** The trust
+ model is a genuine differentiator *and* has a named residual risk. Copy
+ that states the first without the second is a defect, not a
+ simplification.
+2. **The scriptless path is the product, not the fallback.** If an
+ affordance exists in the grid and not in the table view, the table view
+ is unfinished. "Fallback" is the wrong word and invites the wrong
+ design.
+3. **One instance, one team, no federation.** Never design a flow that
+ implies data can move to another instance, or that a person outside
+ this team is a first-class participant.
+4. **The chrome is the suite's; the grid is ours.** Anything that appears
+ in Docs and the home too belongs to `estilo` and should look identical
+ there. A spreadsheet's menu bar and formula bar are Sheets'.
+5. **A preference resolves before first paint or it is a bug.** This is a
+ correctness rule that happens to be about design.
+
+## Accessibility & Inclusion
+
+- **WCAG AA (4.5:1) is the floor for text**, and it is test-enforced
+ against the theme's own pairs.
+- **A retained imported fill may fail that floor, and that is correct.**
+ Rendering an imported file as authored is the ruling; silently
+ correcting a colour would lie about the document. The obligation that
+ follows is to *say so* — a per-cell marker plus a notice — and leave
+ the decision to the person. Never quietly recolour a cell.
+- **The page header's rule is decorative and carries no contrast floor.**
+ It is deliberately absent from rastrillo's contrast pair table; a later
+ pass must not argue it up to 1.4.11's 3:1.
+- **The theme switcher must work with scripting off** — a person who
+ needs a high-contrast theme is not well served by being told to enable
+ JavaScript first.
+- Three light/dark states, not two: "follow the machine" is a real
+ preference.
diff --git a/docs/design/2026-09-01-design-review.md b/docs/design/2026-09-01-design-review.md
new file mode 100644
index 0000000..321d66a
--- /dev/null
+++ b/docs/design/2026-09-01-design-review.md
@@ -0,0 +1,262 @@
+# 🤖 Sheets — design review, 2026-09-01
+
+**Method:** dual-agent Impeccable `critique`. Assessment A (design review)
+and Assessment B (deterministic detector) ran isolated and did not see
+each other's output. Both were synthesised against screenshots of the
+real binary, driven in Chromium at 1440×900 and 390×844, in both colour
+schemes. Branch `design-catchup`, cut from `core` at b54989d, with
+rastrillo v0.23.0.
+
+**Mode:** Operate. Scanability, consistency and the real usage scene
+outrank expression.
+
+**Design health: 17/40.** Below the 20–32 band that most working
+interfaces land in. Three of the five surfaces carry a defect that stops
+the screen working.
+
+---
+
+## The headline
+
+The prose is composed for a sealed-at-rest team spreadsheet. The
+composition is not.
+
+The specificity in this app is almost entirely in its copy, and the copy
+is genuinely good — `sheets.html:5`, "Everyone on this instance can open
+every sheet here", is a line no ordinary spreadsheet would dare write,
+placed on the screen where a person decides what to put in a sheet.
+
+But copy is the cheapest layer to make specific, and every *structural*
+decision is generic. The one place the product is licensed to invent —
+a spreadsheet's own chrome, per the suite rule that the chrome is
+estilo's and the grid is ours — got a title label and three ghost
+buttons. There is no cell-reference box, which is the most recognisable
+piece of spreadsheet furniture there is.
+
+The deterministic detector found **nothing** (0 findings across all six
+templates and both JS modules). Every defect below is a semantic one: the
+markup is well-formed, correctly labelled and correctly structured. It is
+pointed at the wrong names.
+
+---
+
+## P0 — three screens that do not work
+
+### 1. The dark-mode grid is white-on-white
+
+`sheets.css:66-82` themes `.jexcel`, `.jexcel > tbody > tr > td` and
+`.jexcel > thead > tr > td`. Those are **Jspreadsheet v4** class names.
+The vendored blob is **v5**, whose DOM is `.jss_container`,
+`.jss_content` and `.jss_worksheet` — verified against
+`static/vendor/jspreadsheet.css`, which contains no `.jexcel` selector at
+all. Not one of those overrides matches anything.
+
+In light mode this is invisible: `--rst-bg` is white and so is the
+vendor's default. In dark mode the cells keep the vendor's hard-coded
+`#ffffff` while `color` inherits `--rst-text` from `<body>`. The result
+is near-white text on white. The numbers are ghosts.
+
+The file's own opening comment says one hard-coded grey in the grid
+chrome "is a grey that stays wrong in two themes out of three". The rule
+was written correctly and the selector missed.
+
+It also silently kills the selection tint at `sheets.css:80`, which is
+the only feedback the app gives about which cell is selected.
+
+**Fix:** replace `.jexcel` with `.jss_worksheet` throughout, and add
+`.jss_container` / `.jss_content` so the surface behind the last column
+is themed too. Then add a guard test asserting that at least one `.jss_*`
+selector in `sheets.css` also appears in `vendor/jspreadsheet.css` — this
+is a vendor-upgrade trap that will recur on the next bump, and it is
+exactly the class of failure that survives a green suite.
+
+### 2. Below 800px there is no way to sign out
+
+`tokens.css:1401` sets `[rst-shell-topbar] [rst-shell-tail] { display:
+none }`. It is revealed by an `[rst-shell-menu][open]` that immediately
+precedes it (`tokens.css:1409`), or by the viewport reaching 800px
+(`tokens.css:1428`). `layout.html` never renders an `[rst-shell-menu]`.
+
+So on every phone — and on any half-width desktop window — the
+navigation and the entire account dropdown are `display:none` with
+nothing that can reveal them. No sign out. No theme switcher.
+
+That second one matters more than it looks: the theme switcher is a form
+rather than a script specifically because "a person who needs a
+high-contrast theme is not well served by being told to enable scripting
+first". Being told to find a laptop is no better.
+
+**Fix:** render `<details rst-shell-menu name="rst-menus"><summary>Menu
+</summary></details>` as the immediate previous sibling of `<div
+rst-shell-tail>`, so the `+` combinator fires. Sharing the `rst-menus`
+name group means opening it closes the account dropdown. rastrillo hides
+it itself above 800px, so no media query of our own is needed.
+
+### 3. The sign-in help sentence renders inside a 28px icon badge
+
+`signin.html:31` writes `<p rst-help>`. In rastrillo, `rst-help` is not
+helper text — it is an **icon badge**: `tokens.css:1245` gives it
+`display:inline-flex; width:28px; height:28px; border:1px solid;
+border-radius:7px`.
+
+A 90-character sentence is being poured into a 28px box that centres its
+overflow. Hence the one-word-per-line column and the stray border stroke
+across the text on desktop, and text running off the left edge of the
+viewport on mobile.
+
+First screen, every visitor, every viewport — and it is the sentence
+carrying the sign-in reassurance. Reassurance delivered inside a visibly
+broken component reads as incompetence, not honesty.
+
+**Fix:** the attribute for field help text is `rst-field-help`
+(`tokens.css:1056`). Move the paragraph inside the `rst-field` so it sits
+under the input.
+
+---
+
+## P1 — the scriptless table view is a link farm
+
+`table.html:45-46` wraps every cell's content in a bare `<a>`, and
+`sheets.css` styles the table but never the anchor. The result is roughly
+650 default-blue underlined links per page. Empty cells render `&nbsp;`
+inside an anchor, producing a grid of tiny underline stubs — 650 tab
+stops, with no visual difference between `94200` and nothing at all.
+
+Compounding it, `--rst-border` is undefined (below), so the table has no
+cell borders whatsoever. Columns E–Z collapse while A–D stretch, so the
+grid geometry a spreadsheet depends on is gone.
+
+This is the screen the spec calls the product, and "fallback" the wrong
+word for. Right now it is the fallback in the apologetic sense —
+including in its own subtitle, which says "with no JavaScript at all":
+implementation vocabulary aimed at the wrong reader, describing what the
+view lacks rather than what it is.
+
+**Fix:** style `[data-cell-table] td > a` to inherit colour, drop the
+underline, and fill the cell (`display:block`, a min block size) so the
+target exists without being a mark; show the affordance on `:hover` and
+`:focus-visible` with `--rst-accent-soft`. Drop the `&nbsp;`. Right-align
+numeric cells and set `table-layout: fixed` so sparse data keeps its
+geometry.
+
+---
+
+## P1 — five invented token names
+
+`sheets.css` uses five `--rst-*` custom properties that are defined
+nowhere in `tokens.css` or `theme.css`:
+
+| Token | Real name | Sites | Fallback? |
+|---|---|---|---|
+| `--rst-border` | `--rst-line` | 26, 71, 111 | **no** |
+| `--rst-text-mut` | `--rst-text-muted` | 49, 53, 77, 119, 135 | **no** |
+| `--rst-warning` | — (none ships) | 53, 93 | yes |
+| `--rst-font-mono` | — | 40 | yes |
+| `--rst-shell-bar-h` | — | 16 | yes |
+
+The three without a fallback are the ones that bite. `border: 1px solid
+var(--rst-border)` is invalid at computed-value time, so the whole
+shorthand resolves to `initial` — `border-style: none`. That is why the
+table view has no cell borders and the sheet bar has no divider. The five
+`--rst-text-mut` sites lose their colour and inherit full-strength text,
+so nothing muted is actually muted.
+
+`--rst-shell-bar-h` always falls back to the 3.5rem literal, so
+`calc(100dvh - …)` at `sheets.css:16` is wrong whenever the bar's real
+height differs — on mobile the sheet bar wraps to two rows and the last
+grid row is clipped under the fold.
+
+**Already in flight.** The session working in the primary `sheets-core`
+checkout has an uncommitted `internal/guardstest/csstokens_test.go` whose
+comment reads "the check that would have caught five invented token names
+shipping to a canary". It is these five. This review confirms them
+independently; the fix belongs to that branch, not this one.
+
+---
+
+## P2 — smaller, but real
+
+- **`data-contrast` is never set.** `sheets.css:88-99`'s low-contrast
+ corner flag is well-conceived and well-commented, and it is dead code:
+ nothing in Go, JS or any template emits the attribute. `contrast.go`
+ computes the warned list and the notice fires, so a person is told
+ which cells are unreadable and given no way to see which. The one
+ Sheets-specific visual mark in the app never appears.
+- **"Fill down" fills one cell.** `grid.js:183-188` writes to
+ `selection.r + 1` and stops. Every spreadsheet a collaborator has used
+ means "fill the selected range" by that label, so they will press it
+ repeatedly and conclude the app is broken. Either relabel to `Copy
+ down` / `Copy right` until the range version exists, or build the
+ range version.
+- **Nothing has an undo.** A cell write is a POST; browser-back does not
+ reverse it; version history is deferred. With cell-level last-write-
+ wins, one person can silently replace another's formula with no record
+ either of them can see. See the open question below.
+- **No cell reference on screen.** To use a fill command you must
+ remember which cell you selected. The only cue is Jspreadsheet's own
+ header shading.
+- **Raw doubles as display values.** `0.10514018691588785` in the table
+ view, clipped mid-digit in the grid. Three renderings of D2 across
+ three surfaces, only one of them labelled.
+- **The grid page has no `<h1>`.** The title is a `<span
+ data-sheet-title>`, so the document outline is empty on the app's
+ primary screen.
+- **`table.html:5` reads "Table view — rows 1 of 20 pages."** It splices
+ a row count and a page count into one ungrammatical phrase.
+- **`table.html:12` uses an inline `style="overflow-x:auto"`** — the only
+ raw style declaration in an app that is otherwise entirely attribute
+ grammar and tokens.
+- **Sheet list order is unstable on tied `UpdatedAt`.** Two sheets
+ created in the same minute swap places between renders. No secondary
+ sort key.
+- **Three names for one app in three screens:** `Sheets` (topbar brand),
+ `Oficina Sheets` (`index.html`), `Sign in to Oficina Sheets`.
+- **`sheets.html:4` "Your sheets" is contradicted by its own subtitle**
+ one line below. If they are the instance's sheets, say so in the
+ heading rather than correcting it underneath.
+
+---
+
+## What is working
+
+1. **The list row overlay is used correctly.** `sheets.html:22-27` writes
+ `rst-lrow` / `rst-row-main` / `rst-row-sub`, which picks up
+ rastrillo's `::after { inset: 0; position: absolute }` — the whole row
+ is a click target without nesting anchors. It is the only screen that
+ feels finished, and that is because the framework was trusted rather
+ than fought.
+2. **The trust copy lands where it matters.** Not in a settings page: in
+ the subtitle of the screen where you decide what to put in a sheet.
+3. **The formula/display split.** Display strings in the cells, raw input
+ in a side map, the formula bar as the reliable read path with the
+ in-cell swap as best effort. That is the correct model of what a
+ spreadsheet is, and it is why the app can honestly claim the server
+ owns calc.
+4. **`autofocus` on the table view's editing input.** A person clicks a
+ cell and can type immediately, with no script. More of that thinking,
+ applied to how the table view *looks*, is most of what P1 asks for.
+
+---
+
+## Three questions
+
+1. **If the table view is the product and the grid is the enhancement,
+ why did the grid get a formula bar, fill commands, live sync and a
+ contrast marker, while the table view got a `title` tooltip?** Every
+ affordance flowed downhill from the grid. What does the table view
+ look like if it is designed *first*, and does the grid then become a
+ skin over it rather than a second app?
+
+2. **The honest scoping lives on `/`** — the page a working collaborator
+ passes once and never returns to. The sheet page, where numbers are
+ actually typed, says nothing about what is and is not protected. If
+ the trust model is the differentiator, does it belong on the marketing
+ surface or the working surface?
+
+3. **Is history the deferred feature, or is undo the missing primitive?**
+ In a cell-level last-write-wins grid shared by two people, undo may be
+ what makes last-write-wins survivable at all, with history merely its
+ readable form. The deferred table asks every deferral for a written
+ trigger. The honest trigger here might be "the first time one of us
+ silently overwrote the other's formula" — and that fires the day the
+ second person joins.
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.