diff --git a/UI_AUDIT.html b/UI_AUDIT.html new file mode 100644 index 0000000..d6d1382 --- /dev/null +++ b/UI_AUDIT.html @@ -0,0 +1,762 @@ + + +
+ + +OpenStrap · edge · v0.9.26+57 · UI/UX audit
++ The analytics engine returns six fields per metric. The interface renders one of them. + Every other problem in this audit is downstream of that. +
+ +Verdict
+5.9 /10
++ Not bad — undifferentiated. The craft is well above the Flutter-health median: + real tokens, real null-honesty copy, a measured contrast test already in CI. What it is not + is this product's interface. It renders like a competent WHOOP clone sitting on top + of an engine that could beat WHOOP on the one axis WHOOP can't compete on: telling the truth + about what it doesn't know. +
+The finding
+
+ Analytics hands the UI a Metric. It carries a value, a confidence, a tier
+ (AUTH / HIGH / ESTIMATE / RELATIVE),
+ the inputs it used, the drivers that moved it, and a note saying why it's absent. Five of those
+ six survive the trip to a pixel almost nowhere.
+
+ The band's whole claim is that it never guesses.
The interface renders that claim as a dash.
+
102
+bare — literals in lib/ui. 1 call site of needMessageFromNote, the function that turns need_baseline:have=3,need=14 into a sentence a human can act on. detail_cards.dart:818
1 of 13
+ArcGauge call sites pass confidence; it defaults to 1.0. Every sparkline, bar and time series has no confidence parameter at all. An ESTIMATE respiratory rate and an AUTH heart rate render as identical pixels. charts.dart:36
3
+reads of Metric.note across all 41,588 lines. The reason a number is missing is computed, stored, shipped to the device — and then dropped one layer before the eye.
0
+places where a drivers list is shown as an attribution. Oura's Contributors panel is the category baseline in 2026; this app computes something strictly better and never draws it.
+By discipline
+Design system — tokens, type, components
+6.5
+ +UX craft — states, a11y, motion
+6.0
+ +Information architecture — nav, screens, flows
+5.5
+ +Data visualisation — charts, gauges, hypnogram
+5.5
+ +
+ The design system scores highest because it exists: a 4 pt spacing scale at 93% adoption,
+ 753 AppColors and 615 AppText call sites, dark mode re-derived rather than
+ inverted. The failure is not absence of a system — it is two systems, both re-exported
+ from the same barrel, with 21 files importing both. design.dart:36
+
Ranked findings
+| Sev | Finding | Where | Fix |
|---|---|---|---|
| P0 | +Muted caption text fails WCAG AA — 2.71:1, 2.40:1 and 2.20:1 depending on surface. It is the app's caption and label voice, so it fails everywhere. | +theme.dart:159,165 223 uses |
+ Two hex values. A contrast test harness already exists. | +
| P0 | +Zero reduced-motion support. 13 infinite .repeat() loops, including a 2.6 s breathing icon on every empty state that never stops. |
+ motion.dart:32 state_card.dart:43 |
+ One guard inside the DsMotion extension fixes all 34 caller files. |
+
| P0 | +A 958-line design gallery ships in release builds, routed from Settings under a "Developer" heading. | +profile_screen.dart:747 | +Wrap the route in kDebugMode. |
+
| P0 | +No downsampling anywhere. A night of 1 Hz HR is ~30,000 chart points per frame, with an O(n) linear scan on every drag pixel. | +charts.dart:381,624 | +Min/max bucketing to 2× pixel width. _bucketAvg already exists — promote it. |
+
| P0 | +The Insights screen returns SizedBox.shrink() while loading and when empty. Its content silently does not exist. |
+ coach_cards.dart :176,365,436,534 |
+ Skeleton, then StateCard with the have/need line. |
+
| P1 | +Today's tiles push Sleep/Heart/Body — the same classes that are already tabs 1/2/3. Two live instances with divergent scroll and drill state; "back" means two different things depending on how you arrived. | +today_screen.dart:441 vs app.dart:236 |
+ Tiles switch tabs. Never push a destination that is also a tab. | +
| P1 | +The onboarding validator contradicts its own copy: the screen promises "leave a field blank and only that metric stays unknown — never guessed", then requires all four fields. | +profile_setup_screen .dart:139 vs :193 |
+ Allow blanks. Gate Continue on sex alone. | +
| P1 | +No escape from pairing. Bond-refused and picker-cancelled both fall through to one generic "Couldn't pair". This is the drop-off cliff of a hardware app. | +pairing_screen.dart :178,190 |
+ Distinct refused/cancelled states, plus a skip-to-shell path. | +
| P1 | +The app's most visible element ignores the type scale. BigStat and MetricCard override AppText.hero/display/metric; hero numbers render at 26, 27, 36, 40, 44, 46, 48, 52, 54, 64 and 72 px across screens, at 14 different letter-spacings. |
+ big_stat.dart:66 metric_card.dart:112 |
+ Promote metricXl…metricSm into AppText; delete the overrides. |
+
| P1 | +A shadow chart library. Five line-chart implementations, three gauges, two hypnograms, two sparklines — and coach_render.dart's copies interpolate straight across null gaps, which is a lie in a null-honest app. |
+ coach_render.dart coach_chart.dart |
+ One engine each. ~400 lines net deletion. | +
| P1 | +A dual-axis chart normalises each series to its own min/max on a shared grid. Every crossing and convergence it draws is an artifact. | +coach_render.dart:234 | +Delete. Two stacked charts, or index to a common base. | +
| P1 | +Journey — the richest view in the app at 1,300+ lines — is reachable through exactly one card that hides itself for the first 24 hours. New users never learn it exists. | +today_screen.dart:416 | +Promote to a tab. | +
| P2 | +23 Semantics against 236 onTap:. Charts are blanket-ExcludeSemantics. Confidence is a 7 px hue-only dot with no label. |
+ kit.dart:498 charts.dart:437 |
+ Semantic labels on every interactive surface; colour + word + shape for status. | +
| P2 | +Sub-44 pt tap targets on primary controls (34 pt segmented controls, 40 pt back button); 68 candidate sites. | +controls.dart:70 app_scaffold.dart:43 |
+ Enforce the floor inside Pressable so no call site can opt out. |
+
| P2 | +Unbounded workout history built into a non-lazy ListView(children:), each row wrapped in its own animation ticker. One ListView.builder exists in the entire UI. |
+ workouts_screen.dart :354,461 |
+ ListView.builder; stagger only the first viewport. |
+
| P2 | +Hypnogram — the signature visual of any sleep app — has no minimum segment width (a 30 s wake epoch is sub-pixel on an 8 h night), no time axis, no scrub, and shouldRepaint => true. |
+ hypnogram.dart:99,123 | +Clamp to ≥1.5 px, add hour ticks and scrub-to-read. | +
| P2 | +Four god-screens: 2,728 / 2,208 / 2,113 / 1,700 lines. Meanwhile ~5,300 bespoke day-view lines sit beside a 489-line generic one that already works. | +live_session · workouts profile · today |
+ Split by responsibility; route the rest through MetricScreen. |
+
| P2 | +The app calls itself "OpenStrap" during onboarding and "Edge" once you're inside. Jargon leaks as primary labels: MS RMSSD, pNN50, SD1/SD2. |
+ welcome_screen.dart:365 spot_check_screen.dart:186 |
+ One name. Method notes hold the method names, not stat captions. | +
Ship blockers
+2.20:1 at worst against a 4.5:1 requirement, across 223 call sites. Two hex values in theme.dart.
Users with vestibular disorders get a permanently pulsing empty state and a shimmer that never stops. Also a ticker burning battery on an idle screen.
+A design gallery and a fake-route fixture reachable from user-facing Settings.
+Every sleep detail view rebuilds a 30k-segment path per frame and scans it linearly on each drag pixel. This is the jank users will describe as "the app feels cheap".
+Target
++ Not "more polish". The target is an interface whose structure is the honesty guarantee, + where a fabricated number is unrepresentable rather than merely avoided. +
+ +1
+A dash is a bug. One MetricValue widget: value → note-derived reason ("4 of 14 nights") → device reason ("not synced since 2 pm") → a dash only as an assertion failure. Target: ≤5 literal dashes in lib/ui, down from 102.
2
+Tier is an encoding, not a badge. ESTIMATE renders in lighter weight with a hatched band; RELATIVE plots against a zero-line that means "your normal"; confidence below 0.4 dashes the stroke. Enforced in the one chart engine so no call site can regress it. No yellow, no warning triangle — the way Apple Weather greys distant forecast days.
3
+Drivers are the hero, not a tap-through. Three signed attributions under the score, always expanded — "HRV −18% vs baseline · sleep debt 1 h 40 m · late alcohol logged". This is Oura's Contributors panel, except causal, and the engine already computes it.
+4
+Day 0 shows a contract, not a skeleton. No zeros, no shimmer implying a number is imminent: "Readiness needs 3 nights. 1 done. First score Thursday morning" — alongside live raw HR, the only thing that is real on day 0 and the proof the band is talking.
+5
+One hero, not three dials. WHOOP's three-dial home forces the user to arbitrate. Readiness alone above the fold in the morning; strain-so-far against today's ceiling in the evening; everything else earns its way below. The AI briefing is not a co-hero — an LLM paragraph at the top makes a local-first app look like a wrapper.
+6
+The receipt. Every score links to its inputs: which records, which gaps, coverage in real units ("6 h 12 m of 7 h 40 m in bed"), which algo version. When a version change re-scores a past day, say so on the card instead of silently mutating history.
+7
+Lead with what WHOOP cannot do. A smart alarm that wakes you in light sleep, using live per-second staging, with a morning receipt — "woke you at 06:31, 4 minutes into light sleep". Plus a raw 1 Hz viewer and one-tap export. No subscription product can offer either.
+Sequence
+1
+Roughly a day. Almost entirely mechanical.
+theme.dart fix contrast across 223 call sites — then assert it in CI, which the palette already supports.DsMotion extension gives 34 files reduced-motion; kill the 13 infinite loops.kDebugMode wrapper removes the gallery from release.Pressable, so no future call site can opt out.timeline_screen.2
+The phase that changes what the product is.
+MetricValue replaces all 102 dashes with reasons.confidence and tier become required parameters on every series-bearing widget.3
+Net deletion. The diff should be negative.
+kit/design pair, codemod, delete the loser, drop the re-export.fontSize:, Color(0x, Colors.white and literal dashes — the harness already exists in dart_source_test.dart, and it is the only fix in this document that is permanent.4
+Where it stops looking like a hobby build.
++ The uncomfortable summary: the hard part is already built. On-device staging, corrected RR + intervals, personal baselines, per-metric confidence, honest nulls — years of work that no + competitor ships. The interface throws away five of the six fields that work produces, and + then looks like every other recovery app. +
++ Phase 1 is a day. Phase 2 is what makes it this app. +
+