kayforms 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +337 -0
  3. package/examples/react-demo/README.md +337 -0
  4. package/examples/react-demo/eslint.config.js +22 -0
  5. package/examples/react-demo/index.html +13 -0
  6. package/examples/react-demo/package.json +33 -0
  7. package/examples/react-demo/public/apple-touch-icon.png +0 -0
  8. package/examples/react-demo/public/favicon-96x96.png +0 -0
  9. package/examples/react-demo/public/favicon.ico +0 -0
  10. package/examples/react-demo/public/favicon.svg +17 -0
  11. package/examples/react-demo/public/icons.svg +24 -0
  12. package/examples/react-demo/public/site.webmanifest +21 -0
  13. package/examples/react-demo/public/web-app-manifest-192x192.png +0 -0
  14. package/examples/react-demo/public/web-app-manifest-512x512.png +0 -0
  15. package/examples/react-demo/src/App.css +184 -0
  16. package/examples/react-demo/src/App.tsx +825 -0
  17. package/examples/react-demo/src/assets/hero.png +0 -0
  18. package/examples/react-demo/src/assets/react.svg +1 -0
  19. package/examples/react-demo/src/assets/vite.svg +1 -0
  20. package/examples/react-demo/src/index.css +627 -0
  21. package/examples/react-demo/src/main.tsx +10 -0
  22. package/examples/react-demo/tsconfig.app.json +25 -0
  23. package/examples/react-demo/tsconfig.json +7 -0
  24. package/examples/react-demo/tsconfig.node.json +24 -0
  25. package/examples/react-demo/vite.config.ts +7 -0
  26. package/kayforms.jpg +0 -0
  27. package/package.json +26 -0
  28. package/packages/angular/package.json +43 -0
  29. package/packages/angular/src/index.ts +198 -0
  30. package/packages/angular/tsconfig.json +8 -0
  31. package/packages/angular/tsup.config.ts +17 -0
  32. package/packages/core/README.md +337 -0
  33. package/packages/core/package.json +37 -0
  34. package/packages/core/src/batch.ts +106 -0
  35. package/packages/core/src/devtools.ts +329 -0
  36. package/packages/core/src/field.ts +167 -0
  37. package/packages/core/src/form.ts +448 -0
  38. package/packages/core/src/index.ts +71 -0
  39. package/packages/core/src/registry.ts +126 -0
  40. package/packages/core/src/signal.ts +399 -0
  41. package/packages/core/src/time-travel.ts +275 -0
  42. package/packages/core/src/validation.ts +243 -0
  43. package/packages/core/tsconfig.json +8 -0
  44. package/packages/core/tsup.config.ts +16 -0
  45. package/packages/devtools/extension/background.js +35 -0
  46. package/packages/devtools/extension/content-script.js +10 -0
  47. package/packages/devtools/extension/devtools.html +9 -0
  48. package/packages/devtools/extension/devtools.js +8 -0
  49. package/packages/devtools/extension/manifest.json +19 -0
  50. package/packages/devtools/extension/panel.css +505 -0
  51. package/packages/devtools/extension/panel.html +108 -0
  52. package/packages/devtools/extension/panel.js +354 -0
  53. package/packages/devtools/package.json +38 -0
  54. package/packages/devtools/src/index.ts +95 -0
  55. package/packages/devtools/src/panel.ts +226 -0
  56. package/packages/devtools/src/styles.ts +422 -0
  57. package/packages/devtools/src/timeline.ts +283 -0
  58. package/packages/devtools/tsconfig.json +8 -0
  59. package/packages/devtools/tsup.config.ts +17 -0
  60. package/packages/react/package.json +46 -0
  61. package/packages/react/src/index.ts +279 -0
  62. package/packages/react/tsconfig.json +8 -0
  63. package/packages/react/tsup.config.ts +17 -0
  64. package/packages/solid/package.json +42 -0
  65. package/packages/solid/src/index.ts +206 -0
  66. package/packages/solid/tsconfig.json +8 -0
  67. package/packages/solid/tsup.config.ts +17 -0
  68. package/packages/svelte/package.json +42 -0
  69. package/packages/svelte/src/index.ts +199 -0
  70. package/packages/svelte/tsconfig.json +8 -0
  71. package/packages/svelte/tsup.config.ts +17 -0
  72. package/packages/vanilla/package.json +38 -0
  73. package/packages/vanilla/src/index.ts +254 -0
  74. package/packages/vanilla/tsconfig.json +8 -0
  75. package/packages/vanilla/tsup.config.ts +17 -0
  76. package/packages/vue/package.json +42 -0
  77. package/packages/vue/src/index.ts +217 -0
  78. package/packages/vue/tsconfig.json +8 -0
  79. package/packages/vue/tsup.config.ts +17 -0
  80. package/pnpm-workspace.yaml +3 -0
  81. package/tsconfig.base.json +21 -0
@@ -0,0 +1,399 @@
1
+ // ============================================================================
2
+ // @kayforms/core — Signal Engine
3
+ // ============================================================================
4
+ // Minimal, high-performance reactive primitives with automatic dependency
5
+ // tracking. Inspired by Solid, Preact Signals, and the TC39 Signals proposal.
6
+ //
7
+ // Architecture:
8
+ // Signal<T> — writable reactive value
9
+ // Computed<T> — read-only derived value (lazy, cached, glitch-free)
10
+ // Effect — side-effect that auto-tracks dependencies
11
+ // batch() — defer notifications until all writes complete
12
+ // ============================================================================
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** A reactive value that can be read and written */
19
+ export interface Signal<T> {
20
+ /** Read the current value (tracks dependency if inside a computed/effect) */
21
+ readonly value: T;
22
+ /** Write a new value and notify subscribers */
23
+ set(next: T): void;
24
+ /** Read without tracking (useful in event handlers) */
25
+ peek(): T;
26
+ /** Subscribe to changes (returns unsubscribe function) */
27
+ subscribe(listener: (value: T) => void): () => void;
28
+ }
29
+
30
+ /** A read-only derived value that recomputes when dependencies change */
31
+ export interface Computed<T> {
32
+ /** Read the current value (recomputes if stale, tracks dependency) */
33
+ readonly value: T;
34
+ /** Read without tracking */
35
+ peek(): T;
36
+ /** Subscribe to changes */
37
+ subscribe(listener: (value: T) => void): () => void;
38
+ }
39
+
40
+ /** Cleanup function returned by effects */
41
+ export type EffectCleanup = () => void;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Internals
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Base class for all reactive nodes in the dependency graph */
48
+ interface ReactiveNode {
49
+ /** Nodes that depend on this node */
50
+ subscribers: Set<ReactiveNode>;
51
+ /** Nodes this node depends on */
52
+ dependencies: Set<ReactiveNode>;
53
+ /** Called when a dependency changes */
54
+ notify(): void;
55
+ /** Unique ID for debugging */
56
+ _id: number;
57
+ }
58
+
59
+ let nodeIdCounter = 0;
60
+
61
+ /** Stack of currently-evaluating subscribers (for automatic tracking) */
62
+ const subscriberStack: ReactiveNode[] = [];
63
+
64
+ /** Current batch depth (0 = not batching) */
65
+ let batchDepth = 0;
66
+
67
+ /** Pending notifications deferred during a batch */
68
+ const batchQueue = new Set<ReactiveNode>();
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Dependency Tracking
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function getCurrentSubscriber(): ReactiveNode | undefined {
75
+ return subscriberStack[subscriberStack.length - 1];
76
+ }
77
+
78
+ function trackDependency(source: ReactiveNode): void {
79
+ const subscriber = getCurrentSubscriber();
80
+ if (subscriber) {
81
+ source.subscribers.add(subscriber);
82
+ subscriber.dependencies.add(source);
83
+ }
84
+ }
85
+
86
+ function notifySubscribers(source: ReactiveNode): void {
87
+ // Copy to avoid mutation during iteration
88
+ const subs = [...source.subscribers];
89
+ for (const sub of subs) {
90
+ if (batchDepth > 0) {
91
+ batchQueue.add(sub);
92
+ } else {
93
+ sub.notify();
94
+ }
95
+ }
96
+ }
97
+
98
+ function cleanupDependencies(node: ReactiveNode): void {
99
+ for (const dep of node.dependencies) {
100
+ dep.subscribers.delete(node);
101
+ }
102
+ node.dependencies.clear();
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Signal — Writable reactive value
107
+ // ---------------------------------------------------------------------------
108
+
109
+ class SignalImpl<T> implements ReactiveNode {
110
+ _id: number;
111
+ subscribers = new Set<ReactiveNode>();
112
+ dependencies = new Set<ReactiveNode>(); // Always empty for signals
113
+ private _value: T;
114
+ private _listeners = new Set<(value: T) => void>();
115
+
116
+ constructor(initial: T) {
117
+ this._id = nodeIdCounter++;
118
+ this._value = initial;
119
+ }
120
+
121
+ get value(): T {
122
+ trackDependency(this);
123
+ return this._value;
124
+ }
125
+
126
+ set(next: T): void {
127
+ if (Object.is(this._value, next)) return;
128
+ this._value = next;
129
+ notifySubscribers(this);
130
+ // Notify plain listeners
131
+ for (const listener of this._listeners) {
132
+ listener(next);
133
+ }
134
+ }
135
+
136
+ peek(): T {
137
+ return this._value;
138
+ }
139
+
140
+ subscribe(listener: (value: T) => void): () => void {
141
+ this._listeners.add(listener);
142
+ return () => {
143
+ this._listeners.delete(listener);
144
+ };
145
+ }
146
+
147
+ notify(): void {
148
+ // Signals don't get notified — they are leaf sources
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Computed — Derived reactive value (lazy + cached)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ const enum ComputedState {
157
+ Clean,
158
+ Dirty,
159
+ Computing,
160
+ }
161
+
162
+ class ComputedImpl<T> implements ReactiveNode {
163
+ _id: number;
164
+ subscribers = new Set<ReactiveNode>();
165
+ dependencies = new Set<ReactiveNode>();
166
+ private _value: T | undefined = undefined;
167
+ private _fn: () => T;
168
+ private _state: ComputedState = ComputedState.Dirty;
169
+ private _listeners = new Set<(value: T) => void>();
170
+
171
+ constructor(fn: () => T) {
172
+ this._id = nodeIdCounter++;
173
+ this._fn = fn;
174
+ }
175
+
176
+ get value(): T {
177
+ trackDependency(this);
178
+ if (this._state !== ComputedState.Clean) {
179
+ this._recompute();
180
+ }
181
+ return this._value as T;
182
+ }
183
+
184
+ peek(): T {
185
+ if (this._state !== ComputedState.Clean) {
186
+ this._recompute();
187
+ }
188
+ return this._value as T;
189
+ }
190
+
191
+ subscribe(listener: (value: T) => void): () => void {
192
+ this._listeners.add(listener);
193
+ return () => {
194
+ this._listeners.delete(listener);
195
+ };
196
+ }
197
+
198
+ notify(): void {
199
+ const oldState = this._state;
200
+ this._state = ComputedState.Dirty;
201
+
202
+ // Only propagate if we were clean (avoid cascading re-notifications)
203
+ if (oldState === ComputedState.Clean) {
204
+ // Eagerly recompute to check if value actually changed
205
+ const oldValue = this._value;
206
+ this._recompute();
207
+ if (!Object.is(oldValue, this._value)) {
208
+ notifySubscribers(this);
209
+ for (const listener of this._listeners) {
210
+ listener(this._value as T);
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ private _recompute(): void {
217
+ if (this._state === ComputedState.Computing) {
218
+ throw new Error(
219
+ `[kayforms] Circular dependency detected in computed (node #${this._id})`
220
+ );
221
+ }
222
+ this._state = ComputedState.Computing;
223
+ cleanupDependencies(this);
224
+
225
+ subscriberStack.push(this);
226
+ try {
227
+ this._value = this._fn();
228
+ } finally {
229
+ subscriberStack.pop();
230
+ this._state = ComputedState.Clean;
231
+ }
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Effect — Side effect with automatic dependency tracking
237
+ // ---------------------------------------------------------------------------
238
+
239
+ class EffectImpl implements ReactiveNode {
240
+ _id: number;
241
+ subscribers = new Set<ReactiveNode>();
242
+ dependencies = new Set<ReactiveNode>();
243
+ private _fn: () => void | EffectCleanup;
244
+ private _cleanup: EffectCleanup | undefined;
245
+ private _disposed = false;
246
+
247
+ constructor(fn: () => void | EffectCleanup) {
248
+ this._id = nodeIdCounter++;
249
+ this._fn = fn;
250
+ // Run immediately to establish initial dependencies
251
+ this._run();
252
+ }
253
+
254
+ notify(): void {
255
+ if (!this._disposed) {
256
+ this._run();
257
+ }
258
+ }
259
+
260
+ private _run(): void {
261
+ // Run previous cleanup
262
+ if (this._cleanup) {
263
+ this._cleanup();
264
+ this._cleanup = undefined;
265
+ }
266
+
267
+ cleanupDependencies(this);
268
+
269
+ subscriberStack.push(this);
270
+ try {
271
+ const result = this._fn();
272
+ if (typeof result === "function") {
273
+ this._cleanup = result;
274
+ }
275
+ } finally {
276
+ subscriberStack.pop();
277
+ }
278
+ }
279
+
280
+ dispose(): void {
281
+ this._disposed = true;
282
+ if (this._cleanup) {
283
+ this._cleanup();
284
+ this._cleanup = undefined;
285
+ }
286
+ cleanupDependencies(this);
287
+ }
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Public API
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * Create a writable reactive signal.
296
+ *
297
+ * @example
298
+ * ```ts
299
+ * const count = createSignal(0);
300
+ * console.log(count.value); // 0
301
+ * count.set(1);
302
+ * console.log(count.value); // 1
303
+ * ```
304
+ */
305
+ export function createSignal<T>(initial: T): Signal<T> {
306
+ return new SignalImpl(initial);
307
+ }
308
+
309
+ /**
310
+ * Create a read-only computed signal that derives its value from other signals.
311
+ * Recomputes lazily only when dependencies change.
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * const firstName = createSignal('Kay');
316
+ * const lastName = createSignal('Forms');
317
+ * const fullName = createComputed(() => `${firstName.value} ${lastName.value}`);
318
+ * console.log(fullName.value); // 'Kay Forms'
319
+ * ```
320
+ */
321
+ export function createComputed<T>(fn: () => T): Computed<T> {
322
+ return new ComputedImpl(fn);
323
+ }
324
+
325
+ /**
326
+ * Create a side effect that automatically re-runs when its dependencies change.
327
+ * Returns a dispose function to stop the effect.
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * const name = createSignal('World');
332
+ * const dispose = createEffect(() => {
333
+ * console.log(`Hello, ${name.value}!`);
334
+ * return () => console.log('cleanup');
335
+ * });
336
+ * name.set('Kayforms'); // logs cleanup, then 'Hello, Kayforms!'
337
+ * dispose(); // stops tracking
338
+ * ```
339
+ */
340
+ export function createEffect(fn: () => void | EffectCleanup): EffectCleanup {
341
+ const effect = new EffectImpl(fn);
342
+ return () => effect.dispose();
343
+ }
344
+
345
+ /**
346
+ * Batch multiple signal writes into a single notification flush.
347
+ * Subscribers are only notified after the batch completes.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * const a = createSignal(1);
352
+ * const b = createSignal(2);
353
+ * batch(() => {
354
+ * a.set(10);
355
+ * b.set(20);
356
+ * // No notifications fired yet
357
+ * });
358
+ * // All subscribers notified once here
359
+ * ```
360
+ */
361
+ export function batch(fn: () => void): void {
362
+ batchDepth++;
363
+ try {
364
+ fn();
365
+ } finally {
366
+ batchDepth--;
367
+ if (batchDepth === 0) {
368
+ // Flush all pending notifications
369
+ const pending = [...batchQueue];
370
+ batchQueue.clear();
371
+ for (const node of pending) {
372
+ node.notify();
373
+ }
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Run a function without tracking any signal reads as dependencies.
380
+ * Useful for reading signals inside event handlers or effects without
381
+ * creating unwanted subscriptions.
382
+ *
383
+ * @example
384
+ * ```ts
385
+ * createEffect(() => {
386
+ * const tracked = name.value; // tracked
387
+ * const untracked = untrack(() => other.value); // NOT tracked
388
+ * });
389
+ * ```
390
+ */
391
+ export function untrack<T>(fn: () => T): T {
392
+ // Temporarily clear the subscriber stack so no dependencies are tracked
393
+ const saved = subscriberStack.splice(0, subscriberStack.length);
394
+ try {
395
+ return fn();
396
+ } finally {
397
+ subscriberStack.push(...saved);
398
+ }
399
+ }
@@ -0,0 +1,275 @@
1
+ // ============================================================================
2
+ // @kayforms/core — Time-Travel Debugging
3
+ // ============================================================================
4
+ // Records form history and exposes undo/redo/jumpTo APIs.
5
+ // Fits within a <1KB bundle budget.
6
+ // ============================================================================
7
+
8
+ import { createEffect, batch } from "./signal";
9
+ import { type FormStore } from "./form";
10
+
11
+ export interface TimeTravelEntry {
12
+ timestamp: number;
13
+ values: any;
14
+ errors: any;
15
+ formLevelErrors?: any;
16
+ changedField?: string;
17
+ touched?: Record<string, boolean>;
18
+ }
19
+
20
+ export interface TimeTravelOptions {
21
+ maxHistory?: number;
22
+ }
23
+
24
+ export interface TimeTravelMethods {
25
+ undo(): void;
26
+ redo(): void;
27
+ jumpTo(index: number): void;
28
+ clearHistory(): void;
29
+ getHistory(): TimeTravelEntry[];
30
+ getCursor(): number;
31
+ importHistory(importedHistory: TimeTravelEntry[]): void;
32
+ }
33
+
34
+ // Global hook for Chrome Extension / DevTools bridge
35
+ if (typeof window !== "undefined") {
36
+ const g = window as any;
37
+ if (!g.__KAYFORMS_DEVTOOLS__) {
38
+ const listeners = new Set<() => void>();
39
+ g.__KAYFORMS_DEVTOOLS__ = {
40
+ forms: {},
41
+ listeners,
42
+ onHistoryChange(listener: () => void) {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ },
46
+ };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Enable time-travel debugging on a FormStore instance.
52
+ * Attaches undo, redo, jumpTo, clearHistory, and getHistory methods to the form.
53
+ */
54
+ export function enableTimeTravel<T extends Record<string, unknown>>(
55
+ form: FormStore<T>,
56
+ options: TimeTravelOptions = {}
57
+ ): FormStore<T> & TimeTravelMethods {
58
+ const maxHistory = options.maxHistory ?? 100;
59
+ let history: TimeTravelEntry[] = [];
60
+ let cursor = -1;
61
+ let isRestoring = false;
62
+
63
+ // Tiny dot-path differences finder
64
+ function findDiffPath(a: any, b: any, prefix = ""): string | undefined {
65
+ if (a === b) return undefined;
66
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
67
+ return prefix;
68
+ }
69
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
70
+ for (const key of keys) {
71
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
72
+ const diff = findDiffPath(a[key], b[key], nextPrefix);
73
+ if (diff !== undefined) return diff;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ // Tiny nested getByPath
79
+ function getByPath(obj: any, path: string): any {
80
+ return path.split(".").reduce((acc, part) => acc && acc[part], obj);
81
+ }
82
+
83
+ // Effect tracks changes to values, errors, and touched signals
84
+ const unsubscribe = createEffect(() => {
85
+ const vals = form.values.value;
86
+ const errs = form.errors.value;
87
+
88
+ const touched: Record<string, boolean> = {};
89
+ const fields = (form as any)._fields;
90
+ if (fields) {
91
+ for (const [path, field] of fields) {
92
+ touched[path] = field.touched.value;
93
+ }
94
+ }
95
+
96
+ if (isRestoring) return;
97
+
98
+ const clonedVals = structuredClone(vals);
99
+ const clonedErrs = structuredClone(errs);
100
+ const formLevelSignal = (form as any)._formLevelErrors;
101
+ const formLevelErrors = formLevelSignal ? structuredClone(formLevelSignal.value) : {};
102
+
103
+ const prevEntry = history[history.length - 1];
104
+ let changedField: string | undefined = undefined;
105
+
106
+ if (prevEntry) {
107
+ changedField = findDiffPath(prevEntry.values, clonedVals);
108
+ if (
109
+ changedField === undefined &&
110
+ JSON.stringify(prevEntry.errors) === JSON.stringify(clonedErrs) &&
111
+ JSON.stringify(prevEntry.touched) === JSON.stringify(touched)
112
+ ) {
113
+ return;
114
+ }
115
+ }
116
+
117
+ const entry: TimeTravelEntry = {
118
+ timestamp: Date.now(),
119
+ values: clonedVals,
120
+ errors: clonedErrs,
121
+ formLevelErrors,
122
+ touched,
123
+ changedField,
124
+ };
125
+
126
+ if (cursor >= 0 && cursor < history.length - 1) {
127
+ history = history.slice(0, cursor + 1);
128
+ }
129
+
130
+ history.push(entry);
131
+ if (history.length > maxHistory) {
132
+ history.shift();
133
+ }
134
+ cursor = history.length - 1;
135
+
136
+ // Notify listeners and DevTools Chrome Extension
137
+ if (typeof window !== "undefined") {
138
+ const g = window as any;
139
+ if (g.__KAYFORMS_DEVTOOLS__) {
140
+ for (const l of g.__KAYFORMS_DEVTOOLS__.listeners) {
141
+ try { l(); } catch (_) {}
142
+ }
143
+ }
144
+ window.dispatchEvent(
145
+ new CustomEvent("kayforms:history-change", {
146
+ detail: { formId: form.id || "default", cursor, historyLength: history.length },
147
+ })
148
+ );
149
+ }
150
+ });
151
+
152
+ function jumpTo(index: number): void {
153
+ if (index < 0 || index >= history.length) return;
154
+ cursor = index;
155
+ const entry = history[index];
156
+
157
+ isRestoring = true;
158
+ batch(() => {
159
+ // 1. Restore form level values
160
+ form.values.set(structuredClone(entry.values));
161
+
162
+ // 2. Restore form-level errors
163
+ const formLevelSignal = (form as any)._formLevelErrors;
164
+ if (formLevelSignal && entry.formLevelErrors) {
165
+ formLevelSignal.set(structuredClone(entry.formLevelErrors));
166
+ }
167
+
168
+ // 3. Restore field specific values, errors, touched
169
+ const fields = (form as any)._fields;
170
+ if (fields) {
171
+ for (const [path, field] of fields) {
172
+ const val = getByPath(entry.values, path);
173
+ field.value.set(val);
174
+
175
+ const err = getByPath(entry.errors, path);
176
+ field.error.set(err);
177
+
178
+ const isTouched = entry.touched?.[path] ?? false;
179
+ field.touched.set(isTouched);
180
+ }
181
+ }
182
+ });
183
+ isRestoring = false;
184
+
185
+ if (typeof window !== "undefined") {
186
+ window.dispatchEvent(
187
+ new CustomEvent("kayforms:history-change", {
188
+ detail: { formId: form.id || "default", cursor, historyLength: history.length },
189
+ })
190
+ );
191
+ }
192
+ }
193
+
194
+ const methods: TimeTravelMethods = {
195
+ undo() {
196
+ if (cursor > 0) {
197
+ jumpTo(cursor - 1);
198
+ }
199
+ },
200
+ redo() {
201
+ if (cursor < history.length - 1) {
202
+ jumpTo(cursor + 1);
203
+ }
204
+ },
205
+ jumpTo,
206
+ clearHistory() {
207
+ history = [];
208
+ cursor = -1;
209
+
210
+ const fields = (form as any)._fields;
211
+ const touched: Record<string, boolean> = {};
212
+ if (fields) {
213
+ for (const [path, field] of fields) {
214
+ touched[path] = field.touched.peek();
215
+ }
216
+ }
217
+
218
+ history.push({
219
+ timestamp: Date.now(),
220
+ values: structuredClone(form.values.peek()),
221
+ errors: structuredClone(form.errors.peek()),
222
+ formLevelErrors: (form as any)._formLevelErrors ? structuredClone((form as any)._formLevelErrors.peek()) : {},
223
+ touched,
224
+ });
225
+ cursor = 0;
226
+
227
+ if (typeof window !== "undefined") {
228
+ window.dispatchEvent(
229
+ new CustomEvent("kayforms:history-change", {
230
+ detail: { formId: form.id || "default", cursor, historyLength: history.length },
231
+ })
232
+ );
233
+ }
234
+ },
235
+ getHistory() {
236
+ return history;
237
+ },
238
+ getCursor() {
239
+ return cursor;
240
+ },
241
+ importHistory(importedHistory) {
242
+ if (!Array.isArray(importedHistory) || importedHistory.length === 0) return;
243
+ history = importedHistory;
244
+ cursor = history.length - 1;
245
+ jumpTo(cursor);
246
+ },
247
+ };
248
+
249
+ // Attach methods to form instance
250
+ Object.assign(form, methods);
251
+
252
+ // Register with global devtools registry
253
+ const formId = form.id || "default";
254
+ if (typeof window !== "undefined") {
255
+ const g = window as any;
256
+ if (g.__KAYFORMS_DEVTOOLS__) {
257
+ g.__KAYFORMS_DEVTOOLS__.forms[formId] = form;
258
+ }
259
+ }
260
+
261
+ // Hook form dispose
262
+ const originalDispose = form.dispose;
263
+ form.dispose = () => {
264
+ unsubscribe();
265
+ if (typeof window !== "undefined") {
266
+ const g = window as any;
267
+ if (g.__KAYFORMS_DEVTOOLS__) {
268
+ delete g.__KAYFORMS_DEVTOOLS__.forms[formId];
269
+ }
270
+ }
271
+ originalDispose.call(form);
272
+ };
273
+
274
+ return form as FormStore<T> & TimeTravelMethods;
275
+ }