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
requestAnimationFrameto 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,
SpaceandEnterare 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. Thegymtimer.liveschema is therefore now2; 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:
durationMsbecomesduration, an ISO 8601 duration string, and three fields are added —end(ISO 8601 instant) andstartTS/endTS(human-readable local time, minute precision).gymtimer.historyis therefore now schema2. 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
Escapeis bound to Stop, valid fromRUNNINGandPAUSED(see §7.1). While the help modal is open,Escapekeeps 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
nextvoice synonym it already had. The action itself, its state transitions, and itsLapname in this specification are unchanged — only the on-screen label moved.
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.
| 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. |
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.
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.
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.
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.htmlandstyles.cssare 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.
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
localStoragekeys, 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.
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:SSwith 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.
Three states: IDLE, RUNNING, PAUSED.
| 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.
- 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.
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).
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.
| 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. |
sessionElapsed = accumulatedMs + (state === RUNNING ? Date.now() - runningSince : 0)
lapElapsed = sessionElapsed - lapBaselineMs
lifetimeTotal = sum(history[].durationMs) + sessionElapsed
- Start:
sessionStart = now,accumulatedMs = 0,runningSince = now,lapBaselineMs = 0. - Lap: append
lapElapsedto the lap list, thenlapBaselineMs = 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.
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
visibilitychangewhen 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.
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.
Both keyboard and touch are first-class. Neither is a fallback for the other.
| 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.
Space,Enter, andEscapeall callpreventDefault()when they act. Without it,Spacescrolls 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
SpaceorEnterpress 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/Enterbinding (Stop, Import, Export) holds keyboard focus,SpaceandEnterare 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 ownEscapebinding is unaffected by this exception — it is notSpaceorEnter, so it always reaches the document-level handler. - While the help modal of §7.6 is open, the document-level handler ignores
SpaceandEnter. 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 doesEscape, natively, per §7.1.
- A large primary target performs the action in the
Spacecolumn 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
Entercolumn. 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.
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
visibilitychangewhen the document becomes visible and the state is stillRUNNING. Browsers release the lock when a tab is backgrounded and do not restore it automatically. - Requires a secure context.
localhostqualifies, so local development works. Serving from a LAN IP over plain HTTP does not, and the call silently does nothing.
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.
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 doEscapeand 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, andnext, 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
Spaceas 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.
- 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 inH: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.
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.)
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
stateisRUNNING, 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
stateisPAUSED, the session resumes in the paused state with no time added. - If the record is malformed or its
schemais unrecognised, it is discarded and the app starts inIDLE. A corrupt live record must never block startup.
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.
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.
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.
- Produces a file named
gymtimer-YYYY-MM-DD.json, dated with the export day. - Content is the complete
gymtimer.historyobject, unmodified, pretty-printed. - Implemented with a
Bloband 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.
- 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.
schemaequals2.sessionsis an array.- Every element has an
id,start,end,startTSandendTSthat are non-empty strings, adurationthat parses as an ISO 8601 duration in the form written by §9.2, and alapCountthat 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.
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.
| 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. |
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.
The implementation is correct when all of the following hold.
- In
IDLE, lap and session read zero and lifetime reads the sum of stored history. - Pressing
SpacefromIDLEstarts all three timers from the correct bases. - Pressing
Spacewhile running resets the lap display to zero, leaves session and lifetime unaffected, and adds one row to the top of the lap list. - The new lap row shows the duration of the lap that just ended and the session time at which it ended.
- Every displayed value shows whole seconds only, floored, with no sub-second digits anywhere.
- Pressing
Enterwhile running freezes all three displays. PressingSpacewhile paused does nothing. - Pressing
Enterwhile paused resumes all three displays from their frozen values, with no time added for the pause. - A session of ten minutes containing a five-minute pause records a
durationMsof five minutes. - 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. - Stopping a session immediately after starting it still writes a record.
- 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.
- Reloading while paused restores the paused session with no time added.
- Backgrounding the tab for several minutes and returning shows correct values within one tick, with no visible catch-up.
- No timer interval is running while the app is in
IDLEorPAUSED. - Exporting then importing the same file into the same device leaves the history unchanged.
- Importing a file from a second device produces the union of both histories, with no duplicates, in chronological order.
- Importing a malformed file changes nothing and reports the problem.
- Clearing
localStoragereturns the app to a cleanIDLEstate with a lifetime of zero, without errors. - Blocking all external asset requests leaves the app fully functional, with legible text and working controls.
- Deleting or renaming the
template/directory has no effect on the running application. - No file inside
template/has been modified from the version Claude Design produced. - 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. - A stopped session's history record carries
durationas an ISO 8601 string, anendequal tostartplus that duration, andstartTS/endTSin local time with minute precision. - A session lasting 1 h 5 min 21 s records
"duration": "PT1H5M21S", and one lasting 250 ms records"duration": "PT0.25S". - Importing a schema-1 file changes nothing and reports the problem, exactly like any other invalid file.
?opens the help modal from any state and closes it again; it lists every command with its states and key binding, andSpaceandEnterwhile it is open do not start, lap, pause, or resume anything.- 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.
Escapestops the session fromRUNNINGorPAUSED, exactly as the Stop control does, and is ignored fromIDLE. While the help modal is open,Escapeonly closes the modal and does not also stop the session.- 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.