oficina / sheets Public

feat(table): column width, the last affordance the scriptless view lacked

PRODUCT.md's second principle — the scriptless path is the product, not
the fallback — was unmet, and the spec named that as debt. Bold,
italic, alignment, number format, fill and text colour reached the
table view on 2026-09-02. Column width was the one left, and it had no
form behind it at all: handleSizePost decoded JSON only.

It now takes a form the way handleFormatPost does. sizeFromForm
converges on the same sizeWrite and validates nothing itself — the
exactly-one-of-col-and-row rule, the bounds and the store call all
still run once, on whichever request arrived, so the two decoders
cannot drift into two rule sets. That is the decision formatFromForm
already recorded; this follows it rather than inventing a second shape.

Presence and not value decides which of col and row was named: an
absent field and a field holding "0" are different requests, and column
0 is a real column. Same reason the JSON shape uses pointers.

The redirect target is built from the sheet id in the path and a page
this bounds itself, never from the request — this repo has already
shipped one fix in the open-redirect family.

The control is one column picker beside the sort and filter form, not
an input in each of 26 headers: a header carrying a number input in
every column is a header nobody can read.

Undo for a format or a resize is still absent and still has its own row
in the deferred table. Different gap, different trigger.
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 bad02cb270ccb959a5b10b6aa9f35adda0cce5fc parent 1dc18d8
4 files changed, +195 −2
  • docs/superpowers/specs/2026-08-30-sheets-design.md +1 −1
  • internal/sheets/handlers_size.go +65 −1
  • internal/sheets/sizeform_test.go +96 −0
  • internal/sheets/templates/table.html +33 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-08-30-sheets-design.md b/docs/superpowers/specs/2026-08-30-sheets-design.md
index 6602356..5f6d9b7 100644
--- a/docs/superpowers/specs/2026-08-30-sheets-design.md
+++ b/docs/superpowers/specs/2026-08-30-sheets-design.md
@@ -1064,7 +1064,7 @@ it is a fallback rather than a scramble.
| `client` shape for anything but the grid | A surface genuinely needs client-held keys |
| Print / PDF export | Asked for. docs-e6 keeps its layout engine's page model generic over content boxes so this stays open: paginated grid, repeating header rows, scale-to-width, explicit page breaks, charts as vector |
| Live sheet range embedded in a doc | A known future crossing of two collaboration models |
-| **Formatting from the scriptless table view** | PRODUCT.md's second principle says the scriptless path is the product, not the fallback, and that an affordance in the grid and not in the table view means the table view is unfinished. Bold, italic, alignment, number format, fill, text colour and column widths all landed in the grid on 2026-09-02 and none of them is reachable from the table view, so the principle is currently unmet and this row is the debt, named rather than discovered. The `POST .../format` route is scriptless-ready already — it takes a rectangle and a patch, and a form could post one — so the work is the table view's own controls and not the backend. Trigger: **the next slice that touches the table view**, or Paul asking for it, whichever is first. The grid's menu bar is scriptless wherever it can be meanwhile: File, Data, Help and Edit's undo are links and forms that work with scripting off; only the Format menu and the toolbar are script-driven, and both are hidden rather than shown dead. |
+| ~~Formatting from the scriptless table view~~ | **Closed 2026-09-04.** PRODUCT.md's second principle says the scriptless path is the product, not the fallback, and this row was the debt while it went unmet. Bold, italic, alignment, number format, fill and text colour reached the table view on 2026-09-02; **column width was the last one left** and had no form behind it at all — `handleSizePost` decoded JSON only. It now takes a form the same way `handleFormatPost` does: `sizeFromForm` converges on the same `sizeWrite`, and the exactly-one-of-col-and-row rule, the bounds and the store call still run once, so the two decoders cannot drift into two rule sets. The control is one column picker beside the sort and filter form rather than an input in each of 26 headers. Undo for a format or a resize is still absent and still has its own row below — that is a different gap with a different trigger. |
| **Undo for formatting and for a resize** | Undo replays input events (§7), and neither a format write nor a resize appends one — so Ctrl-Z after bolding undoes the last VALUE edit instead, which is surprising in the way this app tries not to be. The UI therefore does not offer undo for either. Trigger: **a format event kind**, which changes the undo model and is not a thing to do in passing. |
| ~~Surfacing an imported fill/ink pairing that fails WCAG AA~~ | **Trigger fired and built, 2026-09-01.** Plan 2's grid work landed, and with it `Format.InkHex` (the authored font colour, which nothing retained before), a WCAG ratio, and a notice naming the failing cells on both the grid and the table view. Surfaced, never overridden — §11.1's ruling stands. |
diff --git a/internal/sheets/handlers_size.go b/internal/sheets/handlers_size.go
index 3bef977..02f39a3 100644
--- a/internal/sheets/handlers_size.go
+++ b/internal/sheets/handlers_size.go
@@ -3,6 +3,7 @@ package sheets
import (
"encoding/json"
"net/http"
+ "strconv"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -42,8 +43,21 @@ func (a *App) handleSizePost(w http.ResponseWriter, r *http.Request) {
}
defer h.Release()
+ // One route, two decoders, converging on one sizeWrite before
+ // anything is checked — the same shape handleFormatPost took, and
+ // for the same reason: the bounds, the exactly-one rule and the
+ // store call must exist once. The scriptless table view posts a
+ // form here; the grid posts JSON.
var in sizeWrite
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
+ form := isFormPost(r)
+ if form {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
+ var ok bool
+ if in, ok = sizeFromForm(r); !ok {
+ http.Error(w, "Malformed request.", http.StatusBadRequest)
+ return
+ }
+ } else if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
http.Error(w, "Malformed request.", http.StatusBadRequest)
return
}
@@ -89,9 +103,59 @@ func (a *App) handleSizePost(w http.ResponseWriter, r *http.Request) {
}
a.touchSheet(sheetIDFrom(r))
a.publish(tabIDFrom(r), resp)
+ if form {
+ // 303 back to the table, built here from the sheet id in the
+ // path and a page this bounds itself — never from the request.
+ // Same reasoning as the format route's redirect.
+ http.Redirect(w, r, tableReturn(sheetIDFrom(r), r.FormValue("page")), http.StatusSeeOther)
+ return
+ }
writeJSON(w, http.StatusOK, resp)
}
+// sizeFromForm decodes a sizeWrite out of an ordinary HTML form.
+//
+// Nothing is validated here. The exactly-one-of-col-and-row rule, the
+// bounds and the store call all run afterwards in the handler on
+// whichever request arrived — so the form path and the JSON path cannot
+// drift into two rule sets, which is the same decision formatFromForm
+// records.
+//
+// Presence, not value, decides which of col and row was named: an
+// absent field and a field holding "0" are different requests, and
+// column 0 is a real column. That is the same reason the JSON shape
+// uses pointers.
+func sizeFromForm(r *http.Request) (sizeWrite, bool) {
+ var in sizeWrite
+ if err := r.ParseForm(); err != nil {
+ return in, false
+ }
+ read := func(name string) (*int, bool) {
+ if _, present := r.Form[name]; !present {
+ return nil, true
+ }
+ n, err := strconv.Atoi(r.FormValue(name))
+ if err != nil {
+ return nil, false
+ }
+ return &n, true
+ }
+ col, ok := read("col")
+ if !ok {
+ return in, false
+ }
+ row, ok := read("row")
+ if !ok {
+ return in, false
+ }
+ size, err := strconv.Atoi(r.FormValue("size"))
+ if err != nil {
+ return in, false
+ }
+ in.Col, in.Row, in.Size = col, row, size
+ return in, true
+}
+
// sizesResponse builds a payload carrying the tab's sizes and no cells.
//
// The WHOLE size table, not just the one that changed. It is one small
diff --git a/internal/sheets/sizeform_test.go b/internal/sheets/sizeform_test.go
new file mode 100644
index 0000000..a38e2a4
--- /dev/null
+++ b/internal/sheets/sizeform_test.go
@@ -0,0 +1,96 @@
+package sheets
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// PRODUCT.md's second principle: the scriptless path is the product,
+// not the fallback. An affordance in the grid and not in the table view
+// means the table view is unfinished — and column width was the last of
+// the seven that had no form behind it.
+func TestAColumnCanBeWidenedWithNoScriptAtAll(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ form := url.Values{"col": {"2"}, "size": {"180"}, "page": {"1"}}
+ req := postForm(fmt.Sprintf("/api/sheets/%d/tabs/%d/size", s.ID, s.TabID), form)
+ rec := do(t, app, as(req, cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("scriptless resize = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+ // Back to the table, never to a target the caller chose.
+ if loc := rec.Header().Get("Location"); loc != fmt.Sprintf("/sheets/%d/table?page=1", s.ID) {
+ t.Fatalf("Location = %q", loc)
+ }
+
+ h, err := app.openTabFor(s)
+ if err != nil {
+ t.Fatalf("open tab: %v", err)
+ }
+ defer h.Release()
+ cols, _, err := h.Sizes()
+ if err != nil {
+ t.Fatalf("Sizes: %v", err)
+ }
+ if cols[2] != 180 {
+ t.Fatalf("column 2 is %d wide, want 180 — the form did not reach the store", cols[2])
+ }
+}
+
+// The JSON route refuses both-or-neither rather than guessing. The form
+// path must refuse the same way, or the two decoders are two rule sets.
+func TestTheResizeFormRefusesBothOrNeither(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ path := fmt.Sprintf("/api/sheets/%d/tabs/%d/size", s.ID, s.TabID)
+
+ for _, form := range []url.Values{
+ {"col": {"1"}, "row": {"1"}, "size": {"100"}},
+ {"size": {"100"}},
+ {"col": {"notanumber"}, "size": {"100"}},
+ {"col": {"1"}, "size": {"notanumber"}},
+ } {
+ rec := do(t, app, as(postForm(path, form), cookies))
+ if rec.Code == http.StatusSeeOther {
+ t.Errorf("the form %v was accepted", form)
+ }
+ }
+}
+
+// A viewer must not resize: it is a layout change everybody watching
+// the sheet sees.
+func TestAViewerCannotResizeThroughTheForm(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
+ adaCookies := signInAs(t, app, "ada@elsewhere.example")
+
+ form := url.Values{"col": {"0"}, "size": {"200"}, "page": {"1"}}
+ rec := do(t, app, as(postForm(fmt.Sprintf("/api/sheets/%d/tabs/%d/size", s.ID, s.TabID), form), adaCookies))
+ if rec.Code == http.StatusSeeOther {
+ t.Fatalf("a viewer resized a column: %d", rec.Code)
+ }
+}
+
+// The control itself, with every script stripped out.
+func TestTheTableViewOffersAWidthControlWithoutScript(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ body := withoutScript(do(t, app,
+ as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String())
+ for _, want := range []string{
+ fmt.Sprintf(`action="/api/sheets/%d/tabs/%d/size"`, s.ID, s.TabID),
+ `name="col"`,
+ `name="size"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("with scripts stripped, %q is gone — the table view cannot resize", want)
+ }
+ }
+}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index c83c6c8..5f9e334 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -54,6 +54,39 @@ Descending</label>
</div>
</form>
+{{if .Data.MayWrite}}
+{{/* Column width, scriptless. PRODUCT.md's second principle: an
+ affordance in the grid and not here means this view is unfinished,
+ and width was the last of the seven that had no form behind it —
+ bold, italic, alignment, number format, fill and text colour all
+ landed with the cells they format.
+
+ One form with a column picker rather than a control in each of the
+ 26 headers: it matches the sort and filter control directly above
+ it, and a header carrying a number input in every column is a
+ header nobody can read.
+
+ The size route takes this form and the grid's JSON on one path —
+ see sizeFromForm — so a width set here and a width dragged there
+ are the same write, and both fan out to every open tab. */}}
+<form rst-form method="post" action="/api/sheets/{{.Data.Sheet.ID}}/tabs/{{.Data.Sheet.TabID}}/size">
+<input type="hidden" name="page" value="{{.Data.Page}}">
+<div rst-field-row>
+<div rst-field>
+<label rst-field-label for="widthCol">Column width</label>
+<select rst-input id="widthCol" name="col">
+{{range $i, $name := .Data.Cols}}<option value="{{$i}}">{{$name}}</option>{{end}}
+</select>
+</div>
+<div rst-field>
+<label rst-field-label for="widthPx">pixels</label>
+<input rst-input="short" id="widthPx" name="size" type="number" min="20" max="600" value="120">
+</div>
+<button rst-btn="primary" type="submit">Set width</button>
+</div>
+</form>
+{{end}}
+
{{if .Data.View.Active}}
<p rst-field-help>This view is sorted or filtered. It changes what everybody
sees on this tab, and it changes nothing about the sheet itself — the