| 1 | # π€ Sheets β design review, 2026-09-01 |
| 2 | |
| 3 | **Method:** dual-agent Impeccable `critique`. Assessment A (design review) |
| 4 | and Assessment B (deterministic detector) ran isolated and did not see |
| 5 | each other's output. Both were synthesised against screenshots of the |
| 6 | real binary, driven in Chromium at 1440Γ900 and 390Γ844, in both colour |
| 7 | schemes. Branch `design-catchup`, cut from `core` at b54989d, with |
| 8 | rastrillo v0.23.0. |
| 9 | |
| 10 | **Mode:** Operate. Scanability, consistency and the real usage scene |
| 11 | outrank expression. |
| 12 | |
| 13 | **Design health: 17/40.** Below the 20β32 band that most working |
| 14 | interfaces land in. Three of the five surfaces carry a defect that stops |
| 15 | the screen working. |
| 16 | |
| 17 | --- |
| 18 | |
| 19 | ## The headline |
| 20 | |
| 21 | The prose is composed for a sealed-at-rest team spreadsheet. The |
| 22 | composition is not. |
| 23 | |
| 24 | The specificity in this app is almost entirely in its copy, and the copy |
| 25 | is genuinely good β `sheets.html:5`, "Everyone on this instance can open |
| 26 | every sheet here", is a line no ordinary spreadsheet would dare write, |
| 27 | placed on the screen where a person decides what to put in a sheet. |
| 28 | |
| 29 | But copy is the cheapest layer to make specific, and every *structural* |
| 30 | decision is generic. The one place the product is licensed to invent β |
| 31 | a spreadsheet's own chrome, per the suite rule that the chrome is |
| 32 | estilo's and the grid is ours β got a title label and three ghost |
| 33 | buttons. There is no cell-reference box, which is the most recognisable |
| 34 | piece of spreadsheet furniture there is. |
| 35 | |
| 36 | The deterministic detector found **nothing** (0 findings across all six |
| 37 | templates and both JS modules). Every defect below is a semantic one: the |
| 38 | markup is well-formed, correctly labelled and correctly structured. It is |
| 39 | pointed at the wrong names. |
| 40 | |
| 41 | --- |
| 42 | |
| 43 | ## P0 β three screens that do not work |
| 44 | |
| 45 | ### 1. The dark-mode grid is white-on-white |
| 46 | |
| 47 | `sheets.css:66-82` themes `.jexcel`, `.jexcel > tbody > tr > td` and |
| 48 | `.jexcel > thead > tr > td`. Those are **Jspreadsheet v4** class names. |
| 49 | The vendored blob is **v5**, whose DOM is `.jss_container`, |
| 50 | `.jss_content` and `.jss_worksheet` β verified against |
| 51 | `static/vendor/jspreadsheet.css`, which contains no `.jexcel` selector at |
| 52 | all. Not one of those overrides matches anything. |
| 53 | |
| 54 | In light mode this is invisible: `--rst-bg` is white and so is the |
| 55 | vendor's default. In dark mode the cells keep the vendor's hard-coded |
| 56 | `#ffffff` while `color` inherits `--rst-text` from `<body>`. The result |
| 57 | is near-white text on white. The numbers are ghosts. |
| 58 | |
| 59 | The file's own opening comment says one hard-coded grey in the grid |
| 60 | chrome "is a grey that stays wrong in two themes out of three". The rule |
| 61 | was written correctly and the selector missed. |
| 62 | |
| 63 | It also silently kills the selection tint at `sheets.css:80`, which is |
| 64 | the only feedback the app gives about which cell is selected. |
| 65 | |
| 66 | **Fix:** replace `.jexcel` with `.jss_worksheet` throughout, and add |
| 67 | `.jss_container` / `.jss_content` so the surface behind the last column |
| 68 | is themed too. Then add a guard test asserting that at least one `.jss_*` |
| 69 | selector in `sheets.css` also appears in `vendor/jspreadsheet.css` β this |
| 70 | is a vendor-upgrade trap that will recur on the next bump, and it is |
| 71 | exactly the class of failure that survives a green suite. |
| 72 | |
| 73 | ### 2. Below 800px there is no way to sign out |
| 74 | |
| 75 | `tokens.css:1401` sets `[rst-shell-topbar] [rst-shell-tail] { display: |
| 76 | none }`. It is revealed by an `[rst-shell-menu][open]` that immediately |
| 77 | precedes it (`tokens.css:1409`), or by the viewport reaching 800px |
| 78 | (`tokens.css:1428`). `layout.html` never renders an `[rst-shell-menu]`. |
| 79 | |
| 80 | So on every phone β and on any half-width desktop window β the |
| 81 | navigation and the entire account dropdown are `display:none` with |
| 82 | nothing that can reveal them. No sign out. No theme switcher. |
| 83 | |
| 84 | That second one matters more than it looks: the theme switcher is a form |
| 85 | rather than a script specifically because "a person who needs a |
| 86 | high-contrast theme is not well served by being told to enable scripting |
| 87 | first". Being told to find a laptop is no better. |
| 88 | |
| 89 | **Fix:** render `<details rst-shell-menu name="rst-menus"><summary>Menu |
| 90 | </summary></details>` as the immediate previous sibling of `<div |
| 91 | rst-shell-tail>`, so the `+` combinator fires. Sharing the `rst-menus` |
| 92 | name group means opening it closes the account dropdown. rastrillo hides |
| 93 | it itself above 800px, so no media query of our own is needed. |
| 94 | |
| 95 | ### 3. The sign-in help sentence renders inside a 28px icon badge |
| 96 | |
| 97 | `signin.html:31` writes `<p rst-help>`. In rastrillo, `rst-help` is not |
| 98 | helper text β it is an **icon badge**: `tokens.css:1245` gives it |
| 99 | `display:inline-flex; width:28px; height:28px; border:1px solid; |
| 100 | border-radius:7px`. |
| 101 | |
| 102 | A 90-character sentence is being poured into a 28px box that centres its |
| 103 | overflow. Hence the one-word-per-line column and the stray border stroke |
| 104 | across the text on desktop, and text running off the left edge of the |
| 105 | viewport on mobile. |
| 106 | |
| 107 | First screen, every visitor, every viewport β and it is the sentence |
| 108 | carrying the sign-in reassurance. Reassurance delivered inside a visibly |
| 109 | broken component reads as incompetence, not honesty. |
| 110 | |
| 111 | **Fix:** the attribute for field help text is `rst-field-help` |
| 112 | (`tokens.css:1056`). Move the paragraph inside the `rst-field` so it sits |
| 113 | under the input. |
| 114 | |
| 115 | --- |
| 116 | |
| 117 | ## P1 β the scriptless table view is a link farm |
| 118 | |
| 119 | `table.html:45-46` wraps every cell's content in a bare `<a>`, and |
| 120 | `sheets.css` styles the table but never the anchor. The result is roughly |
| 121 | 650 default-blue underlined links per page. Empty cells render ` ` |
| 122 | inside an anchor, producing a grid of tiny underline stubs β 650 tab |
| 123 | stops, with no visual difference between `94200` and nothing at all. |
| 124 | |
| 125 | Compounding it, `--rst-border` is undefined (below), so the table has no |
| 126 | cell borders whatsoever. Columns EβZ collapse while AβD stretch, so the |
| 127 | grid geometry a spreadsheet depends on is gone. |
| 128 | |
| 129 | This is the screen the spec calls the product, and "fallback" the wrong |
| 130 | word for. Right now it is the fallback in the apologetic sense β |
| 131 | including in its own subtitle, which says "with no JavaScript at all": |
| 132 | implementation vocabulary aimed at the wrong reader, describing what the |
| 133 | view lacks rather than what it is. |
| 134 | |
| 135 | **Fix:** style `[data-cell-table] td > a` to inherit colour, drop the |
| 136 | underline, and fill the cell (`display:block`, a min block size) so the |
| 137 | target exists without being a mark; show the affordance on `:hover` and |
| 138 | `:focus-visible` with `--rst-accent-soft`. Drop the ` `. Right-align |
| 139 | numeric cells and set `table-layout: fixed` so sparse data keeps its |
| 140 | geometry. |
| 141 | |
| 142 | --- |
| 143 | |
| 144 | ## P1 β five invented token names |
| 145 | |
| 146 | `sheets.css` uses five `--rst-*` custom properties that are defined |
| 147 | nowhere in `tokens.css` or `theme.css`: |
| 148 | |
| 149 | | Token | Real name | Sites | Fallback? | |
| 150 | |---|---|---|---| |
| 151 | | `--rst-border` | `--rst-line` | 26, 71, 111 | **no** | |
| 152 | | `--rst-text-mut` | `--rst-text-muted` | 49, 53, 77, 119, 135 | **no** | |
| 153 | | `--rst-warning` | β (none ships) | 53, 93 | yes | |
| 154 | | `--rst-font-mono` | β | 40 | yes | |
| 155 | | `--rst-shell-bar-h` | β | 16 | yes | |
| 156 | |
| 157 | The three without a fallback are the ones that bite. `border: 1px solid |
| 158 | var(--rst-border)` is invalid at computed-value time, so the whole |
| 159 | shorthand resolves to `initial` β `border-style: none`. That is why the |
| 160 | table view has no cell borders and the sheet bar has no divider. The five |
| 161 | `--rst-text-mut` sites lose their colour and inherit full-strength text, |
| 162 | so nothing muted is actually muted. |
| 163 | |
| 164 | `--rst-shell-bar-h` always falls back to the 3.5rem literal, so |
| 165 | `calc(100dvh - β¦)` at `sheets.css:16` is wrong whenever the bar's real |
| 166 | height differs β on mobile the sheet bar wraps to two rows and the last |
| 167 | grid row is clipped under the fold. |
| 168 | |
| 169 | **Already in flight.** The session working in the primary `sheets-core` |
| 170 | checkout has an uncommitted `internal/guardstest/csstokens_test.go` whose |
| 171 | comment reads "the check that would have caught five invented token names |
| 172 | shipping to a canary". It is these five. This review confirms them |
| 173 | independently; the fix belongs to that branch, not this one. |
| 174 | |
| 175 | --- |
| 176 | |
| 177 | ## P2 β smaller, but real |
| 178 | |
| 179 | - **`data-contrast` is never set.** `sheets.css:88-99`'s low-contrast |
| 180 | corner flag is well-conceived and well-commented, and it is dead code: |
| 181 | nothing in Go, JS or any template emits the attribute. `contrast.go` |
| 182 | computes the warned list and the notice fires, so a person is told |
| 183 | which cells are unreadable and given no way to see which. The one |
| 184 | Sheets-specific visual mark in the app never appears. |
| 185 | - **"Fill down" fills one cell.** `grid.js:183-188` writes to |
| 186 | `selection.r + 1` and stops. Every spreadsheet a collaborator has used |
| 187 | means "fill the selected range" by that label, so they will press it |
| 188 | repeatedly and conclude the app is broken. Either relabel to `Copy |
| 189 | down` / `Copy right` until the range version exists, or build the |
| 190 | range version. |
| 191 | - **Nothing has an undo.** A cell write is a POST; browser-back does not |
| 192 | reverse it; version history is deferred. With cell-level last-write- |
| 193 | wins, one person can silently replace another's formula with no record |
| 194 | either of them can see. See the open question below. |
| 195 | - **No cell reference on screen.** To use a fill command you must |
| 196 | remember which cell you selected. The only cue is Jspreadsheet's own |
| 197 | header shading. |
| 198 | - **Raw doubles as display values.** `0.10514018691588785` in the table |
| 199 | view, clipped mid-digit in the grid. Three renderings of D2 across |
| 200 | three surfaces, only one of them labelled. |
| 201 | - **The grid page has no `<h1>`.** The title is a `<span |
| 202 | data-sheet-title>`, so the document outline is empty on the app's |
| 203 | primary screen. |
| 204 | - **`table.html:5` reads "Table view β rows 1 of 20 pages."** It splices |
| 205 | a row count and a page count into one ungrammatical phrase. |
| 206 | - **`table.html:12` uses an inline `style="overflow-x:auto"`** β the only |
| 207 | raw style declaration in an app that is otherwise entirely attribute |
| 208 | grammar and tokens. |
| 209 | - **Sheet list order is unstable on tied `UpdatedAt`.** Two sheets |
| 210 | created in the same minute swap places between renders. No secondary |
| 211 | sort key. |
| 212 | - **Three names for one app in three screens:** `Sheets` (topbar brand), |
| 213 | `Oficina Sheets` (`index.html`), `Sign in to Oficina Sheets`. |
| 214 | - **`sheets.html:4` "Your sheets" is contradicted by its own subtitle** |
| 215 | one line below. If they are the instance's sheets, say so in the |
| 216 | heading rather than correcting it underneath. |
| 217 | |
| 218 | --- |
| 219 | |
| 220 | ## What is working |
| 221 | |
| 222 | 1. **The list row overlay is used correctly.** `sheets.html:22-27` writes |
| 223 | `rst-lrow` / `rst-row-main` / `rst-row-sub`, which picks up |
| 224 | rastrillo's `::after { inset: 0; position: absolute }` β the whole row |
| 225 | is a click target without nesting anchors. It is the only screen that |
| 226 | feels finished, and that is because the framework was trusted rather |
| 227 | than fought. |
| 228 | 2. **The trust copy lands where it matters.** Not in a settings page: in |
| 229 | the subtitle of the screen where you decide what to put in a sheet. |
| 230 | 3. **The formula/display split.** Display strings in the cells, raw input |
| 231 | in a side map, the formula bar as the reliable read path with the |
| 232 | in-cell swap as best effort. That is the correct model of what a |
| 233 | spreadsheet is, and it is why the app can honestly claim the server |
| 234 | owns calc. |
| 235 | 4. **`autofocus` on the table view's editing input.** A person clicks a |
| 236 | cell and can type immediately, with no script. More of that thinking, |
| 237 | applied to how the table view *looks*, is most of what P1 asks for. |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ## Three questions |
| 242 | |
| 243 | 1. **If the table view is the product and the grid is the enhancement, |
| 244 | why did the grid get a formula bar, fill commands, live sync and a |
| 245 | contrast marker, while the table view got a `title` tooltip?** Every |
| 246 | affordance flowed downhill from the grid. What does the table view |
| 247 | look like if it is designed *first*, and does the grid then become a |
| 248 | skin over it rather than a second app? |
| 249 | |
| 250 | 2. **The honest scoping lives on `/`** β the page a working collaborator |
| 251 | passes once and never returns to. The sheet page, where numbers are |
| 252 | actually typed, says nothing about what is and is not protected. If |
| 253 | the trust model is the differentiator, does it belong on the marketing |
| 254 | surface or the working surface? |
| 255 | |
| 256 | 3. **Is history the deferred feature, or is undo the missing primitive?** |
| 257 | In a cell-level last-write-wins grid shared by two people, undo may be |
| 258 | what makes last-write-wins survivable at all, with history merely its |
| 259 | readable form. The deferred table asks every deferral for a written |
| 260 | trigger. The honest trigger here might be "the first time one of us |
| 261 | silently overwrote the other's formula" β and that fires the day the |
| 262 | second person joins. |
| 263 | |