docs: the stopwatch, and why its reading is floored and banked

`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.
This commit is contained in:
2026-09-22 10:24:08 +02:00
parent 83995c39db
commit cb35cc9c95
2 changed files with 273 additions and 12 deletions
+16
View File
@@ -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.
+257 -12
View File
@@ -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<ClockDefaults>` |
| 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.