Skip to content

Latest commit

 

History

History
473 lines (331 loc) · 33.5 KB

File metadata and controls

473 lines (331 loc) · 33.5 KB

Gym Timer, Technical Specification

Version 1.5 Status: complete, ready for implementation Scope: functional and architectural only. All visual design, layout, typography, and colour decisions are excluded and are handled separately.

Changes from v1.0

  • All time values display whole seconds. Hundredths are gone from the lap timer.
  • The render loop drops from requestAnimationFrame to a fixed interval, since nothing on screen changes faster than once per second.
  • The offline requirement is removed. The app assumes an internet connection is normally available and may load assets from a CDN.
  • A template/ directory holds a read-only UI reference produced by Claude Design.

Changes from v1.1

  • Keyboard handling gains an explicit exception: when a control with no key binding (Stop, Import, Export) holds keyboard focus, Space and Enter are left to native button activation. Without this, the document-level handler would make those controls unreachable by keyboard.
  • A successful import shows a brief non-blocking notice stating how many sessions were added, using the same notice element as error reporting.
  • The three timer displays are derived from a single floored session-seconds value per render, so they tick over at exactly the same instant (see §4).

Changes from v1.2

  • An alt mode toggle ("Alt", in the masthead) supports two alternating exercises with a two-slot lap pipeline (see §5.3).
  • Live laps are stored as {dur, at} objects (duration and session-elapsed at log time) instead of bare durations, because alt-mode laps overlap in time and their "session at" can no longer be derived by summing durations. The gymtimer.live schema is therefore now 2; schema-1 records are discarded on load, which at worst loses one in-flight session. History records are unchanged.

Changes from v1.3

  • The history record changes shape: durationMs becomes duration, an ISO 8601 duration string, and three fields are added — end (ISO 8601 instant) and startTS / endTS (human-readable local time, minute precision). gymtimer.history is therefore now schema 2. No migration is provided: schema-1 records are not readable, on import or on load, and are handled by the existing rules for an unreadable file (§11).
  • A Help control in the masthead, bound to ?, opens a modal listing every command with its key binding and its voice words (§7.6).

Changes from v1.4

  • Escape is bound to Stop, valid from RUNNING and PAUSED (see §7.1). While the help modal is open, Escape keeps closing the modal instead, exactly as before; it does not also stop the session.
  • The Lap command is labelled Next wherever the UI names it (primary control, timer heading, help modal row), matching the next voice synonym it already had. The action itself, its state transitions, and its Lap name in this specification are unchanged — only the on-screen label moved.

1. Purpose

A single-screen stopwatch for use during gym sessions. It tracks:

  • the time since the last lap mark, which in practice is a set or a rest interval,
  • the duration of the current session,
  • the total training time accumulated across every session ever recorded.

The primary interaction is a single repeated action: mark a lap. Everything else is secondary.


2. Constraints

Constraint Requirement
Hosting Static files only. Served locally or from any static host. No backend, no server-side logic.
Network An internet connection is assumed to be available. External assets such as fonts and icons may be loaded from a CDN.
Resilience The timer's behaviour must not depend on any external asset. If a CDN fails, the app still runs and remains usable.
Storage Browser-local only. No accounts, no sync service.
Portability Data moves between devices via manual JSON file export and import.
Dependencies No runtime JavaScript dependencies. No build step, no bundler, no package manager.
Platforms Phone and desktop browsers, treated as equally important.

3. Architecture

3.1 File layout

index.html
app.js
voice.js           on-device voice control, specified separately in voice.md
styles.css
s.webm             action click sound
template/          reference only, never served, never modified

app.js is loaded as an ES module (<script type="module" src="app.js">). No bundler, no transpiler, no node_modules.

3.2 Serving

Any static file server. For local use, python3 -m http.server 8000 or equivalent. Opening index.html over the file:// protocol is not supported, because ES modules and the wake lock API both require an HTTP origin.

3.3 Rationale

The application consists of three derived time values and one list. A framework would introduce a build step, a dependency tree, and a toolchain to maintain, in exchange for state management this app does not need. Plain ES modules keep the whole thing readable in one sitting and editable with no tooling.

External CSS assets are a different matter and are permitted, because they replace hand-written work rather than adding machinery. They are also purely presentational, so they carry no risk to correctness.

3.4 UI design reference

The template/ directory contains an HTML file produced by Claude Design. It is the authoritative reference for the visual design: layout, type scale, hierarchy, colour, spacing, and control treatment.

Rules:

  • The contents of template/ are read only. Nothing in that directory is edited, refactored, reformatted, or deleted during implementation. It stays exactly as Claude Design produced it, so it remains a clean point of comparison and can be regenerated or replaced independently.
  • It is a reference, not a runtime asset. It is never served, never linked, never imported, and never fetched. The application must behave identically whether the directory is present or absent.
  • The working index.html and styles.css are written by adapting from the reference, not by pointing at it.
  • Where the reference and this specification disagree on behaviour, structure, or naming, this specification wins. The reference has authority over appearance only.
  • The reference is expected to be a static mockup. It may show a single state, may contain placeholder times and placeholder lap rows, and may lack the markup hooks the application needs. Adding ids, data attributes, and the state variants described in sections 5 and 7 is part of implementation, and is done in the working files rather than in template/.
  • If the design is revised, the new file replaces the old one in template/. The working files are then updated to match. The reference is never edited in place to reflect a change made in the app.

3.5 Suggested internal structure

Not mandatory, but the natural decomposition is:

  • State module: holds the timer state object and the transition functions, and is the only thing that mutates state.
  • Persistence module: reads and writes the two localStorage keys, and handles import and export.
  • Render module: reads state, writes to the DOM. Pure output, no state mutation.
  • Input module: binds keyboard and pointer events to transition functions.
  • Wake lock module: isolated so its failure cannot affect anything else.

4. Core model

Three time values are displayed simultaneously and at all times, in every state.

Value Definition Display format Resets on
Lap Time since the last lap mark, or since session start if no lap has been marked M:SS Lap, Start
Session Total running time of the current session, excluding paused time H:MM:SS Start
Lifetime Sum of all completed session durations, plus the current session's elapsed time H:MM:SS Never

Rules:

  • Marking a lap resets only the lap value. Session and lifetime continue uninterrupted.
  • All values are stored internally as milliseconds and floored to whole seconds for display. Flooring, not rounding, so the display never shows a second the timer has not actually reached.
  • All three displays are derived from a single floored session-seconds value per render (lap = sessionSec − floor(lapBaseline), lifetime = floor(historyTotal) + sessionSec), rather than flooring three independent millisecond values. Independent flooring puts each value's second boundary at a different sub-second offset, so the displays tick over on different render ticks, which is visually distracting. Deriving from one value makes all three flip at exactly the same instant.
  • The lap display uses M:SS with no hour component. A lap running past 59:59 rolls into three-digit minutes rather than gaining an hours field. A rest interval over an hour means the session is effectively over, and the format should not be complicated for a case that does not happen.
  • In IDLE, lap and session display zero and lifetime displays the stored historical total.
  • Lifetime is always derived by summing the stored history at read time, never maintained as its own stored counter. An import, or a manual edit of the history file, therefore produces a correct lifetime immediately with no reconciliation step.

5. State machine

Three states: IDLE, RUNNING, PAUSED.

5.1 Transitions

Action Valid from Result Effect
Start IDLE RUNNING Begins a new session. Session and lap timers start from zero. Lifetime begins advancing.
Lap RUNNING RUNNING Appends the current lap duration to the live lap list, then resets the lap timer to zero without stopping it.
Pause RUNNING PAUSED All three timers freeze.
Resume PAUSED RUNNING All three timers continue from where they froze.
Stop RUNNING, PAUSED IDLE Finalises the session, appends a summary to history, clears the live lap list and live state.

Any action not listed as valid from the current state is ignored. It is not an error, it produces no feedback, it simply does nothing.

5.2 Rules

  • Paused time never counts toward the session duration, and therefore never counts toward lifetime.
  • A lap cannot be marked while paused.
  • Stop always writes a session record to history, unconditionally. There is no minimum duration, no minimum lap count, no discard threshold. A session started and immediately stopped produces a record with a near-zero duration and zero laps. This is deliberate: threshold logic is a source of surprise, and a stray two-second record is harmless inside a lifetime total.
  • There is no undo, no lap deletion, and no lap editing. A mistakenly marked lap stays. This is the single largest deliberate reduction in scope in this specification.

5.3 Alt mode

A small "Alt" toggle in the masthead, for tracking two alternating exercises. The preference persists in its own localStorage key (gymtimer.alt) and can be flipped in any state.

When enabled, the lap display becomes a two-slot pipeline of live counters, both ticking:

  • Initially only the current counter runs.
  • Lap slides the current counter into the previous slot without resetting it — it keeps running — and starts a fresh current counter at zero.
  • The next Lap logs the previous counter to the lap list, slides the current counter (still running) into the previous slot, and starts a fresh current counter.

The two counters are stacked, current above previous, at the same size as the single counter in normal mode.

A counter therefore lives across two lap intervals before it is logged, so a recorded lap in alt mode spans two presses (from the start of exercise A until the switch away from exercise B), and consecutive rows overlap in time. Each lap stores its own log-time session value; the lap list's "session at" column is that value, not a cumulative sum.

Turning alt mode off while a previous counter exists logs that counter immediately so its time is not lost. Turning it on mid-session simply leaves the previous slot empty until the next lap. The previous slot shows when empty. Stop discards both in-flight counters, exactly as it discards the single in-flight lap in normal mode.

State field: altBaselineMs — session elapsed at which the previous counter started, or null when the slot is empty. The previous display derives as sessionSec − floor(altBaselineMs).


6. Timekeeping

6.1 Principle

Elapsed time is always derived from stored epoch timestamps, never accumulated by adding a delta on each tick. Tick accumulation drifts, and it drifts badly when the tab is backgrounded, which is exactly what happens when a phone screen turns off mid-session.

This principle matters more now than in v1.0, not less. A slower render loop would compound accumulation error, but because nothing is accumulated, the loop's frequency has no effect on accuracy at all.

6.2 State fields

Field Meaning
sessionStart Epoch milliseconds at which the session was started. Used for the history record.
accumulatedMs Total running milliseconds banked before the most recent resume.
runningSince Epoch milliseconds at which the current running stretch began. Null when paused.
lapBaselineMs Value of session elapsed at the moment the last lap was marked.

6.3 Derivations

sessionElapsed = accumulatedMs + (state === RUNNING ? Date.now() - runningSince : 0)
lapElapsed     = sessionElapsed - lapBaselineMs
lifetimeTotal  = sum(history[].durationMs) + sessionElapsed

6.4 Transition effects on the time fields

  • Start: sessionStart = now, accumulatedMs = 0, runningSince = now, lapBaselineMs = 0.
  • Lap: append lapElapsed to the lap list, then lapBaselineMs = sessionElapsed.
  • Pause: accumulatedMs = sessionElapsed, runningSince = null.
  • Resume: runningSince = now.
  • Stop: compute final sessionElapsed, write the history record, clear all fields.

Lap durations are stored in the list in full millisecond precision, and floored only at display time. This keeps the sum of displayed laps from diverging noticeably from the session total.

6.5 Render loop

A fixed setInterval at 250 ms, running only while state is RUNNING.

  • 250 ms guarantees the display updates within a quarter second of every real second boundary, which is imperceptible on a value read at a glance.
  • Each tick derives the three values, formats them, and writes to the DOM only where the formatted string has changed. In practice this means roughly one DOM write per second per field, and three ticks out of four do nothing.
  • The interval is cleared on Pause and on Stop, so a paused or idle app performs no work at all.
  • An additional render is forced on visibilitychange when the document becomes visible, and immediately after every state transition, so the display is never stale for even a quarter second after a user action.

requestAnimationFrame is not used. It exists to synchronise with the display refresh rate, which is irrelevant when the fastest-changing value updates once per second.

Because all values are derived from timestamps, a tab that has been hidden for an hour shows correct values on its first tick back, with no catch-up logic.

6.6 Clock changes

Date.now() is used throughout, so a device clock change or a daylight saving transition mid-session will distort the recorded duration. This is accepted. performance.now() would avoid it but would not survive a page reload, which is a requirement that matters more.


7. Input

Both keyboard and touch are first-class. Neither is a fallback for the other.

7.1 Key bindings

Key IDLE RUNNING PAUSED
Space Start Lap ignored
Enter ignored Pause Resume
Escape ignored Stop Stop
? Help Help Help

? toggles the help modal (§7.6) and is the only binding that does the same thing in every state. It is also the only binding that works while the modal is open, where it closes it; Escape closes it too, natively — and while the modal is open, that native close is all Escape does. It does not also stop the session.

Space is deliberately inert while paused. Resuming requires either Enter or an explicit tap on the Resume control. This prevents a pocketed phone or a leaning forearm from silently restarting the clock during a long rest.

Escape stopping the session is deliberate despite Stop otherwise requiring a deliberate tap (§7.3): Escape is not reachable by accident the way a mis-tap is, and a keyboard user needs a fast way to end a session without hunting for the least prominent control on screen.

7.2 Keyboard handling requirements

  • Space, Enter, and Escape all call preventDefault() when they act. Without it, Space scrolls the page.
  • All controls are <button type="button">.
  • After any pointer interaction with a button, that button must be blurred. Otherwise the browser fires the focused button on the next Space or Enter press and the action runs twice.
  • Key handling is bound at the document level, not to a focused element.
  • Repeat events from a held key are ignored. Only the initial keydown counts.
  • Exception: when a control that has no Space/Enter binding (Stop, Import, Export) holds keyboard focus, Space and Enter are left to native button activation and the document-level handler does nothing. This keeps those controls operable by keyboard. Pointer users are unaffected, because pointer interaction blurs the button. Stop's own Escape binding is unaffected by this exception — it is not Space or Enter, so it always reaches the document-level handler.
  • While the help modal of §7.6 is open, the document-level handler ignores Space and Enter. They belong to the dialog, and starting or lapping a timer from behind an open modal would be invisible to the user. ? still applies, and closes it; so does Escape, natively, per §7.1.

7.3 Pointer controls

  • A large primary target performs the action in the Space column for the current state. It should be the dominant interactive element, sized to be hit without looking.
  • A secondary control performs the action in the Enter column. It must be visually and physically separated from the primary target to avoid mis-taps.
  • A Stop control is present, is the least prominent, and is bound to no key. Ending a session should require deliberate intent.
  • Labels on the primary and secondary controls change with state, so the currently available action is always readable.
  • A Help control sits in the masthead, small and low in prominence, and opens the modal of §7.6.

7.4 Screen wake lock

While state is RUNNING, the app requests a screen wake lock so the phone does not sleep mid-session.

  • Call navigator.wakeLock.request('screen'), guarded by a feature check and wrapped in a try/catch. Any failure is silent and the app continues normally.
  • Retain the returned sentinel so the lock can be released explicitly rather than left to garbage collection.
  • Release the lock on Pause and on Stop.
  • Re-request on visibilitychange when the document becomes visible and the state is still RUNNING. Browsers release the lock when a tab is backgrounded and do not restore it automatically.
  • Requires a secure context. localhost qualifies, so local development works. Serving from a LAN IP over plain HTTP does not, and the call silently does nothing.

7.5 Click sound

Every executed action plays s.webm (a short click, shipped alongside the app files), regardless of how it was triggered — button, key, or anything else. Actions covered: Start, Lap, Pause, Resume, Stop, Export, Import (on file selection, valid or not), the Alt toggle, and opening or closing the help modal. An action that is ignored in the current state plays nothing, consistent with §5.1's silent-ignore rule. One shared Audio element, rewound and replayed on each play. Any failure — missing file, autoplay policy, unsupported codec — is silent and must never affect the timer.

7.6 Help

A modal listing every command the app accepts, so neither the key bindings nor the voice vocabulary has to be discovered by experiment.

  • Opened from a control in the masthead, and by the ? key from anywhere. ? closes it again, as do Escape and a click outside the panel.
  • One row per command, in the order Start, Next, Pause, Resume, Stop, Alt, Export, Import, Help. Each row shows the command name, the states it is accepted in, its key binding, and its voice words. The modal lists itself, since it now has a key binding of its own. "Next" is the on-screen label for the Lap command (§4, §5.1); the row's voice words are still lap, reset, and next, per the vocabulary table (voice.md §4).
  • Commands with no key binding and commands with no voice word show an explicit dash rather than an empty cell, so absence reads as deliberate.
  • The voice column is rendered only when voice reports supported (voice.md §5). On a browser where no voice interface exists, listing voice commands would advertise something the user cannot reach, which is the failure voice.md §5.2 exists to avoid.
  • The listed voice words are derived from the same vocabulary table the host gates commands with. They are never a second, hand-maintained copy.
  • Opening the modal does not touch timer state and is available in every state.
  • Closing it leaves no button focused, by whichever route it closed. Otherwise the dialog's own Close control, or the masthead control that opened it, would hold focus and take the next Space as a native activation under the §7.2 exception, costing the user a lap.
  • The row contents are the only place the app describes its own bindings; §7.1 and voice.md §4 remain the specification, and the modal must agree with them.

8. Lap list

  • Rendered below the timers, most recent entry at the top.
  • Each row shows: lap number, lap duration in M:SS, and the session time at that mark in H:MM:SS.
  • Lap numbers ascend with time, so the newest lap carries the highest number and sits at the top. The list therefore reads as a descending sequence.
  • The lap currently in progress is not a row. It is the large lap display above.
  • The list is live only. It is cleared on Stop and on Start, and it is never written to history or included in an export.
  • No maximum length. A long session simply scrolls. Expect up to roughly 40 rows.

9. Persistence

Two primary localStorage keys, with distinct lifecycles and distinct purposes. (Two further keys hold device preferences: gymtimer.alt for the alt-mode toggle and gymtimer.settings for voice, per voice.md §13. Preferences are never exported.)

9.1 gymtimer.live

The in-progress session. Its only job is to survive a page reload.

Written on every state transition, and additionally on visibilitychange when the document becomes hidden, which is the last reliable moment before a mobile browser may discard the page.

{
  "schema": 2,
  "state": "RUNNING",
  "sessionStart": 1753600000000,
  "accumulatedMs": 812340,
  "runningSince": 1753600812340,
  "lapBaselineMs": 750000,
  "altBaselineMs": null,
  "laps": [{"dur": 45230, "at": 45230}, {"dur": 61100, "at": 106330}]
}

Cleared on Stop.

Reload behaviour. On load, if this key exists:

  • If state is RUNNING, the session resumes with the clock advanced by the real wall time that passed while the page was gone. This is what a physical stopwatch does, and a reload during a session is almost always accidental.
  • If state is PAUSED, the session resumes in the paused state with no time added.
  • If the record is malformed or its schema is unrecognised, it is discarded and the app starts in IDLE. A corrupt live record must never block startup.

9.2 gymtimer.history

An append-only list of completed session summaries. This is the permanent record and the sole basis for the lifetime total.

{
  "schema": 2,
  "sessions": [
    {
      "id": "2026-07-27T09:14:03.221Z",
      "start": "2026-07-27T09:14:03.221Z",
      "end": "2026-07-27T10:19:24.221Z",
      "startTS": "Jul 27 2026 9:14 AM",
      "endTS": "Jul 27 2026 10:19 AM",
      "duration": "PT1H5M21S",
      "lapCount": 24
    }
  ]
}
Field Type Notes
id string ISO 8601 timestamp of session start. Doubles as the dedupe key for imports.
start string ISO 8601 instant, UTC, same value as id, kept separate so id can change format later without breaking meaning.
end string ISO 8601 instant, UTC. start plus duration, which is the wall-clock end only for a session that was never paused.
startTS, endTS string The same two instants in the device's local time zone, formatted MMM D YYYY h:mm AM/PM, e.g. Jul 7 2026 5:07 PM. Minute precision, no seconds. Present so a raw export is readable without converting UTC by hand; the app writes them and never parses them back. Month names are fixed English abbreviations (internationalisation is out of scope, §12).
duration string ISO 8601 duration of running time only, paused time excluded. Written as PT[nH][nM][nS], with seconds carrying up to three decimal places, so full millisecond precision is retained even though display is in whole seconds. A zero-length session is PT0S.
lapCount integer Number of laps marked. Individual lap times are not retained.

duration is the sole source of the lifetime total, so it is parsed on read. Because a record is only ever accepted if its duration parses, and because the parse is not repeated per render tick (the total is cached and recomputed whenever the history object is replaced), this costs nothing at render time.

Individual lap times are intentionally discarded at Stop. They are useful during a session and of no use afterward, given that exercise names, weights, and reps are out of scope, so a lap time cannot be attributed to anything.

9.3 History visibility

History is never rendered in the application. There is no history screen, no session list, no statistics view. The only value derived from it and shown on screen is the lifetime total. The JSON export is the archive and the only way to inspect past sessions.

9.4 Storage failure

If localStorage is unavailable or a write throws, for example due to a quota error or private browsing restrictions, the app must continue to function as an in-memory timer for the current session. A persistence failure must never prevent the timer from running. A single non-blocking notice is acceptable, but it must not interrupt the primary interaction.


10. Import and export

10.1 Export

  • Produces a file named gymtimer-YYYY-MM-DD.json, dated with the export day.
  • Content is the complete gymtimer.history object, unmodified, pretty-printed.
  • Implemented with a Blob and an object URL applied to a temporary anchor. The object URL is revoked afterward. No library.
  • Available in any state, including mid-session. It exports completed sessions only, so the in-progress session is not included.

10.2 Import

  • Triggered by a file input restricted to .json.
  • The file is parsed and validated before anything is written.

Validation rules. The file is rejected in full unless all of the following hold:

  • It parses as JSON and the root is an object.
  • schema equals 2.
  • sessions is an array.
  • Every element has an id, start, end, startTS and endTS that are non-empty strings, a duration that parses as an ISO 8601 duration in the form written by §9.2, and a lapCount that is a non-negative finite integer.

A schema-1 file is rejected like any other unreadable file. There is no migration path: the older format is not converted, on import or on load.

Partial import is not permitted. A file is either fully valid and fully applied, or rejected with an explanatory message and no change to stored data. A successful import shows a brief non-blocking notice stating how many sessions were added.

Merge strategy. Imported sessions are merged with existing history:

  • Records are deduplicated by id, with the existing record winning on collision.
  • The union is sorted ascending by start.
  • The result replaces gymtimer.history.

This makes the operation idempotent and order-independent, so a phone file and a laptop file can be imported into each other in either direction, repeatedly, and both devices converge to the same history.

Import never touches gymtimer.live. Importing during a session is safe and leaves the running timer untouched, though the lifetime display will jump to reflect the newly merged sessions.

10.3 Schema versioning

Both stored objects carry a schema integer: gymtimer.live is at 2 and gymtimer.history is at 2. Any future format change increments it, and the loader either migrates or discards older records. Records with a schema the app does not understand, higher or lower, are treated as unreadable and rejected rather than guessed at.


11. Error handling summary

Situation Behaviour
Corrupt or unreadable gymtimer.live Discard, start in IDLE.
Corrupt or unreadable gymtimer.history Treat as empty, and do not overwrite it until an action writes history. Warn once.
localStorage write fails Continue in memory. Warn once, non-blocking.
Wake lock unavailable or rejected Silent, no warning.
External asset fails to load Silent. The app falls back to system fonts and text labels and remains fully usable.
Invalid import file Reject entirely, explain why, change nothing.
Action invalid for current state Ignore silently.

12. Out of scope

Explicitly excluded by decision rather than omission:

Exercise names, sets, reps, weights, or any training data beyond time. Rest targets or countdowns. Vibration or notifications of any kind. The only sound is the button click (§7.5). Sub-second precision anywhere in the interface. Undo, lap deletion, or lap editing. A visible history or session browser. Charts, statistics, or trends. Per-lap labels or notes. Multiple concurrent or named timers. Weekly, monthly, or any windowed totals. Cloud sync or accounts. Offline support, service workers, and installability. Internationalisation. All visual design decisions.


13. Acceptance criteria

The implementation is correct when all of the following hold.

  1. In IDLE, lap and session read zero and lifetime reads the sum of stored history.
  2. Pressing Space from IDLE starts all three timers from the correct bases.
  3. Pressing Space while running resets the lap display to zero, leaves session and lifetime unaffected, and adds one row to the top of the lap list.
  4. The new lap row shows the duration of the lap that just ended and the session time at which it ended.
  5. Every displayed value shows whole seconds only, floored, with no sub-second digits anywhere.
  6. Pressing Enter while running freezes all three displays. Pressing Space while paused does nothing.
  7. Pressing Enter while paused resumes all three displays from their frozen values, with no time added for the pause.
  8. A session of ten minutes containing a five-minute pause records a durationMs of five minutes.
  9. Stopping appends exactly one history record, clears the lap list, returns the app to IDLE, and leaves the lifetime total increased by exactly the session duration.
  10. Stopping a session immediately after starting it still writes a record.
  11. Reloading the page mid-session restores the running session, with session and lifetime advanced by the real time that elapsed while the page was gone, and the lap list intact.
  12. Reloading while paused restores the paused session with no time added.
  13. Backgrounding the tab for several minutes and returning shows correct values within one tick, with no visible catch-up.
  14. No timer interval is running while the app is in IDLE or PAUSED.
  15. Exporting then importing the same file into the same device leaves the history unchanged.
  16. Importing a file from a second device produces the union of both histories, with no duplicates, in chronological order.
  17. Importing a malformed file changes nothing and reports the problem.
  18. Clearing localStorage returns the app to a clean IDLE state with a lifetime of zero, without errors.
  19. Blocking all external asset requests leaves the app fully functional, with legible text and working controls.
  20. Deleting or renaming the template/ directory has no effect on the running application.
  21. No file inside template/ has been modified from the version Claude Design produced.
  22. Every executed action plays s.webm, whether triggered by pointer or key; ignored actions play nothing. No other audio is produced, and a missing or unplayable sound file has no effect on any other behaviour.
  23. A stopped session's history record carries duration as an ISO 8601 string, an end equal to start plus that duration, and startTS / endTS in local time with minute precision.
  24. A session lasting 1 h 5 min 21 s records "duration": "PT1H5M21S", and one lasting 250 ms records "duration": "PT0.25S".
  25. Importing a schema-1 file changes nothing and reports the problem, exactly like any other invalid file.
  26. ? opens the help modal from any state and closes it again; it lists every command with its states and key binding, and Space and Enter while it is open do not start, lap, pause, or resume anything.
  27. The help modal's voice column is absent on a browser where the voice interface is not rendered, and present with the full vocabulary where it is.
  28. Escape stops the session from RUNNING or PAUSED, exactly as the Stop control does, and is ignored from IDLE. While the help modal is open, Escape only closes the modal and does not also stop the session.
  29. The Lap command reads "Next" everywhere the UI names it — the primary control while running or paused, the timer heading, and its help modal row — while its key binding (Space), voice words, and states are unchanged.