@ngstato/angular 0.4.0 → 0.4.2

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.
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, InjectionToken, isDevMode, makeEnvironmentProviders, signal, computed, Injectable, Component } from '@angular/core';
3
3
  import { configureHttp, devTools, createStore } from '@ngstato/core';
4
- import { JsonPipe } from '@angular/common';
4
+ import { JsonPipe, KeyValuePipe } from '@angular/common';
5
5
 
6
6
  // ─────────────────────────────────────────────────────
7
7
  // @ngstato/angular — injectStore()
@@ -39,8 +39,10 @@ function provideStato(config = {}) {
39
39
  // FONCTION PRINCIPALE — createAngularStore()
40
40
  // ─────────────────────────────────────────────────────
41
41
  function createAngularStore(config) {
42
- // 1. Créer le store core
43
- const coreStore = createStore(config);
42
+ // 1. Créer le store core — SANS l'initialiser
43
+ // L'init sera fait après que l'angularStore soit prêt,
44
+ // pour que les effects reçoivent la bonne référence dès le 1er run.
45
+ const coreStore = createStore(config, { skipInit: true });
44
46
  // 2. Créer un Signal pour chaque propriété du state
45
47
  const signals = {};
46
48
  const initialState = coreStore.getState();
@@ -68,10 +70,22 @@ function createAngularStore(config) {
68
70
  });
69
71
  }
70
72
  // 6. Exposer chaque computed comme Signal computed
71
- const { computed: computedConfig } = config;
73
+ // On force la lecture des signals Angular pour que le computed()
74
+ // soit réactif via le système de change detection d'Angular.
75
+ const { computed: computedConfig, selectors: selectorsConfig } = config;
72
76
  if (computedConfig) {
73
77
  for (const key of Object.keys(computedConfig)) {
74
- const computedSignal = computed(() => coreStore[key]);
78
+ const fn = computedConfig[key];
79
+ if (typeof fn !== 'function')
80
+ continue;
81
+ const computedSignal = computed(() => {
82
+ // Lire les signals Angular pour déclencher le tracking
83
+ const snapshot = {};
84
+ for (const stateKey of Object.keys(signals)) {
85
+ snapshot[stateKey] = signals[stateKey]();
86
+ }
87
+ return fn(snapshot);
88
+ });
75
89
  Object.defineProperty(angularStore, key, {
76
90
  get: () => computedSignal,
77
91
  enumerable: true,
@@ -79,6 +93,26 @@ function createAngularStore(config) {
79
93
  });
80
94
  }
81
95
  }
96
+ // 6b. Exposer chaque selector memoïzé comme Signal computed
97
+ if (selectorsConfig) {
98
+ for (const key of Object.keys(selectorsConfig)) {
99
+ const fn = selectorsConfig[key];
100
+ if (typeof fn !== 'function')
101
+ continue;
102
+ const selectorSignal = computed(() => {
103
+ const snapshot = {};
104
+ for (const stateKey of Object.keys(signals)) {
105
+ snapshot[stateKey] = signals[stateKey]();
106
+ }
107
+ return fn(snapshot);
108
+ });
109
+ Object.defineProperty(angularStore, key, {
110
+ get: () => selectorSignal,
111
+ enumerable: true,
112
+ configurable: true
113
+ });
114
+ }
115
+ }
82
116
  // 7. Exposer chaque action directement
83
117
  const { actions } = config;
84
118
  if (actions) {
@@ -86,13 +120,15 @@ function createAngularStore(config) {
86
120
  angularStore[name] = (...args) => coreStore.__store__.dispatch(name, ...args);
87
121
  }
88
122
  }
89
- // 8. Unifier le lifecycle avec @ngstato/core
90
- // (init est idempotent: onInit ne sera appelé qu'une fois)
91
- coreStore.__store__.init(angularStore);
92
- // 9. Exposer destroy pour le cleanup
123
+ // 8. Exposer destroy pour le cleanup
93
124
  angularStore.__destroy__ = () => {
94
125
  coreStore.__store__.destroy(angularStore);
95
126
  };
127
+ // 9. Initialiser le store UNE SEULE FOIS avec l'angularStore
128
+ // C'est ici que onInit() est appelé et que les effects démarrent,
129
+ // directement avec la bonne référence (angularStore avec Signals).
130
+ // Aucun double _runEffects — un seul init, un seul run.
131
+ coreStore.__store__.init(angularStore);
96
132
  return angularStore;
97
133
  }
98
134
  // ─────────────────────────────────────────────────────
@@ -245,11 +281,36 @@ class StatoDevToolsComponent {
245
281
  activeTab = signal('actions');
246
282
  logs = signal([]);
247
283
  selectedLog = signal(null);
248
- // Position et taille
284
+ activeLogId = signal(null);
285
+ isTimeTraveling = signal(false);
286
+ // Snapshot global — latest known state per store
287
+ globalState = computed(() => {
288
+ const seen = new Map();
289
+ for (const log of this.logs()) {
290
+ if (!seen.has(log.storeName)) {
291
+ seen.set(log.storeName, log.nextState);
292
+ }
293
+ }
294
+ return seen;
295
+ });
296
+ // Time-travel computed
297
+ canUndo = computed(() => this.logs().length > 0);
298
+ canRedo = computed(() => {
299
+ if (!this.isTimeTraveling())
300
+ return false;
301
+ const id = this.activeLogId();
302
+ if (id === null)
303
+ return false;
304
+ if (id === -1)
305
+ return this.logs().length > 0;
306
+ const idx = this.logs().findIndex(l => l.id === id);
307
+ return idx > 0; // can go forward (lower index = newer)
308
+ });
309
+ // Position & size
249
310
  posX = signal(24);
250
- posY = signal(window.innerHeight - 500);
251
- panelWidth = signal(420);
252
- panelHeight = signal(460);
311
+ posY = signal(window.innerHeight - 520);
312
+ panelWidth = signal(440);
313
+ panelHeight = signal(480);
253
314
  // Drag state
254
315
  isDragging = false;
255
316
  isResizing = false;
@@ -266,6 +327,8 @@ class StatoDevToolsComponent {
266
327
  this.unsub = devTools.subscribe((state) => {
267
328
  this.logs.set(state.logs);
268
329
  this.isOpen.set(state.isOpen);
330
+ this.activeLogId.set(state.activeLogId);
331
+ this.isTimeTraveling.set(state.isTimeTraveling);
269
332
  });
270
333
  document.addEventListener('mousemove', this.boundMouseMove);
271
334
  document.addEventListener('mouseup', this.boundMouseUp);
@@ -285,6 +348,65 @@ class StatoDevToolsComponent {
285
348
  formatTime(iso) {
286
349
  return new Date(iso).toTimeString().slice(0, 8);
287
350
  }
351
+ // ── Time-travel actions ────────────────────────────
352
+ onTravelTo(log) {
353
+ // Toggle detail view
354
+ this.selectLog(log);
355
+ // Jump to this action's state
356
+ devTools.travelTo(log.id);
357
+ }
358
+ onUndo() { devTools.undo(); }
359
+ onRedo() { devTools.redo(); }
360
+ onResume() { devTools.resume(); }
361
+ onReplay(log, event) {
362
+ event.stopPropagation();
363
+ devTools.replay(log.id);
364
+ }
365
+ isFutureLog(log) {
366
+ if (!this.isTimeTraveling())
367
+ return false;
368
+ const activeId = this.activeLogId();
369
+ if (activeId === null)
370
+ return false;
371
+ if (activeId === -1)
372
+ return true; // all logs are "future"
373
+ const activeIdx = this.logs().findIndex(l => l.id === activeId);
374
+ const logIdx = this.logs().findIndex(l => l.id === log.id);
375
+ return logIdx < activeIdx; // newer logs (lower index) are "future"
376
+ }
377
+ // ── Export/Import ──────────────────────────────────
378
+ onExport() {
379
+ const snapshot = devTools.exportSnapshot();
380
+ const json = JSON.stringify(snapshot, null, 2);
381
+ const blob = new Blob([json], { type: 'application/json' });
382
+ const url = URL.createObjectURL(blob);
383
+ const a = document.createElement('a');
384
+ a.href = url;
385
+ a.download = `ngstato-snapshot-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
386
+ a.click();
387
+ URL.revokeObjectURL(url);
388
+ }
389
+ onImport() {
390
+ const input = document.querySelector('input[type="file"]');
391
+ input?.click();
392
+ }
393
+ onFileSelected(event) {
394
+ const file = event.target.files?.[0];
395
+ if (!file)
396
+ return;
397
+ const reader = new FileReader();
398
+ reader.onload = () => {
399
+ try {
400
+ const snapshot = JSON.parse(reader.result);
401
+ devTools.importSnapshot(snapshot);
402
+ }
403
+ catch (e) {
404
+ console.error('[ngStato DevTools] Invalid snapshot file:', e);
405
+ }
406
+ };
407
+ reader.readAsText(file);
408
+ event.target.value = '';
409
+ }
288
410
  // ── Drag ───────────────────────────────────────────
289
411
  onDragStart(e) {
290
412
  if (e.target.classList.contains('btn-icon'))
@@ -323,8 +445,8 @@ class StatoDevToolsComponent {
323
445
  this.isResizing = false;
324
446
  }
325
447
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: StatoDevToolsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
326
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: StatoDevToolsComponent, isStandalone: true, selector: "ngstato-devtools", ngImport: i0, template: `
327
- <!-- Bouton flottant -->
448
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: StatoDevToolsComponent, isStandalone: true, selector: "ngstato-devtools, stato-devtools", ngImport: i0, template: `
449
+ <!-- Floating button -->
328
450
  @if (!isOpen()) {
329
451
  <button class="devtools-fab" (click)="toggle()">
330
452
  🛠 Stato
@@ -338,7 +460,7 @@ class StatoDevToolsComponent {
338
460
  [class.devtools-panel--minimized]="isMinimized()"
339
461
  [style.left.px]="posX()"
340
462
  [style.top.px]="posY()"
341
- [style.width.px]="isMinimized() ? 200 : panelWidth()"
463
+ [style.width.px]="isMinimized() ? 220 : panelWidth()"
342
464
  [style.height]="isMinimized() ? 'auto' : panelHeight() + 'px'"
343
465
  >
344
466
 
@@ -347,19 +469,24 @@ class StatoDevToolsComponent {
347
469
  class="devtools-header"
348
470
  (mousedown)="onDragStart($event)"
349
471
  >
350
- <span class="devtools-title">🛠 Stato</span>
472
+ <span class="devtools-title">
473
+ 🛠 Stato
474
+ @if (isTimeTraveling()) {
475
+ <span class="tt-badge">TIME-TRAVEL</span>
476
+ }
477
+ </span>
351
478
  <div class="devtools-header-actions">
352
479
  @if (!isMinimized()) {
353
- <button class="btn-icon" (click)="clear()" title="Vider">🗑</button>
480
+ <button class="btn-icon" (click)="clear()" title="Clear">🗑</button>
354
481
  }
355
- <button class="btn-icon" (click)="toggleMinimize()" title="Minimiser/Agrandir">
482
+ <button class="btn-icon" (click)="toggleMinimize()" title="Minimize">
356
483
  {{ isMinimized() ? '▲' : '▼' }}
357
484
  </button>
358
- <button class="btn-icon" (click)="toggle()" title="Fermer">✕</button>
485
+ <button class="btn-icon" (click)="toggle()" title="Close">✕</button>
359
486
  </div>
360
487
  </div>
361
488
 
362
- <!-- Resize handle — coin bas droite -->
489
+ <!-- Resize handle -->
363
490
  @if (!isMinimized()) {
364
491
  <div
365
492
  class="devtools-resize"
@@ -387,17 +514,55 @@ class StatoDevToolsComponent {
387
514
  </button>
388
515
  </div>
389
516
 
390
- <!-- Tab Actions -->
517
+ <!-- Time-travel toolbar -->
518
+ @if (activeTab() === 'actions' && logs().length) {
519
+ <div class="tt-toolbar">
520
+ <button
521
+ class="tt-btn"
522
+ (click)="onUndo()"
523
+ [disabled]="!canUndo()"
524
+ title="Undo (step back)"
525
+ >⏪</button>
526
+ <button
527
+ class="tt-btn"
528
+ (click)="onRedo()"
529
+ [disabled]="!canRedo()"
530
+ title="Redo (step forward)"
531
+ >⏩</button>
532
+ @if (isTimeTraveling()) {
533
+ <button
534
+ class="tt-btn tt-btn--resume"
535
+ (click)="onResume()"
536
+ title="Resume live state"
537
+ >▶ Live</button>
538
+ }
539
+ <div class="tt-spacer"></div>
540
+ <button
541
+ class="tt-btn tt-btn--export"
542
+ (click)="onExport()"
543
+ title="Export state snapshot (JSON)"
544
+ >📤</button>
545
+ <button
546
+ class="tt-btn tt-btn--import"
547
+ (click)="onImport()"
548
+ title="Import state snapshot"
549
+ >📥</button>
550
+ </div>
551
+ }
552
+
553
+ <!-- Tab: Actions -->
391
554
  @if (activeTab() === 'actions') {
392
555
  <div class="devtools-content">
393
556
  @if (!logs().length) {
394
- <div class="devtools-empty">Aucune action pour l'instant</div>
557
+ <div class="devtools-empty">No actions yet</div>
395
558
  }
396
559
  @for (log of logs(); track log.id) {
397
560
  <div
398
561
  class="log-item"
399
562
  [class.log-item--error]="log.status === 'error'"
400
- (click)="selectLog(log)"
563
+ [class.log-item--active]="activeLogId() === log.id"
564
+ [class.log-item--future]="isFutureLog(log)"
565
+ (click)="onTravelTo(log)"
401
566
  >
402
567
  <div class="log-item__left">
403
568
  <span class="log-status">{{ log.status === 'success' ? '✓' : '✗' }}</span>
@@ -405,11 +570,16 @@ class StatoDevToolsComponent {
405
570
  </div>
406
571
  <div class="log-item__right">
407
572
  @if (log.status === 'error') {
408
- <span class="log-error-badge">erreur</span>
573
+ <span class="log-error-badge">error</span>
409
574
  } @else {
410
575
  <span class="log-duration">{{ log.duration }}ms</span>
411
576
  }
412
577
  <span class="log-time">{{ formatTime(log.at) }}</span>
578
+ <button
579
+ class="btn-icon btn-replay"
580
+ (click)="onReplay(log, $event)"
581
+ title="Replay this action"
582
+ >🔄</button>
413
583
  </div>
414
584
  </div>
415
585
 
@@ -419,11 +589,11 @@ class StatoDevToolsComponent {
419
589
  <div class="log-detail__error">{{ log.error }}</div>
420
590
  }
421
591
  <div class="log-detail__section">
422
- <span class="log-detail__label">Avant</span>
592
+ <span class="log-detail__label">Before</span>
423
593
  <pre>{{ log.prevState | json }}</pre>
424
594
  </div>
425
595
  <div class="log-detail__section">
426
- <span class="log-detail__label">Après</span>
596
+ <span class="log-detail__label">After</span>
427
597
  <pre>{{ log.nextState | json }}</pre>
428
598
  </div>
429
599
  </div>
@@ -432,13 +602,18 @@ class StatoDevToolsComponent {
432
602
  </div>
433
603
  }
434
604
 
435
- <!-- Tab State -->
605
+ <!-- Tab: State -->
436
606
  @if (activeTab() === 'state') {
437
607
  <div class="devtools-content">
438
- @if (logs().length) {
439
- <pre class="state-view">{{ logs()[0].nextState | json }}</pre>
608
+ @if (globalState().size) {
609
+ @for (entry of globalState() | keyvalue; track entry.key) {
610
+ <div class="state-store-block">
611
+ <div class="state-store-name">{{ entry.key }}</div>
612
+ <pre class="state-view">{{ entry.value | json }}</pre>
613
+ </div>
614
+ }
440
615
  } @else {
441
- <div class="devtools-empty">Aucun state disponible</div>
616
+ <div class="devtools-empty">No state available</div>
442
617
  }
443
618
  </div>
444
619
  }
@@ -446,12 +621,21 @@ class StatoDevToolsComponent {
446
621
 
447
622
  </div>
448
623
  }
449
- `, isInline: true, styles: [".devtools-fab{position:fixed;bottom:1.5rem;left:1.5rem;background:#1e293b;color:#fff;border:none;border-radius:999px;padding:.5rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer;z-index:9999;box-shadow:0 4px 12px #0000004d;transition:background .15s}.devtools-fab:hover{background:#334155}.devtools-panel{position:fixed;background:#0f172a;border-radius:12px;box-shadow:0 8px 32px #0006;z-index:9999;display:flex;flex-direction:column;overflow:hidden;font-family:Courier New,monospace;min-width:200px;min-height:40px}.devtools-panel--minimized{border-radius:8px}.devtools-header{display:flex;justify-content:space-between;align-items:center;padding:.6rem .75rem;background:#1e293b;border-bottom:1px solid #334155;cursor:grab;-webkit-user-select:none;user-select:none}.devtools-header:active{cursor:grabbing}.devtools-title{color:#e2e8f0;font-size:.82rem;font-weight:600;font-family:system-ui}.devtools-header-actions{display:flex;gap:.25rem}.btn-icon{background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.8rem;padding:.15rem .35rem;border-radius:4px;line-height:1}.btn-icon:hover{background:#334155;color:#fff}.devtools-resize{position:absolute;bottom:2px;right:4px;color:#334155;font-size:.9rem;cursor:nwse-resize;-webkit-user-select:none;user-select:none;line-height:1}.devtools-resize:hover{color:#64748b}.devtools-tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}.tab{padding:.4rem .75rem;background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.78rem;font-family:system-ui}.tab:hover{color:#e2e8f0}.tab--active{color:#3b82f6;border-bottom:2px solid #3b82f6}.devtools-content{overflow-y:auto;flex:1;padding:.25rem 0}.devtools-empty{padding:2rem;text-align:center;color:#475569;font-size:.78rem;font-family:system-ui}.log-item{display:flex;justify-content:space-between;align-items:center;padding:.35rem .75rem;cursor:pointer;border-bottom:1px solid #1e293b}.log-item:hover{background:#1e293b}.log-item--error{background:#1a0a0a}.log-item__left{display:flex;align-items:center;gap:.4rem;overflow:hidden}.log-item__right{display:flex;align-items:center;gap:.4rem;flex-shrink:0}.log-status{font-size:.72rem;color:#22c55e;flex-shrink:0}.log-item--error .log-status{color:#ef4444}.log-name{color:#e2e8f0;font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.log-duration{color:#64748b;font-size:.7rem}.log-time{color:#475569;font-size:.68rem}.log-error-badge{background:#7f1d1d;color:#fca5a5;font-size:.68rem;padding:.1rem .35rem;border-radius:4px}.log-detail{background:#0a0f1a;padding:.6rem .75rem;border-left:3px solid #3b82f6;margin:0 .4rem .4rem;border-radius:0 4px 4px 0}.log-detail__error{color:#fca5a5;font-size:.72rem;margin-bottom:.4rem}.log-detail__section{margin-bottom:.4rem}.log-detail__label{color:#64748b;font-size:.68rem;display:block;margin-bottom:.2rem;font-family:system-ui}pre{color:#86efac;font-size:.7rem;margin:0;white-space:pre-wrap;word-break:break-all;max-height:140px;overflow-y:auto}.state-view{color:#86efac;font-size:.7rem;padding:.75rem;margin:0;white-space:pre-wrap;word-break:break-all}\n"], dependencies: [{ kind: "pipe", type: JsonPipe, name: "json" }] });
624
+
625
+ <!-- Hidden file input for import -->
626
+ <input
627
+ #fileInput
628
+ type="file"
629
+ accept=".json"
630
+ style="display: none"
631
+ (change)="onFileSelected($event)"
632
+ />
633
+ `, isInline: true, styles: [":host{font-family:system-ui,-apple-system,sans-serif}.devtools-fab{position:fixed;bottom:1.5rem;left:1.5rem;background:#1e293b;color:#fff;border:none;border-radius:999px;padding:.5rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer;z-index:9999;box-shadow:0 4px 12px #0000004d;transition:background .15s}.devtools-fab:hover{background:#334155}.devtools-panel{position:fixed;background:#0f172a;border-radius:12px;box-shadow:0 8px 32px #00000080;z-index:9999;display:flex;flex-direction:column;overflow:hidden;font-family:Courier New,monospace;min-width:200px;min-height:40px;border:1px solid #1e293b}.devtools-panel--minimized{border-radius:8px}.devtools-header{display:flex;justify-content:space-between;align-items:center;padding:.6rem .75rem;background:#1e293b;border-bottom:1px solid #334155;cursor:grab;-webkit-user-select:none;user-select:none}.devtools-header:active{cursor:grabbing}.devtools-title{color:#e2e8f0;font-size:.82rem;font-weight:600;font-family:system-ui;display:flex;align-items:center;gap:.4rem}.tt-badge{background:#7c3aed;color:#fff;font-size:.6rem;padding:.12rem .4rem;border-radius:4px;font-weight:700;letter-spacing:.05em;animation:tt-pulse 1.5s ease-in-out infinite}@keyframes tt-pulse{0%,to{opacity:1}50%{opacity:.6}}.devtools-header-actions{display:flex;gap:.25rem}.btn-icon{background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.8rem;padding:.15rem .35rem;border-radius:4px;line-height:1}.btn-icon:hover{background:#334155;color:#fff}.devtools-resize{position:absolute;bottom:2px;right:4px;color:#334155;font-size:.9rem;cursor:nwse-resize;-webkit-user-select:none;user-select:none;line-height:1}.devtools-resize:hover{color:#64748b}.devtools-tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}.tab{padding:.4rem .75rem;background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.78rem;font-family:system-ui;transition:color .15s}.tab:hover{color:#e2e8f0}.tab--active{color:#3b82f6;border-bottom:2px solid #3b82f6}.tt-toolbar{display:flex;align-items:center;gap:.2rem;padding:.3rem .5rem;background:#0f172a;border-bottom:1px solid #1e293b}.tt-btn{background:#1e293b;border:1px solid #334155;color:#94a3b8;padding:.2rem .5rem;border-radius:4px;font-size:.72rem;cursor:pointer;transition:all .15s;font-family:system-ui}.tt-btn:hover:not(:disabled){background:#334155;color:#e2e8f0;border-color:#475569}.tt-btn:disabled{opacity:.3;cursor:not-allowed}.tt-btn--resume{background:#7c3aed;border-color:#7c3aed;color:#fff;font-weight:600}.tt-btn--resume:hover:not(:disabled){background:#6d28d9}.tt-btn--export,.tt-btn--import{font-size:.68rem}.tt-spacer{flex:1}.devtools-content{overflow-y:auto;flex:1;padding:.25rem 0}.devtools-empty{padding:2rem;text-align:center;color:#475569;font-size:.78rem;font-family:system-ui}.log-item{display:flex;justify-content:space-between;align-items:center;padding:.35rem .75rem;cursor:pointer;border-bottom:1px solid #1e293b;transition:background .1s}.log-item:hover{background:#1e293b}.log-item--error{background:#1a0a0a}.log-item--active{background:#1e1b4b!important;border-left:3px solid #7c3aed}.log-item--future{opacity:.35}.log-item__left{display:flex;align-items:center;gap:.4rem;overflow:hidden;flex:1}.log-item__right{display:flex;align-items:center;gap:.35rem;flex-shrink:0}.log-status{font-size:.72rem;color:#22c55e;flex-shrink:0}.log-item--error .log-status{color:#ef4444}.log-name{color:#e2e8f0;font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.log-duration{color:#64748b;font-size:.7rem}.log-time{color:#475569;font-size:.68rem}.log-error-badge{background:#7f1d1d;color:#fca5a5;font-size:.68rem;padding:.1rem .35rem;border-radius:4px}.btn-replay{font-size:.65rem;opacity:.5}.btn-replay:hover{opacity:1}.log-detail{background:#0a0f1a;padding:.6rem .75rem;border-left:3px solid #3b82f6;margin:0 .4rem .4rem;border-radius:0 4px 4px 0}.log-detail__error{color:#fca5a5;font-size:.72rem;margin-bottom:.4rem}.log-detail__section{margin-bottom:.4rem}.log-detail__label{color:#64748b;font-size:.68rem;display:block;margin-bottom:.2rem;font-family:system-ui}pre{color:#86efac;font-size:.7rem;margin:0;white-space:pre-wrap;word-break:break-all;max-height:140px;overflow-y:auto}.state-store-block{border-bottom:1px solid #1e293b}.state-store-block:last-child{border-bottom:none}.state-store-name{padding:.4rem .75rem .2rem;color:#3b82f6;font-size:.72rem;font-family:system-ui;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.state-view{color:#86efac;font-size:.7rem;padding:.25rem .75rem .75rem;margin:0;white-space:pre-wrap;word-break:break-all}\n"], dependencies: [{ kind: "pipe", type: JsonPipe, name: "json" }, { kind: "pipe", type: KeyValuePipe, name: "keyvalue" }] });
450
634
  }
451
635
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: StatoDevToolsComponent, decorators: [{
452
636
  type: Component,
453
- args: [{ selector: 'ngstato-devtools', standalone: true, imports: [JsonPipe], template: `
454
- <!-- Bouton flottant -->
637
+ args: [{ selector: 'ngstato-devtools, stato-devtools', standalone: true, imports: [JsonPipe, KeyValuePipe], template: `
638
+ <!-- Floating button -->
455
639
  @if (!isOpen()) {
456
640
  <button class="devtools-fab" (click)="toggle()">
457
641
  🛠 Stato
@@ -465,7 +649,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
465
649
  [class.devtools-panel--minimized]="isMinimized()"
466
650
  [style.left.px]="posX()"
467
651
  [style.top.px]="posY()"
468
- [style.width.px]="isMinimized() ? 200 : panelWidth()"
652
+ [style.width.px]="isMinimized() ? 220 : panelWidth()"
469
653
  [style.height]="isMinimized() ? 'auto' : panelHeight() + 'px'"
470
654
  >
471
655
 
@@ -474,19 +658,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
474
658
  class="devtools-header"
475
659
  (mousedown)="onDragStart($event)"
476
660
  >
477
- <span class="devtools-title">🛠 Stato</span>
661
+ <span class="devtools-title">
662
+ 🛠 Stato
663
+ @if (isTimeTraveling()) {
664
+ <span class="tt-badge">TIME-TRAVEL</span>
665
+ }
666
+ </span>
478
667
  <div class="devtools-header-actions">
479
668
  @if (!isMinimized()) {
480
- <button class="btn-icon" (click)="clear()" title="Vider">🗑</button>
669
+ <button class="btn-icon" (click)="clear()" title="Clear">🗑</button>
481
670
  }
482
- <button class="btn-icon" (click)="toggleMinimize()" title="Minimiser/Agrandir">
671
+ <button class="btn-icon" (click)="toggleMinimize()" title="Minimize">
483
672
  {{ isMinimized() ? '▲' : '▼' }}
484
673
  </button>
485
- <button class="btn-icon" (click)="toggle()" title="Fermer">✕</button>
674
+ <button class="btn-icon" (click)="toggle()" title="Close">✕</button>
486
675
  </div>
487
676
  </div>
488
677
 
489
- <!-- Resize handle — coin bas droite -->
678
+ <!-- Resize handle -->
490
679
  @if (!isMinimized()) {
491
680
  <div
492
681
  class="devtools-resize"
@@ -514,17 +703,55 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
514
703
  </button>
515
704
  </div>
516
705
 
517
- <!-- Tab Actions -->
706
+ <!-- Time-travel toolbar -->
707
+ @if (activeTab() === 'actions' && logs().length) {
708
+ <div class="tt-toolbar">
709
+ <button
710
+ class="tt-btn"
711
+ (click)="onUndo()"
712
+ [disabled]="!canUndo()"
713
+ title="Undo (step back)"
714
+ >⏪</button>
715
+ <button
716
+ class="tt-btn"
717
+ (click)="onRedo()"
718
+ [disabled]="!canRedo()"
719
+ title="Redo (step forward)"
720
+ >⏩</button>
721
+ @if (isTimeTraveling()) {
722
+ <button
723
+ class="tt-btn tt-btn--resume"
724
+ (click)="onResume()"
725
+ title="Resume live state"
726
+ >▶ Live</button>
727
+ }
728
+ <div class="tt-spacer"></div>
729
+ <button
730
+ class="tt-btn tt-btn--export"
731
+ (click)="onExport()"
732
+ title="Export state snapshot (JSON)"
733
+ >📤</button>
734
+ <button
735
+ class="tt-btn tt-btn--import"
736
+ (click)="onImport()"
737
+ title="Import state snapshot"
738
+ >📥</button>
739
+ </div>
740
+ }
741
+
742
+ <!-- Tab: Actions -->
518
743
  @if (activeTab() === 'actions') {
519
744
  <div class="devtools-content">
520
745
  @if (!logs().length) {
521
- <div class="devtools-empty">Aucune action pour l'instant</div>
746
+ <div class="devtools-empty">No actions yet</div>
522
747
  }
523
748
  @for (log of logs(); track log.id) {
524
749
  <div
525
750
  class="log-item"
526
751
  [class.log-item--error]="log.status === 'error'"
527
- (click)="selectLog(log)"
752
+ [class.log-item--active]="activeLogId() === log.id"
753
+ [class.log-item--future]="isFutureLog(log)"
754
+ (click)="onTravelTo(log)"
528
755
  >
529
756
  <div class="log-item__left">
530
757
  <span class="log-status">{{ log.status === 'success' ? '✓' : '✗' }}</span>
@@ -532,11 +759,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
532
759
  </div>
533
760
  <div class="log-item__right">
534
761
  @if (log.status === 'error') {
535
- <span class="log-error-badge">erreur</span>
762
+ <span class="log-error-badge">error</span>
536
763
  } @else {
537
764
  <span class="log-duration">{{ log.duration }}ms</span>
538
765
  }
539
766
  <span class="log-time">{{ formatTime(log.at) }}</span>
767
+ <button
768
+ class="btn-icon btn-replay"
769
+ (click)="onReplay(log, $event)"
770
+ title="Replay this action"
771
+ >🔄</button>
540
772
  </div>
541
773
  </div>
542
774
 
@@ -546,11 +778,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
546
778
  <div class="log-detail__error">{{ log.error }}</div>
547
779
  }
548
780
  <div class="log-detail__section">
549
- <span class="log-detail__label">Avant</span>
781
+ <span class="log-detail__label">Before</span>
550
782
  <pre>{{ log.prevState | json }}</pre>
551
783
  </div>
552
784
  <div class="log-detail__section">
553
- <span class="log-detail__label">Après</span>
785
+ <span class="log-detail__label">After</span>
554
786
  <pre>{{ log.nextState | json }}</pre>
555
787
  </div>
556
788
  </div>
@@ -559,13 +791,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
559
791
  </div>
560
792
  }
561
793
 
562
- <!-- Tab State -->
794
+ <!-- Tab: State -->
563
795
  @if (activeTab() === 'state') {
564
796
  <div class="devtools-content">
565
- @if (logs().length) {
566
- <pre class="state-view">{{ logs()[0].nextState | json }}</pre>
797
+ @if (globalState().size) {
798
+ @for (entry of globalState() | keyvalue; track entry.key) {
799
+ <div class="state-store-block">
800
+ <div class="state-store-name">{{ entry.key }}</div>
801
+ <pre class="state-view">{{ entry.value | json }}</pre>
802
+ </div>
803
+ }
567
804
  } @else {
568
- <div class="devtools-empty">Aucun state disponible</div>
805
+ <div class="devtools-empty">No state available</div>
569
806
  }
570
807
  </div>
571
808
  }
@@ -573,7 +810,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
573
810
 
574
811
  </div>
575
812
  }
576
- `, styles: [".devtools-fab{position:fixed;bottom:1.5rem;left:1.5rem;background:#1e293b;color:#fff;border:none;border-radius:999px;padding:.5rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer;z-index:9999;box-shadow:0 4px 12px #0000004d;transition:background .15s}.devtools-fab:hover{background:#334155}.devtools-panel{position:fixed;background:#0f172a;border-radius:12px;box-shadow:0 8px 32px #0006;z-index:9999;display:flex;flex-direction:column;overflow:hidden;font-family:Courier New,monospace;min-width:200px;min-height:40px}.devtools-panel--minimized{border-radius:8px}.devtools-header{display:flex;justify-content:space-between;align-items:center;padding:.6rem .75rem;background:#1e293b;border-bottom:1px solid #334155;cursor:grab;-webkit-user-select:none;user-select:none}.devtools-header:active{cursor:grabbing}.devtools-title{color:#e2e8f0;font-size:.82rem;font-weight:600;font-family:system-ui}.devtools-header-actions{display:flex;gap:.25rem}.btn-icon{background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.8rem;padding:.15rem .35rem;border-radius:4px;line-height:1}.btn-icon:hover{background:#334155;color:#fff}.devtools-resize{position:absolute;bottom:2px;right:4px;color:#334155;font-size:.9rem;cursor:nwse-resize;-webkit-user-select:none;user-select:none;line-height:1}.devtools-resize:hover{color:#64748b}.devtools-tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}.tab{padding:.4rem .75rem;background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.78rem;font-family:system-ui}.tab:hover{color:#e2e8f0}.tab--active{color:#3b82f6;border-bottom:2px solid #3b82f6}.devtools-content{overflow-y:auto;flex:1;padding:.25rem 0}.devtools-empty{padding:2rem;text-align:center;color:#475569;font-size:.78rem;font-family:system-ui}.log-item{display:flex;justify-content:space-between;align-items:center;padding:.35rem .75rem;cursor:pointer;border-bottom:1px solid #1e293b}.log-item:hover{background:#1e293b}.log-item--error{background:#1a0a0a}.log-item__left{display:flex;align-items:center;gap:.4rem;overflow:hidden}.log-item__right{display:flex;align-items:center;gap:.4rem;flex-shrink:0}.log-status{font-size:.72rem;color:#22c55e;flex-shrink:0}.log-item--error .log-status{color:#ef4444}.log-name{color:#e2e8f0;font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.log-duration{color:#64748b;font-size:.7rem}.log-time{color:#475569;font-size:.68rem}.log-error-badge{background:#7f1d1d;color:#fca5a5;font-size:.68rem;padding:.1rem .35rem;border-radius:4px}.log-detail{background:#0a0f1a;padding:.6rem .75rem;border-left:3px solid #3b82f6;margin:0 .4rem .4rem;border-radius:0 4px 4px 0}.log-detail__error{color:#fca5a5;font-size:.72rem;margin-bottom:.4rem}.log-detail__section{margin-bottom:.4rem}.log-detail__label{color:#64748b;font-size:.68rem;display:block;margin-bottom:.2rem;font-family:system-ui}pre{color:#86efac;font-size:.7rem;margin:0;white-space:pre-wrap;word-break:break-all;max-height:140px;overflow-y:auto}.state-view{color:#86efac;font-size:.7rem;padding:.75rem;margin:0;white-space:pre-wrap;word-break:break-all}\n"] }]
813
+
814
+ <!-- Hidden file input for import -->
815
+ <input
816
+ #fileInput
817
+ type="file"
818
+ accept=".json"
819
+ style="display: none"
820
+ (change)="onFileSelected($event)"
821
+ />
822
+ `, styles: [":host{font-family:system-ui,-apple-system,sans-serif}.devtools-fab{position:fixed;bottom:1.5rem;left:1.5rem;background:#1e293b;color:#fff;border:none;border-radius:999px;padding:.5rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer;z-index:9999;box-shadow:0 4px 12px #0000004d;transition:background .15s}.devtools-fab:hover{background:#334155}.devtools-panel{position:fixed;background:#0f172a;border-radius:12px;box-shadow:0 8px 32px #00000080;z-index:9999;display:flex;flex-direction:column;overflow:hidden;font-family:Courier New,monospace;min-width:200px;min-height:40px;border:1px solid #1e293b}.devtools-panel--minimized{border-radius:8px}.devtools-header{display:flex;justify-content:space-between;align-items:center;padding:.6rem .75rem;background:#1e293b;border-bottom:1px solid #334155;cursor:grab;-webkit-user-select:none;user-select:none}.devtools-header:active{cursor:grabbing}.devtools-title{color:#e2e8f0;font-size:.82rem;font-weight:600;font-family:system-ui;display:flex;align-items:center;gap:.4rem}.tt-badge{background:#7c3aed;color:#fff;font-size:.6rem;padding:.12rem .4rem;border-radius:4px;font-weight:700;letter-spacing:.05em;animation:tt-pulse 1.5s ease-in-out infinite}@keyframes tt-pulse{0%,to{opacity:1}50%{opacity:.6}}.devtools-header-actions{display:flex;gap:.25rem}.btn-icon{background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.8rem;padding:.15rem .35rem;border-radius:4px;line-height:1}.btn-icon:hover{background:#334155;color:#fff}.devtools-resize{position:absolute;bottom:2px;right:4px;color:#334155;font-size:.9rem;cursor:nwse-resize;-webkit-user-select:none;user-select:none;line-height:1}.devtools-resize:hover{color:#64748b}.devtools-tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}.tab{padding:.4rem .75rem;background:transparent;color:#64748b;border:none;cursor:pointer;font-size:.78rem;font-family:system-ui;transition:color .15s}.tab:hover{color:#e2e8f0}.tab--active{color:#3b82f6;border-bottom:2px solid #3b82f6}.tt-toolbar{display:flex;align-items:center;gap:.2rem;padding:.3rem .5rem;background:#0f172a;border-bottom:1px solid #1e293b}.tt-btn{background:#1e293b;border:1px solid #334155;color:#94a3b8;padding:.2rem .5rem;border-radius:4px;font-size:.72rem;cursor:pointer;transition:all .15s;font-family:system-ui}.tt-btn:hover:not(:disabled){background:#334155;color:#e2e8f0;border-color:#475569}.tt-btn:disabled{opacity:.3;cursor:not-allowed}.tt-btn--resume{background:#7c3aed;border-color:#7c3aed;color:#fff;font-weight:600}.tt-btn--resume:hover:not(:disabled){background:#6d28d9}.tt-btn--export,.tt-btn--import{font-size:.68rem}.tt-spacer{flex:1}.devtools-content{overflow-y:auto;flex:1;padding:.25rem 0}.devtools-empty{padding:2rem;text-align:center;color:#475569;font-size:.78rem;font-family:system-ui}.log-item{display:flex;justify-content:space-between;align-items:center;padding:.35rem .75rem;cursor:pointer;border-bottom:1px solid #1e293b;transition:background .1s}.log-item:hover{background:#1e293b}.log-item--error{background:#1a0a0a}.log-item--active{background:#1e1b4b!important;border-left:3px solid #7c3aed}.log-item--future{opacity:.35}.log-item__left{display:flex;align-items:center;gap:.4rem;overflow:hidden;flex:1}.log-item__right{display:flex;align-items:center;gap:.35rem;flex-shrink:0}.log-status{font-size:.72rem;color:#22c55e;flex-shrink:0}.log-item--error .log-status{color:#ef4444}.log-name{color:#e2e8f0;font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.log-duration{color:#64748b;font-size:.7rem}.log-time{color:#475569;font-size:.68rem}.log-error-badge{background:#7f1d1d;color:#fca5a5;font-size:.68rem;padding:.1rem .35rem;border-radius:4px}.btn-replay{font-size:.65rem;opacity:.5}.btn-replay:hover{opacity:1}.log-detail{background:#0a0f1a;padding:.6rem .75rem;border-left:3px solid #3b82f6;margin:0 .4rem .4rem;border-radius:0 4px 4px 0}.log-detail__error{color:#fca5a5;font-size:.72rem;margin-bottom:.4rem}.log-detail__section{margin-bottom:.4rem}.log-detail__label{color:#64748b;font-size:.68rem;display:block;margin-bottom:.2rem;font-family:system-ui}pre{color:#86efac;font-size:.7rem;margin:0;white-space:pre-wrap;word-break:break-all;max-height:140px;overflow-y:auto}.state-store-block{border-bottom:1px solid #1e293b}.state-store-block:last-child{border-bottom:none}.state-store-name{padding:.4rem .75rem .2rem;color:#3b82f6;font-size:.72rem;font-family:system-ui;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.state-view{color:#86efac;font-size:.7rem;padding:.25rem .75rem .75rem;margin:0;white-space:pre-wrap;word-break:break-all}\n"] }]
577
823
  }] });
578
824
 
579
825
  // ─────────────────────────────────────────────────────