Skip to content

Motion, states, typography and accessibility polish (Phase 11) - #41

Merged
Phantom-VK merged 6 commits into
mainfrom
feat/rebuild-11-polish
Aug 21, 2026
Merged

Motion, states, typography and accessibility polish (Phase 11)#41
Phantom-VK merged 6 commits into
mainfrom
feat/rebuild-11-polish

Conversation

@Phantom-VK

@Phantom-VK Phantom-VK commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Summary

Polish pass across all six views. No new features, no new bridge methods.

A code review caught that the first version of the dialog-backdrop fix looked right in isolation but didn't actually work under Radix Presence -- see "Motion" below for what was actually wrong and how it's fixed now, verified by frame-sampling opacity through real open/close cycles rather than just checking computed styles at rest.

Not included: a full usability pass with a genuine next-day re-review (structurally can't happen in one sitting), and live screen reader verification under Orca/NVDA/VoiceOver (not available in this environment) -- both still outstanding before this ships.

Motion

Surface Finding Fix
Dialog backdrop Referenced tailwindcss-animate classes that aren't installed -- dead, so it snapped in/out instantly First attempt used a CSS transition, which reports animationName: "none" to Radix Presence (Presence only waits for animationend) -- still snapped both ways, confirmed by frame-sampling. Root-caused that DialogOverlay wraps its node in react-remove-scroll, which makes Presence's exit-detection unreliable for that element specifically (traced through Radix's actual source, not guessed). Fixed with forceMount (at both the DialogPortal and DialogOverlay layers) plus a delayed-visibility CSS transition that doesn't depend on Presence's animation detection at all. Verified both directions now animate correctly.
Dialog panel / Select popover Same dead-transition issue Real @keyframes + animation (what Presence needs), verified via the same frame sampling
Tooltip Same dead tailwindcss-animate classes as the dialog (not wired into any view yet, but fixed for the next consumer) Same @keyframes pattern
ContextBar Animated width (layout-triggering) transform: scaleX() + left origin
ContextBar / AnalysisProgress / Resources download bar The determinate fill kept rounded-full while being scaled, so the corner radius scaled with it and flattened into a visible ellipse at low percentages Removed rounded-full from the determinate fill (kept it on the indeterminate sliding pill, whose scale is fixed, not data-driven, so it doesn't squish)
Button / Switch / base Progress transition-all Narrowed to the actual properties that change
Notice banner No entrance transition at all @starting-style fade

Loading / empty / error states

Surface Loading Empty Error
Registry grid N/A (models preloaded before app renders) Fixed -- had no empty state at all; silently blank grid N/A
Parser results / logs Determinate/indeterminate progress + dialog Already named the next action Surfaces via job.error
Compare results / projection Job progress Already named the next action (two distinct empty states) Surfaces via job.error
Fit Check estimate Already correct -- no bare spinner where progress is knowable N/A Already correct (verdict + reason)
Resources scan Fixed -- spinner showed immediately even for a load resolving in <300ms N/A Fixed -- was a dead end with no retry
Resources download Already correct (determinate/indeterminate, per-row) N/A Already correct, distinct from static notes
App boot (bridge) Already correct N/A Fixed -- was a dead end with no retry

Added a small useDelayedFlag hook (200ms) so Resources' and Fit Check's initial-load spinners don't flash for fast loads.

Accessibility

  • Keyboard: every interactive element is a real <button>/<a> (no raw div/span onClick), so reachability and Enter/Space activation already worked. Found and fixed 8 hand-rolled controls with no focus-visible styling.
  • A follow-up review caught that adding outline-none to those 8 controls, paired with a box-shadow-based ring, strips the only focus indicator that survives forced-colors mode (Windows High Contrast) -- and that this wasn't unique to the 8 new controls: every focus-visible:ring-* control in the app already had this gap, including Button.tsx itself. Fixed with one global forced-colors: active rule restoring a real outline for :focus-visible everywhere, rather than patching 8 files and leaving the rest broken.
  • Screen reader: every icon-only control already has a distinct accessible name. Live verification under a real screen reader not performed -- flagging as outstanding.
  • Colour/contrast: contrast suite still green; confirmed prefers-contrast: more actually strengthens --border alpha (not a no-op).
  • Preferences: prefers-reduced-motion, prefers-reduced-transparency, prefers-contrast all already implemented from earlier work; spot-verified each does something real.

Typography and density

  • grep -rn "text-\[\|font-size:" src/ | grep -v styles/ -- clean, no hardcoded sizes outside the type scale.
  • Verified all six views at 1024x640 and 2560x1440 live in the built app: zero horizontal overflow anywhere.
  • Found and fixed one real density defect: Resources had no max-w-* cap, so its rows stretched to ~2282px at 2560px wide (content stranded with huge dead whitespace). Capped to max-w-5xl, matching the convention used elsewhere.

Test plan

  • npx tsc --noEmit clean
  • npx vitest run -- 97 passing
  • npm run build clean
  • pytest -- 325 passing, src/norefund/gui/ untouched
  • Verified live in the built app under Xvfb at each step, including frame-by-frame sampling of the dialog open/close cycle to confirm the animation fixes actually take effect (not just that the CSS parses)

Fixes real desyncs, not just style nits: the Settings/dialog backdrop
referenced tailwindcss-animate utility classes that aren't installed in
this project, so it was a dead no-op snapping in/out instantly while the
panel correctly faded and scaled beside it -- replaced with a matching
opacity fade on the same duration tokens as the panel.

ContextBar animated width, which forces layout on every frame; switched
to transform: scaleX() with a left origin, matching the pattern already
used correctly elsewhere (AnalysisProgress, the download progress bar).

Replaced transition-all with the actual properties that change
(transition-colors on Button/Switch, transition-transform on the base
Progress indicator) so hover/press states aren't riding an unspecified
property list.

Gave the onboarding notice banner an entrance fade via @starting-style --
it previously popped into existence with no transition at all.
…iews

Registry, Parser results/logs, Compare results/projection, and Fit
Check's estimate already had reasonable states; the real gaps were:

- Registry had no empty state at all -- an empty registry or (if the
  filter derivation ever changes) a filter with no matches rendered a
  silently blank grid instead of naming what happened.
- App boot failure and Resources' initial scan failure were both dead
  ends: a bare error string with no way to recover short of restarting
  the app. Both now offer Retry.
- Resources' and Fit Check's initial loading spinners showed
  immediately even for a load that resolves in well under 300ms, which
  reads as a flash/flicker rather than useful feedback. Added
  useDelayedFlag so the spinner only appears once the wait has gone on
  long enough to actually need explaining.
No hardcoded font sizes outside styles/ (grep clean); tabular figures
already applied everywhere a formatted number renders.

Verified all six views at 1024x640 and 2560x1440: no horizontal
overflow anywhere. Found and fixed the one real density defect --
Resources had no max-width cap, so its rows stretched to ~2282px at
2560px wide, leaving its actual content (a status dot, name, path, and
a button) stranded with huge dead whitespace. Capped to max-w-5xl,
matching the convention Calculator and Fit Check already use.
Keyboard: every interactive element is a real <button>/<a> (no raw
div/span onClick handlers), so reachability and Enter/Space activation
already came for free. The real gap was focus-visible styling: eight
hand-rolled controls -- sidebar nav items, the notice banner's dismiss
button, Compare's Clear/Select-all/Select-none buttons, Parser's file
remove button, Registry's provider filter pills and docs link, and
Resources' "open page" link -- had no focus ring beyond whatever the
browser's unstyled default happens to be, unlike every shadcn-based
control which already carries the app's --ring token. All eight now use
the same focus-visible:ring-[3px] focus-visible:ring-ring/50 recipe
Button.tsx already established.

Screen reader: every icon-only control already had a distinct
accessible name (aria-label or sr-only text); confirmed no icon-only
button ships without one. Live testing under Orca/NVDA/VoiceOver was
not possible in this environment -- that verification is still
outstanding and should happen before this ships.

Colour and preferences: re-ran the contrast suite (still green), and
confirmed prefers-contrast/reduced-motion/reduced-transparency are all
already implemented in theme.css/motion.css from earlier phases, with
prefers-contrast: more verified to actually strengthen --border alpha
rather than being a no-op media query.
…s fixes actually take effect

The prior commit's dialog backdrop fix didn't work: a transition-only
rule reports animationName: "none" to Radix Presence, which only waits
for animationend, so both entrance and exit still snapped. Confirmed
live by frame-sampling opacity/transform through real open/close cycles
before touching anything.

Switched .anim-modal, .anim-overlay and .anim-popover to real @Keyframes
+ animation, which is what Presence actually needs to hold a closing
node open long enough to animate. That fixed the modal panel and the
popover, verified the same way -- but not the backdrop specifically:
DialogOverlay wraps its node in react-remove-scroll, which makes
Presence's getComputedStyle check unreliable for that one element (it
unmounts before animationstart ever fires). Root-caused by tracing
Radix's actual Presence source rather than guessing further.

Fixed the backdrop with a different mechanism: forceMount (at both the
DialogPortal and DialogOverlay layers -- DialogPortal wraps each child
in its own outer Presence too, so forceMount has to be set on both to
actually stick) keeps it permanently in the DOM so Presence can't
prematurely remove it, and a plain CSS transition with a
delayed-visibility switch (not dependent on Presence's animation
detection at all) handles the actual fade. Confirmed via the same frame
sampling: entrance and exit both now animate correctly. Also applied to
tooltip.tsx, which had the identical dead tailwindcss-animate classes.

Also:
- Removed rounded-full from the determinate fill in ContextBar,
  AnalysisProgress and the Resources download bar -- scaling a rounded
  corner scales its radius too, flattening the cap into a visible ellipse
  at low percentages. The indeterminate sliding-pill variant keeps it
  (its scale is fixed, not data-driven, so there's no squish).
- Added a global forced-colors:active rule restoring a real outline for
  :focus-visible. The gap wasn't specific to the 8 controls fixed in the
  a11y commit -- every focus-visible:ring-* control in the app
  (including Button.tsx itself) relies on box-shadow, which forced-colors
  mode strips, and outline-none removes the one thing that would
  otherwise still show. One rule fixes all of them, not just the new
  ones.
…roll

The forceMount workaround for the overlay's exit animation kept
DialogOverlayImpl (and the react-remove-scroll instance Radix wraps it
in) mounted for the app's entire lifetime, arming a document-level
wheel listener that blocked scroll outside the dialog even while every
dialog was closed. Its premise (react-remove-scroll makes Presence's
exit-animation detection unreliable for this element) didn't hold up
against the library source and wasn't actually the cause.

The real bug: this codebase's own DialogOverlay wrapper was a plain
function component, not React.forwardRef, so the ref DialogPortal's
outer Presence layer needs to find the overlay's DOM node never
reached it -- it always saw no node and unmounted the overlay
instantly instead of waiting for the exit animation. Fixed by making
DialogOverlay forward its ref, and reverted the overlay CSS to plain
@Keyframes + animation like .anim-modal already uses successfully.

Verified via frame sampling through real open/close cycles (both a
synthetic Escape and a real button click): entrance and exit both fade
smoothly and in sync with the panel, and the scroll lock is now only
live while a dialog is actually open, not permanently.
@Phantom-VK Phantom-VK self-assigned this Aug 21, 2026
@Phantom-VK
Phantom-VK merged commit 5dcfd7e into main Aug 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant