Repository navigation
feat(web): give display figures an optical size with Inter's opsz axis - #948
Conversation
Closes #835 in part; the rest of the issue's premise no longer holds. The default `@fontsource-variable/inter` entry loads seven `-wght-` faces, which pin every size to Inter's opsz 14 text cut, so `font-optical-sizing` on them is inert. Measured in Chromium: "Recorded 1,248 eggs" at 32px is 331px wide at opsz 14 and 310px at opsz 32, and 331px at both on the wght face. Loading the `opsz.css` entry is the whole behaviour change, because `font-optical-sizing: auto` is the CSS initial value. The full entry costs 118.9 KiB of precache and lands at 1,979.79 KiB, 79.79 over #825's ceiling. The five Inter subsets no locale renders are dropped from the PRECACHE instead, not from the build: they still ship in dist and are still fetched on demand by unicode-range. 1,848.93 KiB, 11.96 below main.
Comments in main.tsx and vite.config.ts trimmed to the measurement a reader cannot derive. Dropped opticalSize.test.ts's last block: its serif assertion duplicates styles.test.ts, and pinning five literal figure sizes would fail on any legitimate restyle with no defect behind it.
The first version measured a stat figure's width. Every figure this slice is for carries font-variant-numeric: tabular-nums, which fixes each digit to one advance by design, so the two cuts are REQUIRED to render the same width: the check passed at ~1px of noise whichever face was loaded. It now probes letters at 32px and 14px and separately asserts the app's own display text resolves to that same family, so the proof applies to the screen.
After framesAfter frames captured at c882c91. Same stack recipe ( DashboardHistory (a Field Console ledger)Flocks (a setup list)Daily entryExpenses (the largest Inter figure in the app)Login |
What actually changed, enlargedAt page scale the frames above look identical, which is the correct result for a change of letterform
Daily entry at 390 moves most because it carries the most Inter figures. Login moves least because its Crops from those same 1:1 frames, magnified with no smoothing. The |
verify-sw.test.mjs's minimal fixture didn't include the Inter latin/ latin-ext opsz faces #835 added as check 5, so "passes and reports headroom..." failed CI. Extend the fixture with zero-byte stubs for both faces (and, ahead of the next commit, a matching runtime-caching route) so the passing case satisfies every current check while the failing (over-ceiling) case keeps failing for the size reason alone.
…sized #948 review round 1 (Codex gpt-6-sol), P2: #835's opsz.css import left font-optical-sizing at its CSS default (auto) everywhere, and auto varies CONTINUOUSLY with an element's real font-size — so MUI body1 and TableCell at their real 16px on phones drifted toward the display cut too, not just the six intended stat figures. Pin font-variation-settings: "opsz" 14 once, globally, at styles.css's :root (inherited by everything), and clear that pin with an explicit "normal" on exactly the six stat figures, handing them back to automatic, size-driven sizing. Updates the two tests that assumed no opsz declaration would ever exist: opticalSize.test.ts's "never pins opsz" case now allows the one root pin and still rejects a pin anywhere else; the Playwright spec's body assertion was a detached 14px probe that never rendered anything MUI actually ships, replaced with a real-page check (real display figure vs. real ~16px body text at phone width), shown failing before this fix (0fd0b97) and passing after.
#948 review round 1 (Codex gpt-6-sol), P2: #835's globIgnores dropped Cyrillic/Cyrillic-ext/Greek/Greek-ext/Vietnamese from the precache with no replacement, so a farm or customer name in one of those scripts had no offline path once the browser's own HTTP cache aged out. Add a narrow CacheFirst runtime-caching route keyed to exactly those five woff2 filenames (by name, not just extension, so it can never match /api or /health). verify-sw.mjs's check 2 now allows exactly one runtime-caching route and asserts, against the real emitted worker, that it's CacheFirst, matches all five dropped subsets, matches neither already-precached Latin subset, and cannot answer any of the denylist's own /api or /health probe paths — mutation-tested by removing the route, widening its pattern to match everything, and swapping its strategy, all three shown red before this commit and green after. Precache stays 1849.11 KiB, 50.89 KiB under the #825 ceiling — the route adds a service-worker rule, not precached bytes.
|
Fixed all three findings; head is now CI fix —
|
…t requests #948 review round 2 (Codex gpt-6-sol), P2: the Inter extended-subset CacheFirst route's RegExp urlPattern is tested by Workbox against the request's FULL href, unanchored — so it also matched /api/v1/x/inter-greek-opsz-normal-deadbeef.woff2, a same-origin path that merely ends with the right suffix. verify-sw.mjs's check 2 only probed bare denylist paths (/api, /health, ...), never one with a font-like suffix appended, so it never exercised the actual gap. Replace the regex with a match-callback function (font-cache-match.mjs's matchesInterExtendedSubsetFont, a plain dependency-free module both vite.config.ts and the test import directly): same-origin, url.pathname starting with /assets/, ending in .woff2, and request.destination === "font" — a signal a regex cannot see at all. verify-sw.mjs's check 2 now requires the runtime route to be a match-callback function (a RegExp fails outright) and statically verifies the function's source carries all four guards, keyed on substrings that survive minification (destructured parameter *property* names and member-expression/string-literal text, never the renamed local bindings). Confirmed the old d33e1f5 regex itself matches the vulnerable path, and that the old verify-sw.mjs (checked out at d33e1f5) passed a fixture built from that exact regex — the gap was real. The new guard rejects it outright ("rejects a RegExp runtime-caching route" test), and three more mutations (dropping the /assets/ prefix, dropping the destination check, restoring the d33e1f5 regex via a real vite build) all shown red before green. matchesInterExtendedSubsetFont also gets direct behavioral tests (never eval'd text, a real function import) proving the review's exact case: a font-suffixed API path is rejected, the real asset path is accepted, plus cross-origin and non-font-destination rejection. Precache unchanged: 1849.11 KiB, 50.89 KiB under the #825 ceiling — a route matcher, not precached bytes.
#948 review round 2 (Codex gpt-6-sol), P3: the amendment claimed no font-optical-sizing/font-variation-settings declaration existed anywhere and that opticalSize.test.ts would fail if one ever appeared — true when #835 first landed, false since #948's own P2 fix pinned font-variation-settings: "opsz" 14 at styles.css's :root. A maintainer following the old text could "fix" the doc's claimed invariant by removing that pin and reopening the 16px phone-body regression. Add an amendment describing the root text-cut pin, the stat-figure overrides that clear it, and what opticalSize.test.ts now actually enforces (exactly one numeric pin, at the root; nothing else pins a value). dotnet test tests/Cluckwork.Application.Tests --filter "FullyQualifiedName~ImagePin|FullyQualifiedName~RealTree" run before committing: 14/14 passed.
|
Fixed both round 2 findings; head is now Codex gpt-6-sol round 2, P2 —
|
|
The review loop stops here by owner decision (2026-09-24). The PR ships at Codex gpt-6-sol round 3 at
The owner judged more tests not worth adding for these. |

















































A slice of epic #674, milestone "SPA revamp", inherited from #656.
Closes #835
Component plan.
docs/designs/822-mui-revamp.mddelivery row 13, "#835 opsz", whose design is D7.2.Two of this issue's three premises were false, and both were measured
The face. The issue's 2026-09-14 amendment is right and this PR confirms it by measurement rather than by reading CSS.
@fontsource-variable/inter's default entry loads seven-wght-faces. In Chromium, "Recorded 1,248 eggs" at 32px is 331px wide at opsz 14 and 310px at opsz 32 on theopszface, and 331px at both on thewghtone. The axis was not un-tuned, it was absent.font-optical-sizing: autois the CSS initial value, so swapping the import is the entire behaviour change: a 32px figure gets the display cut, a 14px row keeps the text cut, and no declaration is added anywhere.The targets. The issue asks for
opszonh1,h2and the stat figures.h1,h2and every Dashboard, Sales, Reports, Inventory and Stock figure areGeorgia, serifsince #864, pinned byfarmTheme.policy.test.ts's "uses serif page headings" case, and Georgia has no variable axes. That half of the issue has no target left. What the axis does reach is the six figures still set in--font: Daily Entry's grade figure (1.375rem) and readout value (1.6rem), the entry chip figure (1.35rem), History's sellable figure (1.5rem), and the Expenses period total (2rem, the largest Inter text in the app).The budget. D7.2 priced the swap at +118.9 KiB against a projected 1,806 KiB baseline, and named a fallback of "one display cut for
h1/h2only" if it did not fit. Baseline one0c1f68is actually 1,860.89 KiB, and that fallback is unbuildable for the reason above. So neither the cost nor the escape hatch held.Precache, measured by building each option, not by arithmetic
mainmain(wght, 7 subsets)opsz.css, 7 subsetsopszlatin + latin-ext,wghtfor the restopszfacesopsz.css, five subsets out of the PRECACHEThe shipped option keeps every subset in
dist. The UI is en/es/tl, all inside latin and latin-ext, soglobIgnoresdrops the offline precopy of the other five;unicode-rangestill fetches them on demand for a Cyrillic or Greek farm or customer name. The two rejected latin-only rows save more and lose those glyphs even online. The ceiling was not touched. This slice pays for its own axis and returns 11.96 KiB.Evidence
Before/after 1:1 frames are attached as comments. At page scale the change is correctly undramatic: 0.14% to 2.34% of pixels differ across Dashboard, Expenses, History, Daily entry and Login. Magnified, the 32px figure is visibly a finer, tighter cut and body copy is unchanged. Note the figures carry
font-variant-numeric: tabular-nums, so their width cannot change by design — only the cut does.Tests
webunit (files)webunit (tests)Full quick suite at head against an isolated stack: 97 passed, 1 skipped, 0 failed, both projects.
npm run typecheckclean inweb/andtools/simulation/ui/.dotnet test --filter "ImagePin|RealTree"14/14.Mutation checks, every one shown red before it was claimed
main.tsxback to the default entryloads Inter faces that carry the opsz axis, and no wght-only facemain.tsxtowght.cssexplicitlyglobIgnoreswidened to**/inter-*.woff2drops only subsets outside the app's localeslatin-extadded toglobIgnoresnot.toMatch(/\blatin\b/)arm)font-optical-sizing: noneon:rootnever turns optical sizing offfont-variation-settings: "opsz" 32on:rootnever pins opsz to one value for every sizeverify-sw.mjschecks 5a and 5b, naming the two wght faces found in the precache32px text renders identically with optical sizing on and offThat last one is not a hand-edited file. It is the same spec against two real builds on two isolated stacks, green on the
opszbuild and red on thewghtone.One guard rewritten because it proved nothing
The browser spec first measured a stat figure's width. Every figure this slice is for carries
tabular-nums, which fixes each digit to one advance by design, so the two cuts are required to render the same width. It passed at ~1px of noise whichever face was loaded. It now probes letters at 32px and 14px and separately asserts the app's own display text resolves to that same family, so the proof applies to the screen.Deletions
No CSS class, component or i18n key was deleted, so no repo-wide grep was needed. One test block was dropped in the deslop commit: its serif assertion duplicated
styles.test.ts:26, and pinning five literal figure sizes would have failed on any legitimate restyle with no defect behind it.Docs
docs/designs/822-mui-revamp.mdD7.2 carries an amendment recording which of the five options was chosen and why, since that paragraph says the doc records it.web/scripts/verify-sw.mjs's ceiling comment is corrected: it still projected "+118.9 KiB, ~1,907 KiB, over by ~7" and named the unbuildableh1/h2fallback. No GLOSSARY or Help change: #835 adds no concept and renames no control.