oficina / fichas Public

Move Fichas off the frozen rastrillo path; tokens.css stops the iOS zoom

Fichas imported rastrillo from github.com/carlosframework/rastrillo, which
stops at v0.23.0, so it had missed every rastrillo ui fix since 2026-08-31
and carried its own copy of the no-zoom-on-tap rule in fichas.css. It now
imports amadan.net/rastrillo/rastrillo at 29e4491, as Sheets does (Fichas
has no other amadan rastrillo requirement to agree with; estilo still
requires only the old path, which stays in go.mod as its indirect).

- Every import moved. TestNoPreMoveRastrillo forbids the old path in any
  .go file, tests and scripts included.
- The CLI is a go.mod tool at the same version; migration-check runs
  `go tool rastrillo`.
- `make release` stamps amadan.net/rastrillo/rastrillo.BuildVersion. The
  old symbol is silently ignored by the linker, so /api/version would
  have said "dev" forever. TestReleaseStampsTheServedVersion pins it.
- `go tool rastrillo doctor --fix` re-copied tokens.css and datetime.js.
  The calendar.js it added is left out and recorded in vendoredIsMine:
  without it datetime.js keeps the browser's picker, as today.
- estilo's shim and busy.js stay. TestNoOldBusyRuleIsServed fetches every
  script both shells link and fails if any but busy.js carries the busy
  rule (the library's shim still does).
- The stopgap no-zoom rule is gone from fichas.css, and so is the
  fichas.css link the plain layout gained only to carry it.
  TestNoZoomOnTap now covers seven pages; on v0.23 without the stopgap it
  fails on all seven (12.5-14px).
- `make vet` also vets the browser-tagged tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e93afe345536a2736579902a444dae74ac76ce8d parent 83f6d62
24 files changed, +942 −118
  • AGENTS.md +14 −0
  • Makefile +5 −2
  • cmd/fichas/main.go +3 −3
  • go.mod +3 −2
  • go.sum +2 −0
  • internal/fichas/app.go +4 −4
  • internal/fichas/auth.go +2 −2
  • internal/fichas/catalogue_test.go +2 −2
  • internal/fichas/handlers.go +2 −2
  • internal/fichas/migrations.go +3 −3
  • internal/fichas/render.go +2 −2
  • internal/fichas/static/datetime.js +420 −60
  • internal/fichas/static/fichas.css +0 −15
  • internal/fichas/static/tokens.css +303 −6
  • internal/fichas/templates/layout.html +0 −4
  • internal/fichastest/browser_test.go +20 −3
  • internal/fichastest/gates_test.go +1 −1
  • internal/fichastest/harness_test.go +1 −1
  • internal/fichastest/rastrillo_path_test.go +111 −0
  • internal/fichastest/signin_flow_test.go +1 −1
  • internal/fichastest/signin_test.go +2 −2
  • internal/fichastest/vendored_test.go +39 −1
  • scripts/mockups/main.go +1 −1
  • scripts/preview/main.go +1 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
go vet -tags browser ./internal/fichastest/
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.656s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	(cached)
ok  	amadan.net/oficina/fichas/internal/fichastest	0.372s
?   	amadan.net/oficina/fichas/scripts/mockups	[no test files]
?   	amadan.net/oficina/fichas/scripts/preview	[no test files]
?   	amadan.net/oficina/fichas/scripts/writeestilo	[no test files]
=== migration-check ===
go tool rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md
index d21b0eb..94783b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,6 +40,20 @@ mechanically.
- The gate is `make ci` (vet + gofmt + tests) — the same definition
CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
stack is cgo-free by design.
+- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
+ `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing
+ it gets no fix, and its own checks cannot tell (Fichas zoomed iOS on
+ every tap that way until it carried a copy of the fix).
+ `TestNoPreMoveRastrillo` forbids every import of it. It stays in go.mod
+ only as an indirect requirement of estilo. The CLI is pinned as a
+ `tool` in go.mod: run it as `go tool rastrillo …`, never a `rastrillo`
+ from PATH.
+- **Run `go tool rastrillo doctor` on every rastrillo upgrade**, and
+ `--fix` to re-copy what drifted. Never `--force`: `rastrillo.js` is
+ estilo's shim (the library's still carries the old busy rule, which
+ fights busy.js; `TestNoOldBusyRuleIsServed` reads what is served), and
+ `calendar.js` is left out on purpose (datetime.js falls back to the
+ browser's picker without it). Both are recorded in `vendoredIsMine`.
## Serving
diff --git a/Makefile b/Makefile
index 1a8e365..4273383 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ build:
release: version-check
@mkdir -p releases
CGO_ENABLED=0 GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) \
- go build -ldflags="-s -w -X 'github.com/carlosframework/rastrillo.BuildVersion=$(VERSION)'" \
+ go build -ldflags="-s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)'" \
-o $(RELEASE_BIN) ./cmd/$(APP)
@echo
@echo "built $(RELEASE_BIN) reporting version $(VERSION)"
@@ -85,8 +85,11 @@ version-check:
test:
go test ./...
+# Also vets the browser-tagged tests: they are outside `make test`, and a
+# file behind a tag that nothing compiles rots silently (it did here once).
vet:
go vet ./...
+ go vet -tags browser ./internal/fichastest/
fmt-check:
@out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@@ -101,7 +104,7 @@ fmt-check:
# fresh clone's very first CI run with "make: rastrillo: Command not
# found", and nothing in the scaffold said what to install.
migration-check:
- go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
+ go tool rastrillo migration check
# ci is the one gate: what a runner executes and what you run before
# pushing are the same definition. amadan's runner falls back to this
diff --git a/cmd/fichas/main.go b/cmd/fichas/main.go
index 2087097..47e3806 100644
--- a/cmd/fichas/main.go
+++ b/cmd/fichas/main.go
@@ -6,9 +6,9 @@ import (
"log/slog"
"os"
- "github.com/carlosframework/rastrillo"
- "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/mail"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/mail"
fichas "amadan.net/oficina/fichas/internal/fichas"
)
diff --git a/go.mod b/go.mod
index 9cbc513..367c3c0 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ toolchain go1.26.5
require (
amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
- github.com/carlosframework/rastrillo v0.23.0
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
@@ -17,6 +17,7 @@ require (
require (
github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/carlosframework/rastrillo v0.23.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
@@ -38,4 +39,4 @@ require (
modernc.org/sqlite v1.55.0 // indirect
)
-tool github.com/carlosframework/rastrillo/cmd/rastrillo
+tool amadan.net/rastrillo/rastrillo/cmd/rastrillo
diff --git a/go.sum b/go.sum
index acce90b..2bbffcc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8=
amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index daabd02..175dc6b 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -9,10 +9,10 @@ import (
"github.com/go-chi/chi/v5"
- "github.com/carlosframework/rastrillo/csrf"
- "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/mail"
- "github.com/carlosframework/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/mail"
+ "amadan.net/rastrillo/rastrillo/migrate"
)
// App wires the whole app: schema, router, static files. It returns a
diff --git a/internal/fichas/auth.go b/internal/fichas/auth.go
index 4629897..3858eb8 100644
--- a/internal/fichas/auth.go
+++ b/internal/fichas/auth.go
@@ -8,8 +8,8 @@ import (
"strings"
"amadan.net/oficina/estilo.git/chrome"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/mail"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/mail"
)
// signinPath is where every sign-in outcome lands. It must match the
diff --git a/internal/fichas/catalogue_test.go b/internal/fichas/catalogue_test.go
index 67bbfb5..2b00236 100644
--- a/internal/fichas/catalogue_test.go
+++ b/internal/fichas/catalogue_test.go
@@ -8,8 +8,8 @@ import (
"testing"
"amadan.net/oficina/fichas/internal/fichas/seal"
- "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"gorm.io/gorm"
)
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index c527e8a..bd137d1 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -9,8 +9,8 @@ import (
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/db"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
diff --git a/internal/fichas/migrations.go b/internal/fichas/migrations.go
index b3cf739..5dbebf5 100644
--- a/internal/fichas/migrations.go
+++ b/internal/fichas/migrations.go
@@ -3,9 +3,9 @@ package fichas
import (
"embed"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/migrate"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
//go:embed migrations/*.sql
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index 8f78862..0486d8f 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -9,8 +9,8 @@ import (
"time"
"amadan.net/oficina/estilo.git/chrome"
- "github.com/carlosframework/rastrillo"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/ui"
"amadan.net/oficina/fichas/internal/fichas/icons"
)
diff --git a/internal/fichas/static/datetime.js b/internal/fichas/static/datetime.js
index c95890a..285d8ea 100644
--- a/internal/fichas/static/datetime.js
+++ b/internal/fichas/static/datetime.js
@@ -24,7 +24,12 @@
data-rst-date-hint guidance under the list, {example}
substituted here with a date this
locale's own formatter wrote
- data-rst-date-pick label for the native-picker button
+ data-rst-date-pick label for the calendar button
+ data-rst-date-pick-time label for a TIME field's button, which
+ opens a clock rather than a calendar
+ data-rst-date-calendar the calendar overlay's own name
+ data-rst-date-prev-month label for the overlay's back chevron
+ data-rst-date-next-month label for the overlay's forward one
data-rst-date-results live-region text, {n} substituted
data-rst-date-result-one live-region text for exactly one
data-rst-date-quick-* the seven quick-pick labels: today,
@@ -58,7 +63,22 @@
The pure half is exported for Node at the bottom of the file, behind
a guard a browser never takes; ui/datetime_node.mjs drives it
- against ui/testdata/datetime/*.json. */
+ against ui/testdata/datetime/*.json.
+
+ Two things live NEXT to the strict grammar rather than inside it.
+ parseAll offers a reading where the grammar refuses one, by finishing
+ a half-typed word into the names this locale actually has: "5j" is
+ not a date, and 5 January, 5 June and 5 July are three, nearest
+ first. It never overrules the grammar — an exact reading wins
+ outright — and every completion is shown with its date written out
+ before anyone commits it.
+
+ And the calendar itself is ui/calendar.js, a sibling file. This one
+ reads and writes dates as TEXT; that one draws six weeks of them.
+ The seam is window.rastrilloCalendar, looked up at enhance time, so
+ an app that ships this file without that one still has a working
+ field — the button falls back to the browser's own picker, the same
+ way everything here falls back when an attribute is missing. */
(function () {
"use strict";
@@ -399,6 +419,20 @@
return list;
}
+ // The index for this table, built once and kept on it. Cached
+ // against the vocabulary itself, not just the locale: a page could
+ // in principle carry two, and a stale index would parse the second
+ // field with the first field's words.
+ function indexFor(vocab, tbl) {
+ var key = "", name;
+ for (name in vocab) if (Object.prototype.hasOwnProperty.call(vocab, name)) key += name + "=" + vocab[name] + "\u0000";
+ if (tbl.indexKey !== key) {
+ tbl.index = index(vocab, tbl);
+ tbl.indexKey = key;
+ }
+ return tbl.index;
+ }
+
// A separator is never a letter, whatever block it lives in. The
// Arabic comma sits at \u060c, inside the same range as the letters
// either side of it, so a class drawn by range alone reads
@@ -444,8 +478,15 @@
// being counted ("3 months"). A fact about the typing, not about
// any language, which is why it can be recorded without knowing
// which language was typed.
- function push(tok) {
+ //
+ // Where the token was read is recorded alongside it, because
+ // completion below rewrites exactly one token and hands the
+ // sentence back to the grammar unchanged around it. A scanner that
+ // forgets where it read a word cannot offer to finish it.
+ function push(tok, from, to) {
tok.glued = glued;
+ tok.from = from;
+ tok.to = to;
toks.push(tok);
glued = true;
}
@@ -453,15 +494,15 @@
if (BREAKS.test(s.charAt(pos))) { pos++; glued = false; continue; }
m = /^(\d{1,2}):(\d{2})/.exec(s.slice(pos));
if (m) {
- push({ t: "clock", h: +m[1], mi: +m[2] });
+ push({ t: "clock", h: +m[1], mi: +m[2] }, pos, pos + m[0].length);
pos += m[0].length;
continue;
}
hit = matchAt(s, pos, idx);
- if (hit) { push(hit.tok); pos += hit.len; continue; }
+ if (hit) { push(hit.tok, pos, pos + hit.len); pos += hit.len; continue; }
m = /^\d+/.exec(s.slice(pos));
if (m) {
- push({ t: "num", n: +m[0], digits: m[0].length });
+ push({ t: "num", n: +m[0], digits: m[0].length }, pos, pos + m[0].length);
pos += m[0].length;
continue;
}
@@ -471,7 +512,7 @@
pos++;
}
if (pos === start) pos++;
- push({ t: "other", raw: s.slice(start, pos) });
+ push({ t: "other", raw: s.slice(start, pos) }, start, pos);
}
return toks;
}
@@ -491,16 +532,7 @@
ctx = ctx || {};
var raw = tbl.fold(text);
if (!raw) return null;
- // Cached against the vocabulary itself, not just the locale: a
- // page could in principle carry two, and a stale index would parse
- // the second field with the first field's words.
- var key = "";
- for (var name in vocab) if (Object.prototype.hasOwnProperty.call(vocab, name)) key += name + "=" + vocab[name] + "\u0000";
- if (tbl.indexKey !== key) {
- tbl.index = index(vocab, tbl);
- tbl.indexKey = key;
- }
- var idx = tbl.index;
+ var idx = indexFor(vocab, tbl);
var prev = ctx.prev || null;
var base = ctx.base || null;
@@ -916,6 +948,92 @@
return out ? { date: out, hasDate: hasDate, hasTime: hasTime } : null;
}
+ /* ── completion ──────────────────────────────────────────────────
+ The grammar above is exact, and says so: one word it does not know
+ makes the whole reading null. That refusal is the promise which
+ keeps this field from ever committing a date nobody typed, and it
+ is not up for negotiation.
+
+ Completion is the other half of that bargain. It never overrules a
+ reading — an exact one wins outright and nothing below runs — it
+ only offers one where the grammar had none. "5j" is not a date.
+ "5 January", "5 June" and "5 July" are three, and putting all
+ three on screen with their dates written out in full is precisely
+ how a guess stops being a guess: the person reads the answer
+ before they accept it.
+
+ Nearest first, which is the whole of "the fifth of the next month
+ starting with j": inferYear already rolls a bare day-and-month
+ forward to its next occurrence, so ordering the readings by date
+ IS that rule. Nothing here knows what a month is called. */
+
+ // How many finishings one unfinished word may offer. A single letter
+ // is the front of three months in English, and of more than that
+ // once the weekdays and the vocabulary are in; a list nobody can
+ // read at a glance has stopped being a suggestion.
+ var MAX_COMPLETIONS = 6;
+
+ // Every word in this locale's index that the run could still become.
+ // A prefix, never an infix: people type the front of a word, so "ju"
+ // finishes to June and July and "une" finishes to nothing.
+ //
+ // Two kinds are skipped. A literal is punctuation Intl threads
+ // between numbers, which nobody is halfway through typing; and "at"
+ // is optional everywhere it appears, so finishing a word INTO it
+ // changes no reading and only spends a row.
+ function completionsFor(run, idx) {
+ var out = [], seen = {}, i, e;
+ if (!run) return out;
+ for (i = 0; i < idx.length; i++) {
+ e = idx[i];
+ if (e.role === "word" && (e.kind === "ignore" || e.kind === "at")) continue;
+ if (e.text.length <= run.length) continue;
+ if (e.text.slice(0, run.length) !== run) continue;
+ if (seen[e.text]) continue;
+ seen[e.text] = true;
+ out.push(e.text);
+ }
+ return out;
+ }
+
+ // parseAll(text, vocab, tbl, now, ctx) -> [reading, ...], best first,
+ // and empty where the grammar and the completions both come back
+ // with nothing. This is what the field itself reads with; parse
+ // stays exactly as strict as it was, and the read-back gate still
+ // holds it to reading its own writing with no help from here.
+ function parseAll(text, vocab, tbl, now, ctx) {
+ var exact = parse(text, vocab, tbl, now, ctx);
+ if (exact) return [exact];
+ var raw = tbl.fold(text);
+ if (!raw) return [];
+ var idx = indexFor(vocab, tbl);
+ var toks = scan(raw, idx), at = -1, i;
+ for (i = 0; i < toks.length; i++) {
+ if (toks[i].t !== "other") continue;
+ // Two unfinished words is not a typo anybody is mid-way through,
+ // it is prose — and finishing both would multiply out into a
+ // list of dates nobody asked for.
+ if (at >= 0) return [];
+ at = i;
+ }
+ if (at < 0) return [];
+ var names = completionsFor(toks[at].raw, idx), out = [], seen = {}, read, key;
+ for (i = 0; i < names.length; i++) {
+ // The sentence handed back to the grammar with that one run
+ // finished, spaced off on both sides so a finished word cannot
+ // glue itself onto its neighbour and mean something else.
+ read = parse(raw.slice(0, toks[at].from) + " " + names[i] + " " + raw.slice(toks[at].to),
+ vocab, tbl, now, ctx);
+ if (!read) continue;
+ key = String(read.date.getTime()) + (read.hasTime ? "t" : "");
+ if (seen[key]) continue;
+ seen[key] = true;
+ out.push(read);
+ }
+ out.sort(function (a, b) { return a.date - b.date; });
+ return out.slice(0, MAX_COMPLETIONS);
+ }
+
/* ── the page ────────────────────────────────────────────────────
Everything below is behind the document guard, so Node loads the
parser above and nothing else. */
@@ -926,6 +1044,12 @@
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
+ // A time field's button opens a clock, not a calendar, and says so
+ // with the one glyph everybody reads the same way.
+ var CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
+ '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
+
var seq = 0;
function toWire(kind, d) {
@@ -998,6 +1122,10 @@
var setLabel = attr(native, "data-rst-date-set", "Set");
var hintText = attr(native, "data-rst-date-hint", "");
var pickLabel = attr(native, "data-rst-date-pick", "Open the calendar");
+ var clockLabel = attr(native, "data-rst-date-pick-time", "Open the clock");
+ var calLabel = attr(native, "data-rst-date-calendar", "Calendar");
+ var prevLabel = attr(native, "data-rst-date-prev-month", "Previous month");
+ var nextLabel = attr(native, "data-rst-date-next-month", "Next month");
var manyFmt = attr(native, "data-rst-date-results", "{n} suggestions");
var oneFmt = attr(native, "data-rst-date-result-one", "1 suggestion");
var quick = {
@@ -1057,21 +1185,12 @@
native.setAttribute("tabindex", "-1");
native.setAttribute("aria-hidden", "true");
- var pick = null;
- if (canPick(native)) {
- pick = document.createElement("button");
- pick.type = "button";
- pick.setAttribute("rst-dtp-pick", "");
- pick.setAttribute("aria-label", pickLabel);
- pick.innerHTML = CAL_SVG;
- wrap.insertBefore(pick, list);
- }
-
var rows = []; // [{li, date, hasTime}] — selectable rows only
var active = -1;
var baseOf = null; // range end: () => Date|null, the start's value
var onCommit = null;
var isEnd = false;
+ var mine = false; // true while WE are the ones writing to the carrier
function current() { return fromWire(kind, native.value, new Date()); }
function show(d) { return fmt ? fmt.format(d) : toWire(kind, d); }
@@ -1087,10 +1206,10 @@
else input.removeAttribute("aria-activedescendant");
}
- // extra is the row's variant ("set") or a second attribute
- // ("rst-dtp-quick"): the vocabulary spells a variant in the
- // value slot and a kind as an attribute of its own, so one
- // parameter cannot be both, and the caller says which.
+ // extra is the row's variant ("set", "alt") or a second attribute
+ // ("rst-dtp-quick"): the vocabulary spells a variant in the value
+ // slot and a kind as an attribute of its own, so one parameter
+ // cannot be both, and the caller says which.
function addRow(label, meta, date, hasTime, variant, extra) {
var li = document.createElement("li");
li.setAttribute("rst-dtp-row", variant || "");
@@ -1155,11 +1274,22 @@
list.textContent = "";
rows = [];
var text = String(query || "").trim();
- var read = text ? parse(text, vocab, tbl, now, ctx()) : null;
- if (read) addRow(show(read.date), setLabel, read.date, read.hasTime, "set");
- var quickPicks = picks(now), i, skip = read ? toWire(kind, read.date) : "";
+ var reads = text ? parseAll(text, vocab, tbl, now, ctx()) : [];
+ var i, skip = {}, wire;
+ for (i = 0; i < reads.length; i++) {
+ // The first reading is the one Enter takes, and it is
+ // labelled as such. The rest are the other months a
+ // half-typed word could still have meant — offered, never
+ // chosen, each with its date written out in full, which is
+ // what keeps a completion from being a guess.
+ addRow(show(reads[i].date), i === 0 ? setLabel : "", reads[i].date, reads[i].hasTime,
+ i === 0 ? "set" : "alt");
+ skip[toWire(kind, reads[i].date)] = true;
+ }
+ var quickPicks = picks(now);
for (i = 0; i < quickPicks.length; i++) {
- if (toWire(kind, quickPicks[i].date) === skip) continue;
+ wire = toWire(kind, quickPicks[i].date);
+ if (skip[wire]) continue;
addRow(quickPicks[i].label, show(quickPicks[i].date), quickPicks[i].date, kind !== "date", "", "rst-dtp-quick");
}
if (hintText) {
@@ -1170,30 +1300,196 @@
list.appendChild(hint);
}
status.textContent = rows.length === 1 ? oneFmt : manyFmt.replace("{n}", String(rows.length));
- // Text that failed to parse leaves NOTHING armed: a quick pick
+ // Text that read as nothing leaves NOTHING armed: a quick pick
// sitting under the cursor would make Enter on a date the
// grammar did not understand commit today instead of refusing.
- setActive(rows.length && (read || !text) ? 0 : -1);
+ setActive(rows.length && (reads.length || !text) ? 0 : -1);
+ }
+
+ // What the carrier itself allows, plus — for a range's end — the
+ // start it must not come before. Asked fresh on every question
+ // rather than captured once, because the start moves.
+ function bounds() {
+ var when = new Date();
+ var lo = fromWire(kind, native.getAttribute("min"), when);
+ var hi = fromWire(kind, native.getAttribute("max"), when);
+ var start = baseOf ? baseOf() : null;
+ if (isEnd && start && (!lo || start > lo)) lo = midnight(start);
+ return { min: lo, max: hi };
+ }
+
+ // What the text in the box currently reads as, committing
+ // nothing. Null where it reads as nothing at all.
+ function previewOf(text) {
+ var t = String(text || "").trim();
+ if (!t) return null;
+ var reads = parseAll(t, vocab, tbl, new Date(), ctx());
+ return reads.length ? reads[0].date : null;
+ }
+
+ // The calendar overlay, when calendar.js is on the page. Looked
+ // up here rather than at load time so the two files may arrive
+ // in either order: both are deferred and this runs on
+ // DOMContentLoaded, by which point both have executed.
+ //
+ // Absent, the button falls back to the browser's own panel —
+ // the same posture this file takes towards a missing attribute.
+ // A page that links one script and not the other gets the old
+ // behaviour rather than a dead control.
+ var makeCal = (typeof window !== "undefined" && window.rastrilloCalendar) || null;
+ var cal = null;
+
+ if (makeCal && kind !== "time") {
+ cal = makeCal({
+ id: id,
+ locale: locale,
+ labels: { calendar: calLabel, prev: prevLabel, next: nextLabel },
+ value: current,
+ bounds: bounds,
+ onPick: function (day) {
+ // A day chosen on the grid keeps the clock the field
+ // already holds: picking the 5th for a meeting says
+ // nothing about what time it starts, and throwing the time
+ // away would be a second edit nobody asked for.
+ var had = current();
+ commit(new Date(day.getFullYear(), day.getMonth(), day.getDate(),
+ had ? had.getHours() : 9, had ? had.getMinutes() : 0), kind !== "date");
+ },
+ onDismiss: function () {
+ closeAll();
+ input.focus();
+ }
+ });
+ cal.el.id = id + "-cal";
+ wrap.appendChild(cal.el);
+ }
+
+ var pick = null;
+ if (cal || kind === "time" || canPick(native)) {
+ pick = document.createElement("button");
+ pick.type = "button";
+ pick.setAttribute("rst-dtp-pick", "");
+ pick.setAttribute("aria-label", kind === "time" ? clockLabel : pickLabel);
+ pick.setAttribute("aria-expanded", "false");
+ pick.setAttribute("aria-haspopup", cal ? "dialog" : "listbox");
+ pick.innerHTML = kind === "time" ? CLOCK_SVG : CAL_SVG;
+ wrap.insertBefore(pick, list);
+ }
+
+ /* ── which popup is open ─────────────────────────────────────
+ One region under the field, in one of three states, so there
+ is only ever one thing to close, one thing the arrow keys
+ drive, and one Escape:
+
+ "list" the suggestions — readings, quick picks, the hint
+ "cal" the month grid, on a date or datetime field
+ "slots" every half hour, on a time field
+
+ The calendar REPLACES the suggestions rather than sitting
+ beside them, because two popups under one field is two things
+ the same keystroke could mean. The words keep working while it
+ is open: typing moves the grid, and Enter still commits what
+ the text reads as. */
+
+ var mode = "";
+
+ function closeAll() {
+ mode = "";
+ list.hidden = true;
+ active = -1;
+ input.setAttribute("aria-expanded", "false");
+ input.setAttribute("aria-controls", listId);
+ input.removeAttribute("aria-activedescendant");
+ if (cal) cal.close();
+ if (pick) pick.setAttribute("aria-expanded", "false");
+ wrap.classList.remove("is-above");
}
- function open() {
+ // Which side of the field the popup opens on. Below by default,
+ // above where there is no room below and there is room above: a
+ // panel hanging off the bottom of the window is a panel whose
+ // last week nobody can click.
+ function place() {
+ var panel = mode === "cal" && cal ? cal.el : list;
+ wrap.classList.remove("is-above");
+ if (!panel || panel.hidden) return;
+ var box = wrap.getBoundingClientRect();
+ var need = panel.getBoundingClientRect().height;
+ if (need > window.innerHeight - box.bottom && box.top > need) {
+ wrap.classList.add("is-above");
+ }
+ }
+
+ function openList() {
+ if (cal) cal.close();
+ mode = "list";
list.hidden = false;
input.setAttribute("aria-expanded", "true");
+ input.setAttribute("aria-controls", listId);
+ if (pick) pick.setAttribute("aria-expanded", "false");
rebuild(input.value);
+ place();
}
- function close() {
+ function openCal() {
list.hidden = true;
- input.setAttribute("aria-expanded", "false");
- input.removeAttribute("aria-activedescendant");
active = -1;
+ input.removeAttribute("aria-activedescendant");
+ mode = "cal";
+ // Opened on what the text already says, so somebody who typed
+ // "next march" and then reached for the calendar arrives in
+ // March rather than in this month.
+ cal.open(previewOf(input.value));
+ input.setAttribute("aria-expanded", "true");
+ input.setAttribute("aria-controls", cal.el.id);
+ if (pick) pick.setAttribute("aria-expanded", "true");
+ place();
+ }
+
+ // A time field has no calendar, so its button opens the clock:
+ // every half hour of its own day, written in the field's own
+ // locale and scrolled to the one it is already showing. The same
+ // list element and the same keys as the suggestions — one popup,
+ // one Escape, one set of arrows.
+ function openSlots() {
+ var base = current() || new Date();
+ var day = midnight(base);
+ var want = base.getHours() * 60 + base.getMinutes();
+ var i, at, best = -1, near = 0, gap;
+ mode = "slots";
+ list.hidden = false;
+ list.textContent = "";
+ rows = [];
+ for (i = 0; i < 48; i++) {
+ at = new Date(day.getFullYear(), day.getMonth(), day.getDate(),
+ Math.floor(i / 2), (i % 2) * 30);
+ gap = Math.abs(at.getHours() * 60 + at.getMinutes() - want);
+ if (best < 0 || gap < best) { best = gap; near = rows.length; }
+ addRow(show(at), "", at, true, "", "rst-dtp-quick");
+ }
+ input.setAttribute("aria-expanded", "true");
+ input.setAttribute("aria-controls", listId);
+ if (pick) pick.setAttribute("aria-expanded", "true");
+ status.textContent = manyFmt.replace("{n}", String(rows.length));
+ setActive(near);
+ place();
+ // The nearest half hour put where the eye lands. The list's own
+ // scrollTop rather than scrollIntoView, which would also scroll
+ // the page out from under the field.
+ if (rows[near]) {
+ list.scrollTop = rows[near].li.offsetTop - Math.round(list.clientHeight / 2);
+ }
}
function commit(date, hasTime) {
+ // Ours, so the change listener below leaves the repaint alone:
+ // this path has already painted.
+ mine = true;
native.value = toWire(kind, date);
native.dispatchEvent(new Event("change", { bubbles: true }));
+ mine = false;
input.value = show(date);
- close();
+ closeAll();
if (onCommit) onCommit(date, hasTime);
}
@@ -1205,43 +1501,58 @@
// would with no script at all.
input.value = "";
if (native.value) {
+ mine = true;
native.value = "";
native.dispatchEvent(new Event("change", { bubbles: true }));
+ mine = false;
}
return;
}
- var read = parse(text, vocab, tbl, new Date(), ctx());
- if (read) commit(read.date, read.hasTime);
+ var reads = parseAll(text, vocab, tbl, new Date(), ctx());
+ if (reads.length) commit(reads[0].date, reads[0].hasTime);
else paint(); // unreadable text is not a value: put the old one back
}
input.addEventListener("focus", function () {
input.select();
- open();
+ if (!mode) openList();
+ });
+ input.addEventListener("click", function () {
+ if (!mode) openList();
+ });
+ input.addEventListener("input", function () {
+ // The live half of the brief: while the grid is up, the words
+ // drive it. Every keystroke re-reads the text and moves the
+ // calendar to what it now says, committing nothing and taking
+ // focus out of nothing.
+ if (mode === "cal" && cal) return cal.show(previewOf(input.value));
+ openList();
});
- input.addEventListener("click", open);
- input.addEventListener("input", function () { open(); });
input.addEventListener("keydown", function (e) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
- if (list.hidden) return open();
+ // Down out of the box is down into whatever is open: the
+ // grid takes focus, and the arrow keys become days.
+ if (mode === "cal" && cal) { cal.focusGrid(); break; }
+ if (!mode) { openList(); break; }
if (rows.length) setActive(Math.min(active + 1, rows.length - 1));
break;
case "ArrowUp":
e.preventDefault();
- if (list.hidden) return open();
+ if (mode === "cal" && cal) { cal.focusGrid(); break; }
+ if (!mode) { openList(); break; }
if (rows.length) setActive(Math.max(active - 1, 0));
break;
case "Enter":
e.preventDefault();
- if (!list.hidden && active >= 0) commit(rows[active].date, rows[active].hasTime);
- else { commitText(); close(); }
+ if (mode !== "cal" && !list.hidden && active >= 0) commit(rows[active].date, rows[active].hasTime);
+ else { commitText(); closeAll(); }
break;
case "Escape":
e.preventDefault();
paint();
- close();
+ closeAll();
break;
}
});
@@ -1260,15 +1571,46 @@
if (e.key === "Enter") e.preventDefault();
});
- wrap.addEventListener("focusout", function (e) {
- if (wrap.contains(e.relatedTarget)) return;
- commitText();
- close();
+ // Focus left the field, so commit what is in the box and shut
+ // the popup — but only if it really left.
+ //
+ // The check is deferred by a tick, and that tick is the whole
+ // point. Paging the calendar REBUILDS the grid, which removes the
+ // very cell that had focus; the browser fires focusout with a
+ // null relatedTarget on the way out and calendar.js puts focus on
+ // the new cell immediately afterwards. Reading relatedTarget
+ // alone, every arrow key inside the grid looked like a person
+ // walking away: the field committed whatever the box happened to
+ // say and slammed the panel shut under the cursor. A tick later
+ // the answer is not in doubt — document.activeElement is either
+ // back inside this field or it is not.
+ wrap.addEventListener("focusout", function () {
+ setTimeout(function () {
+ if (wrap.contains(document.activeElement)) return;
+ commitText();
+ closeAll();
+ }, 0);
});
if (pick) {
+ // The press must not blur the box: a blur commits the text,
+ // and not committing it yet is the whole reason the button is
+ // being pressed.
+ pick.addEventListener("mousedown", function (e) { e.preventDefault(); });
pick.addEventListener("click", function () {
- close();
+ if (mode === "cal" || mode === "slots") {
+ closeAll();
+ input.focus();
+ return;
+ }
+ input.focus();
+ if (cal) return openCal();
+ if (kind === "time") return openSlots();
+ // Neither a calendar nor a clock, which means calendar.js is
+ // not on the page. The browser's own panel is better than a
+ // button that does nothing, and this is the only line in
+ // this file that opens it.
+ closeAll();
try {
native.showPicker();
} catch (err) {
@@ -1276,9 +1618,22 @@
}
});
}
- // A pick made in the native panel lands in the carrier; repaint.
+
+ // The carrier changed underneath us: a pick in the browser's own
+ // panel, a range's start seeding its end, an app's own script.
+ // Repaint, so the box shows what the form will actually post.
+ //
+ // The guard here used to be "unless the combobox has focus",
+ // which silently swallowed the one case it most needed to catch.
+ // The native panel opens WITHOUT moving focus, so a date picked
+ // in it never reached the box and the field looked like it had
+ // ignored the selection. It now skips only our own commits, which
+ // have painted already, and a change that arrives while somebody
+ // is genuinely mid-word with a popup open.
native.addEventListener("change", function () {
- if (document.activeElement !== input) paint();
+ if (mine) return;
+ if (mode && document.activeElement === input) return;
+ paint();
});
paint();
@@ -1333,6 +1688,11 @@
// The pure half, for ui/datetime_node.mjs. A browser has no `module`
// and never takes this branch.
if (typeof module !== "undefined" && module && module.exports) {
- module.exports = { parse: parse, tables: tables, displayOptions: displayOptions };
+ module.exports = {
+ parse: parse,
+ parseAll: parseAll,
+ tables: tables,
+ displayOptions: displayOptions
+ };
}
})();
diff --git a/internal/fichas/static/fichas.css b/internal/fichas/static/fichas.css
index 8d59ea7..dae61c4 100644
--- a/internal/fichas/static/fichas.css
+++ b/internal/fichas/static/fichas.css
@@ -243,18 +243,3 @@
menus inside the list bar stay flex items of the bar. */
#list-filters { display: contents; }
-/* ---- No zoom on tap. iOS Safari zooms into any form control whose text
- is under 16px, and rastrillo's base size is 14px, so every tap into a
- field zoomed the page. The fix is the size, on touch screens only.
- `maximum-scale=1` in the viewport tag would also stop it — by switching
- off pinch-zoom for everyone, which is an accessibility failure. Every
- rastrillo app inherits the same 14px; this belongs upstream too. */
-@media (pointer: coarse) {
- /* :is() takes the specificity of its strongest argument (0,3,1) for all
- three. Written as a plain list, `select` and `textarea` were (0,0,1)
- and lost to rastrillo's [rst-textarea] and friends — the browser test
- caught them still at 14px. */
- :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) {
- font-size: max(16px, 1em);
- }
-}
diff --git a/internal/fichas/static/tokens.css b/internal/fichas/static/tokens.css
index 16a8809..10f6da5 100644
--- a/internal/fichas/static/tokens.css
+++ b/internal/fichas/static/tokens.css
@@ -62,8 +62,10 @@
Padding values are load-bearing for SC 2.5.8 Target Size (AA, new in
WCAG 2.2): pagination chips measure about 30px, row action pills
- about 27px and buttons about 34px on their smaller axis, all clear of
- the 24px minimum. Do not shrink them without re-measuring.
+ about 27px, and the button sizes about 28px (sm), 34px (default) and
+ 44px (lg) on their smaller axis, all clear of the 24px minimum. Do
+ not shrink them without re-measuring; the button block shows the
+ arithmetic.
Type sizes are rem, not px, so the whole scale tracks a reader who has
raised their browser's default font size. Spacing is rem for the same
@@ -73,6 +75,7 @@
/* ── Scale: type and spacing. Theme-independent. Radius is not here —
it is part of the theme's shape axis; see themes/. ────────────── */
:root {
+ --rst-fs-lg: 1.0625rem; /* 17px — the one step above base */
--rst-fs-base: 0.875rem; /* 14px at the browser default */
--rst-fs-sm: 0.78125rem; /* 12.5px */
--rst-fs-xs: 0.71875rem; /* 11.5px */
@@ -244,6 +247,56 @@ body {
filter: brightness(0.9);
}
+/* ── Button sizes ─────────────────────────────────────────────────────
+ Three steps, and they are a size scale rather than a set of variants:
+ size composes with primary/ghost/danger, so `rst-btn="primary lg"` is
+ the ordinary spelling of a form's submit.
+
+ The default step is the one the page-header action and the row pill
+ want — small, dense, a control beside other controls. It was the ONLY
+ step for a long time, and that is how a form's submit ended up
+ wearing it: a 34px, 12.5px-labelled button stretched across a 44rem
+ column, which reads as a skinny bar rather than the thing the whole
+ screen is asking you to press. A CTA is not a toolbar button; give it
+ `lg`.
+
+ Measured on the smaller axis, against SC 2.5.8's 24px floor — padding
+ both sides, plus the label's line box, plus the 1px border top and
+ bottom (the border is why the default step is 34px and not 32px). The
+ header comment carries these too; keep the two in step:
+ sm 0.3rem × 2 + 11.5px × 1.4 + 2 = 9.6 + 16.1 + 2 ≈ 28px
+ default 0.45rem × 2 + 12.5px × 1.4 + 2 = 14.4 + 17.5 + 2 ≈ 34px
+ lg 0.7rem × 2 + 14px × 1.4 + 2 = 22.4 + 19.6 + 2 ≈ 44px
+ sm is the tightest thing in the file that is still a button. It is
+ for a dense row of them, not for making an ordinary button smaller,
+ and there is deliberately nothing below it. */
+.rst-btn--sm, [rst-btn~="sm"] {
+ font-size: var(--rst-fs-xs);
+ padding: 0.3rem 0.55rem;
+}
+.rst-btn--lg, [rst-btn~="lg"] {
+ font-size: var(--rst-fs-base);
+ padding: 0.7rem 1.15rem;
+}
+
+/* block — a button that fills its container, for the one-action form
+ (sign in, sign up, a confirm page) where the submit IS the screen.
+ It centres its own label: .rst-btn sets no justify-content, so a
+ stretched button's text sits at the row's flex-start and reads as a
+ mistake rather than a choice.
+
+ Pair it with lg. A block button at the default size is precisely the
+ skinny bar described above — the width comes from the container and
+ the height does not follow, so the ratio goes wrong in proportion to
+ how wide the column is. Nothing here enforces that pairing, because a
+ narrow container is a legitimate place for a block button at the
+ default size; the wide ones are where it fails. */
+.rst-btn--block, [rst-btn~="block"] {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+}
+
/* busy — the loading state rastrillo.js gives every submit button on
its way out, and the one state of a button that is not a variant:
any .rst-btn can wear it. The spinner is a real child element
@@ -656,7 +709,7 @@ body {
document outline, not a paragraph dressed up as one. */
.rst-empty__title, [rst-empty-title] {
color: var(--rst-text);
- font-size: 1.0625rem;
+ font-size: var(--rst-fs-lg);
font-weight: 600;
margin: 0 0 var(--rst-sp-2);
}
@@ -733,12 +786,132 @@ body {
.rst-badge--negative, [rst-badge~="negative"] { background: var(--rst-tone-negative-bg); border-color: var(--rst-tone-negative-fg); color: var(--rst-tone-negative-fg); }
.rst-badge--neutral, [rst-badge~="neutral"] { background: var(--rst-tone-neutral-bg); border-color: var(--rst-tone-neutral-fg); color: var(--rst-tone-neutral-fg); }
-/* meter — 4px capacity bar; the number rides beside it as text. */
+/* meter — 4px capacity bar; the number rides beside it as text.
+
+ The bar is a native <meter> (spec §6-v2.4), and styling one is the
+ one place in this file where the same declaration has to be written
+ three times: once on the element, once for the WebKit/Blink
+ pseudo-elements, once for the Firefox one. They cannot share a rule.
+ A selector list containing a pseudo-element an engine does not know
+ is invalid AS A WHOLE in that engine, so ::-webkit-meter-bar and
+ ::-moz-meter-bar in one comma-separated rule would style nothing
+ anywhere. That is why the repetition below is not tidied up.
+
+ The two engines also disagree about which pseudo-element is which.
+ In WebKit/Blink ::-webkit-meter-bar is the TRACK and
+ ::-webkit-meter-optimum-value is the FILL; in Firefox the element
+ itself is the track and ::-moz-meter-bar is the FILL. Read the three
+ blocks with that in mind or they look contradictory.
+
+ HONESTLY FLAGGED: the -moz- rule is unverified. This project's
+ browser drive is Chromium, so the WebKit/Blink path below is measured
+ and the Firefox path is written from the spec and reasoned about. It
+ was shipped that way knowingly (Paul's call). If a Firefox drive ever
+ lands, this is the first rule to point it at.
+
+ The old markup was a <span> with an <i> in it sized by
+ --rst-meter-fill. That custom property is gone; an app that set it is
+ setting nothing now, which is why it is called out in the changelog
+ rather than only here. */
.rst-meter, [rst-meter] { align-items: center; color: var(--rst-text-muted); display: inline-flex; font-size: var(--rst-fs-xs); gap: var(--rst-sp-2); }
-.rst-meter__bar, [rst-meter-bar] { background: var(--rst-accent-soft); border-radius: 2px; flex: 1; height: 4px; min-width: 34px; overflow: hidden; }
-.rst-meter__bar i, [rst-meter-bar] i { background: var(--rst-accent); border-radius: 2px; display: block; height: 100%; width: var(--rst-meter-fill, 0%); }
+.rst-meter__bar, [rst-meter-bar] { appearance: none; background: var(--rst-accent-soft); block-size: 4px; border: 0; border-radius: 2px; flex: 1; min-inline-size: 34px; }
+.rst-meter__bar::-webkit-meter-inner-element, [rst-meter-bar]::-webkit-meter-inner-element { block-size: 100%; }
+.rst-meter__bar::-webkit-meter-bar, [rst-meter-bar]::-webkit-meter-bar { background: var(--rst-accent-soft); block-size: 100%; border: 0; border-radius: 2px; }
+.rst-meter__bar::-webkit-meter-optimum-value, [rst-meter-bar]::-webkit-meter-optimum-value { background: var(--rst-accent); border-radius: 2px; }
+.rst-meter__bar::-moz-meter-bar, [rst-meter-bar]::-moz-meter-bar { background: var(--rst-accent); border-radius: 2px; }
.rst-meter__num, [rst-meter-num] { font-variant-numeric: tabular-nums; white-space: nowrap; }
+/* stats — the instrument strip a dashboard or a report opens with: a
+ lead reading at headline size, companion readings beside it, divided
+ by hairlines inside one card.
+
+ One band, one cell, one lead variant. The headline stat and the
+ counts next to it are the same component at two sizes (spec
+ §6-v2.4), so there is no second kind to keep in step with this one.
+
+ flex-wrap and a flex-basis rather than a grid with a column count.
+ Any number of cells has to work — three on one dashboard, five on
+ the next — and a grid would need the caller to say how many. The
+ basis is what decides when the strip becomes two rows, and the lead
+ cell's is larger so it keeps the line to itself when the others
+ wrap under it.
+
+ The hairline is border-inline-start on every cell but the first,
+ which is what makes the strip read as ONE instrument rather than as
+ a row of cards. Logical, so it lands on the right in Arabic. A
+ wrapped cell keeps its line, and that is the one visual compromise
+ here: at two rows the first cell of the second row draws a leading
+ hairline against the card's edge. It is a hairline, and the
+ alternative — :nth-child arithmetic against a column count nobody
+ declared — is the grid this deliberately is not. */
+.rst-stats, [rst-stats] {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ display: flex;
+ flex-wrap: wrap;
+}
+.rst-stat, [rst-stat] {
+ border-inline-start: 1px solid var(--rst-line);
+ display: flex;
+ flex: 1 1 9rem;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-inline-size: 0;
+ padding: var(--rst-sp-4);
+}
+.rst-stat:first-child, [rst-stat]:first-child {
+ border-inline-start: 0;
+}
+.rst-stat--lead, [rst-stat~="lead"] {
+ flex-basis: 14rem;
+}
+/* The eyebrow. Uppercase and tracked, the same grammar every label in
+ this system uses above a value, so a band of cells reads as one row
+ of labels rather than as four headings. */
+.rst-stat__label, [rst-stat-label] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ font-weight: 650;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+/* tabular-nums so a column of readings lines up on its digits, and so
+ a number that changes on a poll does not jitter its neighbours. */
+.rst-stat__num, [rst-stat-num] {
+ font-size: 1.5rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 650;
+ line-height: 1.1;
+}
+.rst-stat--lead .rst-stat__num, [rst-stat~="lead"] [rst-stat-num] {
+ font-size: 2.1rem;
+}
+/* The delta is TEXT in a colour, not a colour. Its sign is written by
+ the caller and is the signal a reader who cannot tell the two hues
+ apart is reading; the colour is the second one (WCAG 2.2 AA 1.4.1).
+ Both hues are held at 4.5:1 against this card by the contrast gate
+ in the same commit that added them — they are text, so the floor
+ applies whether or not the colour carries meaning of its own. */
+.rst-stat__delta, [rst-stat-delta] {
+ font-size: var(--rst-fs-sm);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+.rst-stat__delta[data-tone="positive"], [rst-stat-delta][rst-tone~="positive"] {
+ color: var(--rst-tone-positive-fg);
+}
+.rst-stat__delta[data-tone="negative"], [rst-stat-delta][rst-tone~="negative"] {
+ color: var(--rst-tone-negative-fg);
+}
+.rst-stat__delta[data-tone="neutral"], [rst-stat-delta][rst-tone~="neutral"] {
+ color: var(--rst-text-muted);
+}
+.rst-stat__note, [rst-stat-note] {
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+}
+
/* person — avatar + name + email; avatar is decoration, text is identity. */
.rst-person, [rst-person] { align-items: center; color: inherit; display: inline-flex; gap: 0.7rem; min-width: 0; text-decoration: none; }
.rst-person__av, [rst-person-av] { align-items: center; background: var(--rst-accent); border-radius: 50%; color: var(--rst-on-accent); display: flex; flex: none; font-size: 0.6875rem; font-weight: 600; height: 28px; justify-content: center; width: 28px; }
@@ -1039,6 +1212,17 @@ details[open] > summary > .rst-caret, details[open] > summary > [rst-caret] { tr
and it is wide enough for the postcodes and codes it is for. The
max keeps it from overflowing a column narrower than itself. */
.rst-input--short, [rst-input~="short"] { inline-size: 8rem; max-inline-size: 100%; }
+/* primary is the form's headline field — the title or name the record is
+ known by — and the size is the whole signal, because the label above it
+ stays the same quiet line every other field gets. font-size has to be
+ restated rather than inherited: .rst-input sets `font: inherit`, so the
+ shorthand would otherwise reset it back to the body's 14px. 550 is the
+ weight .rst-nm and .rst-person__name already give a name; 600 reads as a
+ heading, which this is not. The padding grows with the type so the box
+ keeps its proportions instead of looking like a squeezed plain input.
+ 17px is also where iOS Safari stops zooming a focused input, which the
+ 14px base does not clear — a real bonus, not the reason. */
+.rst-input--primary, [rst-input~="primary"] { font-size: var(--rst-fs-lg); font-weight: 550; padding: 0.6rem 0.7rem; }
textarea.rst-input, textarea[rst-input] { min-height: 5rem; resize: vertical; }
/* field-text/field-textarea (the interim pair the manifest generator
emits) mark a required label with * and put the textarea on its own
@@ -1288,6 +1472,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
animation: rst-spin 0.8s linear infinite;
}
@keyframes rst-spin { to { transform: rotate(1turn); } }
+
+/* A running job's determinate bar: a native <progress>, styled the same
+ way and for the same reasons as [rst-meter-bar] — see that block for
+ why the three engines need three rules that cannot be merged, and for
+ the same honest flag about the -moz- one being unverified here.
+ <progress>'s pseudo-elements are its own: ::-webkit-progress-bar is
+ the track, ::-webkit-progress-value the fill, ::-moz-progress-bar the
+ fill. It is a block on its own line under the sentence, because it
+ measures the job the sentence is about. */
+.rst-job__bar, [rst-job-bar] { appearance: none; background: var(--rst-accent-soft); block-size: 4px; border: 0; border-radius: 2px; display: block; inline-size: 100%; margin-block-start: var(--rst-sp-2); }
+.rst-job__bar::-webkit-progress-bar, [rst-job-bar]::-webkit-progress-bar { background: var(--rst-accent-soft); border-radius: 2px; }
+.rst-job__bar::-webkit-progress-value, [rst-job-bar]::-webkit-progress-value { background: var(--rst-accent); border-radius: 2px; }
+.rst-job__bar::-moz-progress-bar, [rst-job-bar]::-moz-progress-bar { background: var(--rst-accent); border-radius: 2px; }
@media (prefers-reduced-motion: reduce) {
.rst-spin, [rst-spin] { animation: none; opacity: 0.5; }
}
@@ -1343,6 +1540,72 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-muted); }
.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
+/* The popup opens above the field where there is no room below it —
+ datetime.js measures and sets .is-above, because a panel hanging off
+ the bottom of the window is a panel whose last week nobody can click.
+ One rule for both popups: only ever one of them is open. */
+.rst-dtp.is-above .rst-dtp__list, [rst-dtp].is-above [rst-dtp-list], .rst-dtp.is-above .rst-cal, [rst-dtp].is-above [rst-cal] { inset-block-end: 100%; margin-block-end: 4px; margin-block-start: 0; top: auto; }
+/* A completion offered rather than chosen — the other months a
+ half-typed word could still have meant. Lighter than the reading
+ above it, because it is a second-best by construction. */
+.rst-dtp__row--alt .rst-dtp__label, [rst-dtp-row~="alt"] [rst-dtp-label] { color: var(--rst-text-muted); }
+
+/* ── The calendar overlay (ui/calendar.js): the month grid a date
+ field's button opens. A real <table> with real column headers,
+ because that is what a calendar is, with role="grid" over the top
+ and a roving tabindex so exactly one day sits in the tab order.
+
+ Sized in rem so the grid keeps its shape when a page is zoomed:
+ seven columns of text, not seven columns of pixels. A fixed inline
+ size rather than the field's, because a calendar is a calendar
+ whatever it hangs under — a month stretched across a wide input is
+ a grid nobody can aim at.
+
+ Everything here is logical (inline/block, never left/right), so a
+ right-to-left page mirrors the panel and the grid together. The
+ chevrons are the one thing that needs saying twice, below.
+ ───────────────────────────────────────────────────────────────── */
+.rst-cal, [rst-cal] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inline-size: 18rem; inset-inline-start: 0; margin-block-start: 4px; padding: var(--rst-sp-2); position: absolute; top: 100%; z-index: 40; }
+.rst-cal__head, [rst-cal-head] { align-items: center; display: flex; gap: var(--rst-sp-1); justify-content: space-between; padding-block-end: var(--rst-sp-2); }
+.rst-cal__title, [rst-cal-title] { flex: 1; font-size: var(--rst-fs-sm); font-weight: 600; text-align: center; }
+.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
+.rst-cal__nav:hover:not(:disabled), [rst-cal-nav]:hover:not(:disabled) { background: var(--rst-accent-soft); color: var(--rst-text); }
+/* A month with nothing pickable in it — past the field's min, or past
+ its max. Dimmed and inert rather than removed, so the header keeps
+ its shape and the title stays where the eye left it. */
+.rst-cal__nav:disabled, [rst-cal-nav]:disabled { cursor: default; opacity: 0.35; }
+.rst-cal__nav svg, [rst-cal-nav] svg { block-size: 1.1rem; inline-size: 1.1rem; }
+/* One chevron, drawn once and turned round for the other direction, so
+ "back" always points at the start of the line — which is the other
+ end of the box in a right-to-left page, and handled there by the
+ panel's own direction rather than a second rule. */
+[rst-cal-nav~="next"] svg, .rst-cal__nav--next svg { transform: scaleX(-1); }
+.rst-cal__grid, [rst-cal-grid] { border-collapse: collapse; inline-size: 100%; table-layout: fixed; }
+.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
+.rst-cal__day, [rst-cal-day] { border-radius: var(--rst-radius-sm); cursor: pointer; font-size: var(--rst-fs-sm); font-variant-numeric: tabular-nums; line-height: 2.15; text-align: center; }
+.rst-cal__day:hover, [rst-cal-day]:hover { background: var(--rst-accent-soft); }
+/* The days either side of the month. Still pickable — reaching for the
+ 1st from the row above it is how people use a calendar — but plainly
+ not part of the month on screen. */
+.rst-cal__day.is-outside, [rst-cal-day].is-outside { color: var(--rst-text-faint); }
+/* Today is a ring rather than a fill, so it can still be read when the
+ selection is sitting on top of it. */
+.rst-cal__day.is-today, [rst-cal-day].is-today { box-shadow: inset 0 0 0 1px var(--rst-line-strong); font-weight: 600; }
+/* What the text in the box currently reads as: an outline that follows
+ the words as they are typed. Deliberately quieter than the selection
+ — it is a preview, and nothing has been chosen yet. */
+.rst-cal__day.is-preview, [rst-cal-day].is-preview { box-shadow: inset 0 0 0 2px var(--rst-accent); }
+/* The committed value. aria-selected says so in the accessibility tree;
+ this is the matching visual, never the only signal. */
+.rst-cal__day.is-selected, [rst-cal-day].is-selected { background: var(--rst-accent); color: var(--rst-on-accent); font-weight: 600; }
+.rst-cal__day.is-selected.is-outside, [rst-cal-day].is-selected.is-outside { color: var(--rst-on-accent); }
+/* Outside the field's own min/max. aria-disabled carries it to a screen
+ reader and calendar.js refuses the pick; this says the same to the
+ eye. */
+.rst-cal__day.is-disabled, [rst-cal-day].is-disabled { color: var(--rst-text-faint); cursor: default; text-decoration: line-through; }
+.rst-cal__day.is-disabled:hover, [rst-cal-day].is-disabled:hover { background: none; }
+.rst-cal__day:focus-visible, [rst-cal-day]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+
/* ── Shells: the page frames an app's layout template wraps .rst-page
in — topbar and sidebar. Written in logical properties throughout,
so a right-to-left locale gets a mirrored shell for free: the rail
@@ -1618,6 +1881,40 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-error__cta, [rst-error-cta] { display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); justify-content: center; margin-block: var(--rst-sp-5) 0; }
.rst-error__ref, [rst-error-ref] { color: var(--rst-text-faint); margin-block: 2rem 0; }
+/* ---------------------------------------------------------------------
+ Touch: the two zooms nobody asked for.
+
+ Mobile Safari zooms the whole page when it focuses a form control
+ whose text is under 16px, and this file's base type is 14px on
+ purpose. So every text-entry control comes back up to 16px where the
+ pointer is coarse, and stays 14px under a mouse. max() rather than a
+ flat 1rem, so a control deliberately set larger keeps its size; rem
+ rather than px, so a reader who raised the browser default keeps the
+ increase. This is the fix. The other one — user-scalable=no in the
+ viewport tag — buys the same quiet by taking pinch-zoom away from
+ everyone, which is a WCAG 1.4.4 failure and not on offer here.
+
+ Double-tap is the second zoom: tap a button or a row twice in quick
+ succession and the second tap lands as a zoom gesture rather than a
+ click. touch-action: manipulation drops that gesture on interactive
+ elements only, so panning and pinch-zoom are untouched, and so is
+ double-tapping prose or an image, which is a real way to read a
+ narrow column. :where() weighs nothing, so any rule can override it.
+ --------------------------------------------------------------------- */
+
+@media (pointer: coarse) {
+ input, select, textarea,
+ .rst-input, [rst-input],
+ .rst-textarea, [rst-textarea],
+ .rst-search input[type="search"], [rst-search] input[type="search"] {
+ font-size: max(1rem, 1em);
+ }
+}
+
+:where(a[href], button, input, select, textarea, label, summary, [role="button"], [role="tab"], [role="option"]) {
+ touch-action: manipulation;
+}
+
/* ---------------------------------------------------------------------
Utilities, last on purpose.
diff --git a/internal/fichas/templates/layout.html b/internal/fichas/templates/layout.html
index 8e641bb..2b86bfd 100644
--- a/internal/fichas/templates/layout.html
+++ b/internal/fichas/templates/layout.html
@@ -12,10 +12,6 @@
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
-{{/* fichas.css here too, not only in the app shell: it carries the
- no-zoom-on-tap rule, and the sign-in field is the one every visit
- starts by tapping. TestNoZoomOnTap covers both layouts. */}}
-<link rel="stylesheet" href="{{asset "static/fichas.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
<script defer src="{{asset "static/busy.js"}}"></script>
{{iconAssets}}
diff --git a/internal/fichastest/browser_test.go b/internal/fichastest/browser_test.go
index a732076..90600ff 100644
--- a/internal/fichastest/browser_test.go
+++ b/internal/fichastest/browser_test.go
@@ -27,8 +27,8 @@ import (
"testing"
"time"
- "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/harness"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/harness"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/chromedp"
@@ -197,8 +197,25 @@ func TestNoZoomOnTap(t *testing.T) {
for _, page := range []struct{ path, ready string }{
{"/cards/new", `input[name="f_name"]`},
{"/signin", `input[name="address"]`},
+ // The list's search, and the type designer's forms.
+ {"/", `input[name="q"]`},
+ {"/cards/1", `textarea`},
+ {"/cards/1/edit", `input[name="f_name"]`},
+ {"/t/t_customer/fields/new", `textarea[name="options"]`},
+ {"/types/new", `input[name="label"]`},
} {
- rig := browserApp(t, nil)
+ // One card, so the list shows its search bar and there is a card
+ // to log a call against and to edit.
+ rig := browserApp(t, func(ctx context.Context, d *db.DB, cfg fichas.Config) {
+ m, err := fichas.EnsureMember(ctx, d.G, cfg.Sealer, TestOwner)
+ if err != nil {
+ t.Fatalf("member: %v", err)
+ }
+ if _, err := fichas.CreateCard(ctx, d.G, cfg.Sealer, fichas.TypeCustomer, fichas.Person(m.ID),
+ map[string]string{fichas.FieldName: "Brightwater Labs"}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ })
var smallest float64
var coarse bool
rig.Run(
diff --git a/internal/fichastest/gates_test.go b/internal/fichastest/gates_test.go
index 8a77913..502aee3 100644
--- a/internal/fichastest/gates_test.go
+++ b/internal/fichastest/gates_test.go
@@ -10,7 +10,7 @@ import (
"testing"
fichas "amadan.net/oficina/fichas/internal/fichas"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/db"
)
// canaries are plaintexts the app seals. None may appear in any file it
diff --git a/internal/fichastest/harness_test.go b/internal/fichastest/harness_test.go
index 60e2ecd..2e6092a 100644
--- a/internal/fichastest/harness_test.go
+++ b/internal/fichastest/harness_test.go
@@ -17,7 +17,7 @@ import (
"strings"
"testing"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/db"
fichas "amadan.net/oficina/fichas/internal/fichas"
"amadan.net/oficina/fichas/internal/fichas/seal"
diff --git a/internal/fichastest/rastrillo_path_test.go b/internal/fichastest/rastrillo_path_test.go
new file mode 100644
index 0000000..dc99470
--- /dev/null
+++ b/internal/fichastest/rastrillo_path_test.go
@@ -0,0 +1,111 @@
+package fichastest
+
+import (
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// preMoveRastrillo is rastrillo's module path before it moved to
+// amadan.net/rastrillo/rastrillo (2026-08-31). Nothing after v0.23.0 was
+// released under it, so code importing it gets no rastrillo fix — and the
+// vendored-asset test cannot tell, because it compares against the same
+// frozen ui. That is how Fichas kept iOS zooming on every tap until it
+// carried its own copy of the fix (polish audit r3, S13).
+const preMoveRastrillo = "github.com/carlosframework/rastrillo"
+
+// rastrilloPath is where it lives now.
+const rastrilloPath = "amadan.net/rastrillo/rastrillo"
+
+// repoRoot is the module root, two levels above this package.
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ root, err := filepath.Abs(filepath.Join("..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
+ t.Fatalf("no go.mod at %s: %v", root, err)
+ }
+ return root
+}
+
+// goImports is every import of every .go file under root, by file.
+func goImports(t *testing.T, root string) map[string][]string {
+ t.Helper()
+ out := map[string][]string{}
+ fset := token.NewFileSet()
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() && (d.Name() == ".git" || d.Name() == "releases") {
+ return filepath.SkipDir
+ }
+ if d.IsDir() || !strings.HasSuffix(path, ".go") {
+ return nil
+ }
+ f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(root, path)
+ for _, imp := range f.Imports {
+ p, _ := strconv.Unquote(imp.Path.Value)
+ out[rel] = append(out[rel], p)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// TestNoPreMoveRastrillo fails on any import of rastrillo's frozen
+// pre-move path, test files, scripts and browser-tagged files included.
+func TestNoPreMoveRastrillo(t *testing.T) {
+ for file, imps := range goImports(t, repoRoot(t)) {
+ for _, imp := range imps {
+ if imp == preMoveRastrillo || strings.HasPrefix(imp, preMoveRastrillo+"/") {
+ t.Errorf("%s imports %q, rastrillo's frozen pre-move path; use %s", file, imp, rastrilloPath)
+ }
+ }
+ }
+}
+
+// TestReleaseStampsTheServedVersion: `make release` stamps BuildVersion
+// with -X, and the Go linker ignores an -X naming a package the binary
+// does not contain — no error, no warning. Stamp the old path while the
+// binary serves the new one and /api/version answers "dev" for every
+// release, which is exactly the answer the platform's deploy check
+// cannot tell from a binary that was never recycled. The stamped package
+// must be the rastrillo cmd/fichas serves through.
+func TestReleaseStampsTheServedVersion(t *testing.T) {
+ root := repoRoot(t)
+ mk, err := os.ReadFile(filepath.Join(root, "Makefile"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := regexp.MustCompile(`-X '?([^'=\s]+)\.BuildVersion=`).FindSubmatch(mk)
+ if m == nil {
+ t.Fatal("the Makefile's release does not stamp BuildVersion with -X")
+ }
+ stamped := string(m[1])
+ if stamped != rastrilloPath {
+ t.Errorf("make release stamps %s.BuildVersion; the served version is %s.BuildVersion", stamped, rastrilloPath)
+ }
+ served := false
+ for _, imp := range goImports(t, root)[filepath.Join("cmd", "fichas", "main.go")] {
+ served = served || imp == stamped
+ }
+ if !served {
+ t.Errorf("cmd/fichas does not import %s, so -X %s.BuildVersion stamps nothing", stamped, stamped)
+ }
+}
diff --git a/internal/fichastest/signin_flow_test.go b/internal/fichastest/signin_flow_test.go
index f493850..486a37b 100644
--- a/internal/fichastest/signin_flow_test.go
+++ b/internal/fichastest/signin_flow_test.go
@@ -14,7 +14,7 @@ import (
"testing"
fichas "amadan.net/oficina/fichas/internal/fichas"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/db"
)
// capturingMailer records what the app sends instead of sending it.
diff --git a/internal/fichastest/signin_test.go b/internal/fichastest/signin_test.go
index fcb4736..9170460 100644
--- a/internal/fichastest/signin_test.go
+++ b/internal/fichastest/signin_test.go
@@ -11,8 +11,8 @@ import (
"testing"
fichas "amadan.net/oficina/fichas/internal/fichas"
- "github.com/carlosframework/rastrillo"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
)
// productionApp wires the app the way main.go does — no test identity, no
diff --git a/internal/fichastest/vendored_test.go b/internal/fichastest/vendored_test.go
index 792bc19..71f4f97 100644
--- a/internal/fichastest/vendored_test.go
+++ b/internal/fichastest/vendored_test.go
@@ -8,9 +8,11 @@ import (
"bytes"
"os"
"path/filepath"
+ "regexp"
+ "strings"
"testing"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo/ui"
)
// vendoredTheme is the theme this app was scaffolded with. Change it
@@ -30,6 +32,11 @@ var vendoredIsMine = map[string]bool{
// Calma with Fichas' accent, from estilo (estilo AGENTS.md, decisions
// table, row 8). Pinned to estilo instead, by TestThemeIsEstilosForFichas.
"theme.css": true,
+ // Deliberately absent. datetime.js opens rastrillo's calendar overlay
+ // when calendar.js is on the page and the browser's own picker when it
+ // is not — which is what Fichas has always had. Adopting the overlay
+ // is a visible change of its own, not part of moving rastrillo's path.
+ "calendar.js": true,
}
// TestThemeIsEstilosForFichas pins static/theme.css to the estilo this app
@@ -118,3 +125,34 @@ func TestDoorFilesAreEstilos(t *testing.T) {
}
}
}
+
+// scriptSrc is every <script src> on a page.
+var scriptSrc = regexp.MustCompile(`<script[^>]*\ssrc="([^"]+)"`)
+
+// TestNoOldBusyRuleIsServed: the shim rastrillo ships still carries the
+// old busy rule inside it, and loaded beside estilo's busy.js the two
+// rules fight over the same button. `rastrillo doctor --fix --force`, or
+// a hand re-copy after an upgrade, would put it back without a word, so
+// this reads what the pages actually link and the server actually serves
+// — both shells — rather than the files on disk.
+func TestNoOldBusyRuleIsServed(t *testing.T) {
+ h := newApp(t)
+ for _, page := range []string{"/", "/signin"} {
+ srcs := scriptSrc.FindAllStringSubmatch(get(t, h, page).Body.String(), -1)
+ var busyJS int
+ for _, m := range srcs {
+ if strings.Contains(m[1], "/busy.") {
+ busyJS++
+ continue
+ }
+ // busy.js is the rule; any other script carrying it is a second copy.
+ body := get(t, h, m[1]).Body.String()
+ if strings.Contains(body, "function busySubmit") {
+ t.Errorf("%s links %s, which carries the old busy rule; serve estilo's shim (go run ./scripts/writeestilo)", page, m[1])
+ }
+ }
+ if busyJS != 1 {
+ t.Errorf("%s links busy.js %d times, want once", page, busyJS)
+ }
+ }
+}
diff --git a/scripts/mockups/main.go b/scripts/mockups/main.go
index 4abd0e2..ffebaf5 100644
--- a/scripts/mockups/main.go
+++ b/scripts/mockups/main.go
@@ -27,7 +27,7 @@ import (
"sort"
"strings"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo/ui"
"amadan.net/oficina/fichas/internal/fichas/icons"
)
diff --git a/scripts/preview/main.go b/scripts/preview/main.go
index c482dc5..f9aa747 100644
--- a/scripts/preview/main.go
+++ b/scripts/preview/main.go
@@ -23,7 +23,7 @@ import (
"strconv"
fichas "amadan.net/oficina/fichas/internal/fichas"
- "github.com/carlosframework/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/db"
)
const (