oficina / sheets Public

docs: PRODUCT.md and the first design review, against the real binary

Two artifacts, both from the Impeccable pass Paul asked for.

PRODUCT.md is the durable product record: users, purpose, positioning,
constraints, principles. Its facts are Paul's answers of 2026-09-01, not
inferences — primary user is the author and his collaborators dogfooding,
and all four commitments (sealed at rest honestly scoped, one instance
per team, no affordance script-required, light/dark per person) are
binding on every later design decision. It records the absences too: no
customers, no testimonials, no benchmarks, nothing to fabricate.

The review ran two isolated assessments over screenshots of the real
binary in Chromium, desktop and mobile, both schemes. 17/40. The
deterministic detector found nothing at all, which is the interesting
part: every defect is semantic. The markup is well-formed, correctly
labelled and correctly structured, and pointed at the wrong names.

Three screens do not work. The dark grid is white-on-white because every
Jspreadsheet override targets v4's .jexcel while the vendored blob is v5
and emits .jss_*. Below 800px the shell hides the account tail and the
layout renders no rst-shell-menu to reveal it, so there is no way to sign
out on a phone. The sign-in help sentence is wrapped in rst-help, which
is a 28px icon badge rather than helper text.

None of those are fixed here. sheets.css and layout.html are being
rewritten right now in the primary checkout, and one of the findings —
the five invented token names — is already the subject of an uncommitted
guard test there. Landing a second copy of that work would cost a merge
conflict across 145 changed lines and buy nothing.
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 bedfdd6da0966939cd9228c6bdcba7785c30134f parent 25aac19
2 files changed, +433 −0
  • PRODUCT.md +171 −0
  • docs/design/2026-09-01-design-review.md +262 −0

CI passed — run details

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.