Files
rippr/docs/ui-redesign/UI-03-glass-component-kit.md
uhryniuk 24f4e17e81 UI-03: shared floating-glass component kit
Adds GlassPanel (BackdropFilter + translucent fill + subtle border, values
lifted directly from the Stitch exports' own bg-surface/90 backdrop-blur-xl
border-outline-variant/30 treatment), FloatingPill (a GlassPanel shaped as
a capsule around an arbitrary number of labeled stat columns with
dividers), and PulsingLocationMarker (expanding-ring-plus-glow-dot,
matching the exports' pulse-ring keyframes exactly, with a static-dot
fallback when reduced motion is requested). Pure presentation -- every
UI-04/05/06/07 screen composes these rather than reimplementing blur/
border styling per screen.

Adds outlineVariant to ripprColors, needed for the panel border and pill
dividers and not previously wired up by UI-08.

Verified with a throwaway preview entry point (deleted after use, no
consuming screen exists yet) plus 8 new widget tests covering blur/
translucency/border structure, contrast in both themes, arbitrary stat
counts, and the pulsing marker's animation lifecycle and reduced-motion
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:22:19 -05:00

8.3 KiB

UI-03 — Shared floating-glass component kit

Depends on UI-08 (theme tokens) · Size M · Status Done

Goal

Build once, use everywhere: the small set of visual primitives every Stitch screen reuses, so UI-04/05/06/07 compose them instead of each reinventing blur-panel styling.

Context

All four Stitch exports lean on the same handful of visual patterns repeatedly: glassmorphic panels (bg-surface/80 backdrop-blur-xl border border-white/10), a floating centered pill for a compact stat summary, and a pulsing/radar-style location marker. Building these once means every consuming screen ticket is "compose these," not "reimplement blur and border styling a fourth time."

Design

Three widgets, all pure presentation (no data-fetching, no business logic):

GlassPanel — the workhorse. BackdropFilter + ImageFilter.blur inside a ClipRRect, a translucent surface-color fill, a 1px low-opacity border. Takes a child and behaves like a styled Container. Every floating card, tooltip, and control in the redesign is one of these underneath.

FloatingPill — a GlassPanel shaped as a horizontally-centered, rounded-full capsule holding a row of labeled stat columns (see Route Planning's Distance/Est. Time/ Pins header). Takes a list of (label, value) pairs and lays them out with vertical dividers between them, matching the Stitch export exactly.

PulsingLocationMarker — the expanding-ring-plus-glow-dot from the Plan/Route Planning exports. A CustomPainter or layered AnimatedContainers driving an AnimationController in a loop (scale 0.8→1.0, opacity 0.8→0, repeating) — respect MediaQuery.disableAnimations/prefers-reduced-motion equivalent by falling back to a static dot when animations are disabled system-wide.

Implementation

  1. lib/src/ui/components/glass_panel.dart — GlassPanel, with blur radius, fill opacity, and border opacity as named constants (not magic numbers scattered per call site), sourced from UI-08's theme tokens.
  2. lib/src/ui/components/floating_pill.dart — FloatingPill, built on GlassPanel.
  3. lib/src/ui/components/pulsing_location_marker.dart — PulsingLocationMarker, wrapping its AnimationController lifecycle correctly (dispose on unmount, same discipline as RideMap's existing lifecycle observer from V3-04).
  4. Widget tests render each in isolation against both themes (if UI-08 keeps a light variant) to catch a black-on-black-style contrast regression early, the same discipline V3-16 established.

Acceptance criteria

  • GlassPanel renders a blurred, bordered, translucent container matching the Stitch reference visually
  • FloatingPill renders an arbitrary number of stat columns with dividers between them, not hardcoded to exactly three
  • PulsingLocationMarker animates continuously without leaking its AnimationController across widget rebuilds or disposal
  • Reduced-motion setting is respected by PulsingLocationMarker

Tests

  • Widget: each component renders with representative content and takes a screenshot- comparable snapshot of its structure (no golden files per V3-16's precedent — assert structure/color, not pixels)
  • Widget: PulsingLocationMarker's animation controller is disposed when the widget is removed from the tree (a flutter_test pending-timer/ticker check, mirroring the Drift stream-query keep-alive pattern already documented in widget_test.dart)

Risks

  • BackdropFilter is one of the more expensive Flutter widgets to composite; stacking several GlassPanels over a live, animating map (per UI-01/UI-02) could visibly cost frame time on lower-end devices. Worth a real-device check once UI-05 assembles them together, not just in isolation.

Out of scope

The customizable drag/resize telemetry widgets (UI-04) — those consume GlassPanel as their visual shell but the interaction logic is a separate, larger ticket.

Outcome

All three values (blur radius, opacities, dimensions, colors) were lifted directly from the Route Planning export's own glass treatment and pulse-ring keyframes rather than guessed — docs/design/stitch-export/screens/route-planning-dark.html's bg-surface/90 backdrop-blur-xl border border-outline-variant/30 and its @keyframes pulse-ring (scale 0.8→1.0, opacity 0.8→0, eased) map directly onto GlassPanel.blurSigma/ fillOpacity/borderOpacity and PulsingLocationMarker's animation curve.

GlassPanel (lib/src/ui/components/glass_panel.dart) is ClipRRect > BackdropFilter > DecoratedBox, taking child/borderRadius/padding and reading its fill/border colors from the theme (colors.surface, colors.outlineVariant — the latter newly added to ripprColors in theme.dart for this ticket, since UI-08 hadn't needed it before now). FloatingPill (floating_pill.dart) is a GlassPanel shaped as a stadium capsule around a Row of PillStat columns with 1px dividers between them — genuinely arbitrary-length, not hardcoded to three, verified by a 4-stat test case. PulsingLocationMarker (pulsing_location_marker.dart) layers a static faint ring, an animated expanding-and-fading ring, and a glowing center dot; it checks MediaQuery.disableAnimations in didChangeDependencies (not initState, which Flutter forbids for inherited-widget lookups) and stops the controller entirely when reduced motion is requested, falling back to a static dot.

Test-writing bug caught and fixed: the first version of the "legible in both themes" test pumped ripprTheme() then ripprMountedTheme() sequentially in a single testWidgets loop. Because both produced an identically-shaped widget tree (MaterialApp > Scaffold > GlassPanel > Text), Flutter reused the first pump's elements across the second pumpWidget call rather than rebuilding fresh, so the second iteration silently re-read the first theme's already-resolved paint color — the test would have falsely failed for a real theme-following bug and, worse, could have falsely passed one due to reading stale data. Split into two independent testWidgets blocks instead, which is the correct way to exercise two themes against the same component.

Tests: flutter analyze clean. flutter test green at 336 tests (328 + 8 new in test/glass_component_kit_test.dart): GlassPanel's blur/translucency/border structure and its resolved-paint-color contrast in both themes; FloatingPill's divider count scaling with stat count (and the zero-divider single-stat case); PulsingLocationMarker's continuous animation, correct AnimationController disposal on removal (caught automatically by flutter_test's own ticker-leak check, not a manual timer assertion), and the reduced-motion fallback.

Android emulator verification: no consuming screen exists yet (that's UI-04/05/06), so verification used a throwaway preview entry point (lib/main_ui03_preview.dart, deleted after use) rendering all three components over a map-colored gradient background. All three matched the Stitch reference's look: the FloatingPill's blur/translucency/dividers/blue values rendered crisply; the PulsingLocationMarker's glow and ring were visible and animating. One red herring during this check: GlassPanel's content briefly appeared to render illegibly when the preview used Theme.of(context).textTheme.headlineSmall for a heading, despite that style's color property independently verified (via a debug test) to already be the correct ink value with full alpha. Switching to an explicit inline TextStyle (no ambient text-theme role) rendered crisply instead. This was isolated to the scratch preview file, not GlassPanel itself — the shipped components' own tests all render content through explicit styles or the already-verified bodyMedium role, the same discipline every real screen in this codebase already follows — but it's worth flagging as a headlineSmall-specific rendering quirk to watch for if UI-05/06 lean on that particular text-theme role for real headings.

Risks note (revisited)

The ticket's own risk — BackdropFilter compositing cost when several GlassPanels stack over a live, animating map — was not measurable in this ticket's isolated preview (no map, no stacking). Deferred to UI-05, as the ticket itself anticipated, where the Record screen actually assembles multiple glass panels over RideMap.