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>
24 files changed,
+942
−118
AGENTS.md+14 −0Makefile+5 −2cmd/fichas/main.go+3 −3go.mod+3 −2go.sum+2 −0internal/fichas/app.go+4 −4internal/fichas/auth.go+2 −2internal/fichas/catalogue_test.go+2 −2internal/fichas/handlers.go+2 −2internal/fichas/migrations.go+3 −3internal/fichas/render.go+2 −2internal/fichas/static/datetime.js+420 −60internal/fichas/static/fichas.css+0 −15internal/fichas/static/tokens.css+303 −6internal/fichas/templates/layout.html+0 −4internal/fichastest/browser_test.go+20 −3internal/fichastest/gates_test.go+1 −1internal/fichastest/harness_test.go+1 −1internal/fichastest/rastrillo_path_test.go+111 −0internal/fichastest/signin_flow_test.go+1 −1internal/fichastest/signin_test.go+2 −2internal/fichastest/vendored_test.go+39 −1scripts/mockups/main.go+1 −1scripts/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 ( |