# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text **Depends on** — · **Size** L · **Status** Not started ## Goal Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional positioning into an Android-home-screen-style snapped grid — drag and resize stick to grid cells, a placement that would overlap another widget is rejected rather than silently stacking — and make each widget's label/value text scale to fill its own current size instead of a fixed font that can overflow when small or look lost when large. ## Context Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section): > Speed and other widgets should only appear when recording starts. When toggling off > different statistics, the flow and arrangement of the widgets goes crazy. This should > be "drag and drop" but they stick to a grid much like the home screen on android, and > they can be resizable like android widgets too. Make sure the font and values are > centered in the widgets as well, and scale to the size of the widget (no over or > underflow). (The "only appear when recording starts" half is FB-02, already scoped separately — this ticket is the grid/drag/resize/text-fit half.) ### Today's model — free-form fractions, no grid at all `lib/src/hud/hud_widget_layout.dart` (full file, 122 lines): ```dart const double hudMinWidthFraction = 0.20; const double hudMaxWidthFraction = 0.70; const double hudMinHeightFraction = 0.08; const double hudMaxHeightFraction = 0.40; class HudWidgetLayout { const HudWidgetLayout({ required this.metric, required this.x, required this.y, required this.width, required this.height, required this.visible, }); final HudMetric metric; final double x; // top-left, fraction of the HUD area final double y; final double width; final double height; final bool visible; HudWidgetLayout copyWith({double? x, double? y, double? width, double? height, bool? visible}) => ...; HudWidgetLayout clamped() { final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction); final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction); final clampedX = x.clamp(0.0, 1.0 - clampedWidth); final clampedY = y.clamp(0.0, 1.0 - clampedHeight); return HudWidgetLayout(metric: metric, x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight, visible: visible); } Map toJson() => {'x': x, 'y': y, 'width': width, 'height': height, 'visible': visible}; static HudWidgetLayout fromJson(HudMetric metric, Map json) { try { return HudWidgetLayout(metric: metric, x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(), width: (json['width'] as num).toDouble(), height: (json['height'] as num).toDouble(), visible: json['visible'] as bool).clamped(); } catch (_) { return HudWidgetLayout.defaultFor(metric); } } factory HudWidgetLayout.defaultFor(HudMetric metric) { const columns = 4; const cellWidth = 0.22; const cellHeight = 0.12; const gap = 0.02; final index = HudMetric.values.indexOf(metric); final row = index ~/ columns; final col = index % columns; return HudWidgetLayout( metric: metric, x: 0.02 + col * (cellWidth + gap), y: 0.06 + row * (cellHeight + gap), width: cellWidth, height: cellHeight, visible: index < columns, // only the first 4 (Speed/Avg Speed/Dist/Time) start visible ); } } ``` `HudMetric.values` order (`lib/src/hud/hud_metric.dart`, load-bearing — `defaultFor` uses this indexing directly): `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime, elevationGain, pointsCaptured` (8 total; first 4 start visible). `lib/src/hud/hud_layout_controller.dart` (full file, 52 lines) — the in-memory, authoritative layout during an edit session, persisted to `Config` only on exit: ```dart class HudLayoutController extends StateNotifier> { HudLayoutController(this._config) : super(_config?.hudLayout ?? {for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)}); final Config? _config; void updatePosition(HudMetric metric, double x, double y) { final current = state[metric]; if (current == null) return; state = {...state, metric: current.copyWith(x: x, y: y).clamped()}; } void updateSize(HudMetric metric, double width, double height) { final current = state[metric]; if (current == null) return; state = {...state, metric: current.copyWith(width: width, height: height).clamped()}; } void setVisible(HudMetric metric, bool visible) { final current = state[metric] ?? HudWidgetLayout.defaultFor(metric); state = {...state, metric: current.copyWith(visible: visible)}; } Future persist() async => _config?.setHudLayout(state); } ``` **Why "toggling widgets goes crazy" happens today**: `Config.hudLayout`'s getter (`lib/src/config/config.dart` ~lines 95-113) always returns an entry for *every* `HudMetric`, falling back to `HudWidgetLayout.defaultFor` for any metric never explicitly saved — so `setVisible`'s `state[metric] ?? defaultFor(metric)` fallback is essentially dead code; every metric already has a stored layout the moment the controller exists. That stored layout is whatever it was the last time that metric was visible (or its untouched `defaultFor` position if it's never been shown/moved). Nothing about `setVisible`, `defaultFor`, or `clamped()` is aware of what other widgets currently occupy — `clamped()` only keeps a single widget's own rectangle inside the 0.0–1.0 area, it has no concept of a sibling. So: drag widget A to overlap where widget B's (currently hidden) default position sits, then re-enable B in Settings — B pops up directly on top of A. Resize A larger (up to `hudMaxWidthFraction = 0.70`) and it can swallow B's or C's default slot the same way. This reads as "the whole layout goes crazy" even though, strictly, no *other* widget's own stored position ever actually changes — the illusion is caused by newly-visible widgets landing on top of already-visible ones with zero collision awareness. ### Drag/resize gesture wiring (continuous, per-frame, into shared state) `lib/src/ui/components/draggable_resizable_hud_widget.dart` (`_DraggableResizableHudWidgetState.build`, ~lines 54-132) computes pixel position/size from `widget.layout`'s fractions × `widget.areaSize` every build, and the drag/resize gesture handlers call the parent's callbacks **on every frame of movement**, not just at gesture end: ```dart onLongPressMoveUpdate: (details) { widget.onMoved( layout.x + details.offsetFromOrigin.dx / widget.areaSize.width, layout.y + details.offsetFromOrigin.dy / widget.areaSize.height, ); }, ... onPanUpdate: (details) { // the resize handle widget.onResized( layout.width + details.delta.dx / widget.areaSize.width, layout.height + details.delta.dy / widget.areaSize.height, ); }, ``` `onMoved`/`onResized` are wired straight to `HudLayoutController.updatePosition`/ `updateSize` in `lib/src/ui/components/hud_edit_overlay.dart` (~lines 62-69): ```dart DraggableResizableHudWidget( key: ValueKey(entry.key), layout: entry.value, areaSize: areaSize, editing: _editing, onMoved: (x, y) => notifier.updatePosition(entry.key, x, y), onResized: (w, h) => notifier.updateSize(entry.key, w, h), child: widget.metricBuilder(context, entry.key), ), ``` So every pixel of drag movement recomputes and commits a new fraction to the shared `StateNotifier` (though `persist()` to `SharedPreferences` still only happens on exit — that part is fine and unchanged). This continuous-commit model does not translate directly to a grid: an integer `col`/`row` can't represent "40% of the way toward the next cell," so this ticket changes drag/resize to track a **local, uncommitted pixel offset** during the gesture and only calls the parent callback once, with final snapped grid coordinates, at gesture end. See Design below. ### Fixed-size text, no auto-fit `lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 321-346): ```dart @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ Text( metric.label.toUpperCase(), style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( _value, style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ); } ``` Font sizes are hardcoded regardless of the widget's actual current size — at the smallest allowed size this can ellipsize; at the largest allowed size the same small text looks lost in a big glass panel. `DraggableResizableHudWidget` just centers whatever child it's given (`GlassPanel(child: Center(child: widget.child))`, ~line 64) — no text-scaling logic exists anywhere in this chain. ### Persistence — no migration needed `HudWidgetLayout.fromJson`'s existing `try { ... } catch (_) { return HudWidgetLayout.defaultFor(metric); }` already handles a field-shape change for free: an old saved JSON blob has `x`/`y`/`width`/`height` keys (doubles); this ticket's new `fromJson` reads `col`/`row`/`colSpan`/`rowSpan` (ints) and will throw on old data (missing key, or a cast failure), falling back to `defaultFor` automatically — no schema version, no explicit migration code. Confirmed reasonable: this is local `SharedPreferences`, not a shared/synced format, and there's no production data to preserve. ## Design ### Grid model Fixed **4 columns × 8 rows** spanning the HUD area (matches the current 4-column default-row assumption and gives ample vertical room for the 8-metric list). Replace `HudWidgetLayout`'s `x`/`y`/`width`/`height` doubles with: ```dart const int hudGridColumns = 4; const int hudGridRows = 8; const int hudMinSpan = 1; const int hudMaxColSpan = 4; const int hudMaxRowSpan = 3; class HudWidgetLayout { const HudWidgetLayout({ required this.metric, required this.col, required this.row, required this.colSpan, required this.rowSpan, required this.visible, }); final HudMetric metric; final int col; final int row; final int colSpan; final int rowSpan; final bool visible; ... } ``` - **`clampedToGrid()`** replaces `clamped()` — a *self-contained* bounds check with no awareness of siblings (kept as its own method because `fromJson` and `defaultFor`/`nextFreeSlot` all still need "is this rectangle even inside the grid" independent of collision): ```dart HudWidgetLayout clampedToGrid() { final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan); final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan); final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan); final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan); return HudWidgetLayout(metric: metric, col: clampedCol, row: clampedRow, colSpan: clampedColSpan, rowSpan: clampedRowSpan, visible: visible); } ``` - **Collision helper**, a static/top-level function usable by both the layout file and the controller: ```dart bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) { final aColEnd = a.col + a.colSpan; final aRowEnd = a.row + a.rowSpan; final bColEnd = b.col + b.colSpan; final bRowEnd = b.row + b.rowSpan; return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row; } ``` - **`defaultFor`/`nextFreeSlot`**: keep `defaultFor(metric)` for the *static* initial layout (unchanged conceptually — still purely a function of the metric's own index, no runtime state needed, since the 8 fixed default slots below never overlap each other by construction): ```dart factory HudWidgetLayout.defaultFor(HudMetric metric) { const columns = 4; const rowSpan = 2; final index = HudMetric.values.indexOf(metric); final row = (index ~/ columns) * rowSpan; final col = index % columns; return HudWidgetLayout( metric: metric, col: col, row: row, colSpan: 1, rowSpan: rowSpan, visible: index < columns, ); } ``` (Speed/AvgSpeed/Distance/ElapsedTime → row 0, cols 0-3, visible; MaxSpeed/MovingTime/ ElevationGain/PointsCaptured → row 2, cols 0-3, hidden — 8 distinct, non-overlapping slots, same invariant the existing `defaultFor` test already checks.) Add a **new** function for the actual re-enable-without-collision fix: ```dart /// Scans row-major from (0,0) for the first colSpan×rowSpan slot that doesn't /// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally /// if the grid is fully packed -- a stacked default is better than a crash or an /// exception the rider can't do anything about. static HudWidgetLayout nextFreeSlot( HudMetric metric, { required Map occupied, int colSpan = 1, int rowSpan = 2, }) { final candidate = (int col, int row) => HudWidgetLayout( metric: metric, col: col, row: row, colSpan: colSpan, rowSpan: rowSpan, visible: true, ); for (var row = 0; row <= hudGridRows - rowSpan; row++) { for (var col = 0; col <= hudGridColumns - colSpan; col++) { final c = candidate(col, row); final overlapsAny = occupied.values .where((l) => l.visible && l.metric != metric) .any((other) => hudRectsOverlap(c, other)); if (!overlapsAny) return c; } } return candidate(0, 0); } ``` ### Controller — collision-aware position/size updates, free-slot re-enable `lib/src/hud/hud_layout_controller.dart`: ```dart void updatePosition(HudMetric metric, int col, int row) { final current = state[metric]; if (current == null) return; final candidate = current.copyWith(col: col, row: row).clampedToGrid(); if (_overlapsAnyOther(metric, candidate)) return; // reject: no-op, widget stays put state = {...state, metric: candidate}; } void updateSize(HudMetric metric, int colSpan, int rowSpan) { final current = state[metric]; if (current == null) return; final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid(); if (_overlapsAnyOther(metric, candidate)) return; state = {...state, metric: candidate}; } bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values .where((l) => l.visible && l.metric != metric) .any((other) => hudRectsOverlap(candidate, other)); void setVisible(HudMetric metric, bool visible) { var current = state[metric] ?? HudWidgetLayout.defaultFor(metric); if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) { // The metric's own saved/default slot is now occupied by something else (the // "goes crazy" bug this ticket exists to fix) -- find a genuinely free one // instead of popping up on top of whatever's there. current = HudWidgetLayout.nextFreeSlot( metric, occupied: state, colSpan: current.colSpan, rowSpan: current.rowSpan, ); } state = {...state, metric: current.copyWith(visible: visible)}; } ``` **"Reject and no-op" is the collision rule for drag/resize** — simplest correct behavior, and it's the same operation as "revert to last valid" here since `state` is never mutated until the candidate passes the check (there's nothing to revert *from*, the old value was never overwritten). A drag/resize that would overlap another visible widget simply has no effect for that frame/gesture; the widget stays at its last valid position. `setVisible` gets the one exception — it actively finds a free slot rather than rejecting, since "the metric just doesn't turn on" would be a much worse user experience than briefly landing somewhere else on the grid. ### Drag/resize gesture — snap on gesture end, not live `lib/src/ui/components/draggable_resizable_hud_widget.dart`: track a **local, transient pixel offset** in `_DraggableResizableHudWidgetState` during the gesture (not committed to the controller), paint the widget at `layout position (from grid) + local offset` so the drag still feels smooth and continuous under the finger — then on `onLongPressEnd`/`onPanEnd`, convert the final raw pixel position/size to the nearest grid cell and call `widget.onMoved`/`onResized` exactly once with the snapped integer coordinates, then reset the local offset to zero. ```dart class _DraggableResizableHudWidgetState extends State { bool _grabbed = false; Offset _dragOffset = Offset.zero; // pixels, uncommitted, live during a move gesture Size _resizeDelta = Size.zero; // pixels, uncommitted, live during a resize gesture double get _cellWidth => widget.areaSize.width / hudGridColumns; double get _cellHeight => widget.areaSize.height / hudGridRows; @override Widget build(BuildContext context) { final layout = widget.layout; final left = layout.col * _cellWidth + _dragOffset.dx; final top = layout.row * _cellHeight + _dragOffset.dy; final width = layout.colSpan * _cellWidth + _resizeDelta.width; final height = layout.rowSpan * _cellHeight + _resizeDelta.height; ... onLongPressMoveUpdate: (details) => setState(() => _dragOffset = details.offsetFromOrigin), onLongPressEnd: (_) { final snappedCol = ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round(); final snappedRow = ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round(); widget.onMoved(snappedCol, snappedRow); setState(() { _grabbed = false; _dragOffset = Offset.zero; }); }, onLongPressCancel: () => setState(() { _grabbed = false; _dragOffset = Offset.zero; }), ... // resize handle: onPanUpdate: (details) => setState(() => _resizeDelta = Size(_resizeDelta.width + details.delta.dx, _resizeDelta.height + details.delta.dy)), onPanEnd: (_) { final snappedColSpan = ((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth).round(); final snappedRowSpan = ((layout.rowSpan * _cellHeight + _resizeDelta.height) / _cellHeight).round(); widget.onResized(snappedColSpan, snappedRowSpan); setState(() => _resizeDelta = Size.zero); }, ``` If the parent rejects the move/resize (collision), the widget simply rebuilds at its unchanged `layout` — since `_dragOffset`/`_resizeDelta` are reset to zero right after calling `onMoved`/`onResized` regardless of whether the controller accepted it, the widget visually snaps back to wherever it actually ended up (its last accepted grid cell) the instant the gesture ends. No separate "was it accepted?" callback needed. `widget.onMoved`/`onResized` function signatures change to `void Function(int col, int row)` / `void Function(int colSpan, int rowSpan)`. `lib/src/ui/components/hud_edit_overlay.dart`: update the two callback wire-ups (`onMoved: (col, row) => notifier.updatePosition(entry.key, col, row)`, `onResized: (colSpan, rowSpan) => notifier.updateSize(entry.key, colSpan, rowSpan)`) — no other change needed in this file; `areaSize` is still the raw pixel `Size` from its own `LayoutBuilder`, cell-size division now happens inside `DraggableResizableHudWidget` as shown above. ### Text auto-fit — `FittedBox`, not a computed font size `lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()`: wrap the existing label+value `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/ `TextOverflow.ellipsis` on both `Text`s (structurally impossible to overflow once `FittedBox` owns sizing), and keep the existing `fontSize: 10`/`18` values as the "reference" size `FittedBox` scales down from — the ratio between label and value size is preserved automatically as it scales: ```dart @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return FittedBox( fit: BoxFit.scaleDown, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( metric.label.toUpperCase(), style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant), ), const SizedBox(height: 4), Text( _value, style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)), ), ], ), ); } ``` `FittedBox` only scales *down* (`BoxFit.scaleDown` never enlarges past the reference size) — so a widget resized to the grid's maximum span (4 cols × 3 rows) shows text at its natural 10/18px reference size, not blown up to fill the space. That's an intentional, reasonable simplification for this ticket (matching "no overflow" exactly as asked; "looks proportionally larger in a bigger widget" is a nice-to-have, not named in the feedback) — call this trade-off out plainly in the Outcome section rather than silently under-delivering on it. `DraggableResizableHudWidget`'s own centering (`GlassPanel(child: Center(child: widget.child))`) already satisfies "centered" — no change needed there. ## Implementation 1. Rewrite `lib/src/hud/hud_widget_layout.dart`: grid constants, `col`/`row`/`colSpan`/ `rowSpan` fields, `clampedToGrid()`, `hudRectsOverlap()`, updated `toJson`/ `fromJson`, updated `defaultFor`, new `nextFreeSlot`. 2. Rewrite `lib/src/hud/hud_layout_controller.dart`: `updatePosition`/`updateSize` take ints and reject-on-collision; `setVisible` uses `nextFreeSlot` when the metric's current slot collides. 3. Rewrite `DraggableResizableHudWidget`: local pixel-offset drag state, snap-on-end, updated callback signatures, pixel math from `areaSize / hudGridColumns` / `hudGridRows`. 4. Update `HudEditOverlay`'s two callback closures to the new int signatures. 5. Wrap `_HudMetricValue`'s `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/`overflow` on its two `Text`s. 6. `flutter analyze`, fix any resulting type errors elsewhere that referenced the old `x`/`y`/`width`/`height` fields (grep for `.x`/`.y`/`.width`/`.height` on `HudWidgetLayout` instances across `lib/` and `test/` to be thorough). ## Acceptance criteria - [ ] Dragging a HUD widget in edit mode snaps to a grid cell on release; the widget tracks the finger smoothly during the drag itself (no per-frame jump/snap while still moving). - [ ] Resizing via the handle snaps to whole grid cells on release, same smooth-during- drag behavior. - [ ] Dragging or resizing a widget onto a cell already occupied by another *visible* widget rejects the move — the widget returns to its last valid position/size, no crash, no silent overlap. - [ ] Re-enabling a hidden metric whose saved/default slot is now occupied by another visible widget places it in the next free grid cell instead of stacking on top of the occupier — this is the concrete fix for the "goes crazy" feedback. - [ ] Re-enabling a hidden metric whose slot is still free keeps its exact prior position (unchanged from today's guarantee, still worth re-verifying under the new model). - [ ] HUD widget text (label + value) never overflows or gets ellipsized at any allowed grid size, and stays visually centered within its widget, at both the smallest (`1×1`) and largest (`hudMaxColSpan × hudMaxRowSpan`) allowed sizes. - [ ] An old-shaped saved layout (pre-this-ticket JSON with `x`/`y`/`width`/`height` keys) loads without crashing — falls back to `defaultFor` per metric, exactly as `fromJson`'s existing try/catch already guarantees. - [ ] `flutter analyze` clean, `flutter test` green, test count only goes up. ## Tests `test/hud_widget_layout_test.dart` needs a full rewrite for the new field names/types — keep the same test *intents*, translated to grid coordinates: - JSON round trip (encode/decode a `col`/`row`/`colSpan`/`rowSpan`/`visible` layout exactly). - A malformed/missing-field JSON entry falls back to `defaultFor` (unchanged intent, new field names in the malformed input). - `defaultFor`: every metric gets a distinct (non-overlapping, via `hudRectsOverlap`) default rect; the first four metrics start visible, the rest hidden (unchanged intent). - `clampedToGrid`: a position/span pushed outside `[0, hudGridColumns)`/ `[0, hudGridRows)` or `[hudMinSpan, hudMaxColSpan/RowSpan]` is corrected back inside (parallel structure to the old `clamped` tests: past the right/bottom edge, past the left/top edge, below the legibility floor, above the ceiling, shrink-before-reposition, already-valid-is-unchanged). - New: `hudRectsOverlap` — two identical rects overlap; two rects sharing only an edge (touching, not overlapping) do not; two disjoint rects don't; a rect fully containing another does. - New: `nextFreeSlot` — given a set of `occupied` visible layouts that collide with a metric's own `defaultFor` position, returns some other rect that doesn't collide with any of them; given an empty/all-hidden `occupied` map, returns the same rect `defaultFor` would have (or at least a valid non-colliding one at `(0,0)`). `test/hud_layout_controller_test.dart` needs a parallel rewrite for the new int signatures and the new collision behavior: - `updatePosition`/`updateSize` update only the given metric, still true. - New: `updatePosition` to a cell already occupied by another visible metric is a no-op (state for the target metric is unchanged). - New: `updateSize` that would make a widget overlap a sibling is a no-op. - `setVisible(false)` then `setVisible(true)` restores the last position **when that position is still free** (keep this test, using coordinates guaranteed not to collide with anything else default-visible). - New: `setVisible(true)` when the metric's last position now collides with another visible widget places it somewhere else instead (assert the resulting `state[metric]` doesn't overlap anything, not a specific coordinate). - `persist`/pre-persist-not-visible-to-Config tests carry over unchanged in spirit (just using int fields). Widget-level: extend or add to `test/hud_edit_overlay_test.dart` — a drag gesture ending over an occupied cell leaves the dragged widget's rendered position unchanged from before the gesture; a resize past the point of colliding with a sibling leaves the widget at its pre-gesture size. Also add a widget test putting `_HudMetricValue` (or a representative HUD child) inside a very small `DraggableResizableHudWidget` (1×1 grid cell) and a very large one (`hudMaxColSpan`×`hudMaxRowSpan`) and asserting no `RenderFlex overflowed` exception/error is recorded by `FlutterError.onError` during the pump (the standard way to assert "no overflow" in a widget test — check `test/widget_test.dart`/other existing tests in this repo for the established pattern, if any, otherwise use `tester.takeException()` after pumping and assert it's `null`). ## Risks - `FittedBox(fit: BoxFit.scaleDown)` never enlarges text past its reference size, so a widget resized to the grid's maximum span shows the same 10/18px reference text as a 1×1 widget, just with more empty space around it — not larger text filling the space. This satisfies "no overflow" and "centered" exactly as asked; "text should grow to fill a bigger widget" is not explicitly requested and is treated as future work, not a silent gap — document this trade-off in the Outcome section. - Existing saved HUD layouts (from anyone who ran a build before this ticket) reset to defaults the first time they're loaded post-update, per the fromJson fallback. No user-facing warning is added for this — acceptable given there's no real user base yet; flag it anyway in Outcome for completeness. ## Out of scope FB-02 (hiding the idle Speed panel — separate ticket, this one only touches the recording-state HUD widgets themselves). Making widget text grow to actually fill a larger grid span (see Risks). Any change to which metrics exist or their default visibility set — `HudMetric`'s own enum and `label`s are untouched.