Agents
Acceptance criteria and adopter constraints for implementers.
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, Clock, Store, Stopwatch, Browser, Timer, Testing.
Machine indexes: /llms.txt, /llms-full.txt.
Implementation order
- Scaffold
packages/corewith tsdown (format: ['esm','cjs'],dts: true) and Vitest. - Implement clocks:
createMockClock,createBrowserClock,createTimerClock,detectClock. - Implement
Stopwatch. - Export public API from package entry exactly as named below. Also add a
packages/coretsconfig and register it in the roottsconfig.jsonreferencesarray (root refs are empty today;pnpm typecheck/tsgo -bneeds the project reference). Public APIs need explicit return types becausetsconfig.base.jsonsetsisolatedDeclarations: true. - Typecheck with TS7 (
tsgo);pnpm test+pnpm buildgreen. Dev dependency@typescript/native-previewis pinned in rootpackage.json(notlatest). - Only then Wave 1 / Wave 2 adapters.
Exact public names
interface Clock {
now(): number
schedule(callback: () => void): unknown
cancel(handle: unknown): void
}
interface Store<T> {
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<number> {
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(): ClockDo not rename to elapsed, onTick, addEventListener, etc.
Core acceptance criteria
Clocks
-
createMockClock().now()starts at0and increases only viaadvance. -
scheduleenqueues withdueTime = now + frameDelay(defaultframeDelay: 0) and never runs the callback synchronously. -
advance(ms)throws ifmsis not finite>= 0; otherwise bumpsnowand single-pass flushes due callbacks (stable by due time); nestedscheduleduring flush waits for a lateradvance. -
cancelis idempotent and prevents the callback. -
createTimerClock({ intervalMs })uses that delay forsetTimeout; throws ifintervalMsis not finite> 0. -
createBrowserClockuses rAF +performance.now; throws ifrequestAnimationFrame/cancelAnimationFramemissing (skip or mock in Node unit tests). -
detectClock()returns browser clock whenrequestAnimationFrameis a function, else timer clock. - No
node:imports required for the browser-safe build entry.
Stopwatch
- Default construction uses
detectClock(). - Initial
get()is0; 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. -
stopfreezes elapsed; furtheradvancedoes not changeget(). - Second
startafterstopresumes from accumulated total. - Double
start/ doublestopare no-ops. -
resetsets elapsed to0and stops. -
subscribeunsubscribe 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/destroyfrom a listener are allowed;destroyclears listeners so remaining cleared listeners in the wave are skipped. -
destroystops ticks, clears listeners, freezesget(), ignores laterstart/stop/reset. -
subscribeafterdestroydoes not retain listeners.
Packaging
- Package name
@watchstop/core. -
"type": "module"withexportsfortypes/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)
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
- Bind with
useSyncExternalStore. - MUST NOT pass live
store.get/stopwatch.getasgetSnapshot. Cache the value fromsubscribe(or a versioned snapshot updated only in the listener);getSnapshotreturns 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 withoutwindow.
Svelte (@watchstop/svelte) — Wave 1
- Expose a readable-store shape (
subscribecompatible with$storeauto-subscription). - May wrap
Stopwatchor implement the readable contract by delegating tosubscribe/get.
Vue (@watchstop/vue) — Wave 1
- Composable returns a
ref(or shallow ref) updated fromsubscribe, cleaned up withonScopeDispose. - Initial
refvalue fromget().
Solid (@watchstop/solid) — Wave 1
- Create a signal from
get(); update insubscribe;onCleanupunsubscribes.
Angular (@watchstop/angular) — Wave 2
- Bridge
Store→ Angular signal and/or Observable. - Unsubscribe / end bridging via
DestroyRef(oreffectteardown) — not by requiring core to know Angular. - Core must not import
@angular/*. - Implication for core:
subscribereturn value must be a plain unsubscribe function (already required).
Qwik (@watchstop/qwik) — 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:
Stopwatchinstances are client-owned; core holds no framework callbacks beyond user listeners.
Alpine (@watchstop/alpine) — Wave 2
- Imperative
subscribeinsideAlpine.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)
Vanilla, Preact, and Lit are docs-only — there is no @watchstop/vanilla, @watchstop/preact, or @watchstop/lit package.
- Vanilla: use
Stopwatch+ DOM updates insubscribe. - Preact: prefer
@watchstop/reactwhen compat allows, or core directly. - Lit: subscribe in
connectedCallback, unsubscribe indisconnectedCallback.
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 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.