# Agents (/docs/agents) This page is the contract for coding agents implementing `@watchstop/core` and later framework adapters. Core docs are the source of truth; this page lists **must-pass** behaviors and **adopter constraints** so `Store` is not accidentally React-shaped. Also read: [Architecture](/docs/architecture), [Clock](/docs/core/clock), [Store](/docs/core/store), [Stopwatch](/docs/core/stopwatch), [Browser](/docs/runtimes/browser), [Timer](/docs/runtimes/timer), [Testing](/docs/runtimes/testing). Machine indexes: [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt). ## Implementation order [#implementation-order] 1. Scaffold `packages/core` with **tsdown** (`format: ['esm','cjs']`, `dts: true`) and Vitest. 2. Implement clocks: `createMockClock`, `createBrowserClock`, `createTimerClock`, `detectClock`. 3. Implement `Stopwatch`. 4. Export public API from package entry exactly as named below. **Also** add a `packages/core` tsconfig and register it in the root `tsconfig.json` `references` array (root refs are empty today; `pnpm typecheck` / `tsgo -b` needs the project reference). Public APIs need **explicit return types** because `tsconfig.base.json` sets `isolatedDeclarations: true`. 5. Typecheck with **TS7 (`tsgo`)**; `pnpm test` + `pnpm build` green. Dev dependency `@typescript/native-preview` is pinned in root `package.json` (not `latest`). 6. Only then Wave 1 / Wave 2 adapters. ## Exact public names [#exact-public-names] ```ts interface Clock { now(): number schedule(callback: () => void): unknown cancel(handle: unknown): void } interface Store { get(): T subscribe(listener: (value: T) => void): () => void } type MockClockOptions = { frameDelay?: number } interface MockClock extends Clock { advance(ms: number): void } type TimerClockOptions = { intervalMs?: number } declare class Stopwatch implements Store { constructor(clock?: Clock) start(): void stop(): void reset(): void get(): number subscribe(listener: (elapsed: number) => void): () => void destroy(): void } declare function createBrowserClock(): Clock declare function createTimerClock(options?: TimerClockOptions): Clock declare function createMockClock(options?: MockClockOptions): MockClock declare function detectClock(): Clock ``` Do not rename to `elapsed`, `onTick`, `addEventListener`, etc. ## Core acceptance criteria [#core-acceptance-criteria] ### Clocks [#clocks] * [ ] `createMockClock().now()` starts at `0` and increases only via `advance`. * [ ] `schedule` enqueues with `dueTime = now + frameDelay` (default `frameDelay: 0`) and never runs the callback synchronously. * [ ] `advance(ms)` throws if `ms` is not finite `>= 0`; otherwise bumps `now` and single-pass flushes due callbacks (stable by due time); nested `schedule` during flush waits for a later `advance`. * [ ] `cancel` is idempotent and prevents the callback. * [ ] `createTimerClock({ intervalMs })` uses that delay for `setTimeout`; throws if `intervalMs` is not finite `> 0`. * [ ] `createBrowserClock` uses rAF + `performance.now`; throws if `requestAnimationFrame` / `cancelAnimationFrame` missing (skip or mock in Node unit tests). * [ ] `detectClock()` returns browser clock when `requestAnimationFrame` is a function, else timer clock. * [ ] No `node:` imports required for the browser-safe build entry. ### Stopwatch [#stopwatch] * [ ] Default construction uses `detectClock()`. * [ ] Initial `get()` is `0`; not running. * [ ] `get()` while running is **live** elapsed (as of the call), not only last tick. * [ ] `start` → `advance` → `get()` reflects elapsed; subscribers receive updates on ticks / mutating controls. * [ ] `stop` freezes elapsed; further `advance` does not change `get()`. * [ ] Second `start` after `stop` resumes from accumulated total. * [ ] Double `start` / double `stop` are no-ops. * [ ] `reset` sets elapsed to `0` and stops. * [ ] `subscribe` unsubscribe is idempotent; no calls after unsubscribe. * [ ] Multiple subscribers all receive updates. * [ ] Throwing listener does not block other listeners. * [ ] Notify reentrancy: newly added listeners miss the current wave; removed listeners are not called again in that wave; `start`/`stop`/`reset`/`destroy` from a listener are allowed; `destroy` clears listeners so remaining cleared listeners in the wave are skipped. * [ ] `destroy` stops ticks, clears listeners, freezes `get()`, ignores later `start`/`stop`/`reset`. * [ ] `subscribe` after `destroy` does not retain listeners. ### Packaging [#packaging] * [ ] Package name `@watchstop/core`. * [ ] `"type": "module"` with `exports` for `types` / `import` / `require`. * [ ] Built with tsdown; declarations emitted; public exports have explicit return types (`isolatedDeclarations`). * [ ] Vitest covers the checklist above via `createMockClock`. ## Adopter constraints (design pressure on Store) [#adopter-constraints-design-pressure-on-store] Adapters must stay thin: **only** bridge `get` / `subscribe` (plus exposing `start`/`stop`/`reset`/`destroy` if the adapter’s API includes controls). No clocks or elapsed math in adapters. ### React (`@watchstop/react`) — Wave 1 [#react-watchstopreact--wave-1] * Bind with `useSyncExternalStore`. * **MUST NOT** pass live `store.get` / `stopwatch.get` as `getSnapshot`. Cache the value from `subscribe` (or a versioned snapshot updated only in the listener); `getSnapshot` returns that cached snapshot so it is stable between notifications. * Do not use `useState` + manual subscribe as the primary path. * SSR: initial / server snapshot may use one `get()`; must be safe without `window`. ### Svelte (`@watchstop/svelte`) — Wave 1 [#svelte-watchstopsvelte--wave-1] * Expose a readable-store shape (`subscribe` compatible with `$store` auto-subscription). * May wrap `Stopwatch` or implement the readable contract by delegating to `subscribe`/`get`. ### Vue (`@watchstop/vue`) — Wave 1 [#vue-watchstopvue--wave-1] * Composable returns a `ref` (or shallow ref) updated from `subscribe`, cleaned up with `onScopeDispose`. * Initial `ref` value from `get()`. ### Solid (`@watchstop/solid`) — Wave 1 [#solid-watchstopsolid--wave-1] * Create a signal from `get()`; update in `subscribe`; `onCleanup` unsubscribes. ### Angular (`@watchstop/angular`) — Wave 2 [#angular-watchstopangular--wave-2] * Bridge `Store` → Angular **signal** and/or Observable. * Unsubscribe / end bridging via `DestroyRef` (or `effect` teardown) — **not** by requiring core to know Angular. * Core must **not** import `@angular/*`. * Implication for core: `subscribe` return value must be a plain unsubscribe function (already required). ### Qwik (`@watchstop/qwik`) — Wave 2 [#qwik-watchstopqwik--wave-2] * Subscribe **only on the client** (e.g. `useVisibleTask$` / equivalent). No SSR subscription leaks. * Keep adapter thin so core stays free of framework closures and serialization concerns. * Implication for core: `Stopwatch` instances are client-owned; core holds no framework callbacks beyond user listeners. ### Alpine (`@watchstop/alpine`) — Wave 2 [#alpine-watchstopalpine--wave-2] * Imperative `subscribe` inside `Alpine.data` / magic / plugin helper. * Unsubscribe on Alpine `destroy` / element removal. * Implication for core: destroy/unsubscribe paths must be idempotent (already required). ### Docs-only (no package in v1) [#docs-only-no-package-in-v1] Vanilla, Preact, and Lit are **docs-only** — there is no `@watchstop/vanilla`, `@watchstop/preact`, or `@watchstop/lit` package. * **Vanilla:** use `Stopwatch` + DOM updates in `subscribe`. * **Preact:** prefer `@watchstop/react` when compat allows, or core directly. * **Lit:** subscribe in `connectedCallback`, unsubscribe in `disconnectedCallback`. ## If Store is insufficient for Wave 2 [#if-store-is-insufficient-for-wave-2] Change **core** + these docs + core tests first. Do not special-case timing or subscription semantics inside a single adapter. ## Framework docs [#framework-docs] Framework pages under `/docs/frameworks/*` stay stubs until the matching package ships (Step 7), except normative binding rules already stated (e.g. React `useSyncExternalStore` snapshot caching). Agents implementing adapters should still follow the constraints above. # Architecture (/docs/architecture) Watchstop separates **time** (Clock), **observation** (Store), and **elapsed measurement** (Stopwatch). Frameworks only need `get` + `subscribe`. Runtimes only need `now` + `schedule` + `cancel`. Neither concern belongs inside Stopwatch itself. ## Layers [#layers] ``` Clock providers Core Framework adapters ───────────────── ──── ────────────────── createBrowserClock ─┐ createTimerClock ─┼─► Clock ─► Stopwatch ─► Store ─► React / Svelte / … createMockClock ─┘ │ detectClock ───────────────┘ ``` | Layer | Responsibility | Package | | --------- | ------------------------------------------ | ------------------------------------------------------ | | Clock | Read monotonic time; schedule/cancel ticks | `@watchstop/core` | | Store | Snapshot + subscription | `@watchstop/core` (interface); Stopwatch implements it | | Stopwatch | Accumulate elapsed ms while running | `@watchstop/core` | | Adapters | Bridge `Store` into framework reactivity | `@watchstop/react`, `svelte`, … | ## Design rules [#design-rules] 1. **No framework imports in core.** Angular, Qwik, and Alpine must consume the same `Store` contract as React. 2. **One timer clock for Node, Bun, and Deno.** All three expose `performance.now` and `setTimeout` / `clearTimeout`. Do not ship `@watchstop/node`, `@watchstop/bun`, or `@watchstop/deno`. 3. **Default clock via `detectClock()`.** Prefer browser (`requestAnimationFrame`) when present; otherwise timer clock. No hard `node:` imports in the browser build. 4. **Countdown / Ticker are out of v1.** Only `Stopwatch` ships in the first core release. 5. **Adapters are thin.** No timing math, no clocks, no elapsed accumulation in adapters. ## Data flow while running [#data-flow-while-running] 1. `start()` records `startTime = clock.now()` and schedules the next tick with `clock.schedule`. 2. Each tick recomputes `elapsed = accumulated + (clock.now() - startTime)` and notifies subscribers. 3. `stop()` cancels the scheduled handle, adds the current segment into `accumulated`, and clears `startTime`. 4. `get()` returns the live elapsed value (running or stopped). 5. `destroy()` stops scheduling, clears listeners, and makes further use a no-op or safe idle state (see [Stopwatch](/docs/core/stopwatch)). ## Runtime matrix [#runtime-matrix] | Environment | Factory | `now` | `schedule` / `cancel` | | ----------------- | ----------------------------------- | ----------------------------------------------------- | ------------------------------------------------ | | Browser (display) | `createBrowserClock()` | `performance.now()` | `requestAnimationFrame` / `cancelAnimationFrame` | | Node / Bun / Deno | `createTimerClock({ intervalMs? })` | `performance.now()` | `setTimeout` / `clearTimeout` | | Tests | `createMockClock()` | controllable | controllable + `advance(ms)` | | Auto | `detectClock()` | browser if `requestAnimationFrame` exists, else timer | same | ## Package map (v1) [#package-map-v1] * **Ships first:** `@watchstop/core` * **Wave 1 adapters:** React, Svelte, Vue, Solid * **Wave 2 adapters:** Angular, Qwik, Alpine * **Docs-only:** Vanilla, Preact, Lit See [Agents](/docs/agents) for acceptance criteria and adopter constraints that pressure the `Store` shape. # Watchstop (/docs) Welcome to Watchstop. Core docs are the **source of truth**. Implement packages from those pages, not from guesses. ## Start here [#start-here] * [Architecture](/docs/architecture) — Clock → Store → Stopwatch * [Agents](/docs/agents) — acceptance criteria and adopter constraints * [Clock](/docs/core/clock) · [Store](/docs/core/store) · [Stopwatch](/docs/core/stopwatch) * Runtimes: [Browser](/docs/runtimes/browser) · [Timer](/docs/runtimes/timer) · [Testing](/docs/runtimes/testing) Machine indexes: [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt). # Clock (/docs/core/clock) A `Clock` supplies monotonic time and a schedule/cancel pair. Stopwatch never calls `setTimeout` or `requestAnimationFrame` directly. ## Interface [#interface] ```ts interface Clock { now(): number schedule(callback: () => void): unknown cancel(handle: unknown): void } ``` Export this type from `@watchstop/core` as `Clock`. ## Method contracts [#method-contracts] ### `now(): number` [#now-number] * Returns a **monotonic** timestamp in **milliseconds**. * Prefer `performance.now()` in all shipped clocks. * Values must be comparable with subtraction (`later - earlier`) to produce elapsed ms. * Must not jump backward under normal operation (wall-clock `Date.now()` is not acceptable for production clocks). ### `schedule(callback: () => void): unknown` [#schedulecallback---void-unknown] * Arranges for `callback` to run once in the future (next animation frame, next timer interval, or mock queue). * Returns an opaque `handle` that `cancel` understands. * Must not invoke `callback` synchronously inside `schedule`. * Clocks have **no** `destroy` method. Factories document missing-API failures (e.g. browser clock throws when `requestAnimationFrame` is absent). Cancel remains safe on expired or unknown handles. ### `cancel(handle: unknown): void` [#cancelhandle-unknown-void] * Cancels a pending callback from `schedule`. * Must be **idempotent**: canceling twice, or canceling an unknown/expired handle, must not throw. * After cancel, `callback` must not run (unless it already started). ## Invariants [#invariants] 1. `now()` is side-effect free regarding scheduled callbacks. 2. Each successful `schedule` has at most one corresponding callback invocation unless re-scheduled. 3. Stopwatch owns the loop: clocks do **not** auto-repeat. Repeating ticks are `schedule` → callback → `schedule` again inside Stopwatch. 4. Handles are opaque to Stopwatch; never inspect handle type in Stopwatch beyond passing to `cancel`. ## Factories (see Runtimes) [#factories-see-runtimes] | Export | Page | | ---------------------------- | --------------------------------- | | `createBrowserClock()` | [Browser](/docs/runtimes/browser) | | `createTimerClock(options?)` | [Timer](/docs/runtimes/timer) | | `createMockClock()` | [Testing](/docs/runtimes/testing) | | `detectClock()` | chooses browser vs timer | ## Install + import [#install--import] ```bash pnpm add @watchstop/core ``` ```ts import { type Clock, createBrowserClock, createTimerClock, createMockClock, detectClock, } from '@watchstop/core' ``` # Stopwatch (/docs/core/stopwatch) `Stopwatch` measures elapsed milliseconds. It implements `Store` and uses an injected `Clock` for time and ticks. ## Public API [#public-api] ```ts class Stopwatch implements Store { constructor(clock?: Clock) start(): void stop(): void reset(): void get(): number subscribe(listener: (elapsed: number) => void): () => void destroy(): void } ``` Export `Stopwatch` from `@watchstop/core`. ## Construction [#construction] ```ts import { Stopwatch, detectClock, createMockClock } from '@watchstop/core' const live = new Stopwatch() const explicit = new Stopwatch(detectClock()) const underTest = new Stopwatch(createMockClock()) ``` * `clock` is optional. When omitted, use `detectClock()`. * Initial elapsed is `0`. * Initial state is **stopped** (not running). ## Internal model (normative) [#internal-model-normative] Implementers should keep equivalent state: | Field | Meaning | | ------------- | ---------------------------------------------------- | | `clock` | Injected `Clock` | | `accumulated` | Elapsed ms from completed run segments | | `startTime` | `clock.now()` at last `start`, or unset when stopped | | `running` | Whether a segment is active | | `handle` | Pending `schedule` handle, or unset | | `listeners` | Set of subscribed callbacks | | `destroyed` | Whether `destroy()` has been called | While **running**, visible elapsed is: ```ts accumulated + (clock.now() - startTime) ``` While **stopped**, visible elapsed is `accumulated`. ## Methods [#methods] ### `start(): void` [#start-void] * If `destroyed`, no-op. * If already running, no-op (do **not** reset `startTime`). * Otherwise set `running = true`, `startTime = clock.now()`, schedule the tick loop, and notify if the visible value should refresh (usually still same number at t0; notifying is allowed but not required when value is unchanged). ### `stop(): void` [#stop-void] * If `destroyed` or not running, no-op. * Cancel pending `handle`. * Set `accumulated = accumulated + (clock.now() - startTime)`. * Clear `startTime`, set `running = false`. * Notify listeners with the stopped elapsed value. ### `reset(): void` [#reset-void] * If `destroyed`, no-op. * Cancel pending `handle` if any. * Set `accumulated = 0`, clear `startTime`, set `running = false`. * Notify listeners with `0` when the previous visible value was not already `0`. ### `get(): number` [#get-number] * If destroyed, return the last frozen elapsed (typically `accumulated` after destroy’s stop), or `0` if never started — pick one and keep it stable: **return `accumulated` after destroy completes its internal stop**. * If running, return **live** `accumulated + (clock.now() - startTime)` as of the call (not only last tick). Required for vanilla / Node. * If stopped, return `accumulated`. * Units: **milliseconds** as a finite number (`>= 0` under normal clocks). * There is no separate `peek()` API. React adapters must not use live `get` as `useSyncExternalStore`’s `getSnapshot` — see [Store](/docs/core/store) and [React](/docs/frameworks/react). ### `subscribe(listener: (elapsed: number) => void): () => void` [#subscribelistener-elapsed-number--void---void] * See [Store](/docs/core/store), including **listener reentrancy** rules. * Subscribers fire only on clock ticks and on mutating controls as specified (`start` / `stop` / `reset` / `destroy`). * If `destroyed`, `subscribe` returns a no-op unsubscribe and does not retain the listener. ### `destroy(): void` [#destroy-void] * Idempotent. * Stop the tick loop (same as `stop()` if running). * Clear all listeners without requiring them to unsubscribe first. * Further `start` / `stop` / `reset` are no-ops. * Further `subscribe` does not attach. * `get()` remains safe and returns the frozen elapsed. ## Tick loop [#tick-loop] While running: 1. `handle = clock.schedule(() => onTick())` 2. `onTick`: if not running or destroyed, return; notify listeners with `get()`; if still running and not destroyed, schedule again. A listener may call `stop` / `reset` / `destroy` during notify — do not re-schedule in that case. Browser clocks fire once per frame; timer clocks fire once per `intervalMs`. Stopwatch does not care which — it re-schedules after each callback only while still running and not destroyed. ## Edge cases [#edge-cases] | Case | Behavior | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Double `start` | Second call no-op | | Double `stop` | Second call no-op | | `reset` while running | Cancels loop, elapsed → `0`, stopped | | `start` after `reset` | Fresh segment from 0 | | `destroy` while running | Stops and freezes elapsed; clears listeners | | Listener throws | Other listeners still run | | Listener reentrancy | Added listeners miss current wave; removed skip rest of wave; controls allowed; `destroy` clears so remaining cleared listeners are skipped | | `cancel` after natural fire | Idempotent; no throw | ## Usage [#usage] ```ts import { Stopwatch } from '@watchstop/core' const sw = new Stopwatch() const unsubscribe = sw.subscribe((elapsed) => { console.log(elapsed) }) sw.start() sw.stop() console.log(sw.get()) sw.reset() unsubscribe() sw.destroy() ``` ## Out of scope (v1) [#out-of-scope-v1] * Pause vs stop distinctions beyond `stop` * Lap times * Countdown / Ticker * Persistence / workers / multiplayer # Store (/docs/core/store) `Store` is the only surface framework adapters need. It is deliberately small so React, Svelte, Vue, Solid, Angular, Qwik, and Alpine can all bind without core knowing about them. ## Interface [#interface] ```ts interface Store { get(): T subscribe(listener: (value: T) => void): () => void } ``` Export this type from `@watchstop/core` as `Store`. `Stopwatch` implements `Store` where the value is elapsed milliseconds. ## Method contracts [#method-contracts] ### `get(): T` [#get-t] * Returns the **live** current value synchronously (for `Stopwatch`, live elapsed — see [Stopwatch](/docs/core/stopwatch)). * Must be safe to call at any time after construction (including before any subscription). * Must not notify listeners by itself. * Required for vanilla / Node and any adopter that polls without waiting for a tick. * There is no separate `peek()` API. ### `subscribe(listener: (value: T) => void): () => void` [#subscribelistener-value-t--void---void] * Registers `listener` to be called when the store’s value changes in a way the implementation considers observable. * Returns an **unsubscribe** function. * Unsubscribe is **idempotent** and must not throw. * Calling `subscribe` must **not** require the listener to be invoked synchronously on subscribe (adapters may call `get()` themselves for the initial value). * After unsubscribe, `listener` must not be called again for later updates. * Multiple subscribers are supported; each gets the same value notifications independently. ## Notification rules (Stopwatch as Store) [#notification-rules-stopwatch-as-store] Notify all current listeners when elapsed changes due to: * a running clock tick * `start` / `stop` / `reset` that changes the visible elapsed value (or as those methods already specify) Do **not** notify when: * `get()` is called * subscribe/unsubscribe alone (no value change) * `destroy()` after listeners are cleared (no further notifies) Subscribers fire **only** on clock ticks and on mutating controls (`start` / `stop` / `reset` / `destroy` as specified on those methods) — not on every `get()`. Listener errors: one listener throwing must not prevent other listeners from running. Prefer isolating per-listener `try`/`catch` inside notify. ## Listener reentrancy (normative) [#listener-reentrancy-normative] During a single notify wave: 1. Snapshot the listener list (or equivalent) at wave start, or otherwise ensure **listeners added during the wave do not receive the current wave**. 2. **Listeners removed (unsubscribed) during the wave are not called again** in that wave if they have not already been invoked. 3. The current listener invocation always finishes. 4. `start` / `stop` / `reset` / `destroy` invoked from a listener are allowed. They take effect per their method contracts (immediately for state; scheduling/cancel as specified). Prefer: `destroy` clears listeners so remaining listeners in the same wave that were cleared are skipped if not yet called. ## React and `useSyncExternalStore` (normative) [#react-and-usesyncexternalstore-normative] `get()` is live. React’s `useSyncExternalStore` requires `getSnapshot` to return a value that is **referentially/value-stable between store notifications**. Adapters **MUST NOT** pass live `store.get` (or `stopwatch.get`) as `getSnapshot`. The React adapter must cache the value received via `subscribe` (or a versioned snapshot updated only inside the listener) and have `getSnapshot` return that cached snapshot so it is stable between notifications. Initial / server snapshot may still come from one `get()` call when subscribing begins. See [Agents](/docs/agents) and [React](/docs/frameworks/react). ## Adopter expectations [#adopter-expectations] Adapters must: 1. Read with `get()` for the initial / server snapshot when needed (except React’s ongoing `getSnapshot`, which must use the cached subscribe snapshot above). 2. Subscribe for updates; unsubscribe on teardown (`DestroyRef`, `onCleanup`, `onDestroy`, element `destroy`, etc.). 3. Never call `start`/`stop`/`reset` unless the adapter’s public API intentionally exposes those methods (hooks may return the `Stopwatch` instance or wrap controls). See [Agents](/docs/agents) for framework-specific binding constraints (especially Angular, Qwik, Alpine). # Alpine (/docs/frameworks/alpine) Docs after core ships. Wave 2 `Alpine.data` / plugin helper. # Angular (/docs/frameworks/angular) Docs after core ships. Wave 2 signals/`DestroyRef` bridge. # Lit (/docs/frameworks/lit) Docs after core ships. Docs-only snippet — no dedicated package. # Preact (/docs/frameworks/preact) Docs after core ships. Docs-only — prefer `@watchstop/react` compat or core. # Qwik (/docs/frameworks/qwik) Docs after core ships. Wave 2 client-visible subscribe bridge. # React (/docs/frameworks/react) Wave 1 adapter over `Store` via `useSyncExternalStore`. Full API docs land after the package ships; the binding rule below is **normative** now. ## `useSyncExternalStore` (normative) [#usesyncexternalstore-normative] `Store.get()` / `Stopwatch.get()` returns the **live** value. React requires `getSnapshot` to be stable between notifications. `@watchstop/react` **MUST NOT** pass live `store.get` as `useSyncExternalStore`’s `getSnapshot`. Cache the value received via `subscribe` (or a versioned snapshot updated only in the listener). `getSnapshot` returns that cached snapshot. SSR / server snapshot may use a one-shot `get()` when the hook mounts. Do not use `useState` + manual subscribe as the primary path. See [Store](/docs/core/store) and [Agents](/docs/agents). # Solid (/docs/frameworks/solid) Docs after core ships. Wave 1 signal + `onCleanup` bridge. # Svelte (/docs/frameworks/svelte) Docs after core ships. Wave 1 adapter exposing a readable-store shape. # Vanilla (/docs/frameworks/vanilla) Docs after core ships. Docs-only — no dedicated package. # Vue (/docs/frameworks/vue) Docs after core ships. Wave 1 composable + `ref` bridge. # Browser (/docs/runtimes/browser) `createBrowserClock()` targets display-driven environments (browsers, Electron renderer, etc.) where `requestAnimationFrame` exists. ## Export [#export] ```ts function createBrowserClock(): Clock ``` From `@watchstop/core`. ## Behavior [#behavior] | Method | Implementation | | ---------------- | --------------------------------------------------------------------- | | `now()` | `performance.now()` | | `schedule(cb)` | `requestAnimationFrame(() => cb())` — return the numeric/frame handle | | `cancel(handle)` | `cancelAnimationFrame(handle)` (idempotent wrapper) | ## Requirements [#requirements] * Requires a DOM-like global with `requestAnimationFrame`, `cancelAnimationFrame`, and `performance.now`. * If `requestAnimationFrame` or `cancelAnimationFrame` is missing (not a function), `createBrowserClock()` **throws**. * Do **not** import `node:` modules. * `schedule` must not call `cb` synchronously. ## Usage [#usage] ```ts import { Stopwatch, createBrowserClock } from '@watchstop/core' const sw = new Stopwatch(createBrowserClock()) sw.start() ``` Prefer `new Stopwatch()` / `detectClock()` when you want automatic selection. Use `createBrowserClock()` when you explicitly need rAF even if other clocks are available. ## Notes [#notes] * Tick rate follows the display refresh (typically \~60 Hz). That is intentional for UI stopwatches. * In background tabs, browsers may throttle rAF; elapsed from `get()` stays correct because it uses `performance.now()`, not frame count. # Testing (/docs/runtimes/testing) `createMockClock()` lets tests drive time without real timers or animation frames. ## Export [#export] ```ts type MockClockOptions = { frameDelay?: number } interface MockClock extends Clock { advance(ms: number): void } function createMockClock(options?: MockClockOptions): MockClock ``` From `@watchstop/core`. ## Options [#options] | Option | Default | Meaning | | ------------ | ------- | -------------------------------------------------------- | | `frameDelay` | `0` | Added to `now()` when computing each schedule’s due time | If `frameDelay` is provided and is not a finite number `>= 0`, **throw** (`RangeError`). ## Normative algorithm [#normative-algorithm] Keep: * `currentTime` — starts at `0` * a pending list of `{ handle, callback, dueTime }` ### `now()` [#now] Returns `currentTime`. ### `schedule(callback)` [#schedulecallback] 1. Assign an opaque `handle`. 2. Enqueue `{ handle, callback, dueTime: currentTime + frameDelay }`. 3. Return `handle`. 4. Must **not** run `callback` synchronously inside `schedule`. ### `cancel(handle)` [#cancelhandle] Remove the pending entry if present. Idempotent; must not throw. ### `advance(ms: number): void` [#advancems-number-void] 1. If `ms` is not a finite number `>= 0`, **throw** (`RangeError`). 2. Set `currentTime += ms`. 3. **Single-pass flush:** collect every pending entry with `dueTime <= currentTime`, stable-sorted by `dueTime` then insertion order; remove them from the pending list; invoke each `callback` in that order. 4. Callbacks invoked during this pass may call `schedule` / `cancel`. Newly scheduled entries are **not** flushed in this `advance` — they wait for a later `advance` (including `advance(0)` when already due). Single-pass is required so Stopwatch’s tick loop (`schedule` → notify → `schedule` again) with default `frameDelay: 0` cannot spin forever inside one `advance`. ## Usage with Stopwatch [#usage-with-stopwatch] ```ts import { Stopwatch, createMockClock } from '@watchstop/core' import { expect, test } from 'vitest' test('accumulates elapsed', () => { const clock = createMockClock() const sw = new Stopwatch(clock) sw.start() clock.advance(100) expect(sw.get()).toBe(100) sw.stop() clock.advance(50) expect(sw.get()).toBe(100) sw.start() clock.advance(20) expect(sw.get()).toBe(120) sw.reset() expect(sw.get()).toBe(0) sw.destroy() }) ``` ## Why mock exists [#why-mock-exists] Real `setTimeout` and rAF make elapsed tests flaky. Core Vitest suites **must** use `createMockClock` for deterministic start/stop/reset/subscribe/destroy coverage. Optionally add one smoke test with `createTimerClock` to ensure the timer path schedules without throwing. # Timer (/docs/runtimes/timer) `createTimerClock()` is the **single** server-side / non-rAF clock. Node, Bun, and Deno all share `performance.now` + `setTimeout` / `clearTimeout`. There is no `createNodeClock`, `createBunClock`, or `createDenoClock` in v1. ## Export [#export] ```ts type TimerClockOptions = { intervalMs?: number } function createTimerClock(options?: TimerClockOptions): Clock ``` From `@watchstop/core`. ## Options [#options] | Option | Default | Meaning | | ------------ | ------- | ------------------------------------------------ | | `intervalMs` | `16` | Delay passed to `setTimeout` for each `schedule` | `16` approximates a 60 Hz UI tick for headless use. Callers may choose `100`, `1000`, etc. for coarser updates. If `intervalMs` is provided and is not a finite number `> 0`, `createTimerClock` **throws** (`RangeError`). The default `16` is always valid. ## Behavior [#behavior] | Method | Implementation | | ---------------- | ---------------------------------------------------------------- | | `now()` | `performance.now()` | | `schedule(cb)` | `setTimeout(() => cb(), intervalMs)` — return the timeout handle | | `cancel(handle)` | `clearTimeout(handle)` (idempotent wrapper) | ## Runtime coverage [#runtime-coverage] | Runtime | Supported via this factory | | ------- | --------------------------------------------------------- | | Node.js | Yes | | Bun | Yes | | Deno | Yes | | Browser | Yes (but prefer [Browser](/docs/runtimes/browser) for UI) | ## Usage [#usage] ```ts import { Stopwatch, createTimerClock } from '@watchstop/core' const sw = new Stopwatch(createTimerClock()) const slow = new Stopwatch(createTimerClock({ intervalMs: 100 })) sw.start() ``` ## Detection [#detection] `detectClock()` returns `createBrowserClock()` when `typeof requestAnimationFrame === 'function'`, otherwise `createTimerClock()`. That makes `new Stopwatch()` work in Node/Bun/Deno without configuration. ## Constraints [#constraints] * No `node:timers` / `node:perf_hooks` hard imports required if globals exist; prefer portable globals so the same ESM build runs in Bun/Deno/browser. * Do not schedule with `setInterval` inside the clock — Stopwatch owns the repeat loop via repeated `schedule` calls.