oficina / sheets Public

fix: the four design defects the review found, and guards for two

**Sharing defaulted to owner.** shareableRoles was ordered most-able
first and is rendered straight into a <select> with no option marked
selected, so the browser selected the first one. Fill in an address,
press Share, never open the dropdown — and you handed the sheet away.
The only repair is the new owner giving it back. The order of that slice
is a security control, not a presentation choice, so it now leads with
viewer and ends with owner, and two tests hold it there: one on the
slice, one on the rendered option, because a reordering that reads like
tidying would otherwise restore the bug with every test still green.

**No sign-out below 800px.** rastrillo's topbar hides [rst-shell-tail]
and reveals it from exactly two places: an [rst-shell-menu][open] that
immediately precedes it, and the 800px media query. We shipped no menu,
so on every phone the navigation and the whole account dropdown were
absent — not collapsed, absent. The disclosure is added as the tail's
previous sibling, which is load-bearing: the framework's rule is a `+`
combinator, so moving it anywhere else silently restores the fault. It
deliberately stays out of the account dropdown's "rst-menus" group,
since `name` makes an exclusive accordion and the dropdown lives inside
the tail this menu reveals — grouped, opening one would collapse the
other. A guard test asserts both existence and position, and strips
template comments first so the prose explaining the element cannot
satisfy the check on its own.

**The sign-in help sentence rendered inside a 28px icon badge.**
rst-help is the little "?" beside a label — inline-flex, fixed width and
height, a border. Given a sentence it kept the box and overflowed, so
the paragraph rendered a word or two per line with the badge's border
struck through it, and ran off the left edge of a phone. It is
rst-field-help, inside the field, under the input.

**The table view was a link farm.** Every cell is an anchor because the
link is the edit affordance without a script, and left at browser
defaults that meant ~650 blue underlined links a page — data and
emptiness in the same colour and weight. Cells now read as cells with
the affordance on hover and focus-visible, numbers right-align off the
computed kind rather than a guess at the input string, and the &nbsp;
placeholder is gone because a block anchor with a min-height is already
a target. Its subtitle counted neither rows nor pages; it now counts
pages, and no longer leads with what the page lacks.
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 f5f063ea952a27cff89a5ebd15fdac853060efde parent 01e8adf
9 files changed, +259 −14
  • internal/guardstest/shellmenu_test.go +68 −0
  • internal/sheets/handlers_share.go +18 −5
  • internal/sheets/handlers_share_test.go +57 −0
  • internal/sheets/handlers_table.go +14 −1
  • internal/sheets/static/sheets.css +46 −0
  • internal/sheets/templates/layout.html +28 −0
  • internal/sheets/templates/link.html +6 −1
  • internal/sheets/templates/signin.html +13 −3
  • internal/sheets/templates/table.html +9 −4
diff --git a/internal/guardstest/shellmenu_test.go b/internal/guardstest/shellmenu_test.go
new file mode 100644
index 0000000..19ae263
--- /dev/null
+++ b/internal/guardstest/shellmenu_test.go
@@ -0,0 +1,68 @@
+package guards
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// TestTheTopbarDisclosesItsTailOnNarrowScreens is the guard for a bug
+// that removed sign-out from every phone.
+//
+// rastrillo's topbar sets [rst-shell-tail] { display: none } and reveals
+// it from exactly two places: an [rst-shell-menu][open] that IMMEDIATELY
+// PRECEDES the tail, and a min-width: 800px media query. An app that
+// ships no [rst-shell-menu] therefore has no navigation and no account
+// menu below 800px — not collapsed behind a control, simply absent, with
+// no way for a person to get at them.
+//
+// This is not a thing a Go test would otherwise notice. The markup is
+// valid, every handler works, the page renders, and the whole suite
+// passes; the affordance is missing only in a viewport narrower than the
+// one nobody was measuring in.
+//
+// Two assertions, because either alone is satisfiable while the bug is
+// present: the element must EXIST, and it must be the tail's immediately
+// previous sibling, since the framework's rule is a `+` combinator and
+// an element moved anywhere else in the bar restores the fault while
+// still reading as present.
+func TestTheTopbarDisclosesItsTailOnNarrowScreens(t *testing.T) {
+ b, err := os.ReadFile(filepath.Join(repoRoot(t),
+ "internal/sheets/templates/layout.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Template comments are stripped FIRST, and that matters: the
+ // layout explains this element in a {{/* ... */}} block that names
+ // it, so a check run over the raw file passes on the prose alone
+ // while the element itself is gone.
+ layout := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`).ReplaceAllString(string(b), "")
+
+ if !strings.Contains(layout, "rst-shell-menu") {
+ t.Fatal("layout.html ships no [rst-shell-menu]: below 800px the " +
+ "shell hides [rst-shell-tail] and nothing can reveal it, so " +
+ "there is no navigation and no way to sign out on a phone")
+ }
+
+ // Everything between the menu's close tag and the tail's open tag.
+ // Template actions and comments are fine there; another ELEMENT is
+ // not, because it breaks the `+` combinator.
+ gap := regexp.MustCompile(`(?s)</details>(.*?)<div rst-shell-tail>`)
+ m := gap.FindStringSubmatch(layout)
+ if m == nil {
+ t.Fatal("could not find [rst-shell-menu] followed by " +
+ "[rst-shell-tail] in layout.html")
+ }
+ between := m[1]
+ // Strip Go template actions ({{...}}, comments included) and
+ // whitespace; what remains must hold no tag.
+ between = regexp.MustCompile(`(?s)\{\{.*?\}\}`).ReplaceAllString(between, "")
+ if strings.Contains(between, "<") {
+ t.Errorf("an element sits between [rst-shell-menu] and "+
+ "[rst-shell-tail]: rastrillo reveals the tail with a `+` "+
+ "combinator, so only the immediately previous sibling works. "+
+ "Found: %q", strings.TrimSpace(between))
+ }
+}
diff --git a/internal/sheets/handlers_share.go b/internal/sheets/handlers_share.go
index e86d2e7..599151c 100644
--- a/internal/sheets/handlers_share.go
+++ b/internal/sheets/handlers_share.go
@@ -28,12 +28,25 @@ type linkRow struct {
Created time.Time
}
-// shareableRoles is what the screen offers, most able first.
+// shareableRoles is what the screen offers, LEAST able first.
//
-// Owner is offered: handing a sheet on is a real thing to want, and
-// leaving it out means the only way to transfer one is a database
-// edit.
-var shareableRoles = []SheetRole{SheetOwner, SheetEditor, SheetCommenter, SheetViewer}
+// The order is the security control. A <select> with no selected option
+// defaults to its first, and this list is rendered straight into one, so
+// whichever role leads is what a person shares by default — including
+// the person who fills in an address, presses Share, and never opens the
+// dropdown at all. Most-able-first made that default "owner": the
+// quickest possible path through this screen handed the sheet away, and
+// the only repair is another owner taking it back.
+//
+// Least-able-first makes the same path grant "viewer", and anything
+// more has to be chosen. That is the same instinct the spec applies to
+// link sharing, which is viewer-or-commenter and never editor.
+//
+// Owner is still offered: handing a sheet on is a real thing to want,
+// and leaving it out means the only way to transfer one is a database
+// edit. It is last because it is the one that cannot be undone by the
+// person who did it.
+var shareableRoles = []SheetRole{SheetViewer, SheetCommenter, SheetEditor, SheetOwner}
// handleShare renders who can reach a sheet.
func (a *App) handleShare(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/sheets/handlers_share_test.go b/internal/sheets/handlers_share_test.go
index 7a9c3cb..617a3f4 100644
--- a/internal/sheets/handlers_share_test.go
+++ b/internal/sheets/handlers_share_test.go
@@ -205,3 +205,60 @@ func TestTheShareScreenIsScriptless(t *testing.T) {
}
}
}
+
+// The share screen's default role is the least able one, and this test
+// is the whole enforcement of that.
+//
+// The screen renders shareableRoles straight into a <select> with no
+// option marked selected, so the browser selects the first. That makes
+// the ORDER of this slice a security control rather than a
+// presentational choice: whatever leads it is what a person grants when
+// they fill in an address, press Share, and never touch the dropdown —
+// which is the fastest and therefore most common path through the
+// screen.
+//
+// It led with owner once. The quickest route through the sharing screen
+// handed the sheet away, and the only repair is the new owner giving it
+// back. A reordering that reads like tidying restores that, silently and
+// with every test still green, which is exactly why the assertion is on
+// the first element and not on the set.
+func TestSharingDefaultsToTheLeastAbleRole(t *testing.T) {
+ if shareableRoles[0] != SheetViewer {
+ t.Fatalf("shareableRoles[0] = %q, want %q: the first entry is the "+
+ "<select>'s default, so it is what somebody grants without "+
+ "choosing", shareableRoles[0], SheetViewer)
+ }
+ if shareableRoles[len(shareableRoles)-1] != SheetOwner {
+ t.Errorf("shareableRoles ends with %q, want %q: owner is offered, "+
+ "but it belongs furthest from the default",
+ shareableRoles[len(shareableRoles)-1], SheetOwner)
+ }
+}
+
+// The rendered markup has to agree with the slice above: a <select>
+// whose first <option> is owner is the bug, however the slice is
+// ordered.
+func TestTheShareFormOffersViewerFirst(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ body := do(t, app,
+ as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String()
+
+ i := strings.Index(body, `name="role"`)
+ if i < 0 {
+ t.Fatal("no role select on the share screen")
+ }
+ first := strings.Index(body[i:], "<option")
+ if first < 0 {
+ t.Fatal("the role select has no options")
+ }
+ opt := body[i+first:]
+ if end := strings.Index(opt, "</option>"); end >= 0 {
+ opt = opt[:end]
+ }
+ if !strings.Contains(opt, string(SheetViewer)) {
+ t.Errorf("the first role option is %q, want the viewer one: the "+
+ "browser selects the first option, so this is the default grant", opt)
+ }
+}
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
index d570e05..7f2e9b8 100644
--- a/internal/sheets/handlers_table.go
+++ b/internal/sheets/handlers_table.go
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
+ "amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -31,6 +32,16 @@ type tableCell struct {
Raw string
Display string
Editing bool
+ // Number is true when the COMPUTED value is numeric, which is what
+ // lets the view right-align it. Alignment is the type signal in a
+ // spreadsheet, and the grid gets it from Jspreadsheet for free while
+ // this view has to be told.
+ //
+ // It is taken from the computed kind rather than from Raw, because
+ // `=SUM(B2:B4)` is a formula whose value is a number, and sniffing
+ // the input string would align the two differently. The server owns
+ // every string on screen here; it owns this one bit too.
+ Number bool
}
type tableRow struct {
@@ -253,8 +264,10 @@ func (a *App) tableRowsIn(h *store.Handle, order []int, editR, editC int) []tabl
}
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(h.Value(rowID, colID), h.Format(rowID, colID))
+ cell.Display = display(v, h.Format(rowID, colID))
+ cell.Number = v.Kind == calc.KindNum
}
}
row.Cells = append(row.Cells, cell)
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index c2dc2d4..132bd44 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -192,6 +192,52 @@
font-weight: 600;
}
+/* The cell link, which is every cell.
+ *
+ * Each cell is an anchor, because the link IS the edit affordance in a
+ * view with no script. Left at the browser's default that meant roughly
+ * 650 blue underlined links per page — data and emptiness in the same
+ * colour and the same weight, so `94200` and a blank cell differed only
+ * in glyph width. A spreadsheet you can only read cell by cell is not a
+ * spreadsheet, and this is the view the spec calls the product.
+ *
+ * So the cell reads as a cell and the affordance shows on approach. The
+ * anchor still fills the cell, so the target is the whole box rather
+ * than the few pixels of the glyph — which is also what lets an empty
+ * cell be clickable without a &nbsp; standing in for content.
+ *
+ * :focus-visible carries the same treatment as :hover deliberately.
+ * Keyboard is the primary way through this view, and a focus ring alone
+ * on a 26-column grid is easy to lose. */
+[data-cell-table] td > a {
+ color: inherit;
+ text-decoration: none;
+ display: block;
+ min-block-size: 1.25rem;
+ border-radius: var(--rst-radius-sm);
+}
+
+[data-cell-table] td > a:hover,
+[data-cell-table] td > a:focus-visible {
+ background: var(--rst-accent-soft);
+ color: var(--rst-accent);
+}
+
+/* Numbers right, text left — the alignment IS the type signal in a
+ * spreadsheet, and it is the one the grid gives for free and this view
+ * had to be told.
+ *
+ * Both spellings, because there are two of these tables. The editable
+ * one wraps every cell in an anchor, so the alignment has to reach the
+ * block anchor rather than the cell around it; the anonymous link view
+ * has no anchors, because there is nothing on it to edit. One rule for
+ * each, rather than one selector that quietly covers only the surface
+ * that happened to get tested. */
+[data-cell-table] td[data-kind="number"],
+[data-cell-table] td[data-kind="number"] > a {
+ text-align: end;
+}
+
[data-cell-table] td form {
display: flex;
gap: var(--rst-sp-1);
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index cf850d2..7d2e7c5 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -35,6 +35,34 @@
<a rst-skip href="#main">Skip to content</a>
<header rst-shell-bar>
<a rst-shell-brand href="/">Sheets</a>
+{{if .SignedIn}}
+{{/* The narrow-viewport disclosure, and without it there is no way to
+ sign out on a phone.
+
+ rastrillo's topbar sets [rst-shell-tail] { display: none } and
+ reveals it from exactly two places: an [rst-shell-menu][open] that
+ IMMEDIATELY PRECEDES the tail, and the 800px media query. An app
+ that ships no menu therefore has no navigation and no account below
+ 800px — not collapsed behind something, simply absent. So the
+ position of this element is load-bearing: it is the tail's previous
+ sibling because the framework's rule is a `+` combinator, and
+ moving it anywhere else silently restores the bug.
+
+ It deliberately does NOT join the account dropdown's "rst-menus"
+ group. `name` on <details> makes an exclusive accordion, and the
+ account dropdown lives INSIDE the tail this menu reveals — grouped,
+ opening the account would close this one, collapse the tail, and
+ take the dropdown down with it. They nest, so they must not be
+ exclusive.
+
+ Rendered only when signed in: signed out the tail holds an empty
+ nav, and a control that discloses nothing is worse than no control.
+ rastrillo hides it itself above 800px, so there is no media query
+ of ours here. */}}
+<details rst-shell-menu>
+<summary>Menu</summary>
+</details>
+{{end}}
<div rst-shell-tail>
<nav rst-shell-nav>
{{if .SignedIn}}<a href="/sheets">Your sheets</a>{{end}}
diff --git a/internal/sheets/templates/link.html b/internal/sheets/templates/link.html
index 0c941d9..4f9090e 100644
--- a/internal/sheets/templates/link.html
+++ b/internal/sheets/templates/link.html
@@ -33,7 +33,12 @@ is for.</p>
column that cannot match any cell, so .Editing is false
everywhere — read-only expressed in the data rather than trusted
to this template. */}}
-{{range .Cells}}<td{{if .Raw}} title="{{.Raw}}"{{end}}>{{if .Display}}{{.Display}}{{else}}&nbsp;{{end}}</td>{{end}}
+{{/* data-kind carries the same numeric flag the editable table uses, so
+ a link viewer gets the same right-aligned numbers. The cells here
+ are plain <td> rather than anchors — there is nothing to edit — and
+ with no anchor there is nothing to keep an empty cell open, so the
+ &nbsp; stays on this view and only on this view. */}}
+{{range .Cells}}<td{{if .Number}} data-kind="number"{{end}}{{if .Raw}} title="{{.Raw}}"{{end}}>{{if .Display}}{{.Display}}{{else}}&nbsp;{{end}}</td>{{end}}
</tr>
{{end}}
</tbody>
diff --git a/internal/sheets/templates/signin.html b/internal/sheets/templates/signin.html
index 0732626..8b4fce6 100644
--- a/internal/sheets/templates/signin.html
+++ b/internal/sheets/templates/signin.html
@@ -16,6 +16,19 @@
<div rst-field>
<label rst-field-label for="address">Email address</label>
<input rst-input id="address" name="address" type="email" autocomplete="email" required>
+{{/* rst-field-help, not rst-help. rst-help is a 28x28 icon BADGE —
+ inline-flex, a fixed width and height, a border and a radius — for
+ the little "?" beside a label. Given a sentence it keeps the 28px
+ box and lets the text overflow it, which rendered this paragraph
+ one or two words per line with the badge's border struck through
+ the middle of it, and pushed it off the left edge of the viewport
+ on a phone. rst-field-help is the prose one: block, muted, sized
+ down, spaced off the control above it.
+
+ It lives inside the field, under the input, because that is what
+ the token is for and where guidance about a control belongs. */}}
+<p rst-field-help>We email you a link instead of asking for a password.
+It signs you in once and then stops working.</p>
</div>
{{/* Force is the affordance auth asks for. When a keymail sign-in
fails, auth redirects to ?force=1&err=keymail specifically so this
@@ -28,7 +41,4 @@
<button rst-btn="primary" type="submit">Email me a link</button>
</div>
</form>
-
-<p rst-help>We email you a link instead of asking for a password. It signs
-you in once and then stops working.</p>
{{end}}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index 31fc509..ab34b2b 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -2,8 +2,13 @@
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Data.Sheet.Title}}</h1>
-<p rst-page-header-sub>Table view — rows {{.Data.Page}} of {{.Data.Pages}} pages.
-Every cell is editable here, with no JavaScript at all.</p>
+{{/* Was "rows {{.Data.Page}} of {{.Data.Pages}} pages", which spliced a
+ row count and a page count into one phrase that counted neither.
+ The trailing "with no JavaScript at all" went with it: this view is
+ the product rather than the fallback, and a subtitle that leads with
+ what the page lacks argues the opposite. */}}
+<p rst-page-header-sub>Page {{.Data.Page}} of {{.Data.Pages}}. Every cell
+here is editable.</p>
</div>
<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">History</a>
<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/comments">Comments</a>
@@ -72,7 +77,7 @@ rows are where they always were.</p>
<tr>
<th scope="row">{{.Number}}</th>
{{range .Cells}}
-<td>
+<td{{if .Number}} data-kind="number"{{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
@@ -90,7 +95,7 @@ rows are where they always were.</p>
above. The cell shows its computed value, and the title carries
the input, so a formula is readable without opening it. */}}
<a href="/sheets/{{$sheet.ID}}/table?page={{$page}}&amp;edit={{.R}},{{.C}}"
- aria-label="Edit {{.Ref}}"{{if .Raw}} title="{{.Raw}}"{{end}}>{{if .Display}}{{.Display}}{{else}}&nbsp;{{end}}</a>
+ aria-label="Edit {{.Ref}}"{{if .Raw}} title="{{.Raw}}"{{end}}>{{.Display}}</a>
{{end}}
</td>
{{end}}