oficina / sheets Public

feat(table): formatting on the scriptless path, and the paint to show it

PRODUCT.md's second principle says the scriptless path is the product
and not the fallback, and spec §14 has recorded this exact debt in its
own words since the grid gained a toolbar: "an affordance in the grid
and not in the table view means the table view is unfinished". Bold,
italic, alignment, number format, fill and text colour all landed in the
grid on 2026-09-02. None was reachable here. §14's trigger — "the next
slice that touches the table view" — fires with this one.

**Controls and paint, both.** A screen that can SET bold and cannot SHOW
it is the worse half of the feature: you pick it, nothing visible
happens, and the only way to check is the other screen. So the server
writes the same data- attributes paint.js writes on the grid's td, and
sheets.css styles both from one vocabulary. A second set of names here
would be a second answer to what bold looks like.

**One route, two decoders, converging before any validation.** The form
posts to the API route the grid's toolbar already posts to. validate,
normalize and the maxFormatCells bound all run afterwards, on whichever
patch arrived — so the allowlist exists once. Mutation-tested: skipping
validate for the form path lets an alignment of "diagonal", a colour of
"#ff0000" and a number format of `"><script>` all through.

**Every toggle is a select, not a checkbox, and that is load-bearing.**
formatPatch's fields are pointers precisely so "leave bold alone" and
"turn bold off" are different requests — and an unchecked checkbox is
simply absent from a submission, indistinguishable from a field the form
never had. A checkbox-shaped bold control can make a cell bold and can
never make it not bold. Mutation-tested; without "clear", the toggle is
write-only and the test says so in those words.

**The redirect target is built by the server.** 303 back to a URL made
from the sheet id and a page number this code bounds itself, never from
the request. Mutation-tested with `page=https://evil.example/`, which
without it is an open redirect — a family this repo has already shipped
one fix for.

**One test was passing for the wrong reason and only mutation found it.**
TestKeepLeavesAFieldAlone asserted that choosing a fill does not un-bold
the cell. With the "keep" guard removed it still passed — because
`align=keep` then reaches validate as an unknown alignment, the whole
post 400s, and the cell stays bold because NOTHING was applied. It now
asserts the 303 first. Four mutations planted; the first version caught
three.

handlers_table.go crossed the 300-line cap on the way and is split at
the seam that was already there: tableview.go turns stored cells into
what a page shows, handlers_table.go decides which cells and what to do
with a POST.

Verified in Chromium with javaScriptEnabled:false —
tools/drive/tableformat.mjs picks three formats from selects, follows
the 303, reads font-weight 600 / text-align center /
oklch(0.92 0.085 145) off the cell, and confirms the grid agrees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
Paul Campbell pushed by paul@keymail.dev 393e276f8e2dc8686050b9a1d272c70b509bc1ba parent 6663e21
9 files changed, +829 −56
  • internal/sheets/formatform.go +88 −0
  • internal/sheets/handlers_format.go +55 −1
  • internal/sheets/handlers_table.go +22 −54
  • internal/sheets/handlers_table_format_test.go +267 −0
  • internal/sheets/static/sheets.css +92 −0
  • internal/sheets/tablepaint.go +107 −0
  • internal/sheets/tableview.go +65 −0
  • internal/sheets/templates/table.html +73 −1
  • tools/drive/tableformat.mjs +60 −0

CI passed — run details

diff --git a/internal/sheets/formatform.go b/internal/sheets/formatform.go
new file mode 100644
index 0000000..0dbf5b7
--- /dev/null
+++ b/internal/sheets/formatform.go
@@ -0,0 +1,88 @@
+package sheets
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// formatFromForm decodes a formatPatch out of an ordinary HTML form.
+//
+// The second decoder for one route (this slice's decision D). Both
+// converge on a formatPatch and NOTHING is validated here — validate,
+// normalize and the maxFormatCells bound all run afterwards, in the
+// handler, on whichever patch arrived. If this function checked an
+// alignment or clamped a rectangle, the form path and the JSON path
+// would be two implementations of the rules and would drift, and the
+// drift would only surface when somebody formatted in one view and read
+// the result in the other.
+//
+// **Every toggle is tri-state, and that is not a style choice.**
+// formatPatch's fields are pointers precisely so that "leave bold
+// alone" and "turn bold off" are different requests — and an unchecked
+// checkbox is simply ABSENT from a form submission, indistinguishable
+// from a field the form never had. A checkbox-shaped bold control can
+// therefore express "make it bold" and can never express "make it not
+// bold", which is half a control. So each toggle posts an explicit
+// word: "set", "clear", or nothing at all.
+func formatFromForm(r *http.Request) (formatPatch, bool) {
+ var p formatPatch
+
+ rect := []struct {
+ name string
+ into *int
+ }{{"r1", &p.R1}, {"c1", &p.C1}, {"r2", &p.R2}, {"c2", &p.C2}}
+ for _, f := range rect {
+ v, err := strconv.Atoi(r.FormValue(f.name))
+ if err != nil {
+ return p, false
+ }
+ *f.into = v
+ }
+
+ if b, ok := triState(r.FormValue("bold")); ok {
+ p.Bold = &b
+ }
+ if b, ok := triState(r.FormValue("italic")); ok {
+ p.Italic = &b
+ }
+ // The three string fields carry their own "leave alone", which is
+ // the empty submission — but "" is also a MEANINGFUL value for
+ // align and numfmt (it clears back to the engine's own choice), so
+ // they cannot use the same convention as the booleans. A separate
+ // word does the job: the control posts "keep" when it is not
+ // changing anything.
+ for _, f := range []struct {
+ name string
+ into **string
+ }{{"align", &p.Align}, {"numfmt", &p.NumFmt}, {"fill", &p.Fill}, {"ink", &p.Ink}} {
+ v := r.FormValue(f.name)
+ if v == "keep" {
+ continue
+ }
+ if _, present := r.Form[f.name]; !present {
+ continue
+ }
+ val := v
+ *f.into = &val
+ }
+ return p, true
+}
+
+// triState reads a boolean toggle. "set" and "clear" are the two
+// changes; anything else — including the empty string a "leave alone"
+// option posts, and including a value nobody expected — leaves the
+// field absent.
+//
+// Deliberately not accepting "true"/"false"/"on": "on" is what a
+// checkbox posts, and quietly accepting it would let somebody build the
+// checkbox-shaped control this function exists to rule out, and have it
+// appear to work for exactly the half of its job that it can do.
+func triState(v string) (bool, bool) {
+ switch v {
+ case "set":
+ return true, true
+ case "clear":
+ return false, true
+ }
+ return false, false
+}
diff --git a/internal/sheets/handlers_format.go b/internal/sheets/handlers_format.go
index 6f3f428..940d0de 100644
--- a/internal/sheets/handlers_format.go
+++ b/internal/sheets/handlers_format.go
@@ -3,6 +3,8 @@ package sheets
import (
"encoding/json"
"net/http"
+ "strconv"
+ "strings"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -46,8 +48,20 @@ func (a *App) handleFormatPost(w http.ResponseWriter, r *http.Request) {
}
defer h.Release()
+ // One route, two decoders, converging on one patch BEFORE anything
+ // is validated (this slice's decision D). The scriptless table view
+ // posts a form here; the grid posts JSON. Everything below this
+ // point is identical for both, which is what keeps the allowlist,
+ // the bounds and the cell cap from existing twice.
var p formatPatch
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&p); err != nil {
+ form := isFormPost(r)
+ if form {
+ var ok bool
+ if p, ok = formatFromForm(r); !ok {
+ http.Error(w, "Malformed request.", http.StatusBadRequest)
+ return
+ }
+ } else if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&p); err != nil {
// Not echoed: a number-format pattern is authored content, and
// an error string is a log line waiting to happen (AGENTS.md).
http.Error(w, "Malformed request.", http.StatusBadRequest)
@@ -79,9 +93,49 @@ func (a *App) handleFormatPost(w http.ResponseWriter, r *http.Request) {
}
a.touchSheet(sheetIDFrom(r))
a.publish(tabIDFrom(r), resp)
+ if form {
+ // 303 back to the page, so a reload does not re-post the patch.
+ //
+ // The target is BUILT HERE from the sheet id in the path and a
+ // page number this function bounds itself — never taken from
+ // the request. A caller-supplied return is an open redirect,
+ // and this repo has already shipped one fix in that family
+ // (fix/safe-return-backslash).
+ http.Redirect(w, r, tableReturn(sheetIDFrom(r), r.FormValue("page")), http.StatusSeeOther)
+ return
+ }
writeJSON(w, http.StatusOK, resp)
}
+// isFormPost reports whether this request is the scriptless table
+// view's rather than the grid's.
+//
+// Content-Type and not a query flag or a hidden field: the body's own
+// encoding is the thing that actually differs, a flag could disagree
+// with it, and r.FormValue on a JSON body silently reads nothing at all
+// — which would decode as a patch of zeroes rather than as an error.
+func isFormPost(r *http.Request) bool {
+ ct := r.Header.Get("Content-Type")
+ return strings.HasPrefix(ct, "application/x-www-form-urlencoded") ||
+ strings.HasPrefix(ct, "multipart/form-data")
+}
+
+// tableReturn builds the table page URL to send a form caller back to.
+//
+// page is parsed and bounded rather than concatenated: it arrives from
+// the form, and the one thing a redirect target must not contain is
+// anything the caller chose. An unparseable or out-of-range page falls
+// back to the first, which is a worse landing than the one they came
+// from and a better one than a URL somebody else wrote.
+func tableReturn(sheetID uint64, page string) string {
+ n, err := strconv.Atoi(page)
+ pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage
+ if err != nil || n < 1 || n > pages {
+ n = 1
+ }
+ return "/sheets/" + strconv.FormatUint(sheetID, 10) + "/table?page=" + strconv.Itoa(n)
+}
+
// applyFormat writes the patch across the rectangle and returns the
// coordinates it touched.
//
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
index 023672c..55c3d86 100644
--- a/internal/sheets/handlers_table.go
+++ b/internal/sheets/handlers_table.go
@@ -5,7 +5,6 @@ import (
"strconv"
"strings"
- "amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -42,6 +41,10 @@ type tableCell struct {
// the input string would align the two differently. The server owns
// every string on screen here; it owns this one bit too.
Number bool
+ // Paint is the cell's formatting, rendered the same way the grid's
+ // paint.js renders it — same attributes, same custom properties, so
+ // sheets.css styles both screens from one set of rules.
+ Paint cellPaint
}
type tableRow struct {
@@ -62,12 +65,18 @@ type tableView struct {
Warned []string
View ViewSpec
FilterOps []string
- Page int
- Pages int
- PrevPage int
- NextPage int
- HasPrev bool
- HasNext bool
+ // The same three lists the grid's Format menu is built from, so a
+ // control this page renders is a value the format route accepts —
+ // by construction rather than by remembering to edit both.
+ NumberFormats []numberFormat
+ FillSwatches []swatch
+ InkSwatches []swatch
+ Page int
+ Pages int
+ PrevPage int
+ NextPage int
+ HasPrev bool
+ HasNext bool
}
// handleTable renders a page of a tab as an ordinary HTML table.
@@ -120,6 +129,12 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasNext: page < pages,
MayWrite: mayWrite,
+ // Ranged over in the template, exactly as the grid's Format
+ // menu ranges over them, so a control this page offers is a
+ // value handleFormatPost's allowlist accepts.
+ NumberFormats: numberFormats,
+ FillSwatches: fillSwatches,
+ InkSwatches: inkSwatches,
}
for c := 0; c < tableCols; c++ {
view.Cols = append(view.Cols, colLabel(c))
@@ -245,50 +260,3 @@ func colLabel(n int) string {
}
return label
}
-
-// tableRows builds one page of rows.
-//
-// Shared with the anonymous link view, which passes editR/editC of -1
-// so no cell can ever match and none renders a form. Expressing
-// read-only as a coordinate nothing equals — rather than as a flag the
-// template is trusted to honour — means the read-only view cannot grow
-// an edit form by somebody adding a branch to the template.
-func (a *App) tableRows(h *store.Handle, first, editR, editC int) []tableRow {
- order := make([]int, 0, tableRowsPerPage)
- for i := 0; i < tableRowsPerPage; i++ {
- order = append(order, first+i)
- }
- return a.tableRowsIn(h, order, editR, editC)
-}
-
-// tableRowsIn builds rows in a given order.
-//
-// The order is a list of row POSITIONS, which is what lets a sorted or
-// filtered view reuse every other part of this: each cell still carries
-// its real coordinate, so an edit form posts the row it was always
-// posting and nothing downstream needs to know a view is in effect.
-func (a *App) tableRowsIn(h *store.Handle, order []int, editR, editC int) []tableRow {
- idx := newPosIndex(h)
- rows := make([]tableRow, 0, len(order))
- for _, rowIdx := range order {
- row := tableRow{Number: rowIdx + 1}
- for c := 0; c < tableCols; c++ {
- cell := tableCell{
- R: rowIdx, C: c,
- Ref: colLabel(c) + strconv.Itoa(rowIdx+1),
- Editing: rowIdx == editR && c == editC,
- }
- if rowID, okR := idx.rowID[rowIdx]; okR {
- if colID, okC := idx.colID[c]; okC {
- v := h.Value(rowID, colID)
- cell.Raw, _ = h.RawInput(rowID, colID)
- cell.Display = display(v, h.Format(rowID, colID))
- cell.Number = v.Kind == calc.KindNum
- }
- }
- row.Cells = append(row.Cells, cell)
- }
- rows = append(rows, row)
- }
- return rows
-}
diff --git a/internal/sheets/handlers_table_format_test.go b/internal/sheets/handlers_table_format_test.go
new file mode 100644
index 0000000..0dbd394
--- /dev/null
+++ b/internal/sheets/handlers_table_format_test.go
@@ -0,0 +1,267 @@
+package sheets
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// Formatting from the scriptless table view.
+//
+// PRODUCT.md's second principle: the scriptless path is the product,
+// not the fallback, and spec §14 recorded the gap in its own words —
+// "an affordance in the grid and not in the table view means the table
+// view is unfinished". Bold, italic, alignment, number format, fill and
+// text colour landed in the grid on 2026-09-02 and none of them was
+// reachable here.
+//
+// The tests below are written against the ROUTE the grid already uses,
+// because that is the design (this slice's decision D): one route, two
+// decoders, converging on one formatPatch before anything is validated.
+// A test that exercised a form-only endpoint would pass while the two
+// paths drifted, which is the failure the decision exists to prevent.
+
+func formatURL(s SheetSummary) string {
+ return fmt.Sprintf("/api/sheets/%d/tabs/%d/format", s.ID, s.TabID)
+}
+
+// postFormat sends a form-encoded patch, the way the table view does.
+func postFormatForm(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, fields map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ form := url.Values{}
+ for k, v := range fields {
+ form.Set(k, v)
+ }
+ req := httptest.NewRequest(http.MethodPost, formatURL(s), strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return do(t, app, as(req, cookies))
+}
+
+func tablePage(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, page int) string {
+ t.Helper()
+ u := fmt.Sprintf("/sheets/%d/table?page=%d", s.ID, page)
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, u, nil), cookies))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("table page = %d", rec.Code)
+ }
+ return rec.Body.String()
+}
+
+func TestFormattingFromTheTableViewReachesTheCell(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Scriptless")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"total"}`)
+
+ rec := postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0",
+ "bold": "set", "align": "right", "fill": "yellow", "page": "1",
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("a form post = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+
+ // The grid's own snapshot is the check, not the table's HTML: if
+ // the two disagree the bug is that the form wrote something the
+ // grid cannot read, and asserting against the page that produced it
+ // would hide exactly that.
+ read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ if len(read.Cells) == 0 {
+ t.Fatal("no cells came back")
+ }
+ style, ok := read.Formats[read.Cells[0].F]
+ if !ok {
+ t.Fatalf("the cell carries format key %q and the response has no such style", read.Cells[0].F)
+ }
+ if !style.Bold {
+ t.Error("bold did not reach the cell")
+ }
+ if style.Align != "right" {
+ t.Errorf("align = %q, want right", style.Align)
+ }
+ if style.FillH == nil {
+ t.Error("the fill did not reach the cell")
+ }
+}
+
+// TestTheTableViewPaintsWhatItSets is the other half, and the half that
+// makes this a feature rather than a control.
+//
+// A screen that can set bold and cannot show it is the worse half: you
+// press the button, nothing visible happens, and the only way to check
+// is to open the other screen.
+func TestTheTableViewPaintsWhatItSets(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Painted")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"heading"}`)
+ postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0",
+ "bold": "set", "italic": "set", "align": "center", "fill": "green", "page": "1",
+ })
+
+ page := tablePage(t, app, cookies, s, 1)
+ for _, want := range []string{"data-b", "data-i", `data-a="center"`, "data-fill", "--sheet-h:"} {
+ if !strings.Contains(page, want) {
+ t.Errorf("the table view does not paint %s — it can set the format and not show it", want)
+ }
+ }
+}
+
+// TestAToggleCanBeTurnedOffFromAForm is the tri-state check, and the
+// reason the controls are selects rather than checkboxes.
+//
+// formatPatch distinguishes absent ("leave alone") from false ("clear
+// it") by pointer presence, and an unchecked checkbox is absent from a
+// submission. A checkbox-shaped bold control can therefore make a cell
+// bold and can NEVER make it not bold — half a control, and the half
+// that fails silently.
+func TestAToggleCanBeTurnedOffFromAForm(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Toggle")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
+
+ postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": "1",
+ })
+ if !boldAt(t, app, cookies, cells) {
+ t.Fatal("setup failed: the cell is not bold")
+ }
+
+ postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "clear", "page": "1",
+ })
+ if boldAt(t, app, cookies, cells) {
+ t.Error("a form could not turn bold off — the toggle is write-only")
+ }
+}
+
+// TestKeepLeavesAFieldAlone: the other half of tri-state. A form posts
+// every one of its selects on every submit, so "I am changing the fill"
+// has to be distinguishable from "I am not touching bold" — otherwise
+// choosing a fill would silently un-bold the selection, which is
+// formatPatch's own documented nightmare arriving through a new door.
+func TestKeepLeavesAFieldAlone(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Keep")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
+
+ postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": "1",
+ })
+ // Exactly what the rendered form sends when somebody picks a fill
+ // and touches nothing else: every control present, five of them
+ // still on "keep".
+ rec := postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0",
+ "bold": "keep", "italic": "keep", "align": "keep",
+ "numfmt": "keep", "fill": "blue", "ink": "keep", "page": "1",
+ })
+ // The status check is load-bearing, and its absence made this test
+ // pass for the wrong reason. Without the "keep" guard in
+ // formatFromForm, `align=keep` reaches validate as an unknown
+ // alignment and the whole post 400s — so the cell stays bold
+ // because NOTHING was applied, and an assertion on boldness alone
+ // reads that as success. Caught by planting the mutation.
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("the form every control posts was refused (%d) — a submission "+
+ "where five controls say \"keep\" is the ordinary case, not an error",
+ rec.Code)
+ }
+ if !boldAt(t, app, cookies, cells) {
+ t.Error("choosing a fill un-bolded the cell — every control a form posts " +
+ "is being read as a change, which is the exact bug formatPatch's " +
+ "pointers exist to prevent")
+ }
+}
+
+// TestTheFormRedirectIsBuiltByTheServer: an open redirect is the one
+// thing a POST-then-redirect shape gets wrong, and this repo has
+// already shipped one fix in that family.
+func TestTheFormRedirectIsBuiltByTheServer(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Return")
+
+ for _, page := range []string{"https://evil.example/", "../../..", "999999", "-1", "", "notanumber"} {
+ rec := postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": page,
+ })
+ loc := rec.Header().Get("Location")
+ want := fmt.Sprintf("/sheets/%d/table?page=", s.ID)
+ if !strings.HasPrefix(loc, want) {
+ t.Errorf("page=%q redirected to %q — the target must be built from the "+
+ "sheet id and a bounded page, never from the request", page, loc)
+ }
+ }
+}
+
+// TestAFormPostStillObeysEveryBound: the decoders converge before
+// validation, so the form path must inherit the allowlist, the
+// rectangle bounds and the cell cap rather than restate them.
+func TestAFormPostStillObeysEveryBound(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Bounds")
+
+ for name, fields := range map[string]map[string]string{
+ "an alignment that is not one": {"r1": "0", "c1": "0", "r2": "0", "c2": "0", "align": "diagonal"},
+ "a colour that is not ours": {"r1": "0", "c1": "0", "r2": "0", "c2": "0", "fill": "#ff0000"},
+ "a number format we do not accept": {"r1": "0", "c1": "0", "r2": "0", "c2": "0",
+ "numfmt": `"><script>`},
+ "a patch that changes nothing": {"r1": "0", "c1": "0", "r2": "0", "c2": "0"},
+ "a rectangle out of bounds": {"r1": "0", "c1": "0", "r2": strconv.Itoa(maxWindowRows * 100),
+ "c2": "0", "bold": "set"},
+ } {
+ fields["page"] = "1"
+ rec := postFormatForm(t, app, cookies, s, fields)
+ if rec.Code == http.StatusSeeOther {
+ t.Errorf("%s was accepted (303) — the form decoder is not reaching "+
+ "the same validation the JSON one does", name)
+ }
+ }
+}
+
+// TestAHugeRectangleFromAFormIsRefused is separate because its bound is
+// a different one — maxFormatCells rather than the coordinate range —
+// and because a form is the easiest place to submit one by hand.
+func TestAHugeRectangleFromAFormIsRefused(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Huge")
+ rec := postFormatForm(t, app, cookies, s, map[string]string{
+ "r1": "0", "c1": "0", "r2": strconv.Itoa(maxWindowRows * 9),
+ "c2": strconv.Itoa(maxWindowCols * 9), "bold": "set", "page": "1",
+ })
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("a giant rectangle = %d, want 413", rec.Code)
+ }
+}
+
+// TestJSONStillAnswersJSON: the grid must not start receiving redirects.
+func TestJSONStillAnswersJSON(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "StillJSON")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
+
+ req := httptest.NewRequest(http.MethodPost, formatURL(s),
+ strings.NewReader(`{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ rec := do(t, app, as(req, cookies))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("the grid's own post = %d, want 200", rec.Code)
+ }
+ var g gridResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
+ t.Fatalf("the grid no longer receives JSON: %v", err)
+ }
+}
+
+func boldAt(t *testing.T, app *App, cookies []*http.Cookie, cells string) bool {
+ t.Helper()
+ read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ if len(read.Cells) == 0 {
+ return false
+ }
+ return read.Formats[read.Cells[0].F].Bold
+}
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index b002926..15c277f 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -1042,6 +1042,98 @@
color: var(--sheet-k);
}
+/* ── The table view paints what the grid paints ──────────────────────
+ *
+ * The same attributes, because the server writes the same attributes
+ * (tablepaint.go). PRODUCT.md's second principle is why: the scriptless
+ * path is the product, and a screen that can SET bold but cannot SHOW
+ * it is the worse half of the feature.
+ *
+ * Written as its own block rather than by widening the grid's selectors
+ * to cover both. Those carry `.jss_worksheet` and `!important` for
+ * reasons that belong entirely to the vendored library — an inline
+ * text-align the library writes on every data cell — and none of that
+ * is true here. A shared selector would carry the library's problem
+ * onto a table that does not have it, and would make the restatement
+ * guard's job ambiguous.
+ *
+ * No !important, and that is the tell that these are new paint rather
+ * than a restatement: nothing writes an inline text-align on this
+ * table. The one inline style a cell here carries is the custom
+ * properties, which set values rather than win a cascade. */
+[data-cell-table] td[data-b],
+[data-cell-table] td[data-b] > a {
+ font-weight: 600;
+}
+
+[data-cell-table] td[data-i],
+[data-cell-table] td[data-i] > a {
+ font-style: italic;
+}
+
+/* The numeric rule comes first so an explicit alignment beats it on
+ * source order, the same ordering the grid's block relies on. Both are
+ * (0,2,1) here, so nothing else separates them.
+ *
+ * The existing [data-kind="number"] rule above already right-aligns a
+ * numeric cell; these three are the explicit override, and they have to
+ * come after it in the file for the same reason. */
+[data-cell-table] td[data-a="left"],
+[data-cell-table] td[data-a="left"] > a {
+ text-align: start;
+}
+
+[data-cell-table] td[data-a="center"],
+[data-cell-table] td[data-a="center"] > a {
+ text-align: center;
+}
+
+[data-cell-table] td[data-a="right"],
+[data-cell-table] td[data-a="right"] > a {
+ text-align: end;
+}
+
+/* Ours: hue and chroma from the cell, lightness from the theme, so a
+ * fill somebody picked follows their light/dark choice. Identical to
+ * the grid's rule and deliberately so — a fill must not be one colour
+ * on one screen and another on the other. */
+[data-cell-table] td[data-fill] {
+ background-color: oklch(var(--sheet-fill-l) var(--sheet-c) var(--sheet-h));
+}
+
+[data-cell-table] td[data-ink],
+[data-cell-table] td[data-ink] > a {
+ color: oklch(var(--sheet-ink-l) var(--sheet-kc) var(--sheet-kh));
+}
+
+/* The author's, painted as the file wrote it (spec §11.1). --sheet-fx
+ * and --sheet-k carry a hex that came out of a document — data, checked
+ * by previewsvg.go's safeHex before it was ever written into the
+ * attribute, and not a colour this stylesheet chose. */
+[data-cell-table] td[data-fx] {
+ background-color: var(--sheet-fx);
+}
+
+[data-cell-table] td[data-k],
+[data-cell-table] td[data-k] > a {
+ color: var(--sheet-k);
+}
+
+/* The cell edit form's formatting row. Six selects and a button, on
+ * their own line under the value field: side by side they would push
+ * the table's column wider than the page. */
+[data-cell-format] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-1);
+ margin-block-start: var(--rst-sp-1);
+}
+
+[data-cell-format] select {
+ font-size: var(--rst-fs-xs);
+ padding-block: 0.15rem;
+}
+
/* ── The list's thumbnails ───────────────────────────────────────────
*
* A 26x34 slot at the row's left edge, the same box in the same place
diff --git a/internal/sheets/tablepaint.go b/internal/sheets/tablepaint.go
new file mode 100644
index 0000000..d7abe99
--- /dev/null
+++ b/internal/sheets/tablepaint.go
@@ -0,0 +1,107 @@
+package sheets
+
+import (
+ "html/template"
+ "strconv"
+ "strings"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// cellPaint is a cell's formatting, rendered for the table view.
+//
+// The grid gets this from paint.js, which sets data- attributes on the
+// td and writes the OKLCH components into custom properties. The table
+// view has no script, so the server does the same job — and the result
+// has to be the SAME attributes and the SAME properties, because
+// sheets.css already styles them and a second vocabulary here would be
+// a second implementation of "what bold looks like".
+//
+// PRODUCT.md's second principle is the reason this exists at all: the
+// scriptless path is the product, not the fallback, and a screen that
+// can set bold but cannot show it is the worse half of the feature.
+type cellPaint struct {
+ Bold bool
+ Italic bool
+ Align string // "", "left", "center", "right"
+ // Fill and Ink say which of the two colour mechanisms applies.
+ // Fill is a colour THIS app applied, stored as OKLCH intent and
+ // resolved per theme; FillHex is a colour an imported FILE
+ // authored, painted exactly as written (spec §11.1). They are
+ // mutually exclusive in practice and are kept as separate flags
+ // rather than one enum, because the CSS distinguishes them too.
+ Fill bool
+ Ink bool
+ FillHex bool
+ InkHex bool
+ // LowContrast mirrors the grid's data-contrast="low", so the cells
+ // the contrast notice names are the ones marked on both screens.
+ LowContrast bool
+ // Vars is the inline custom-property declaration. template.CSS
+ // because it is interpolated into a style attribute — and it is
+ // only ever built by paintOf below, from numbers formatted by
+ // strconv and hex strings already validated on the way in.
+ Vars template.CSS
+}
+
+// Any reports whether there is anything to paint. The template uses it
+// to skip the whole attribute block for the overwhelmingly common
+// unformatted cell.
+func (p cellPaint) Any() bool {
+ return p.Bold || p.Italic || p.Align != "" || p.Fill || p.Ink ||
+ p.FillHex || p.InkHex
+}
+
+// paintOf converts a stored Format into the table view's paint.
+//
+// It deliberately reads store.Format directly rather than going through
+// styleOf/wireStyle. wireStyle is the JSON the GRID receives, with short
+// keys and omitempty, shaped for a stream that pays per keystroke; the
+// table view is a rendered page and needs Go values. Routing one through
+// the other would make a change to the wire format silently a change to
+// this page.
+func paintOf(f *store.Format) cellPaint {
+ if f == nil {
+ return cellPaint{}
+ }
+ p := cellPaint{
+ Bold: f.Bold, Italic: f.Italic, Align: f.Align,
+ LowContrast: lowContrast(f),
+ }
+ var vars strings.Builder
+ if f.FillIntentH != nil && f.FillIntentC != nil {
+ p.Fill = true
+ vars.WriteString("--sheet-h:" + num(*f.FillIntentH) + ";")
+ vars.WriteString("--sheet-c:" + num(*f.FillIntentC) + ";")
+ }
+ if f.InkIntentH != nil && f.InkIntentC != nil {
+ p.Ink = true
+ vars.WriteString("--sheet-kh:" + num(*f.InkIntentH) + ";")
+ vars.WriteString("--sheet-kc:" + num(*f.InkIntentC) + ";")
+ }
+ // An imported literal colour, through previewsvg.go's safeHex —
+ // the same check the thumbnail already applies to the same field,
+ // and reused rather than rewritten so the two screens cannot come
+ // to different conclusions about what a colour is.
+ //
+ // It is not belt and braces here. This string came out of somebody
+ // else's .xlsx and is about to be interpolated into a style
+ // attribute, and html/template cannot look inside a template.CSS
+ // value: declaring that type is a promise the caller makes, so the
+ // caller has to be the one that keeps it.
+ if hex := safeHex(f.FillHex); hex != "" {
+ p.FillHex = true
+ vars.WriteString("--sheet-fx:" + hex + ";")
+ }
+ if hex := safeHex(f.InkHex); hex != "" {
+ p.InkHex = true
+ vars.WriteString("--sheet-k:" + hex + ";")
+ }
+ p.Vars = template.CSS(vars.String())
+ return p
+}
+
+// num renders an OKLCH component. 'g' with -1 precision, matching how
+// the grid's JSON carries these, so a fill looks identical on the two
+// screens rather than differing in the last decimal place.
+func num(f float64) string { return strconv.FormatFloat(f, 'g', -1, 64) }
diff --git a/internal/sheets/tableview.go b/internal/sheets/tableview.go
new file mode 100644
index 0000000..70d4507
--- /dev/null
+++ b/internal/sheets/tableview.go
@@ -0,0 +1,65 @@
+package sheets
+
+import (
+ "strconv"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// tableview.go builds the table view's rows.
+//
+// Split out of handlers_table.go when that file reached the 300-line
+// cap, and along a seam that was already there rather than at whatever
+// line the cap happened to fall on: this file turns stored cells into
+// what a page shows, and handlers_table.go decides which cells and what
+// to do with a POST. The split is what the cap is for.
+
+// tableRows builds one page of rows.
+//
+// Shared with the anonymous link view, which passes editR/editC of -1
+// so no cell can ever match and none renders a form. Expressing
+// read-only as a coordinate nothing equals — rather than as a flag the
+// template is trusted to honour — means the read-only view cannot grow
+// an edit form by somebody adding a branch to the template.
+func (a *App) tableRows(h *store.Handle, first, editR, editC int) []tableRow {
+ order := make([]int, 0, tableRowsPerPage)
+ for i := 0; i < tableRowsPerPage; i++ {
+ order = append(order, first+i)
+ }
+ return a.tableRowsIn(h, order, editR, editC)
+}
+
+// tableRowsIn builds rows in a given order.
+//
+// The order is a list of row POSITIONS, which is what lets a sorted or
+// filtered view reuse every other part of this: each cell still carries
+// its real coordinate, so an edit form posts the row it was always
+// posting and nothing downstream needs to know a view is in effect.
+func (a *App) tableRowsIn(h *store.Handle, order []int, editR, editC int) []tableRow {
+ idx := newPosIndex(h)
+ rows := make([]tableRow, 0, len(order))
+ for _, rowIdx := range order {
+ row := tableRow{Number: rowIdx + 1}
+ for c := 0; c < tableCols; c++ {
+ cell := tableCell{
+ R: rowIdx, C: c,
+ Ref: colLabel(c) + strconv.Itoa(rowIdx+1),
+ Editing: rowIdx == editR && c == editC,
+ }
+ if rowID, okR := idx.rowID[rowIdx]; okR {
+ if colID, okC := idx.colID[c]; okC {
+ v := h.Value(rowID, colID)
+ f := h.Format(rowID, colID)
+ cell.Raw, _ = h.RawInput(rowID, colID)
+ cell.Display = display(v, f)
+ cell.Number = v.Kind == calc.KindNum
+ cell.Paint = paintOf(f)
+ }
+ }
+ row.Cells = append(row.Cells, cell)
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index 2eb99de..c83c6c8 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -74,11 +74,17 @@ rows are where they always were.</p>
{{$sheet := .Data.Sheet}}
{{$page := .Data.Page}}
{{$mayWrite := .Data.MayWrite}}
+{{$mayFormat := .Data.MayWrite}}
{{range .Data.Rows}}
<tr>
<th scope="row">{{.Number}}</th>
{{range .Cells}}
-<td{{if .Number}} data-kind="number"{{end}}>
+{{/* The same attribute vocabulary paint.js writes on the grid's td,
+ written here by the server because this view has no script. Same
+ names, same custom properties, one set of rules in sheets.css —
+ a second vocabulary would be a second answer to "what does bold
+ look like". */}}
+<td{{if .Number}} data-kind="number"{{end}}{{with .Paint}}{{if .Bold}} data-b{{end}}{{if .Italic}} data-i{{end}}{{if .Align}} data-a="{{.Align}}"{{end}}{{if .Fill}} data-fill{{end}}{{if .Ink}} data-ink{{end}}{{if .FillHex}} data-fx{{end}}{{if .InkHex}} data-k{{end}}{{if .LowContrast}} data-contrast="low"{{end}}{{if .Any}} style="{{.Vars}}"{{end}}{{end}}>
{{if .Editing}}
{{/* One cell at a time carries a form. Rendering an input in all 650
cells of a page would make the scriptless view the heaviest page
@@ -91,6 +97,72 @@ rows are where they always were.</p>
<input rst-input id="cell-{{.Ref}}" name="raw" type="text" value="{{.Raw}}" autofocus>
<button rst-btn="primary compact" type="submit">Save</button>
</form>
+{{if $mayFormat}}
+{{/* Formatting, for the cell already being edited.
+
+ PRODUCT.md's second principle: the scriptless path is the
+ product, not the fallback, and the grid has had these controls
+ since 2026-09-02 while this view had none. Spec §14 recorded it
+ as debt with the trigger "the next slice that touches the table
+ view".
+
+ It posts to the SAME route the grid's toolbar posts to — one
+ allowlist, one bounds check, one cell cap. Only the decoder
+ differs (handlers_format.go's isFormPost).
+
+ Every toggle is a SELECT and not a checkbox, and that is
+ load-bearing rather than fussy. formatPatch's fields are
+ pointers so "leave bold alone" and "turn bold off" are
+ different requests, and an unchecked checkbox is absent from a
+ submission — indistinguishable from a field that was never
+ there. A checkbox could make a cell bold and could never make
+ it not bold. */}}
+<form method="post" action="/api/sheets/{{$sheet.ID}}/tabs/{{$sheet.TabID}}/format" data-cell-format>
+<input type="hidden" name="r1" value="{{.R}}">
+<input type="hidden" name="c1" value="{{.C}}">
+<input type="hidden" name="r2" value="{{.R}}">
+<input type="hidden" name="c2" value="{{.C}}">
+<input type="hidden" name="page" value="{{$page}}">
+<label class="rst-sr-only" for="b-{{.Ref}}">Bold</label>
+<select rst-input id="b-{{.Ref}}" name="bold">
+<option value="keep">Bold…</option>
+<option value="set">Bold</option>
+<option value="clear">Not bold</option>
+</select>
+<label class="rst-sr-only" for="i-{{.Ref}}">Italic</label>
+<select rst-input id="i-{{.Ref}}" name="italic">
+<option value="keep">Italic…</option>
+<option value="set">Italic</option>
+<option value="clear">Not italic</option>
+</select>
+<label class="rst-sr-only" for="a-{{.Ref}}">Alignment</label>
+<select rst-input id="a-{{.Ref}}" name="align">
+<option value="keep">Align…</option>
+<option value="">Automatic</option>
+<option value="left">Left</option>
+<option value="center">Centre</option>
+<option value="right">Right</option>
+</select>
+<label class="rst-sr-only" for="n-{{.Ref}}">Number format</label>
+<select rst-input id="n-{{.Ref}}" name="numfmt">
+<option value="keep">Number…</option>
+{{range $.Data.NumberFormats}}<option value="{{.Code}}">{{.Label}}</option>{{end}}
+</select>
+<label class="rst-sr-only" for="f-{{.Ref}}">Fill colour</label>
+<select rst-input id="f-{{.Ref}}" name="fill">
+<option value="keep">Fill…</option>
+<option value="none">No fill</option>
+{{range $.Data.FillSwatches}}<option value="{{.Name}}">{{.Label}}</option>{{end}}
+</select>
+<label class="rst-sr-only" for="k-{{.Ref}}">Text colour</label>
+<select rst-input id="k-{{.Ref}}" name="ink">
+<option value="keep">Text…</option>
+<option value="none">Default</option>
+{{range $.Data.InkSwatches}}<option value="{{.Name}}">{{.Label}}</option>{{end}}
+</select>
+<button rst-btn="compact" type="submit">Apply</button>
+</form>
+{{end}}
{{else if $mayWrite}}
{{/* The link is the affordance: it turns this cell into the form
above. The cell shows its computed value, and the title carries
diff --git a/tools/drive/tableformat.mjs b/tools/drive/tableformat.mjs
new file mode 100644
index 0000000..31eb994
--- /dev/null
+++ b/tools/drive/tableformat.mjs
@@ -0,0 +1,60 @@
+/* Drive the table view's formatting with JavaScript SWITCHED OFF.
+ *
+ * The only drive script that runs a page with javaScriptEnabled:false,
+ * and the point of it: PRODUCT.md's second principle says the scriptless
+ * path is the product, not the fallback, so a test that drove this with
+ * scripting on would prove the wrong thing entirely.
+ *
+ * It picks bold, centre and a fill from three selects, submits, follows
+ * the 303, and reads the COMPUTED style off the cell — then reopens the
+ * grid and asserts it agrees. Both halves matter: a screen that can set
+ * bold and not show it is the worse half of the feature.
+ *
+ * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D \
+ * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/tableformat.mjs
+ */
+import net from 'node:net';
+import fs from 'node:fs';
+const pw=await import(process.env.PLAYWRIGHT); const chromium=pw.chromium||(pw.default&&pw.default.chromium);
+const proxy=net.createServer(c=>{const u=net.connect(process.env.SHEETS_SOCK);c.pipe(u);u.pipe(c);c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());});
+await new Promise(r=>proxy.listen(8799,'127.0.0.1',r));
+const base='http://127.0.0.1:8799', OUT=process.env.OUT;
+const browser=await chromium.launch();
+const page=await (await browser.newContext({viewport:{width:1280,height:900},deviceScaleFactor:2})).newPage();
+const errs=[]; page.on('pageerror',e=>errs.push(String(e)));
+await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
+await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
+await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
+await page.fill('#title','Table format'); await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);
+const url=page.url();
+await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
+for(const [r,c,v] of [[0,0,'Region'],[0,1,'Revenue'],[1,0,'North'],[1,1,'1200'],[2,0,'South'],[2,1,'900']])
+ await page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]);
+await page.waitForTimeout(600);
+
+// JavaScript OFF, to prove the scriptless claim.
+const ctx2=await browser.newContext({viewport:{width:1280,height:900},deviceScaleFactor:2,javaScriptEnabled:false,
+ storageState: await page.context().storageState()});
+const p2=await ctx2.newPage();
+await p2.goto(`${base}${new URL(url).pathname}/table?page=1&edit=0,0`);
+console.log('scriptless: the format form is present:', await p2.locator('[data-cell-format]').count()>0);
+await p2.selectOption('select[name=bold]','set');
+await p2.selectOption('select[name=align]','center');
+await p2.selectOption('select[name=fill]','green');
+await Promise.all([p2.waitForNavigation(), p2.click('[data-cell-format] button[type=submit]')]);
+console.log('scriptless: landed on', new URL(p2.url()).pathname + new URL(p2.url()).search);
+const painted = await p2.evaluate(()=>{
+ const td=[...document.querySelectorAll('[data-cell-table] td')].find(t=>t.textContent.includes('Region'));
+ if(!td) return 'cell not found';
+ const cs=getComputedStyle(td);
+ return {attrs:[...td.attributes].map(a=>a.name).join(' '), weight:cs.fontWeight, align:cs.textAlign, bg:cs.backgroundColor};});
+console.log('scriptless: the cell now paints', JSON.stringify(painted));
+await p2.screenshot({path:`${OUT}/table-format.png`, clip:{x:0,y:0,width:1000,height:620}});
+
+// and the grid agrees
+await page.reload(); await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
+await page.waitForTimeout(600);
+console.log('grid agrees:', JSON.stringify(await page.evaluate(()=>window.SheetsGrid.styleAt(0,0))));
+console.log('ERRORS:',JSON.stringify(errs));
+await browser.close(); proxy.close(); process.exit(0);