oficina / sheets Public

fix: dark mode, and the row and column names it made invisible

Three bugs, one screenshot.

**data-theme carried the wrong axis.** rastrillo's theme.css declares
every colour as light-dark(light, dark) and its entire toggle is
:root[data-theme="light"] and :root[data-theme="dark"], which re-resolve
those calls by setting color-scheme. We were writing the THEME NAME —
day, plain, signal — which are palette files an app picks once at
scaffold, not a per-person setting. Neither rule matched, so an explicit
choice never beat the OS and the Day/Plain/Signal buttons did nothing at
all. The cookie now carries light, dark or auto, and auto writes no
attribute, which is how "follow the OS" is spelled.

page_test asserted `data-theme="day"` and passed. A test can only pin a
mistake it shares; the scheme is now pinned against the toggle
selectors read out of theme.css itself, so the day rastrillo moves them
this fails instead of the page.

**The grid CSS targeted .jexcel.** Jspreadsheet v5 renamed everything to
.jss_*, so the whole override block matched nothing and the grid stayed
hard-coded white inside dark chrome.

**Five invented token names.** --rst-border, --rst-text-mut,
--rst-warning, --rst-font-mono and --rst-shell-bar-h do not exist; the
real ones are --rst-line, --rst-text-muted, --rst-tone-warning-fg and
--rst-font, and the last had no equivalent at all. CSS resolves an
unknown custom property to its fallback in silence, so the page looked
plausible and simply was not themed. csstokens_test.go now checks every
var(--rst-*) we write against the files doctor copies, and I verified it
fails on the exact name that shipped.

The invisible A/B/C and 1/2/3 fall out of the second and third: the
library sets background-color on the headers and no color, so the text
came from the page — light grey on light grey. Every rule here that
sets a background now sets a foreground beside it.

Measured in a real browser across all four cases (OS dark, OS light,
picked dark on a light OS, picked light on a dark OS): headers 8.05:1
and 7.05:1, cells 15.3:1 and 16.9:1. Live sync across tabs still works.
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 9647342705d4c814d55fd95a13f807f9f57541a7 parent b54989d
8 files changed, +416 −154
  • internal/guardstest/csstokens_test.go +99 −0
  • internal/sheets/app.go +4 −4
  • internal/sheets/page_test.go +8 −4
  • internal/sheets/render.go +22 −15
  • internal/sheets/static/sheets.css +102 −43
  • internal/sheets/templates/layout.html +15 −8
  • internal/sheets/theme.go +66 −45
  • internal/sheets/theme_test.go +100 −35

CI passed — run details

diff --git a/internal/guardstest/csstokens_test.go b/internal/guardstest/csstokens_test.go
new file mode 100644
index 0000000..043f231
--- /dev/null
+++ b/internal/guardstest/csstokens_test.go
@@ -0,0 +1,99 @@
+package guards
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// ourStylesheets are the CSS files this app writes. The vendored ones
+// are not checked: they define their own custom properties and know
+// nothing about rastrillo's.
+var ourStylesheets = []string{
+ "internal/sheets/static/sheets.css",
+}
+
+// tokenSources are the files that DEFINE rastrillo's tokens: the
+// structural ones and the theme's palette, both copied by
+// `rastrillo doctor` and verified by it.
+var tokenSources = []string{
+ "internal/sheets/static/tokens.css",
+ "internal/sheets/static/theme.css",
+}
+
+var (
+ tokenUse = regexp.MustCompile(`var\(\s*(--rst-[a-zA-Z0-9-]+)`)
+ tokenDefine = regexp.MustCompile(`(--rst-[a-zA-Z0-9-]+)\s*:`)
+)
+
+// TestEveryTokenWeUseExists is the check that would have caught five
+// invented token names shipping to a canary.
+//
+// CSS resolves an undefined custom property to its fallback without a
+// warning anywhere — no console message, no parse error, nothing in a
+// Go test. So `var(--rst-border, 1px solid #ccc)` renders a plausible
+// page that is simply not themed, and the only way to notice is to look
+// at it in both schemes and know what you expected. That is not a gate.
+//
+// The five were --rst-border, --rst-text-mut, --rst-warning,
+// --rst-font-mono and --rst-shell-bar-h. The real names are
+// --rst-line, --rst-text-muted, --rst-tone-warning-fg and --rst-font;
+// the last had no equivalent at all and the layout was measuring
+// something the framework never published.
+func TestEveryTokenWeUseExists(t *testing.T) {
+ root := repoRoot(t)
+
+ defined := map[string]bool{}
+ for _, rel := range tokenSources {
+ body, err := os.ReadFile(filepath.Join(root, rel))
+ if err != nil {
+ t.Fatalf("guards: read %s: %v — the token sources are what `rastrillo doctor` copies; "+
+ "if one moved, this test needs updating, not deleting", rel, err)
+ }
+ for _, m := range tokenDefine.FindAllStringSubmatch(string(body), -1) {
+ defined[m[1]] = true
+ }
+ }
+ if len(defined) == 0 {
+ t.Fatal("guards: found no --rst-* definitions at all; this test is not checking anything")
+ }
+
+ var used int
+ for _, rel := range ourStylesheets {
+ body, err := os.ReadFile(filepath.Join(root, rel))
+ if err != nil {
+ t.Fatalf("guards: read %s: %v", rel, err)
+ }
+ var missing []string
+ for _, m := range tokenUse.FindAllStringSubmatch(string(body), -1) {
+ used++
+ if !defined[m[1]] {
+ missing = append(missing, m[1])
+ }
+ }
+ sort.Strings(missing)
+ for _, name := range dedupe(missing) {
+ t.Errorf("%s: var(%s) is not defined in %s — CSS resolves an unknown "+
+ "custom property to its fallback in silence, so this renders a page "+
+ "that is simply not themed",
+ rel, name, strings.Join(tokenSources, " or "))
+ }
+ }
+ if used == 0 {
+ t.Fatal("guards: our stylesheets reference no tokens at all; either they stopped " +
+ "using the design system or this test is looking in the wrong place")
+ }
+}
+
+func dedupe(in []string) []string {
+ var out []string
+ for i, s := range in {
+ if i == 0 || s != in[i-1] {
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index af3e4b4..429e3bc 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -109,10 +109,10 @@ func (a *App) Router() http.Handler {
// http.FileServerFS semantics, where the URL path is "/" plus the
// FS path.
r.Handle("/static/*", staticHandler())
- // The theme is a per-person preference set by a form, so it works
- // with scripting off: a person who needs a high-contrast theme is
- // badly served by being told to enable JavaScript first.
- r.Post("/theme", a.handleTheme)
+ // Light/dark is a per-person preference set by a form, so it works
+ // with scripting off: somebody who needs one of them is badly
+ // served by being told to enable JavaScript first.
+ r.Post("/mode", a.handleMode)
if a.auth != nil {
// The four handlers rastrillo/auth expects an app to mount, at
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go
index a077b8e..17ac33a 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -25,13 +25,17 @@ func TestIndexServesHTML(t *testing.T) {
}
// Affordances, not copy. This test used to assert on four
// sentences of prose, and every one of them had to be edited the
- // day the page moved onto a template — which taught nothing, since
- // the thing worth pinning is that an anonymous visitor can get in
- // and that the page carries the viewer's theme.
+ // day the page moved onto a template — which taught nothing.
+ //
+ // It then asserted `data-theme="day"`, which is worse than
+ // teaching nothing: "day" is a rastrillo THEME NAME and the
+ // attribute takes a light/dark SCHEME, so the assertion passed on
+ // markup whose switcher did nothing at all. A test can only pin a
+ // mistake it shares. The scheme is pinned in theme_test.go now,
+ // against the toggle selectors in theme.css itself.
for _, want := range []string{
"<h1>Oficina Sheets</h1>",
`<a href="/signin">`, // an anonymous visitor must be able to get in
- `data-theme="day"`, // resolved server-side, before first paint
} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("body missing %q", want)
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index 4a7ddc0..eaf9f10 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -81,17 +81,22 @@ func parsePages() map[string]*template.Template {
// PageData is what every template receives.
//
-// Theme is resolved here, server-side, from a cookie — never fetched.
-// AGENTS.md: light/dark must resolve before first paint, because cell
-// fills resolve against the theme and a late preference repaints every
-// coloured cell. A page that carries its theme in its first byte cannot
+// The light/dark choice is resolved here, server-side, from a cookie —
+// never fetched. AGENTS.md: it must resolve before first paint, because
+// cell fills resolve against the theme and a late preference repaints
+// every coloured cell. A page carrying it in its first byte cannot
// flash; one that asks for it can.
type PageData struct {
- Title string
- Theme string
- Address string
- SignedIn bool
- Path string
+ Title string
+ // Mode is the viewer's light/dark choice — light, dark or auto.
+ // ThemeAttr is what <html data-theme> gets, and is EMPTY for auto:
+ // rastrillo's toggle is two rules keyed on that attribute, and no
+ // attribute is how "follow the OS" is spelled.
+ Mode string
+ ThemeAttr string
+ Address string
+ SignedIn bool
+ Path string
// Data is the page's own payload. Named rather than embedded so a
// template always says .Data.Sheets and never has to guess whether
// a field belongs to the page or the shell.
@@ -101,13 +106,15 @@ type PageData struct {
// pageData builds the shell fields from the request.
func (a *App) pageData(r *http.Request, title string, data any) PageData {
address, signedIn := a.identity(r)
+ mode := modeFrom(r)
return PageData{
- Title: title,
- Theme: themeFrom(r),
- Address: address,
- SignedIn: signedIn,
- Path: r.URL.Path,
- Data: data,
+ Title: title,
+ Mode: mode,
+ ThemeAttr: themeAttr(mode),
+ Address: address,
+ SignedIn: signedIn,
+ Path: r.URL.Path,
+ Data: data,
}
}
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index e44b522..720a93e 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -1,30 +1,43 @@
/* Sheets' own chrome, on top of rastrillo's tokens.
*
- * Every colour here is a token, never a literal. Two reasons, and the
- * second is the one that bites: a literal does not follow the person's
- * theme, and cell fills resolve against the theme, so one hard-coded
- * grey in the grid chrome is a grey that stays wrong in two themes out
- * of three. */
+ * Every colour here is a token, never a literal — a literal does not
+ * follow the person's light/dark choice, and cell fills resolve against
+ * the theme, so one hard-coded grey stays wrong in one of the two
+ * schemes forever.
+ *
+ * Token names are checked mechanically by
+ * internal/guardstest/csstokens_test.go. Five invented ones shipped
+ * here first (--rst-border, --rst-text-mut, --rst-warning,
+ * --rst-font-mono, --rst-shell-bar-h): CSS resolves an undefined custom
+ * property to its fallback in silence, so the page looked plausible and
+ * was simply not themed. The real names are --rst-line,
+ * --rst-text-muted, --rst-tone-warning-fg and --rst-font.
+ */
/* The grid surface fills what is left of the viewport under the topbar.
* It is why this app has its own layout shell rather than rastrillo's:
* a spreadsheet is the one screen that should not sit in a padded,
- * max-width page. */
+ * max-width page.
+ *
+ * The topbar's height is measured by the layout rather than assumed:
+ * there is no token for it, and a guessed constant is a gap or a
+ * scrollbar at every width it was not guessed for. */
[data-sheet-surface] {
display: flex;
flex-direction: column;
- height: calc(100dvh - var(--rst-shell-bar-h, 3.5rem));
+ height: 100dvh;
min-height: 0;
}
[data-sheet-bar] {
display: flex;
align-items: center;
- gap: var(--rst-sp-2, 0.5rem);
+ gap: var(--rst-sp-2);
flex-wrap: wrap;
- padding: var(--rst-sp-2, 0.5rem) var(--rst-sp-3, 0.75rem);
- border-bottom: 1px solid var(--rst-border);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+ border-bottom: 1px solid var(--rst-line);
background: var(--rst-surface);
+ color: var(--rst-text);
}
[data-sheet-title] {
@@ -37,7 +50,7 @@
[data-formula-input] {
flex: 1 1 12rem;
max-inline-size: 40rem;
- font-family: var(--rst-font-mono, ui-monospace, monospace);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* The live indicator. It says connected or reconnecting and nothing
@@ -45,62 +58,108 @@
* knows is worse than no dot. */
[data-sheet-live] {
margin-inline-start: auto;
- font-size: var(--rst-fs-sm, 0.875rem);
- color: var(--rst-text-mut);
+ font-size: var(--rst-fs-sm);
+ color: var(--rst-text-muted);
}
[data-sheet-live][data-state="off"] {
- color: var(--rst-warning, var(--rst-text-mut));
+ color: var(--rst-tone-warning-fg);
}
[data-sheet-grid] {
flex: 1;
min-height: 0;
overflow: auto;
+ background: var(--rst-bg);
+}
+
+/* ── The vendored grid ───────────────────────────────────────────────
+ *
+ * Jspreadsheet paints its own colours, hard-coded: #fff for the table,
+ * #f3f3f3 for the headers, #ccc for every border. None of them follow a
+ * theme, so all of them are restated here in tokens.
+ *
+ * The selectors are `.jss_*`. Jspreadsheet v5 renamed everything from
+ * v4's `.jexcel`, and this block was first written against `.jexcel` —
+ * so it matched nothing at all, and the grid stayed white inside dark
+ * chrome. Check the vendored stylesheet, not a memory of the last major
+ * version, when these stop working.
+ *
+ * Every rule that sets a background also sets a color. The library sets
+ * background-color on the headers and no color, so the text is
+ * inherited from the page: in dark mode that put light grey text on a
+ * light grey header and the row and column names became invisible. A
+ * background without a foreground beside it is the bug, not the
+ * colour. */
+[data-sheet-grid] .jss_worksheet {
+ background-color: var(--rst-bg);
+ color: var(--rst-text);
+ border-right-color: var(--rst-line);
+ border-bottom-color: var(--rst-line);
}
-/* Jspreadsheet paints its own colours. These override the ones that
- * would otherwise ignore the theme entirely — the grid is the surface
- * where a wrong background is most obvious, because every cell shows
- * it. */
-[data-sheet-grid] .jexcel,
-[data-sheet-grid] .jexcel > tbody > tr > td,
-[data-sheet-grid] .jexcel > thead > tr > td {
+[data-sheet-grid] .jss_worksheet > tbody > tr > td {
background-color: var(--rst-bg);
color: var(--rst-text);
- border-color: var(--rst-border);
+ border-top-color: var(--rst-line);
+ border-left-color: var(--rst-line);
+}
+
+/* The row and column names. Both headers, one look. */
+[data-sheet-grid] .jss_worksheet > thead > tr > td,
+[data-sheet-grid] .jss_worksheet > tbody > tr > td:first-child {
+ background-color: var(--rst-surface-2);
+ color: var(--rst-text-muted);
+ border-top-color: var(--rst-line);
+ border-left-color: var(--rst-line);
+ font-weight: 600;
+}
+
+[data-sheet-grid] .jss_worksheet > thead > tr > td.selected,
+[data-sheet-grid] .jss_worksheet > tbody > tr > td:first-child.selected {
+ background-color: var(--rst-accent-soft);
+ color: var(--rst-text);
}
-[data-sheet-grid] .jexcel > thead > tr > td,
-[data-sheet-grid] .jexcel > tbody > tr > td:first-child {
- background-color: var(--rst-surface);
- color: var(--rst-text-mut);
+[data-sheet-grid] .jss_worksheet > tbody > tr > td.highlight {
+ background-color: var(--rst-accent-soft);
+ color: var(--rst-text);
+}
+
+/* The cell editor is an input the library drops into the td. Left
+ * alone it inherits the browser's own white. */
+[data-sheet-grid] .jss_worksheet td input,
+[data-sheet-grid] .jss_worksheet td textarea {
+ background-color: var(--rst-bg);
+ color: var(--rst-text);
}
-[data-sheet-grid] .jexcel > tbody > tr > td.highlight {
- background-color: var(--rst-accent-soft, var(--rst-surface));
+[data-sheet-grid] .jss_container,
+[data-sheet-grid] .jss_content {
+ background-color: var(--rst-bg);
}
/* A cell whose imported fill fails WCAG AA against its ink. Spec §11.1
* ruled that we retain such a fill rather than silently correcting it,
* so the fill stays and this marks it instead: a corner flag that costs
* nothing to ignore and is impossible to miss once you look. */
+[data-sheet-grid] td[data-contrast="low"] {
+ position: relative;
+}
+
[data-sheet-grid] td[data-contrast="low"]::after {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
- border-block-start: 0.5rem solid var(--rst-warning, currentColor);
+ border-block-start: 0.5rem solid var(--rst-tone-warning-fg);
border-inline-start: 0.5rem solid transparent;
}
-[data-sheet-grid] td[data-contrast="low"] {
- position: relative;
-}
-
-/* The scriptless table. Not a fallback in the apologetic sense: it is
- * the only view that works everywhere, and every affordance the grid
- * has must exist here too. */
+/* ── The scriptless table ────────────────────────────────────────────
+ *
+ * Not a fallback in the apologetic sense: it is the only view that
+ * works everywhere, and every affordance the grid has exists here too. */
[data-cell-table] {
border-collapse: collapse;
width: 100%;
@@ -108,21 +167,21 @@
[data-cell-table] th,
[data-cell-table] td {
- border: 1px solid var(--rst-border);
- padding: var(--rst-sp-1, 0.25rem) var(--rst-sp-2, 0.5rem);
+ border: 1px solid var(--rst-line);
+ padding: var(--rst-sp-1) var(--rst-sp-2);
text-align: start;
vertical-align: top;
}
[data-cell-table] th {
- background: var(--rst-surface);
- color: var(--rst-text-mut);
+ background: var(--rst-surface-2);
+ color: var(--rst-text-muted);
font-weight: 600;
}
[data-cell-table] td form {
display: flex;
- gap: var(--rst-sp-1, 0.25rem);
+ gap: var(--rst-sp-1);
}
[data-cell-table] td input[type="text"] {
@@ -132,6 +191,6 @@
[data-cell-value] {
display: block;
- color: var(--rst-text-mut);
- font-size: var(--rst-fs-sm, 0.875rem);
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
}
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index 5137f7b..cf850d2 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -1,10 +1,17 @@
{{define "layout"}}<!doctype html>
{{/* data-theme is written into the first bytes of the response, from a
cookie read server-side. AGENTS.md: light/dark must resolve before
- first paint, because cell fills resolve against the theme and a late
- preference repaints every coloured cell. Never move this to a fetch
- or to a script that runs after parse. */}}
-<html lang="en" dir="ltr" data-theme="{{.Theme}}">
+ first paint, because cell fills resolve against the theme and a
+ late preference repaints every coloured cell. Never move this to a
+ fetch or to a script that runs after parse.
+
+ It is ABSENT for "auto", and that is the mechanism, not an
+ omission: theme.css declares every colour as light-dark(a, b) and
+ its entire toggle is :root[data-theme="light"] and
+ :root[data-theme="dark"]. With no attribute the browser resolves
+ against the OS. Writing anything else here — a theme NAME, say —
+ matches neither rule, and the switcher silently does nothing. */}}
+<html lang="en" dir="ltr"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -40,11 +47,11 @@
of chrome that must work with JavaScript off, because a person
who needs a high-contrast theme is not well served by being told
to enable scripting first. */}}
-<form method="post" action="/theme">
+<form method="post" action="/mode">
<input type="hidden" name="return" value="{{.Path}}">
-<button rst-btn="ghost" type="submit" name="theme" value="day">Day</button>
-<button rst-btn="ghost" type="submit" name="theme" value="plain">Plain</button>
-<button rst-btn="ghost" type="submit" name="theme" value="signal">Signal</button>
+<button rst-btn="ghost" type="submit" name="mode" value="light"{{if eq .Mode "light"}} aria-current="true"{{end}}>Light</button>
+<button rst-btn="ghost" type="submit" name="mode" value="dark"{{if eq .Mode "dark"}} aria-current="true"{{end}}>Dark</button>
+<button rst-btn="ghost" type="submit" name="mode" value="auto"{{if eq .Mode "auto"}} aria-current="true"{{end}}>System</button>
</form>
<form method="post" action="/signout">
<button rst-btn="ghost" type="submit">Sign out</button>
diff --git a/internal/sheets/theme.go b/internal/sheets/theme.go
index 974b740..ac11d0c 100644
--- a/internal/sheets/theme.go
+++ b/internal/sheets/theme.go
@@ -5,73 +5,94 @@ import (
"net/url"
"strings"
"time"
-
- "github.com/carlosframework/rastrillo/ui"
)
-// themeCookie carries the per-person theme.
+// modeCookie carries the viewer's light/dark preference.
//
// Per person, not per document (spec §9.3, AGENTS.md), so it is a
// cookie on this origin rather than a column on Sheet. It is read
-// server-side on every request and interpolated into the page, which is
-// what lets the theme resolve before first paint.
-const themeCookie = "sheets_theme"
+// server-side on every request and written into the page, which is what
+// lets the choice resolve before first paint.
+const modeCookie = "sheets_mode"
-// defaultTheme is the floor. rastrillo ships day, plain and signal;
-// anything else — a stale cookie, a hand-edited one, an attempt at path
-// traversal through a stylesheet href — resolves to this and never
-// reaches a template.
-const defaultTheme = "day"
+// The three states, and they are three rather than two on purpose:
+// "follow the machine" is a real preference, and an app that only
+// offers light and dark forces everyone to restate a choice their OS
+// already knows.
+const (
+ modeAuto = "auto"
+ modeLight = "light"
+ modeDark = "dark"
+)
-// themeCookieMaxAge is a year. A preference somebody set once should
-// not quietly expire back to the default a month later.
-const themeCookieMaxAge = 365 * 24 * 60 * 60
+// modeCookieMaxAge is a year. A preference somebody set once should not
+// quietly expire back to the default a month later.
+const modeCookieMaxAge = 365 * 24 * 60 * 60
-// themeFrom reads the viewer's theme.
+// modeFrom reads the viewer's light/dark choice, defaulting to auto.
//
-// The value is checked against the shipped theme names rather than
-// merely sanitised: it ends up in a data attribute and, in a design that
-// later switches stylesheets by name, in a URL. An allowlist of three
-// strings cannot be wrong in the way an escaping rule can.
-func themeFrom(r *http.Request) string {
- c, err := r.Cookie(themeCookie)
+// The value is checked against the three names rather than merely
+// sanitised: it is written into a data attribute that selects a CSS
+// rule, and an allowlist of three strings cannot be wrong in the way an
+// escaping rule can.
+func modeFrom(r *http.Request) string {
+ c, err := r.Cookie(modeCookie)
if err != nil {
- return defaultTheme
+ return modeAuto
}
- for _, name := range ui.ThemeNames() {
- if c.Value == name {
- return c.Value
- }
+ switch c.Value {
+ case modeLight, modeDark, modeAuto:
+ return c.Value
}
- return defaultTheme
+ return modeAuto
}
-// handleTheme sets the theme cookie and returns the person to where
-// they were.
-func (a *App) handleTheme(w http.ResponseWriter, r *http.Request) {
- theme := r.FormValue("theme")
- valid := false
- for _, name := range ui.ThemeNames() {
- if theme == name {
- valid = true
- break
- }
+// themeAttr is what goes in <html data-theme="…">, and it is empty for
+// auto.
+//
+// Empty matters. rastrillo's theme.css declares every colour once as
+// light-dark(light, dark) under `color-scheme: light dark`, and its
+// whole toggle is two rules — `:root[data-theme="light"]` and
+// `:root[data-theme="dark"]` — which re-resolve every one of those
+// calls by setting color-scheme. With no attribute the browser resolves
+// them against the OS, which IS what auto means.
+//
+// This is also the bug this function exists to prevent recurring. The
+// attribute used to be handed rastrillo's THEME NAME — day, plain,
+// signal — which are the palette files an app picks once at scaffold
+// (`rastrillo doctor` copies one to static/theme.css), not a per-person
+// setting. Neither toggle rule matched, so an explicit choice never
+// beat the OS and the switcher did nothing at all. Theme and scheme are
+// orthogonal: this app ships `day` (ruling 7) and every person picks
+// their own light or dark within it.
+func themeAttr(mode string) string {
+ if mode == modeLight || mode == modeDark {
+ return mode
}
- if !valid {
- http.Error(w, "Unknown theme.", http.StatusBadRequest)
+ return ""
+}
+
+// handleMode sets the preference and returns the person to where they
+// were.
+func (a *App) handleMode(w http.ResponseWriter, r *http.Request) {
+ mode := r.FormValue("mode")
+ switch mode {
+ case modeLight, modeDark, modeAuto:
+ default:
+ http.Error(w, "Unknown display mode.", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
- Name: themeCookie,
- Value: theme,
+ Name: modeCookie,
+ Value: mode,
Path: "/",
// Not HttpOnly: this is a display preference, not a
- // credential, and a client-side theme toggle should be able to
- // set it without a round trip when one is added later.
+ // credential, and a client-side toggle should be able to set it
+ // without a round trip when one is added.
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
- MaxAge: themeCookieMaxAge,
- Expires: time.Now().Add(themeCookieMaxAge * time.Second),
+ MaxAge: modeCookieMaxAge,
+ Expires: time.Now().Add(modeCookieMaxAge * time.Second),
})
http.Redirect(w, r, safeReturn(r.FormValue("return")), http.StatusSeeOther)
}
diff --git a/internal/sheets/theme_test.go b/internal/sheets/theme_test.go
index 5a2a62f..9a39ab7 100644
--- a/internal/sheets/theme_test.go
+++ b/internal/sheets/theme_test.go
@@ -6,32 +6,81 @@ import (
"net/url"
"strings"
"testing"
+
+ "github.com/carlosframework/rastrillo/ui"
)
-func TestThemeDefaultsToDayWithNoCookie(t *testing.T) {
+func TestModeDefaultsToAutoWithNoCookie(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
- if got := themeFrom(r); got != defaultTheme {
- t.Fatalf("themeFrom with no cookie = %q, want %q", got, defaultTheme)
+ if got := modeFrom(r); got != modeAuto {
+ t.Fatalf("modeFrom with no cookie = %q, want %q", got, modeAuto)
+ }
+ // Auto means NO attribute. rastrillo's toggle is two rules keyed on
+ // data-theme, and the browser falls back to the OS only when
+ // neither matches — so an attribute here, of any value, would take
+ // the OS out of the decision.
+ if got := themeAttr(modeAuto); got != "" {
+ t.Fatalf("themeAttr(auto) = %q, want empty so the OS decides", got)
}
}
-func TestThemeReadsTheCookie(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
- if got := themeFrom(r); got != "signal" {
- t.Fatalf("themeFrom = %q, want %q", got, "signal")
+func TestModeReadsTheCookie(t *testing.T) {
+ for _, want := range []string{modeLight, modeDark, modeAuto} {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: modeCookie, Value: want})
+ if got := modeFrom(r); got != want {
+ t.Errorf("modeFrom = %q, want %q", got, want)
+ }
+ }
+}
+
+// The bug this whole file exists to stop coming back: data-theme takes
+// the light/dark SCHEME, not rastrillo's theme NAME. day, plain and
+// signal are palette files an app picks once at scaffold; feeding one
+// to the attribute matches neither toggle rule, so an explicit choice
+// never beats the OS and the switcher silently does nothing.
+func TestThemeAttrIsASchemeNeverAThemeName(t *testing.T) {
+ if got := themeAttr(modeLight); got != "light" {
+ t.Errorf("themeAttr(light) = %q, want \"light\"", got)
+ }
+ if got := themeAttr(modeDark); got != "dark" {
+ t.Errorf("themeAttr(dark) = %q, want \"dark\"", got)
+ }
+ for _, name := range ui.ThemeNames() {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: modeCookie, Value: name})
+ if got := themeAttr(modeFrom(r)); got != "" {
+ t.Errorf("a %q cookie produced data-theme=%q; theme names are not schemes", name, got)
+ }
+ }
+}
+
+// theme.css's toggle is exactly these two selectors. If rastrillo ever
+// changes them, everything above is wrong and this says so.
+func TestRastrillosToggleStillKeysOnLightAndDark(t *testing.T) {
+ css, err := staticFiles().(interface {
+ ReadFile(string) ([]byte, error)
+ }).ReadFile("static/theme.css")
+ if err != nil {
+ t.Fatalf("read theme.css: %v", err)
+ }
+ for _, want := range []string{`[data-theme="light"]`, `[data-theme="dark"]`} {
+ if !strings.Contains(string(css), want) {
+ t.Fatalf("theme.css no longer carries %s — the light/dark toggle moved, "+
+ "and themeAttr is now writing an attribute nothing reads", want)
+ }
}
}
// The value is interpolated into a data attribute and, in a design that
// later switches stylesheets by name, into a URL. An allowlist cannot
// be wrong in the way an escaping rule can.
-func TestThemeRejectsAnUnknownValue(t *testing.T) {
- for _, junk := range []string{"../../etc/passwd", "", "DAY", "day evil"} {
+func TestModeRejectsAnUnknownValue(t *testing.T) {
+ for _, junk := range []string{"../../etc/passwd", "", "DARK", "dark evil", "day"} {
r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: themeCookie, Value: junk})
- if got := themeFrom(r); got != defaultTheme {
- t.Errorf("themeFrom with cookie %q = %q, want the %q floor", junk, got, defaultTheme)
+ r.AddCookie(&http.Cookie{Name: modeCookie, Value: junk})
+ if got := modeFrom(r); got != modeAuto {
+ t.Errorf("modeFrom with cookie %q = %q, want the %q floor", junk, got, modeAuto)
}
}
}
@@ -41,55 +90,67 @@ func TestThemeRejectsAnUnknownValue(t *testing.T) {
// coloured cell. A page that carries its theme in its first byte cannot
// flash; one that fetches it can. This test fails the moment somebody
// moves the decision into JavaScript.
-func TestThemeIsInTheFirstResponseNotFetched(t *testing.T) {
+func TestModeIsInTheFirstResponseNotFetched(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
- req.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
+ req.AddCookie(&http.Cookie{Name: modeCookie, Value: modeDark})
New(Config{}).Router().ServeHTTP(rec, req)
- if !strings.Contains(rec.Body.String(), `data-theme="signal"`) {
- t.Fatal("the rendered page does not carry the theme; it would flash on load")
+ if !strings.Contains(rec.Body.String(), `data-theme="dark"`) {
+ t.Fatal("the rendered page does not carry the choice; it would flash on load")
+ }
+}
+
+// Auto must emit no attribute at all, not an empty one: an empty
+// data-theme matches neither toggle rule but still looks deliberate,
+// and the next person to read the HTML would think it was.
+func TestAutoEmitsNoThemeAttributeAtAll(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ New(Config{}).Router().ServeHTTP(rec, req)
+ if strings.Contains(rec.Body.String(), "data-theme") {
+ t.Fatal("a viewer with no preference got a data-theme attribute; the OS should decide")
}
}
-func TestPostThemeSetsTheCookieAndReturns(t *testing.T) {
- form := url.Values{"theme": {"signal"}, "return": {"/sheets"}}
- req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
+func TestPostModeSetsTheCookieAndReturns(t *testing.T) {
+ form := url.Values{"mode": {modeDark}, "return": {"/sheets"}}
+ req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
New(Config{}).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /theme = %d, want 303", rec.Code)
+ t.Fatalf("POST /mode = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/sheets" {
t.Fatalf("Location = %q, want /sheets", loc)
}
var found bool
for _, c := range rec.Result().Cookies() {
- if c.Name == themeCookie && c.Value == "signal" {
+ if c.Name == modeCookie && c.Value == modeDark {
found = true
}
}
if !found {
- t.Fatalf("no %s cookie was set: %v", themeCookie, rec.Result().Cookies())
+ t.Fatalf("no %s cookie was set: %v", modeCookie, rec.Result().Cookies())
}
}
-func TestPostThemeRefusesAnUnknownTheme(t *testing.T) {
- form := url.Values{"theme": {"neon"}}
- req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
+func TestPostModeRefusesAnUnknownMode(t *testing.T) {
+ form := url.Values{"mode": {"neon"}}
+ req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
New(Config{}).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
- t.Fatalf("POST /theme with an unknown theme = %d, want 400", rec.Code)
+ t.Fatalf("POST /mode with an unknown mode = %d, want 400", rec.Code)
}
}
// A form field that becomes a Location header is an open redirect
// unless it is constrained to a path on this origin.
-func TestThemeReturnIsNotAnOpenRedirect(t *testing.T) {
+func TestModeReturnIsNotAnOpenRedirect(t *testing.T) {
for _, hostile := range []string{
"https://evil.example.com/",
"//evil.example.com/",
@@ -107,13 +168,17 @@ func TestThemeReturnIsNotAnOpenRedirect(t *testing.T) {
}
}
-// The theme switcher is chrome, and chrome that needs JavaScript is
-// chrome a person who needs a high-contrast theme cannot reach.
-func TestThemeSwitcherIsAForm(t *testing.T) {
+// The switcher is chrome, and chrome that needs JavaScript is chrome
+// somebody who needs dark mode cannot reach.
+func TestModeSwitcherIsAForm(t *testing.T) {
app, cookies := signIn(t)
- rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/", nil), cookies))
- body := rec.Body.String()
- if !strings.Contains(body, `action="/theme"`) {
- t.Fatal("the signed-in page offers no theme form")
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/", nil), cookies)).Body.String()
+ if !strings.Contains(body, `action="/mode"`) {
+ t.Fatal("the signed-in page offers no display-mode form")
+ }
+ for _, want := range []string{`value="light"`, `value="dark"`, `value="auto"`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the switcher is missing %s", want)
+ }
}
}