From cb35cc9c95104f5d93f55fd3f88067480e819c37 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 10:24:08 +0200 Subject: [PATCH] docs: the stopwatch, and why its reading is floored and banked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ARCHITECTURE.md` gains §15 for the stopwatch and records the one correctness find this slice turned up: a lap taken inside a segment a reboot later discarded would have dragged the readout, the lap totals and the shade backwards. The reading is floored at the last lap's total — and the floor is banked, not merely displayed, or the shade's free-running chronometer walks away from a tab that is standing still. The package, module, receiver, service and permission tables all count one more; §10 loses the two rows M7 closed. --- CHANGELOG.md | 16 +++ docs/ARCHITECTURE.md | 269 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 273 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aaeee7..f951629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,3 +138,19 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING - Progress on each timer card is drawn by the Expressive wavy indicator, and the wave itself is the state — it flattens when you pause and when the timer is done. +- The stopwatch tab, for real: start, pause and reset, with a readout down to + hundredths of a second that stays legible because the fraction is drawn + smaller than the seconds rather than shouting alongside them. +- Laps, each with its own split and its running total, newest at the top, and + the fastest and slowest of them marked — in colour and in words, so a screen + reader hears the distinction too. The lap you are in the middle of has its own + row, counting up. +- An ongoing notification with Lap and Pause in the shade, which costs no + battery to keep accurate because the system draws the ticking; Clockula posts + once when something actually changes. +- A stopwatch that keeps counting while the app is closed, and that comes back + after a restart paused — with every lap intact — at the time it had banked, + rather than inventing the time it lost. Changing the system clock, in either + direction, cannot move it at all. +- The app's big numbers now sit in fixed columns, so a readout no longer jitters + as its digits change — on every screen that shows one, not only the stopwatch. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 345421e..e4eceaf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Clockula — architecture -How Clockula is built **today**, after M3. Where something does not exist yet, +How Clockula is built **today**, after M7. Where something does not exist yet, this document says so and names the milestone that builds it, rather than describing a plan as though it were code. `PLAN.md` is the "why"; this is the "what, right now". @@ -81,15 +81,16 @@ kit module — the kit's roadmap keeps schedulers app-local. | `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt` — plain Kotlin, no Android | | `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 | | `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` | +| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` | | `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` | | `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` | | `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function | -| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — and `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data | +| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller | | `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` | -| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | +| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | | `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` | | `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations | | `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | @@ -105,13 +106,19 @@ kit module — the kit's roadmap keeps schedulers app-local. | `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) | | `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` | | `timer/di/` | `TimerModule` — `@Binds` for the three seams | +| `stopwatch/` | `StopwatchEngine` and the one seam it talks to — `StopwatchServiceHandle` — plus `StopwatchIntents` | +| `stopwatch/android/` | `ServiceStopwatchHandle`, the seam's Android implementation | +| `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) | +| `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` | +| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam | | `system/` | `RebootRepair` — the boot-id gate | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask | | `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | | `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) | | `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor | -| `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M7–M8 fill them | +| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` | +| `ui/worldclock/` | one top-level tab — a title bar and an empty state until M8 fills it | | `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | --- @@ -292,6 +299,12 @@ broken. Losing the user's row silently would be worse. index and the split, and inserts — so two concurrent laps cannot collide on the unique index. +**M7 changes no schema here.** The table already had every column the tab +needs, so the stopwatch milestone adds no column, no version bump and no +migration; `LapDao` gains exactly one `@Query("SELECT COUNT(*) …")`, which the +engine's lap cap reads so appending a lap never materialises 999 rows. The +database stays at **version 2**. + ### The repeat mask Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion @@ -480,6 +493,42 @@ knowingly-fabricated elapsed reading on screen for two more milestones was not defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its presentation.** +**M7 closes the loop.** `RebootRepair`, `pauseAfterReboot` and `snapshotAt` are +untouched; what M7 adds is what the user then sees, and it is decided in one +pure place, `StopwatchReadings.of` (§15). The rule it carries is that **the mode +comes from the snapshot, not from the stored row**: a `RUNNING` record whose +anchor did not survive reads `PAUSED` at what it banked, so a stale run shows +Resume · Reset rather than a Pause button and a ticking chronometer, on all +three surfaces at once. + +One honest consequence, recorded rather than papered over. A lap recorded inside +a segment the repair later discarded keeps its own cumulative, which can exceed +the run's banked `accumulated` — a stopwatch that ran forty seconds and lapped +twice, then met a reboot, has `accumulated = 0` and `lastLapCumulative = 40s`. +The reading is therefore **floored at the last lap's total** as well as at zero: +a readout sitting below a lap the same run printed is not one anyone can +believe, and the lap is a real measurement. The laps themselves are never +deleted to make the arithmetic tidy, and the in-progress row's split is clamped +at zero for the same reason. Reset clears all of it in one tap. + +The floor is **banked, not only displayed**. Resuming such a run writes +`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping +bank the same floored reading rather than the raw snapshot. Without that the +record would tick from below its own last lap: the tab would sit frozen at the +floored value for as long as the discarded segment lasted while the shade's +chronometer — derived from the floored reading once and then free-running — +counted on without it, and the next lap would record a cumulative *below* the +previous one and drag every readout backwards. `pauseAfterReboot` itself is +still untouched; the banking happens on the way back out, in +`StopwatchRepositoryImpl.start`, `pause` and `lap`. + +For the same reason the engine's verbs guard on **the reading's** mode rather +than the stored row's, so they agree with the controls the user is being +offered: between a reboot and the repair, a record that still says `RUNNING` +reads `PAUSED` everywhere, and its Resume button resumes (banking first, as the +repair would have) instead of hitting a silent "already running" no-op, while +Lap is refused rather than recorded below the last one. + --- ## 6. Preferences @@ -491,7 +540,7 @@ Four slices: |---|---|---| | Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names | | Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | -| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` | +| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` — the last of which M7 is the first caller of, for the in-progress lap's split | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | @@ -541,7 +590,7 @@ before the first frame, and the alternative is an app only "clear data" can fix. ## 7. Dependency injection -Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's: +Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's: | Module | Provides | |---|---| @@ -551,6 +600,7 @@ Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's: | `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` | | `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` | | `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` | +| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline | | `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") | From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the @@ -795,10 +845,9 @@ AppCompat's locale metadata holder service. | Not here | Milestone | |---|---| | The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 | -| Every tab's real content beyond Alarms and Timers — starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those two keep M4's title bars and empty states | M7–M8 | +| Every tab's real content beyond Alarms, Timers and the Stopwatch — the zone list and the analog face. The world clock keeps M4's title bar and empty state | M8 | | Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 | | A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest | -| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 | | ICU city and zone display names, offsets, day differences | M8 | | The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 | | The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 | @@ -809,6 +858,13 @@ AppCompat's locale metadata holder service. | A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 | | A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 | | Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 | +| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 | +| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 | +| A user-configurable lap cap or readout precision. Both are constants with reasons — 999 laps, hundredths — and M10's settings screen is not asked to carry them | not planned for v1 | +| Reordering, grouping or filtering laps. The list is index order, newest first, and that is the whole of it | not planned for v1 | +| A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 | +| An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 | +| Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 | | Screenshots and store listing polish | M11 | Also absent by design: any seeded default data — no starter alarm and no home @@ -1063,11 +1119,18 @@ from `TimerMode` in one total `when`. registration and a foreground service that have to move with the state, so pausing from the pill would otherwise leave the expiry slot pointing at a dead deadline and the service posting a stale notification. Self-healing — the next -sweep would find nothing due — but wasteful and wrong. The stopwatch branches -keep calling `StopwatchRepository` until M7. +sweep would find nothing due — but wasteful and wrong. -The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not -from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story, +**M7 did the same for the stopwatch branches**, for the same reason: after M7 +there is a foreground service that has to move with the state, so pausing from +the pill would otherwise leave the shade showing a running stopwatch, and Stop +would leave the service up with nothing to show. The pill's own contract is +unchanged — it shows for RUNNING and PAUSED, never IDLE; Stop is `reset()` and +never `delete()`; and it gets **no Lap button**, because the pill has two +controls by design. + +The mode and value come from `TimerReadings` / `StopwatchReadings`, both of them +over the stored row's *snapshot* rather than over the row — so §5's whole elapsed-realtime-versus-wall-clock story, stale anchor and all, is honoured once. The consequence that matters: a running timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting for the expiry sweep to write `markExpired`. The pill never counts into @@ -1584,3 +1647,185 @@ live pill already uses for an expired timer, so the two surfaces agree. The readout is `headlineLarge` and the setup panel's entry is `displayMedium` — plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets settled against a working stopwatch in M7, not guessed at in the scaffolding. + +--- + +## 15. The stopwatch + +Read this before changing anything stopwatch-shaped. + +### The shape + +`StopwatchEngine` is the third engine and a peer of the other two: plain Kotlin, +**no `android.*` import ever** (a build rule), reaching the platform through +**one** seam it does not implement, `StopwatchServiceHandle`. Every public entry +point is serialised on one non-reentrant `Mutex`, because they arrive from +threads that know nothing about each other — a notification-button broadcast, +the service's own collector, the tab's ViewModel, the shell's pill, +`ClockulaApp`'s launch pass. + +It is deliberately much smaller than `TimerEngine`, because the stopwatch has +less to own: **no AlarmManager registration at all** (nothing has a deadline), +**no ring** (nothing expires), **no arbitration** (it makes no sound), and one +record rather than N rows. What it does have is four verbs — `start`, `pause`, +`lap`, `reset` — reachable from the shade with no ViewModel in sight, which is +why they live on the engine and not on a screen. `StopwatchRepository` stays the +storage face and gained exactly one method, `lapCount()`; nothing above the +engine calls its lifecycle verbs any more, including `ShellViewModel` (§12). + +Like the other two it is not driven by a Flow: `resync()` is called explicitly. + +### The service type, and why not `systemExempted` + +`specialUse`, with a `stopwatch` subtype, and one new permission. The reasoning +is in §9 and is the decision in this milestone worth looking at hardest: the +easy answer would have the app tell the platform it is continuing alarm +functionality, which it is not. + +The service is up while the run is `RUNNING` **or** `PAUSED`, and down when it +is `IDLE` — exactly the live pill's predicate and exactly +`StopwatchReadings.isActive`, so the shade, the pill and the tab cannot disagree +about whether there is a stopwatch. `PAUSED` is in it so a user who paused from +the shade still has a Resume there. + +One consequence, accepted: after a reboot the repair leaves a running stopwatch +paused, so the service comes back up with a paused notification nobody asked +for. It is truthful — the stopwatch *is* paused and its laps are intact — and +its own Reset clears it in one tap. Resetting automatically would destroy laps +the user recorded, which is worse. + +### The chronometer base is derived and never stored + +The running notification is the **platform chronometer counting up**: +`setUsesChronometer(true)` + `setChronometerCountDown(false)` + `setWhen(base)`, +so the system renders the ticking and the service posts once per state change +rather than sixty times a second. The base is `wallClock.now() - elapsed`, +computed at post time and **never written anywhere** — which is how the +notification gets a wall-clock base without breaking §5's "`StopwatchRun` +carries no wall-clock field whatsoever", a rule a test asserts on the stored +bytes. The timer's equivalent base *is* a stored column and therefore needs +re-anchoring on a clock change; the stopwatch's needs nothing but a re-post, +which is all `onSystemTimeChanged()` does. + +The shade's readout is therefore whole seconds. A *paused* body is frozen, so it +shows the full `StopwatchFormat.precise` value — it is stable, and the precision +is the point of having stopped. The channel is its own, `stopwatch`, at +`IMPORTANCE_LOW` with sound and vibration off: nothing here ever needs to +heads-up, because nothing here ever finishes. That is why there is no +alert-once rule, no `alreadyAlerted` flag and no session window anywhere in this +path. Notification id 1_003; actions are broadcasts into +`StopwatchActionReceiver` carrying **no extras at all**, because there is +exactly one stopwatch. + +### The readout is hundredths, and it is two strings + +`StopwatchFormat.readout` returns a `StopwatchReadout(major, hundredths)`, not +one string: the tab draws `major` at the hero scale and `hundredths` two ranks +down in `onSurfaceVariant`, baseline-aligned. A readout whose hundredths are the +same size as its seconds makes the fastest-changing glyphs the loudest ones and +drags the eye off the number that matters. + +`major` **is** `ClockFormat.elapsed`, called rather than reimplemented, so the +stopwatch, the live pill and the timer row can never disagree about what a +duration reads, and the truncate-never-round rule is inherited rather than +restated. `hundredths` is `(millis % 1000) / 10`, zero-padded through +`Locale.ROOT` for the same glyph-column reason. Truncated, never rounded: a +stopwatch must never read ahead of reality. Hundredths and not tenths (too +coarse to time anything by hand) and not milliseconds (three digits that are +noise at 60 Hz). + +### `StopwatchReadings`: one reading, three callers + +`StopwatchReadings.of(run, elapsedRealtime)` is the single pure reading, and +`LivePillSelector`, `StopwatchNotificationPolicy` and `StopwatchRows` all call +it — the same extraction M6 made for `TimerReadings`, and for the same reason: +three copies of "what does this read right now" is how three surfaces start +disagreeing. `LivePillSelectorTest` passing **unmodified** is the proof the +extraction changed no behaviour. Its two rules are in §5: the mode comes from +the snapshot, and the value is floored at the last lap's total. + +### Best and worst + +Decided in `LapStats`, because the roadmap left them open: + +- **Fewer than three recorded laps ⇒ no emphasis at all** (`LapStats.MIN_LAPS`, + asserted as a constant so a change is deliberate). With one lap there is + nothing to compare; with two, "best" and "worst" mark every row and say the + same thing twice. +- **Ties go to the lowest lap index** — "first to achieve it" is the defensible + reading and it is deterministic. +- **If the fastest and the slowest split are equal, neither exists.** Every lap + identical is not a run with a best and a worst in it. +- **The in-progress lap is never emphasised** and is never in `LapStats`' input: + it has not finished, so it cannot be the fastest. A lap can therefore never be + both, which falls out of the rules rather than needing a guard. + +Best is `colorScheme.primary` and worst is `colorScheme.tertiary` — both scheme +tokens, and `error` is deliberately not used, because a slow lap is not a +failure. Colour is not the only channel: each row carries a content description +naming what it is, since a distinction a screen reader cannot hear is not a +distinction. + +### The tab + +A fixed readout and its controls **above** a lap list — scrolling a long list +must not put Lap out of reach mid-run — and the list runs **newest first**, +which is Google Clock's order (`PLAN.md` §11). A lap row is a three-column table +(index · split · total) and therefore a bespoke `Row` rather than a +`GroupedRow`: `ListItem` has one trailing slot and a table has two figures. + +The first row is the **lap in progress**: index `laps.size + 1`, cumulative the +current reading, split `elapsed - lastLapCumulative` floored at zero — which is +what `stopwatch_last_lap_cumulative_millis` has existed for since M2 with no +caller. It appears **only once at least one lap has been recorded**: with none, +its split *and* its cumulative equal the hero figure, so the tab would print the +same number three times. It is shown while paused too, frozen, because the user +stopped mid-lap and that is true. + +The vocabulary is **Start · Pause/Lap · Resume/Reset**. An idle stopwatch offers +one button: a Lap with nothing to lap and a Reset with nothing to reset are dead +controls. Reset is not offered while running — the user pauses first, which is +Google Clock again; the pill's Stop still resets from any state, which is M4's +contract and unchanged. The word "Stop" is deliberately absent from this tab: in +Clockula "Stop" already means "reset", and a Stop that paused would be the one +inconsistency in the app's verbs. + +The cadence is `StopwatchDefaults.ReadoutTick` — 16 ms, about 60 Hz, the +display's own cadence — and the ticker is subscribed **only while the stored +state is RUNNING**. A paused or idle stopwatch therefore costs nothing, and the +flow is distinct-until-changed, which absorbs the one case where the record says +running and the reading does not move. The cadence is a `Ticker` parameter, never +a `delay` at a call site. + +`StopwatchLaps.MAX = 999`. Beyond it the Lap button is disabled (carrying "Lap +limit reached" as its description) and `StopwatchEngine.lap()` returns null. An +unbounded table behind a button that can be held down is a storage leak the user +cannot see or clear except by resetting — and 999 keeps the index column three +digits wide, which is a layout fact as well as a limit. The guard is the +engine's, under its lock, over `lapCount()`. + +Deliberately no `MaterialShapes` morph and no wavy progress: a stopwatch has no +progress — there is nothing to be a fraction of — and a decorative arc would be +a lie drawn to two decimal places. + +### The typography, settled here for the whole app + +`ui/theme/Type.kt` carried this as a written promise from M0, and M7 settles it +as two things. + +`ClockulaTypography` applies **tabular figures** (`fontFeatureSettings = "tnum"`) +to the display, headline and title roles of the M3 default. That is the app-wide +half: every existing readout — `TimerRow`'s `headlineLarge`, `TimerSetupPanel`'s +`displayMedium`, the live pill's `titleMedium` — stops jittering as its digits +change, with **no call-site change anywhere**. Body and label roles keep +proportional figures, because prose is not a column of numbers. + +`ClockulaReadoutDefaults` names the three roles a readout needs, in the M3 +`*Defaults` shape (composable getters over `MaterialTheme.typography`, so a +theme change still flows): `Hero` (`displayLarge`, `FontWeight.Medium`), +`HeroFraction` (`headlineMedium`, about half the hero's size) and `Figure` +(`bodyLarge` with tabular figures — a figure inside a table row, the one place +body-scale text *is* a column of numbers). No new font, no size outside the M3 +scale, and no weight above Medium: `PLAN.md` §8's "Expressive ≠ big or bold" +applies hardest to the one screen most tempted to break it. A build rule keeps +`fontFeatureSettings` under `ui/theme/` so it is configured once.