oficina / sheets Public
π€ 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 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 . 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-contrastis 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.gocomputes 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-188writes toselection.r + 1and 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 toCopy down/Copy rightuntil 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.10514018691588785in 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:5reads "Table view β rows 1 of 20 pages." It splices a row count and a page count into one ungrammatical phrase.table.html:12uses an inlinestyle="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
- The list row overlay is used correctly.
sheets.html:22-27writesrst-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. - 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.
- 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.
autofocuson 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
- 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
titletooltip? 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?
- 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?
- 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.