# 🤖 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.
