Skip to content

ui: adobe's own font files, visible button edges, and a refresh that keeps contrast - #48

Merged
cemhurturk merged 13 commits into
mainfrom
fix/interface-fonts-contrast
Sep 26, 2026
Merged

cemhurturk merged 13 commits into
mainfrom
fix/interface-fonts-contrast

Conversation

@cemhurturk

Copy link
Copy Markdown
Contributor

This replaces the interface's subset fonts with Adobe's own files, gives outline buttons an edge you can see, and stops reloading content from dimming below contrast. The cue for a reload moves to the Refresh button.

Fonts

The fonts are now Adobe's static WOFF2 files, byte for byte, from Source Sans 3 3.052R, Source Serif 4 4.005R and Source Code Pro 2.042R. Under the SIL Open Font License a subset is a Modified Version, and a Modified Version may not use a Reserved Font Name. Adobe reserves "Source". An unmodified file is an Original Version, so the name stands. That was forced by the license rather than preferred: the files are larger than the subsets, which matters little for assets served once and cached as immutable. The subsets also carried wrong internal names left by instancing: the 500 weight of Source Sans 3 called itself "ExtraLight Medium". A test pins every shipped font to its release file by SHA-256, and the build-output test asserts every font is referenced, so a later re-subset cannot slip back in. The OFL texts are the ones each release ships, and NOTICE.md names the tags.

Contrast

Outline buttons are edged in the input border role, which the brand's contrast report measures at about 4:1. Before, they were edged in the plain border role at about 1.3:1, and Refresh, Check now and the copy buttons read as plain text in the light theme.

Reloading content stays at full contrast. It used to drop to half opacity, which took muted text to about 2.2:1 for as long as a reload took. Every panel still sets aria-busy. The visible cue is the Refresh button, which reads "Refreshing…" while any load on the screen is in flight: a Refresh round, a window change, a filter or a search. It does not track the header's own request alone, because that request finishes long before the report queries do. The label is drawn at full contrast. A source rule refuses any opacity- class in the screens.

The in-flight count is kept by the one data hook every screen uses. It releases exactly once per load, on success, on failure, on abort, on unmount and on a synchronous throw, and each of those paths has its own test.

Closes #45
Closes #46

Verification

The gate ran in CI's order: pnpm lint, pnpm lint:sh, pnpm typecheck and pnpm build. Then pnpm test ran 65 files and 1,550 tests for the services and 46 files and 590 tests for the interface, and the interface suite passed five runs in a row. The stack suites passed 86 tests across seven files, including the browser suite with no content-security-policy violation. The durability suite passed. The screenshots were read in both themes after each change.

Every guard was mutated individually and caught by exactly its own test.

Cem Hurturk added 13 commits September 25, 2026 23:04
Source Sans 3, Source Serif 4 and Source Code Pro were self-hosted as
latin subsets cut from Adobe's variable fonts. Under the SIL Open Font
License a subset is a Modified Version, and a Modified Version may not
carry the Reserved Font Name 'Source' the way these did, both in their
name tables and as the CSS font-family.

Each font file is now Adobe's own unmodified static WOFF2 build from a
tagged GitHub release, byte for byte. NOTICE.md names the release tag
each file came from, and the OFL texts are updated to the exact text
shipped in each release. A test pins every shipped file to its
release's SHA-256, with the release URL in a comment, so a re-subset
cannot slip back in. The built stylesheet still references every font
by URL rather than inlining one as data, which the font-src CSP
depends on.
The outline variant (Refresh, Check now, the copy buttons) used
--cm-border, the default border role, which measures about 1.3:1
against the page in the light theme — a plain line, not a visible
edge. border-input maps to --cm-border-interactive, the input border
role, at about 4:1, and is already the role every text input and
select draws its edge in. Only the outline variant changes.
Content reloading in place was drawn at half opacity, alongside the
aria-busy that already marked it — dropping muted text to about 2.2:1
and body text to about 3.5:1 in the light theme for as long as the
reload took. Every such site (the overview's summary and chart, the
domains, links, clicks, sessions and API key lists, and the breakdown
panels) now keeps aria-busy and drops the opacity.

The visible cue moves to the header's Refresh button instead: it reads
Freshness's own load state, the one thing in the header that reloads
on every round, and shows 'Refreshing…' and disables itself for the
round's duration. A source-rule test refuses opacity-50 anywhere under
screens/, so the invariant is stated once rather than per screen.
The Refresh button read Freshness's own status load as a proxy for the
whole screen: a slower report load left the button re-enabled while
the numbers under it were still on the old window, and a failed
status left it disabled forever if that one request never resolved.

useLoad now registers every attempt with a shared counter in
RefreshContext, begun when it starts and ended exactly once when it
settles — success, failure or superseded — whether that attempt came
from a Refresh press, a window change, a filter or a search. The
button reads the counter, not one particular load, so a 429 on one
request no longer clears the flag while another is still going.

The disabled state also inherited the button base's disabled:opacity-50,
dimming the very cue that replaced the dimmed content #46 removed. It
now overrides back to full contrast while Refreshing… is the reason
it is disabled.
Report's summary and chart, Settings and API keys all mark their
region aria-busy while a reload is in flight, but none of the four had
a test for it — aria-busy is the only signal left now that #46 removed
the opacity dimming, so an untested site could silently stop marking
itself busy.

Settings never depended on the refresh round, so Refresh did nothing
on that screen and there was no reload to test busy against. It now
reloads on a round like every other screen, remounting the form on
each one so a background refresh cannot leave stale fields under a
fresh answer.
The source rule only refused the literal opacity-50: opacity-60 or any
other weight on a busy region passed it, and so did reaching the same
dimming through a colour swap instead of opacity — a class that reads
text-muted-foreground only while a loading/stale/busy flag is set
composites text under the same risk. Nothing under screens/ has a
legitimate reason for either, so both are refused outright rather than
the one value and the one class that happened to be in use.
The font test asserted only that some woff2 reference existed, so
losing one font's @font-face (an accidental edit, a bad merge) would
pass it as long as any other font was still linked. It now checks each
of the eight file stems by name.

The comment above it also had font-src 'self' backwards: that
directive does not match data:, so an inlined font is not a policy
bypass, it is simply blocked and falls back to nothing. Inlining is
still refused — the goal is every font rendering, not merely the
policy holding.
Test names and comments still said a busy region dims or is dimmed —
true before #46 removed the opacity, stale after. Nothing here changes
behaviour.
Settings does not depend on the refresh round again: SettingsBody reads
its answer into local state once and edits it there, and remounting it
on every round (the previous commit's key={round}) threw an in-progress
edit away on a Refresh press, on the 60s focus-return refresh, and
under an open retention-deletion confirmation — none of it saved, none
of it asked about. The counter this branch added still counts
Settings' own initial load regardless; nothing about that needs a
round.

The busy test that depended on the round reload is replaced with one
that reads busy correctly once the settings have loaded, and a new
test that a Refresh round leaves an unsaved edit alone.
Three edges the counter's own tests did not cover: unmounting a load
mid-flight when it ignores its abort signal, a superseded attempt's
abort rejection releasing its slot a second time on top of the
cleanup that already released it, and load() throwing synchronously
before ever returning a promise.

The third was a real leak, not just an unpinned guard: beginLoad() ran
before the call, so a synchronous throw skipped both finish() calls
and left the count up forever. load() is now called inside a try that
releases it before re-throwing, the same defect it would otherwise
still reach React as.
A ternary Prettier wraps across several lines passed the rule, since it
only ever looked at one line at a time. It now matches each
className={…} attribute's whole value instead, so a wrapped ternary is
still caught. The matcher is a one-level brace balancer, not a parser,
so it stays simple: a second level of nested braces inside the
attribute would still close the match early, which the test's own
comment says.
Three revisions of the regex matching a colour swapped in while a
load is stale/loading/busy each missed a shape the last one caught or
a new one it never covered — a class hoisted into a variable, a &&
instead of a ?. A colour swap tied to a loading state is a review
concern, not a pattern a regex over JSX can state, so it stays one
rather than a fourth revision. The opacity rule stays as it is: an
exact match, not a heuristic.
Settings has no reload in place, and the region it was on only renders
once the data has already arrived — the condition it read was always
false by the time anything using it existed. The test that pinned it
asserted a constant: setting the attribute to a literal false passed
it exactly as the real condition did. Removed both, rather than keep
an attribute and a test that state nothing.
@cemhurturk
cemhurturk merged commit 8ee585f into main Sep 26, 2026
5 checks passed
@cemhurturk
cemhurturk deleted the fix/interface-fonts-contrast branch September 26, 2026 19:23
@cemhurturk cemhurturk mentioned this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant