Spec
Implementer index — public names, packaging, and adapter constraints.
This page is the implementation index for @watchstop/core and framework adapters. Behavior is defined on Core, Runtimes, and Frameworks pages; tests are derived from those pages. Consumer usage for coding agents lives on Agents and root AGENTS.md.
Also read: Architecture, Clock, Store, Stopwatch, Options, 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 framework 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, options?: StopwatchOptions)
get running(): boolean
start(): void
stop(): void
reset(): void
get(): number
subscribe(listener: (elapsed: number) => void): () => void
destroy(): void
}
type StopwatchOptions = {
precisionMs?: number
}
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.
Packaging
Package name is @watchstop/core. "type": "module" with exports for types / import / require. Built with tsdown; declarations emitted; public exports have explicit return types (isolatedDeclarations). Vitest covers Core, Runtimes, and Options contracts via createMockClock.
Adopter constraints (design pressure on Store)
Adapters must stay thin: only construct a Stopwatch and bridge get / subscribe / destroy (plus exposing start/stop/reset if the adapter’s API includes controls). No clocks or elapsed math in adapters. Owning an instance and its teardown is allowed; reimplementing timing is not.
Exact public names (adapters)
Every adapter ships exactly one entry point: the binding factory/hook, plus its option and return types. By default the entry point owns a Stopwatch and tears it down. Pass stopwatch to borrow an existing instance (no create, no destroy on teardown). That is the shared-instance path from issue #6.
The generic Store<T> bridge each adapter uses internally is not public API. A Store-wide primitive can still land later when Countdown / Ticker exist; stopwatch?: Stopwatch on adapter options is enough for v1 sharing.
Options are a discriminant — owned construction knobs or a borrowed instance, not both:
type OwnedStopwatchOptions = { clock?: Clock; precisionMs?: number }
type BorrowedStopwatchOptions = { stopwatch: Stopwatch }@watchstop/react:
type UseStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: number
running: boolean
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function useStopwatch(options?: UseStopwatchOptions): StopwatchBinding@watchstop/vue:
type UseStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: Readonly<ShallowRef<number>>
running: Readonly<ShallowRef<boolean>>
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function useStopwatch(options?: UseStopwatchOptions): StopwatchBinding@watchstop/solid:
type UseStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: Accessor<number>
running: Accessor<boolean>
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function useStopwatch(options?: UseStopwatchOptions): StopwatchBinding@watchstop/svelte:
type CreateStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchStore = Readable<number> & {
running: Readable<boolean>
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function createStopwatch(options?: CreateStopwatchOptions): StopwatchStoreReact, Vue, and Solid keep the useStopwatch hook / composable name their ecosystems expect. Svelte uses createStopwatch because svelte/store already exports fromStore / toStore for converting between stores and runes.
Rules for the adapter entry points
- Must not auto-start. Construction / binding is inert until
start(). - Owned teardown is mandatory. When the adapter constructs the instance,
destroy()runs on unmount / scope dispose / root dispose / component destroy. - Borrowed instances are never destroyed by the adapter. Pass
stopwatchto bind; unmount only unsubscribes. - Control identities are stable for the life of the binding, so they are safe in dependency arrays and as event handlers.
- Expose the bound instance as
stopwatchfor callers who need to pass it elsewhere. - Owned options are
clockandprecisionMs(forwarded toStopwatch). Borrowed options are onlystopwatch. See Options.
Packaging (adapters)
@watchstop/coreis a peer dependency, not a regular dependency, plus a dev dependency so tests resolve it. Adapters importStopwatchas a value, but the consumer must already own core to construct or share instances, and a regular dependency invites a second resolved copy whoseStoretype is not identical to the consumer's.- Framework packages stay peer dependencies as before.
React (@watchstop/react)
- 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. useStopwatchcreates the instance with a lazyuseRefand destroys it in auseEffectcleanup when owning — notuseMemo, which React may discard. Strict Mode double-invoke must rebuild and republish the instance rather than leave a destroyed or orphaned stopwatch. When borrowing, skip create/destroy.
Svelte (@watchstop/svelte)
- Expose a readable-store shape (
subscribecompatible with$storeauto-subscription). - May wrap
Stopwatchor implement the readable contract by delegating tosubscribe/get. subscribemust call the listener synchronously withget()before registering it, per Svelte's readable contract.createStopwatchtiesdestroy()toonDestroywhen owning, not to last-unsubscribe: a store may be subscribed and unsubscribed repeatedly, and a stopwatch may legitimately run with zero subscribers. Outside component initialisation it registers no teardown and the caller ownsdestroy(). When borrowing, skiponDestroyteardown of the core instance.
Vue (@watchstop/vue)
- Composable returns a
ref(or shallow ref) updated fromsubscribe, cleaned up withonScopeDispose. - Initial
refvalue fromget(). - When owning,
useStopwatchregisters a secondonScopeDisposefordestroy(), after the unsubscribe. When borrowing, skip destroy.
Solid (@watchstop/solid)
- Create a signal from
get(); update insubscribe;onCleanupunsubscribes. - When owning,
useStopwatchregisters a secondonCleanupfordestroy(), after the unsubscribe. When borrowing, skip destroy.
Angular (@watchstop/angular)
type InjectStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: Signal<number>
running: Signal<boolean>
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function injectStopwatch(options?: InjectStopwatchOptions): StopwatchBinding- Bridge
Store→ Angular signal (writable internally, exposed viaasReadonly). Syncrunningfromstopwatch.runninginside the existingsubscribepath. - Must run in an injection context (
inject/DestroyRef). - Unsubscribe / end bridging via
DestroyRef— not by requiring core to know Angular. Destroy the core instance onDestroyRefonly when owning. - Core must not import
@angular/*. - Implication for core:
subscribereturn value must be a plain unsubscribe function (already required).
Qwik (@watchstop/qwik)
type UseStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: Signal<number>
running: Signal<boolean>
start: QRL<() => void>
stop: QRL<() => void>
reset: QRL<() => void>
stopwatch: Stopwatch
}
declare function useStopwatch(options?: UseStopwatchOptions): StopwatchBinding- Peer is
@qwik.dev/core(Qwik 2), not@builder.io/qwik. - Subscribe only on the client via
useVisibleTask$. No SSR subscription leaks. Syncrunningfromstopwatch.runningin that subscribe callback. - Hold the owned
StopwatchwithnoSerialize()on a signal holder so QRL captures are legal; expose controls as$()QRLs (onClick$={start}). For custom handlers, call methods on the exposedstopwatchinstance instead of nesting QRL invokes. - Task cleanup unsubscribes and, when owning, calls
destroy(). - Keep adapter thin so core stays free of framework closures beyond user listeners.
- Implication for core:
Stopwatchinstances are client-owned; core holds no framework callbacks beyond user listeners. - Package as a Qwik library (
vite build --mode lib,"qwik"field,index.qwik.mjs).
Alpine (@watchstop/alpine)
type CreateStopwatchOptions = OwnedStopwatchOptions | BorrowedStopwatchOptions
type StopwatchBinding = {
elapsed: number
running: boolean
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
init: (this: StopwatchBinding) => void
destroy: () => void
}
declare function createStopwatch(options?: CreateStopwatchOptions): StopwatchBinding- Alpine has no hook/inject context, so the entry point is a factory (same rationale as Svelte).
- No plugin /
Alpine.dataregistration helper — one entry point only; callers may wrapcreateStopwatch()in their ownAlpine.dataif they want a name. initperforms imperativesubscribe(writeselapsedandrunningthroughthisfor Alpine reactivity).destroyunsubscribes and, when owning, destroys the core instance; Alpine invokes it on element removal.- Implication for core: destroy/unsubscribe paths must be idempotent (already required).
If Store is insufficient for an adapter
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/* document the matching package once it ships, except normative binding rules already stated (e.g. React useSyncExternalStore snapshot caching). Follow the constraints above when implementing or updating adapters.