diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md
index 38364f8..a8cad25 100644
--- a/docs/ui-redesign/README.md
+++ b/docs/ui-redesign/README.md
@@ -21,7 +21,7 @@ v3 held to.
|---|---|---|---|---|
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done |
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done |
-| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started |
+| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Done |
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started |
| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started |
| [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Not started |
diff --git a/docs/ui-redesign/UI-03-glass-component-kit.md b/docs/ui-redesign/UI-03-glass-component-kit.md
index dbe5369..7ebd040 100644
--- a/docs/ui-redesign/UI-03-glass-component-kit.md
+++ b/docs/ui-redesign/UI-03-glass-component-kit.md
@@ -1,6 +1,6 @@
# UI-03 — Shared floating-glass component kit
-**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Not started
+**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done
## Goal
Build once, use everywhere: the small set of visual primitives every Stitch screen
@@ -70,3 +70,68 @@ static dot when animations are disabled system-wide.
## 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 `GlassPanel`s
+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`.
diff --git a/lib/src/ui/components/floating_pill.dart b/lib/src/ui/components/floating_pill.dart
new file mode 100644
index 0000000..6cc457e
--- /dev/null
+++ b/lib/src/ui/components/floating_pill.dart
@@ -0,0 +1,99 @@
+/// UI-03: the horizontally-centred, fully-rounded stat summary from the Route Planning
+/// export's Distance/Est. Time/Pins header -- a `GlassPanel` shaped as a capsule, laid
+/// out with vertical dividers between however many stats it's given (not hardcoded to
+/// three, so a screen ticket can use it for two stats or five without a second widget).
+library;
+
+import 'package:flutter/material.dart';
+
+import 'glass_panel.dart';
+
+class PillStat {
+ const PillStat({required this.label, required this.value, this.unit});
+
+ final String label;
+ final String value;
+
+ /// Rendered smaller and after [value], matching the export's `12.4km` treatment -- a unit is not part of the number itself.
+ final String? unit;
+}
+
+class FloatingPill extends StatelessWidget {
+ const FloatingPill({super.key, required this.stats});
+
+ final List stats;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ return GlassPanel(
+ borderRadius: const BorderRadius.all(Radius.circular(999)),
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var i = 0; i < stats.length; i++) ...[
+ if (i > 0)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Container(
+ width: 1,
+ height: 32,
+ color: colors.outlineVariant.withValues(alpha: 0.3),
+ ),
+ ),
+ _StatColumn(stat: stats[i]),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _StatColumn extends StatelessWidget {
+ const _StatColumn({required this.stat});
+
+ final PillStat stat;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ stat.label.toUpperCase(),
+ style: TextStyle(
+ fontSize: 10,
+ letterSpacing: 1.5,
+ color: colors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ Text(
+ stat.value,
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: colors.primary,
+ ),
+ ),
+ if (stat.unit != null) ...[
+ const SizedBox(width: 2),
+ Text(
+ stat.unit!,
+ style: TextStyle(fontSize: 13, color: colors.primary),
+ ),
+ ],
+ ],
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/src/ui/components/glass_panel.dart b/lib/src/ui/components/glass_panel.dart
new file mode 100644
index 0000000..56682b6
--- /dev/null
+++ b/lib/src/ui/components/glass_panel.dart
@@ -0,0 +1,62 @@
+/// UI-03: the one blurred-glass surface every floating card, tooltip, and control in
+/// the redesign is built on, so `backdrop-blur`/translucency/border styling is written
+/// once here rather than reinvented per screen. Pure presentation -- no data-fetching,
+/// no business logic.
+///
+/// Values are lifted directly from the Stitch exports' own glass treatment
+/// (`bg-surface/90 backdrop-blur-xl border border-outline-variant/30`), not guessed:
+/// see `docs/design/stitch-export/screens/route-planning-dark.html`.
+library;
+
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+
+import '../theme.dart' show ripprRadiusLarge;
+
+class GlassPanel extends StatelessWidget {
+ const GlassPanel({
+ super.key,
+ required this.child,
+ this.borderRadius = const BorderRadius.all(Radius.circular(ripprRadiusLarge)),
+ this.padding,
+ });
+
+ final Widget child;
+ final BorderRadiusGeometry borderRadius;
+ final EdgeInsetsGeometry? padding;
+
+ /// Tailwind's `backdrop-blur-xl`. Strong enough that whatever is behind the panel
+ /// (the live map, in every real usage) reads as texture, not detail -- the panel's
+ /// own content is what the eye is meant to land on.
+ static const double blurSigma = 24.0;
+
+ /// `bg-surface/90` -- almost opaque. Enough blur shows through to feel like glass
+ /// over the map rather than a solid card, without ever risking the panel's own text
+ /// losing contrast against whatever happens to be moving underneath it.
+ static const double fillOpacity = 0.9;
+
+ /// `border-outline-variant/30` -- a barely-there seam, not a hard edge.
+ static const double borderOpacity = 0.3;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ return ClipRRect(
+ borderRadius: borderRadius,
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: colors.surface.withValues(alpha: fillOpacity),
+ borderRadius: borderRadius,
+ border: Border.all(
+ color: colors.outlineVariant.withValues(alpha: borderOpacity),
+ ),
+ ),
+ child: padding == null ? child : Padding(padding: padding!, child: child),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/ui/components/pulsing_location_marker.dart b/lib/src/ui/components/pulsing_location_marker.dart
new file mode 100644
index 0000000..9c30224
--- /dev/null
+++ b/lib/src/ui/components/pulsing_location_marker.dart
@@ -0,0 +1,109 @@
+/// UI-03: the expanding-ring-plus-glow-dot user-location marker from the Plan/Route
+/// Planning exports. Values (sizes, colours, the scale/opacity curve) are lifted from
+/// the exports' own `pulse-ring` keyframes, not guessed -- see
+/// `docs/design/stitch-export/screens/route-planning-dark.html`.
+library;
+
+import 'package:flutter/material.dart';
+
+class PulsingLocationMarker extends StatefulWidget {
+ const PulsingLocationMarker({super.key});
+
+ @override
+ State createState() => _PulsingLocationMarkerState();
+}
+
+class _PulsingLocationMarkerState extends State
+ with SingleTickerProviderStateMixin {
+ late final _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 2),
+ );
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _syncWithReducedMotion();
+ }
+
+ /// A static dot -- the ring never expands -- when the platform has reduced motion
+ /// enabled, the same accessibility setting `prefers-reduced-motion` maps to on the
+ /// web this component's design came from.
+ void _syncWithReducedMotion() {
+ final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
+ if (reduceMotion) {
+ _controller.stop();
+ } else if (!_controller.isAnimating) {
+ _controller.repeat();
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).colorScheme;
+ return SizedBox(
+ width: 40,
+ height: 40,
+ child: AnimatedBuilder(
+ animation: _controller,
+ builder: (context, child) {
+ // The keyframes' own 0%/70%/100% easing: scale 0.8 -> 1.0 and opacity
+ // 0.8 -> 0, both eased out rather than linear, so the ring visibly slows as
+ // it fades rather than looking like it's cut off mid-motion.
+ final t = Curves.easeOut.transform(_controller.value);
+ final scale = 0.8 + (0.2 * t);
+ final opacity = 0.8 * (1 - t);
+ return Stack(
+ alignment: Alignment.center,
+ children: [
+ // The faint, static outer ring -- always present, pulse or no pulse.
+ Container(
+ width: 16,
+ height: 16,
+ decoration: BoxDecoration(
+ color: colors.primary.withValues(alpha: 0.3),
+ shape: BoxShape.circle,
+ ),
+ ),
+ Transform.scale(
+ scale: scale,
+ child: Opacity(
+ opacity: opacity,
+ child: Container(
+ width: 12,
+ height: 12,
+ decoration: BoxDecoration(
+ color: colors.primary,
+ shape: BoxShape.circle,
+ ),
+ ),
+ ),
+ ),
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: colors.primary,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: colors.primary.withValues(alpha: 0.5),
+ blurRadius: 10,
+ spreadRadius: 1,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart
index 735dbc8..d7c340b 100644
--- a/lib/src/ui/theme.dart
+++ b/lib/src/ui/theme.dart
@@ -50,6 +50,11 @@ const ripprColors = ColorScheme.dark(
surfaceContainerHighest: Color(0xFF353534),
onSurfaceVariant: Color(0xFFC1C6D5),
outline: _outline,
+ // UI-03: the design system's own `outline-variant` token -- a subtler line than
+ // `outline` itself, for a `GlassPanel`'s 1px border and a `FloatingPill`'s dividers,
+ // where a full-strength outline colour would read as a hard edge rather than a
+ // barely-there seam between tonal layers.
+ outlineVariant: Color(0xFF414753),
error: _danger,
onError: Color(0xFF690005),
);
diff --git a/test/glass_component_kit_test.dart b/test/glass_component_kit_test.dart
new file mode 100644
index 0000000..3baf1c6
--- /dev/null
+++ b/test/glass_component_kit_test.dart
@@ -0,0 +1,152 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:rippr/src/ui/components/floating_pill.dart';
+import 'package:rippr/src/ui/components/glass_panel.dart';
+import 'package:rippr/src/ui/components/pulsing_location_marker.dart';
+import 'package:rippr/src/ui/theme.dart';
+
+/// UI-03: structure/colour assertions, not pixels -- no golden files, per V3-16's own
+/// precedent (see that ticket's Tests section for the reasoning).
+void main() {
+ group('GlassPanel', () {
+ testWidgets('renders a blurred, bordered, translucent container', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(body: GlassPanel(child: Text('content'))),
+ ));
+
+ expect(find.byType(BackdropFilter), findsOneWidget);
+ expect(find.text('content'), findsOneWidget);
+
+ final backdrop = tester.widget(find.byType(BackdropFilter));
+ expect(backdrop.filter, isA());
+
+ final box = tester.widget(find.byType(DecoratedBox).first);
+ final decoration = box.decoration as BoxDecoration;
+ expect(decoration.color, isNotNull);
+ expect(decoration.color!.a, closeTo(GlassPanel.fillOpacity, 0.01),
+ reason: 'the fill must be translucent, not a solid card');
+ expect(decoration.border, isNotNull);
+ });
+
+ Future expectLegible(WidgetTester tester, ThemeData theme) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: theme,
+ home: const Scaffold(body: GlassPanel(child: Text('Distance'))),
+ ));
+ final colors = theme.colorScheme;
+ // The actually-resolved paint colour, not the (null, since the Text above sets
+ // no style of its own) `Text.style` -- this is what the black-on-black bug the
+ // rest of the theme tests guard against would have shown up as.
+ final paragraph = tester.renderObject(find.text('Distance'));
+ final resolvedColor = paragraph.text.style?.color;
+ expect(resolvedColor, isNotNull,
+ reason: 'text inside a GlassPanel must not fall back to an undefined '
+ 'colour -- the same failure mode the ambient bodyColor fix in '
+ 'ripprTheme() exists to prevent');
+ expect(contrastRatio(resolvedColor!, colors.surface), greaterThanOrEqualTo(4.5));
+ }
+
+ // Two separate tests, not one test looping over both themes and re-pumping into
+ // the same tester -- pumping a second MaterialApp with an identically-shaped tree
+ // reuses the first pump's elements rather than rebuilding from the new theme,
+ // which silently re-asserted the first theme's already-resolved colour twice.
+ testWidgets('is legible against the dark ground in the pocketed theme', (tester) {
+ return expectLegible(tester, ripprTheme());
+ });
+
+ testWidgets('is legible against the dark ground in the mounted theme', (tester) {
+ return expectLegible(tester, ripprMountedTheme());
+ });
+ });
+
+ group('FloatingPill', () {
+ testWidgets('renders an arbitrary number of stat columns with dividers between '
+ 'them', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: FloatingPill(
+ stats: [
+ PillStat(label: 'Distance', value: '12.4', unit: 'km'),
+ PillStat(label: 'Est. Time', value: '45', unit: 'm'),
+ PillStat(label: 'Pins', value: '5'),
+ PillStat(label: 'Elevation', value: '210', unit: 'm'),
+ ],
+ ),
+ ),
+ ));
+
+ expect(find.text('DISTANCE'), findsOneWidget);
+ expect(find.text('EST. TIME'), findsOneWidget);
+ expect(find.text('PINS'), findsOneWidget);
+ expect(find.text('ELEVATION'), findsOneWidget);
+ expect(find.text('12.4'), findsOneWidget);
+ expect(find.text('km'), findsOneWidget);
+ expect(find.text('5'), findsOneWidget);
+
+ // n stats -> n-1 dividers, whatever n is -- not hardcoded to three.
+ final dividers = tester
+ .widgetList(find.byType(Container))
+ .where((c) => c.constraints?.maxWidth == 1)
+ .toList();
+ expect(dividers.length, 3);
+ });
+
+ testWidgets('renders correctly with a single stat and no dividers', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ theme: ripprTheme(),
+ home: const Scaffold(
+ body: FloatingPill(stats: [PillStat(label: 'Pins', value: '0')]),
+ ),
+ ));
+
+ expect(find.text('PINS'), findsOneWidget);
+ expect(find.text('0'), findsOneWidget);
+ });
+ });
+
+ group('PulsingLocationMarker', () {
+ testWidgets('animates continuously', (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: Scaffold(body: PulsingLocationMarker()),
+ ));
+
+ await tester.pump(const Duration(milliseconds: 500));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ // Still ticking after a full second -- a one-shot animation would have
+ // completed and stopped producing new frames by now.
+ expect(tester.hasRunningAnimations, isTrue);
+ });
+
+ testWidgets('disposes its AnimationController when removed from the tree',
+ (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: Scaffold(body: PulsingLocationMarker()),
+ ));
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));
+ await tester.pumpAndSettle();
+
+ // No lingering ticker/timer -- flutter_test's own binding fails the test at
+ // tearDown if a ticker from the removed widget is still registered.
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+
+ testWidgets('falls back to a static dot when reduced motion is enabled',
+ (tester) async {
+ await tester.pumpWidget(const MediaQuery(
+ data: MediaQueryData(disableAnimations: true),
+ child: MaterialApp(home: Scaffold(body: PulsingLocationMarker())),
+ ));
+ await tester.pump(const Duration(milliseconds: 500));
+
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+ });
+}