From 1e8876d6b479d59515bd7174fb5f49efe258f640 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 24 Apr 2026 16:41:18 -0400 Subject: [PATCH] style(dashboard): continue design token migration across remaining CSS Replace additional hard-coded colors with design tokens in layout, notifications, panels, timeline, and other CSS files. Co-Authored-By: Claude Opus 4.7 --- dashboard/css/ambient.css | 8 +- dashboard/css/anomaly.css | 12 +- dashboard/css/ble-panel.css | 4 +- dashboard/css/briefing.css | 10 +- dashboard/css/command-palette.css | 6 +- dashboard/css/expert.css | 4 +- dashboard/css/explainability.css | 6 +- dashboard/css/fleet-page.css | 26 +- dashboard/css/floorplan.css | 10 +- dashboard/css/guided-help.css | 12 +- dashboard/css/layout.css | 114 ++++---- dashboard/css/notifications.css | 62 ++--- dashboard/css/panels.css | 84 +++--- dashboard/css/replay.css | 8 +- dashboard/css/security.css | 6 +- dashboard/css/simple.css | 12 +- dashboard/css/simulator.css | 4 +- dashboard/css/sleep.css | 8 +- dashboard/css/timeline.css | 34 +-- dashboard/css/troubleshoot.css | 10 +- dashboard/js/replay.test.js | 444 ++++++++++++++++++++++++++++++ 21 files changed, 664 insertions(+), 220 deletions(-) create mode 100644 dashboard/js/replay.test.js diff --git a/dashboard/css/ambient.css b/dashboard/css/ambient.css index d05e686..7a7d69f 100644 --- a/dashboard/css/ambient.css +++ b/dashboard/css/ambient.css @@ -103,7 +103,7 @@ body.ambient-mode { /* ===== Ambient Status Line ===== */ .ambient-status { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 50%; transform: translateX(-50%); display: flex; @@ -118,7 +118,7 @@ body.ambient-mode { .ambient-status-item { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); } .ambient-status-dot { @@ -363,7 +363,7 @@ body.ambient-mode { } .ambient-alert-btn { - padding: 14px 28px; + paddingvar(--space-350) var(--space-7); border-radius: var(--radius-card); font-size: var(--text-base); font-weight: 600; @@ -491,7 +491,7 @@ body.ambient-mode { /* ===== Responsive Design ===== */ @media (max-width: 768px) { .ambient-container { - padding: 10px; + paddingvar(--space-250); } .ambient-person { diff --git a/dashboard/css/anomaly.css b/dashboard/css/anomaly.css index ec85b97..abfcacf 100644 --- a/dashboard/css/anomaly.css +++ b/dashboard/css/anomaly.css @@ -267,7 +267,7 @@ #feedback-notes-input { width: 100%; min-height: 80px; - padding: 10px; + paddingvar(--space-250); background: var(--bg-input); border: 1px solid var(--slate-6); border-radius: var(--radius-control); @@ -325,7 +325,7 @@ #anomaly-learning-banner { position: fixed; - top: 60px; + topvar(--space-16); left: 50%; transform: translateX(-50%); z-index: 500; @@ -385,7 +385,7 @@ #security-mode-indicator { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-1) 10px; border-radius: var(--radius-modal); font-size: var(--text-xs); @@ -442,7 +442,7 @@ background: none; border: none; cursor: pointer; - padding: 2px; + paddingvar(--space-half); margin-left: var(--space-1); border-radius: var(--radius-control); color: inherit; @@ -775,7 +775,7 @@ .anomaly-history-score { font-size: var(--text-xs); - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); font-weight: 600; flex-shrink: 0; @@ -798,7 +798,7 @@ .anomaly-history-feedback { font-size: var(--text-xs); - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); flex-shrink: 0; } diff --git a/dashboard/css/ble-panel.css b/dashboard/css/ble-panel.css index 19bec11..765aa85 100644 --- a/dashboard/css/ble-panel.css +++ b/dashboard/css/ble-panel.css @@ -86,7 +86,7 @@ .ble-device { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); padding: 10px var(--space-3); background: var(--border-subtle); border-radius: var(--radius-control); @@ -214,7 +214,7 @@ .ble-form-group input[type="color"] { height: 36px; - padding: 2px; + paddingvar(--space-half); cursor: pointer; } diff --git a/dashboard/css/briefing.css b/dashboard/css/briefing.css index 7f9bbc0..aeea8f2 100644 --- a/dashboard/css/briefing.css +++ b/dashboard/css/briefing.css @@ -2,7 +2,7 @@ #briefing-card { position: fixed; - top: 60px; + topvar(--space-16); left: 50%; transform: translateX(-50%); width: 90%; @@ -162,7 +162,7 @@ /* Simple mode briefing card */ .simple-mode #briefing-card { top: auto; - bottom: 20px; + bottomvar(--space-5); transform: translateX(-50%); } @@ -185,7 +185,7 @@ #briefing-indicator { position: fixed; top: 50%; - right: 20px; + rightvar(--space-5); transform: translateY(-50%); width: 40px; height: 40px; @@ -279,8 +279,8 @@ #briefing-settings .setting-toggle::after { content: ''; position: absolute; - top: 2px; - left: 2px; + topvar(--space-half); + leftvar(--space-half); width: 20px; height: 20px; background: var(--text-on-accent); diff --git a/dashboard/css/command-palette.css b/dashboard/css/command-palette.css index 5d8d74c..6a5a538 100644 --- a/dashboard/css/command-palette.css +++ b/dashboard/css/command-palette.css @@ -62,7 +62,7 @@ .cp-search-row { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); padding: 14px var(--space-4); border-bottom: 1px solid var(--border-default); } @@ -93,7 +93,7 @@ color: var(--slate-7); background: var(--highlight-subtle); border-radius: var(--radius-control); - padding: 2px 7px; + paddingvar(--space-half) var(--space-175); flex-shrink: 0; font-family: var(--font-mono); } @@ -143,7 +143,7 @@ .cp-item { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); padding: 9px var(--space-4); cursor: pointer; transition: background 0.1s; diff --git a/dashboard/css/expert.css b/dashboard/css/expert.css index 5948689..3e213bc 100644 --- a/dashboard/css/expert.css +++ b/dashboard/css/expert.css @@ -52,8 +52,8 @@ .toggle-switch .slider:before { width: 36px; height: 36px; - top: 4px; - left: 4px; + topvar(--space-1); + leftvar(--space-1); } .toggle-switch input:checked + .slider:before { diff --git a/dashboard/css/explainability.css b/dashboard/css/explainability.css index 2f27daf..09cce9a 100644 --- a/dashboard/css/explainability.css +++ b/dashboard/css/explainability.css @@ -198,7 +198,7 @@ .zone-badge { display: inline-block; min-width: 20px; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); font-size: var(--text-2xs); font-weight: 600; @@ -248,7 +248,7 @@ .ble-match-header { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); margin-bottom: 10px; } @@ -274,7 +274,7 @@ .ble-match-details { display: flex; flex-direction: column; - gap: 6px; + gapvar(--space-150); } .ble-match-detail { diff --git a/dashboard/css/fleet-page.css b/dashboard/css/fleet-page.css index d44fa82..20855b7 100644 --- a/dashboard/css/fleet-page.css +++ b/dashboard/css/fleet-page.css @@ -66,7 +66,7 @@ body.fleet-page { .nav-btn { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-2) var(--space-4); background: transparent; border: 1px solid var(--border-default); @@ -116,7 +116,7 @@ body.fleet-page { .summary-item { display: flex; - gap: 6px; + gapvar(--space-150); } .summary-label { @@ -143,7 +143,7 @@ body.fleet-page { .btn { display: inline-flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-2) var(--space-4); border-radius: var(--radius-control); font-size: var(--text-sm); @@ -267,7 +267,7 @@ body.fleet-page { .filter-chip { display: inline-flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-1) 10px; background: var(--blue-muted); border: 1px solid var(--blue-border); @@ -509,7 +509,7 @@ body.fleet-page { .status-badge { display: inline-flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-1) 10px; border-radius: var(--radius-card); font-size: var(--text-xs); @@ -559,7 +559,7 @@ body.fleet-page { .firmware-version { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); } .firmware-current { @@ -574,7 +574,7 @@ body.fleet-page { display: inline-flex; align-items: center; gap: var(--space-1); - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); background: var(--warn-bg); border-radius: var(--radius-control); font-size: var(--text-2xs); @@ -694,7 +694,7 @@ body.fleet-page { border: none; color: var(--text-secondary); cursor: pointer; - padding: 6px; + paddingvar(--space-150); font-size: var(--text-base); border-radius: var(--radius-control); transition: all 0.2s; @@ -981,8 +981,8 @@ body.fleet-page { ============================================ */ .toast-container { position: fixed; - bottom: 24px; - right: 24px; + bottomvar(--space-6); + rightvar(--space-6); z-index: 1100; display: flex; flex-direction: column; @@ -1163,9 +1163,9 @@ body.fleet-page { } .toast-container { - right: 12px; - left: 12px; - bottom: 12px; + rightvar(--space-3); + leftvar(--space-3); + bottomvar(--space-3); } .toast { diff --git a/dashboard/css/floorplan.css b/dashboard/css/floorplan.css index 8c9807b..b2e4c30 100644 --- a/dashboard/css/floorplan.css +++ b/dashboard/css/floorplan.css @@ -4,7 +4,7 @@ .floorplan-panel { position: fixed; - top: 60px; + topvar(--space-16); right: 340px; width: 380px; max-height: calc(100vh - 80px); @@ -73,7 +73,7 @@ } .floorplan-btn { - padding: 6px 14px; + paddingvar(--space-150) var(--space-350); border-radius: var(--radius-control); font-size: var(--text-xs); cursor: pointer; @@ -81,7 +81,7 @@ border: none; display: inline-flex; align-items: center; - gap: 6px; + gapvar(--space-150); } .floorplan-btn-primary { @@ -213,7 +213,7 @@ .floorplan-distance-input input { width: 100%; - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); background: var(--border-default); border: 1px solid var(--border-strong); border-radius: var(--radius-control); @@ -255,7 +255,7 @@ /* Responsive adjustments */ @media (max-width: 1200px) { .floorplan-panel { - right: 20px; + rightvar(--space-5); width: 340px; } } diff --git a/dashboard/css/guided-help.css b/dashboard/css/guided-help.css index 3e396b5..f74fc40 100644 --- a/dashboard/css/guided-help.css +++ b/dashboard/css/guided-help.css @@ -7,8 +7,8 @@ /* ===== Guided Help Container ===== */ .guided-help-container { position: fixed; - bottom: 20px; - left: 20px; + bottomvar(--space-5); + leftvar(--space-5); z-index: 200; pointer-events: none; opacity: 0; @@ -46,7 +46,7 @@ .help-title { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); } .help-icon { @@ -269,9 +269,9 @@ /* ===== Responsive Design ===== */ @media (max-width: 480px) { .guided-help-container { - bottom: 10px; - left: 10px; - right: 10px; + bottomvar(--space-250); + leftvar(--space-250); + rightvar(--space-250); } .guided-help-panel { diff --git a/dashboard/css/layout.css b/dashboard/css/layout.css index 4a4925c..77ad779 100644 --- a/dashboard/css/layout.css +++ b/dashboard/css/layout.css @@ -124,7 +124,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 2px; + gapvar(--space-half); padding: var(--space-2) 0; color: var(--text-muted); text-decoration: none; @@ -269,8 +269,8 @@ .live-panel--left { position: fixed; - top: 60px; - left: 20px; + topvar(--space-16); + leftvar(--space-5); width: 280px; max-height: calc(100vh - 80px); padding: var(--space-3); @@ -278,16 +278,16 @@ .live-panel--right { position: fixed; - bottom: 20px; - right: 20px; + bottomvar(--space-5); + rightvar(--space-5); width: 400px; padding: var(--space-3); } .live-panel--presence { position: fixed; - top: 60px; - right: 20px; + topvar(--space-16); + rightvar(--space-5); width: 240px; max-height: calc(100vh - 80px); padding: var(--space-3); @@ -296,8 +296,8 @@ /* Legacy ID-based selectors (match live.html panel IDs) */ #node-panel { position: fixed; - top: 60px; - left: 20px; + topvar(--space-16); + leftvar(--space-5); width: 280px; max-height: calc(100vh - 80px); background: var(--live-node-bg); @@ -309,8 +309,8 @@ #chart-panel { position: fixed; - bottom: 20px; - right: 20px; + bottomvar(--space-5); + rightvar(--space-5); width: 400px; height: 260px; background: var(--live-chart-bg); @@ -321,8 +321,8 @@ #presence-panel { position: fixed; - top: 60px; - right: 20px; + topvar(--space-16); + rightvar(--space-5); width: 300px; background: var(--live-panel-bg); border-radius: var(--radius-card); @@ -342,8 +342,8 @@ #chart-panel { width: calc(100% - 16px); - right: 8px; - left: 8px; + rightvar(--space-2); + leftvar(--space-2); bottom: calc(8px + env(safe-area-inset-bottom)); } } @@ -352,9 +352,9 @@ @media (max-width: 639px) { #chart-panel { width: calc(100% - 16px); - right: 8px; - left: 8px; - bottom: 8px; + rightvar(--space-2); + leftvar(--space-2); + bottomvar(--space-2); height: 200px; } } @@ -392,13 +392,13 @@ dialog.app-dialog::backdrop { /* ── Setup toolbar (floats over 3D canvas in setup page) ── */ .setup-toolbar { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 50%; transform: translateX(-50%); display: flex; - gap: 8px; + gapvar(--space-2); background: var(--overlay-strong); - padding: 8px 16px; + paddingvar(--space-2) var(--space-4); border-radius: var(--radius-card); z-index: 100; } @@ -407,7 +407,7 @@ dialog.app-dialog::backdrop { background: var(--slate-5); border: 1px solid var(--border-default); color: var(--text-primary); - padding: 8px 16px; + paddingvar(--space-2) var(--space-4); border-radius: var(--radius-control); cursor: pointer; font-size: var(--text-sm); @@ -428,8 +428,8 @@ dialog.app-dialog::backdrop { @media (max-width: 639px) { .setup-toolbar { bottom: calc(8px + env(safe-area-inset-bottom)); - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); transform: none; width: auto; overflow-x: auto; @@ -438,7 +438,7 @@ dialog.app-dialog::backdrop { .setup-toolbar button { flex-shrink: 0; - padding: 8px 12px; + paddingvar(--space-2) var(--space-3); font-size: var(--text-xs); } } @@ -473,8 +473,8 @@ dialog.app-dialog::backdrop { #diurnal-banner { top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); transform: none; } } @@ -492,8 +492,8 @@ dialog.app-dialog::backdrop { #anomaly-learning-banner { top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); transform: none; } } @@ -502,7 +502,7 @@ dialog.app-dialog::backdrop { #security-mode-indicator { position: fixed; top: 45px; - right: 20px; + rightvar(--space-5); z-index: 150; } @@ -510,21 +510,21 @@ dialog.app-dialog::backdrop { #security-mode-indicator { top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); - right: 8px; + rightvar(--space-2); } } /* ── Link health panel (floats over canvas) ── */ .link-health-panel { position: fixed; - top: 60px; - right: 20px; + topvar(--space-16); + rightvar(--space-5); z-index: 100; } @media (max-width: 1023px) { .link-health-panel { - right: 8px; + rightvar(--space-2); } } @@ -532,8 +532,8 @@ dialog.app-dialog::backdrop { .link-health-panel { top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); max-height: 40vh; overflow-y: auto; } @@ -542,7 +542,7 @@ dialog.app-dialog::backdrop { /* ── Replay control bar (shown during replay) ── */ .replay-control-bar { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 50%; transform: translateX(-50%); z-index: 150; @@ -550,8 +550,8 @@ dialog.app-dialog::backdrop { @media (max-width: 639px) { .replay-control-bar { - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); transform: none; bottom: calc(60px + env(safe-area-inset-bottom)); } @@ -560,7 +560,7 @@ dialog.app-dialog::backdrop { /* ── Replay tuning panel (floats over canvas) ── */ .replay-tuning-panel { position: fixed; - top: 60px; + topvar(--space-16); right: 340px; width: 280px; z-index: 100; @@ -569,8 +569,8 @@ dialog.app-dialog::backdrop { @media (max-width: 1023px) { .replay-tuning-panel { - right: 8px; - left: 8px; + rightvar(--space-2); + leftvar(--space-2); width: auto; max-width: 340px; } @@ -578,8 +578,8 @@ dialog.app-dialog::backdrop { @media (max-width: 639px) { .replay-tuning-panel { - right: 8px; - left: 8px; + rightvar(--space-2); + leftvar(--space-2); width: auto; top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); @@ -599,7 +599,7 @@ dialog.app-dialog::backdrop { /* ── Portal editor panel (floats over canvas) ── */ #portal-editor-panel { position: fixed; - top: 60px; + topvar(--space-16); left: 320px; width: 240px; z-index: 100; @@ -608,8 +608,8 @@ dialog.app-dialog::backdrop { @media (max-width: 1023px) { #portal-editor-panel { - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); width: auto; max-width: 300px; } @@ -617,8 +617,8 @@ dialog.app-dialog::backdrop { @media (max-width: 639px) { #portal-editor-panel { - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); width: auto; top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); @@ -630,7 +630,7 @@ dialog.app-dialog::backdrop { /* ── Room editor panel (floats over canvas) ── */ #room-editor-panel { position: fixed; - top: 60px; + topvar(--space-16); left: 320px; width: 240px; z-index: 100; @@ -639,8 +639,8 @@ dialog.app-dialog::backdrop { @media (max-width: 1023px) { #room-editor-panel { - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); width: auto; max-width: 300px; } @@ -648,8 +648,8 @@ dialog.app-dialog::backdrop { @media (max-width: 639px) { #room-editor-panel { - left: 8px; - right: 8px; + leftvar(--space-2); + rightvar(--space-2); width: auto; top: auto; bottom: calc(60px + env(safe-area-inset-bottom)); @@ -661,7 +661,7 @@ dialog.app-dialog::backdrop { /* ── GDOP legend (floats over canvas) ── */ #gdop-legend { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 320px; z-index: 100; display: none; @@ -669,14 +669,14 @@ dialog.app-dialog::backdrop { @media (max-width: 1023px) { #gdop-legend { - left: 8px; + leftvar(--space-2); bottom: calc(280px + env(safe-area-inset-bottom)); } } @media (max-width: 639px) { #gdop-legend { - left: 8px; + leftvar(--space-2); bottom: calc(220px + env(safe-area-inset-bottom)); } } diff --git a/dashboard/css/notifications.css b/dashboard/css/notifications.css index 95a10dd..9df4014 100644 --- a/dashboard/css/notifications.css +++ b/dashboard/css/notifications.css @@ -33,7 +33,7 @@ /* Channel Cards */ .channels-list { display: grid; - gap: 15px; + gapvar(--space-375); margin-bottom: var(--space-5); } @@ -41,7 +41,7 @@ background: var(--bg-hover); border: 1px solid var(--slate-5); border-radius: var(--radius-card); - padding: 15px; + paddingvar(--space-375); } .channel-header { @@ -60,7 +60,7 @@ .channel-actions { display: flex; - gap: 10px; + gapvar(--space-250); } .channel-details { @@ -109,7 +109,7 @@ border: none; font-size: var(--text-xl); cursor: pointer; - padding: 5px; + paddingvar(--space-125); opacity: 0.7; transition: opacity 0.2s; } @@ -128,7 +128,7 @@ .checkbox-label { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); color: var(--text-on-accent); cursor: pointer; } @@ -147,7 +147,7 @@ .time-field { display: flex; flex-direction: column; - gap: 5px; + gapvar(--space-125); } .time-field label { @@ -167,18 +167,18 @@ .days-selector { display: flex; flex-direction: column; - gap: 10px; + gapvar(--space-250); } .day-checkbox { display: flex; - gap: 15px; + gapvar(--space-375); } .day-checkbox-label { display: flex; align-items: center; - gap: 5px; + gapvar(--space-125); color: var(--text-on-accent); cursor: pointer; font-size: 0.9em; @@ -194,14 +194,14 @@ .batching-config { display: flex; flex-direction: column; - gap: 15px; + gapvar(--space-375); } .batch-window, .max-batch-size { display: flex; flex-direction: column; - gap: 5px; + gapvar(--space-125); } .batch-window label, @@ -231,8 +231,8 @@ .event-type-toggle { display: flex; align-items: center; - gap: 15px; - padding: 10px; + gapvar(--space-375); + paddingvar(--space-250); background: var(--bg-hover); border-radius: var(--radius-control); } @@ -283,8 +283,8 @@ content: ""; height: 18px; width: 18px; - left: 3px; - bottom: 3px; + leftvar(--space-075); + bottomvar(--space-075); background-color: var(--text-on-accent); transition: 0.3s; border-radius: 50%; @@ -344,7 +344,7 @@ color: var(--text-muted); font-size: var(--text-3xl); cursor: pointer; - padding: 5px; + paddingvar(--space-125); line-height: 1; } @@ -382,7 +382,7 @@ .auth-fields { display: flex; - gap: 10px; + gapvar(--space-250); } .auth-fields input { @@ -403,12 +403,12 @@ #modal-event-types { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 10px; + gapvar(--space-250); } .form-actions { display: flex; - gap: 10px; + gapvar(--space-250); justify-content: flex-end; margin-top: var(--space-5); } @@ -416,8 +416,8 @@ /* Toast Notifications */ .toast { position: fixed; - bottom: 20px; - right: 20px; + bottomvar(--space-5); + rightvar(--space-5); padding: 15px var(--space-5); border-radius: var(--radius-card); color: var(--text-on-accent); @@ -466,16 +466,16 @@ /* Responsive Design */ @media (max-width: 600px) { .notification-settings { - padding: 15px; + paddingvar(--space-375); } .settings-section { - padding: 15px; + paddingvar(--space-375); } .time-range { flex-direction: column; - gap: 10px; + gapvar(--space-250); } .channels-list { @@ -551,7 +551,7 @@ .channel-config-group { margin-bottom: var(--space-5); - padding: 15px; + paddingvar(--space-375); background: var(--bg-hover); border-radius: var(--radius-control); } @@ -575,14 +575,14 @@ .panel-days-selector { display: flex; flex-wrap: wrap; - gap: 10px; + gapvar(--space-250); margin-top: 10px; } .panel-day-checkbox { display: flex; align-items: center; - gap: 5px; + gapvar(--space-125); color: var(--text-on-accent); cursor: pointer; font-size: 0.9em; @@ -597,8 +597,8 @@ .panel-event-type-toggle { display: flex; align-items: center; - gap: 15px; - padding: 10px; + gapvar(--space-375); + paddingvar(--space-250); background: var(--bg-hover); border-radius: var(--radius-control); } @@ -648,8 +648,8 @@ content: ""; height: 18px; width: 18px; - left: 3px; - bottom: 3px; + leftvar(--space-075); + bottomvar(--space-075); background-color: var(--text-on-accent); transition: 0.3s; border-radius: 50%; diff --git a/dashboard/css/panels.css b/dashboard/css/panels.css index 77eb78b..ce177c7 100644 --- a/dashboard/css/panels.css +++ b/dashboard/css/panels.css @@ -263,12 +263,12 @@ /* ----- Toast Notifications ----- */ #toast-container { position: fixed; - bottom: 20px; - right: 20px; + bottomvar(--space-5); + rightvar(--space-5); z-index: 3000; display: flex; flex-direction: column; - gap: 10px; + gapvar(--space-250); pointer-events: none; } @@ -385,7 +385,7 @@ /* ----- Mode Toggle Bar ----- */ .mode-toggle-bar { position: fixed; - top: 40px; + topvar(--space-10); left: 0; right: 0; height: 44px; @@ -426,8 +426,8 @@ content: ''; position: absolute; bottom: 0; - left: 16px; - right: 16px; + leftvar(--space-4); + rightvar(--space-4); height: 2px; background: var(--blue-10); border-radius: 1px 1px 0 0; @@ -498,7 +498,7 @@ background: var(--bg-hover); border-radius: var(--radius-control); outline: none; - margin: 10px; + marginvar(--space-250); } .panel-form-group input[type="range"]::-webkit-slider-thumb { @@ -538,7 +538,7 @@ .panel-form-checkbox { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); cursor: pointer; padding: 6px 0; } @@ -627,7 +627,7 @@ .panel-btn-group { display: flex; - gap: 10px; + gapvar(--space-250); margin-top: var(--space-4); } @@ -828,7 +828,7 @@ color: var(--warn); display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); } #security-dialog .learning-warning::before { @@ -900,7 +900,7 @@ .alert-banner-actions { display: flex; - gap: 10px; + gapvar(--space-250); } .alert-banner-btn { @@ -1027,7 +1027,7 @@ .learning-ready-badge { display: inline-flex; align-items: center; - gap: 6px; + gapvar(--space-150); padding: var(--space-1) 10px; background: var(--ok-muted); border-radius: var(--radius-card); @@ -1091,7 +1091,7 @@ /* Arm/Disarm Button */ .security-arm-btn { width: 100%; - padding: 14px; + paddingvar(--space-350); border-radius: var(--radius-card); font-size: var(--text-md); font-weight: 600; @@ -1101,7 +1101,7 @@ display: flex; align-items: center; justify-content: center; - gap: 10px; + gapvar(--space-250); } .security-arm-btn.disarmed { @@ -1257,9 +1257,9 @@ } #toast-container { - left: 10px; - right: 10px; - bottom: 10px; + leftvar(--space-250); + rightvar(--space-250); + bottomvar(--space-250); } .toast { @@ -1671,7 +1671,7 @@ .contributing-badge { display: inline-block; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); font-size: var(--text-2xs); font-weight: 600; @@ -1702,7 +1702,7 @@ .ble-match-header { display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); margin-bottom: var(--space-3); } @@ -1732,7 +1732,7 @@ .ble-match-details { display: flex; flex-direction: column; - gap: 6px; + gapvar(--space-150); } .ble-match-detail { @@ -1787,7 +1787,7 @@ cursor: pointer; display: flex; align-items: center; - gap: 10px; + gapvar(--space-250); font-size: var(--text-sm); color: var(--slate-11); transition: background 0.2s, color 0.2s; @@ -1845,7 +1845,7 @@ .links-table th, .links-table td { - padding: 6px 6px; + paddingvar(--space-150) var(--space-150); } } @@ -1868,7 +1868,7 @@ .volume-tools { display: flex; - gap: 6px; + gapvar(--space-150); align-items: center; } @@ -1927,7 +1927,7 @@ .trigger-card-actions { display: flex; - gap: 6px; + gapvar(--space-150); align-items: center; flex-shrink: 0; } @@ -1935,8 +1935,8 @@ .error-badge { display: inline-flex; align-items: center; - gap: 3px; - padding: 2px 7px; + gapvar(--space-075); + paddingvar(--space-half) var(--space-175); font-size: var(--text-3xs); font-weight: 600; border-radius: var(--radius-control); @@ -1949,8 +1949,8 @@ .disabled-badge { display: inline-flex; align-items: center; - gap: 3px; - padding: 2px 7px; + gapvar(--space-075); + paddingvar(--space-half) var(--space-175); font-size: var(--text-3xs); font-weight: 600; border-radius: var(--radius-control); @@ -1963,8 +1963,8 @@ .warning-badge { display: inline-flex; align-items: center; - gap: 3px; - padding: 2px 7px; + gapvar(--space-075); + paddingvar(--space-half) var(--space-175); font-size: var(--text-3xs); font-weight: 600; border-radius: var(--radius-control); @@ -2070,8 +2070,8 @@ position: absolute; width: 16px; height: 16px; - left: 2px; - bottom: 2px; + leftvar(--space-half); + bottomvar(--space-half); background: var(--slate-11); border-radius: 50%; transition: transform 0.2s, background 0.2s; @@ -2125,7 +2125,7 @@ .form-control { width: 100%; - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); font-size: var(--text-sm); background: var(--border-subtle); border: 1px solid var(--bg-active); @@ -2382,7 +2382,7 @@ /* Simulator Panel */ .simulator-panel { position: fixed; - top: 40px; + topvar(--space-10); right: 0; bottom: 0; width: 360px; @@ -2459,7 +2459,7 @@ .sim-space-controls { display: flex; flex-direction: column; - gap: 10px; + gapvar(--space-250); margin-bottom: var(--space-3); } @@ -2473,7 +2473,7 @@ .sim-space-controls input[type="number"] { width: 80px; - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); background: var(--border-subtle); border: 1px solid var(--border-strong); border-radius: var(--radius-control); @@ -2491,7 +2491,7 @@ .sim-tool-btn { flex: 1; - padding: 10px; + paddingvar(--space-250); background: var(--border-subtle); border: 1px solid var(--border-strong); border-radius: var(--radius-control); @@ -2645,7 +2645,7 @@ .sim-gdop-controls label { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); font-size: var(--text-sm); color: var(--slate-11); cursor: pointer; @@ -2732,7 +2732,7 @@ .sim-recommendation { display: flex; align-items: flex-start; - gap: 10px; + gapvar(--space-250); padding: 10px var(--space-3); background: var(--border-subtle); border-radius: var(--radius-control); @@ -3044,7 +3044,7 @@ align-items: center; font-size: var(--text-sm); color: var(--text-muted); - gap: 6px; + gapvar(--space-150); } .sim-gdop-controls input[type="checkbox"] { @@ -3178,7 +3178,7 @@ } .sim-rec-priority { - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); font-size: var(--text-3xs); font-weight: 600; @@ -3272,7 +3272,7 @@ display: flex; justify-content: space-between; flex-wrap: wrap; - gap: 10px; + gapvar(--space-250); } .gdop-stat-item { diff --git a/dashboard/css/replay.css b/dashboard/css/replay.css index baead9d..a373ace 100644 --- a/dashboard/css/replay.css +++ b/dashboard/css/replay.css @@ -61,7 +61,7 @@ .replay-info { display: flex; flex-direction: column; - gap: 2px; + gapvar(--space-half); min-width: 150px; } @@ -90,7 +90,7 @@ border: 1px solid var(--bg-hover); border-radius: var(--radius-control); color: var(--slate-11); - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); font-size: var(--text-sm); cursor: pointer; transition: all 0.2s ease; @@ -185,7 +185,7 @@ transition: all 0.2s ease; display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); } .replay-tune-btn:hover { @@ -416,7 +416,7 @@ /* ----- Replay Mode Indicator ----- */ .replay-mode-indicator { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 50%; transform: translateX(-50%); background: var(--blue-10); diff --git a/dashboard/css/security.css b/dashboard/css/security.css index 7c60020..bdbc39d 100644 --- a/dashboard/css/security.css +++ b/dashboard/css/security.css @@ -348,7 +348,7 @@ .alert-banner-actions { display: flex; - gap: 10px; + gapvar(--space-250); flex-shrink: 0; } @@ -390,7 +390,7 @@ #security-mode-indicator { position: fixed; top: 45px; - right: 20px; + rightvar(--space-5); background: var(--alert); color: var(--text-on-accent); padding: 6px var(--space-3); @@ -468,7 +468,7 @@ font-size: var(--text-sm); display: none; align-items: center; - gap: 10px; + gapvar(--space-250); z-index: 150; box-shadow: 0 4px 12px var(--blue-border); } diff --git a/dashboard/css/simple.css b/dashboard/css/simple.css index b5549ee..648ba11 100644 --- a/dashboard/css/simple.css +++ b/dashboard/css/simple.css @@ -348,7 +348,7 @@ body.simple-mode { .simple-room-card .room-status { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); font-size: var(--text-sm); font-weight: 500; padding: var(--space-1) 10px; @@ -373,7 +373,7 @@ body.simple-mode { .simple-room-card .room-occupants { display: flex; flex-wrap: wrap; - gap: 6px; + gapvar(--space-150); margin-bottom: var(--space-2); } @@ -626,13 +626,13 @@ body.simple-mode { .simple-quick-actions .action-badge { position: absolute; - top: 4px; - right: 4px; + topvar(--space-1); + rightvar(--space-1); background: var(--simple-accent-red); color: var(--text-on-accent); font-size: var(--text-3xs); font-weight: 600; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-card); min-width: 16px; text-align: center; @@ -911,7 +911,7 @@ body.simple-mode { .simple-security-toggle .security-toggle-btn { width: 100%; - padding: 14px; + paddingvar(--space-350); border-radius: var(--radius-card); font-size: var(--text-base); font-weight: 600; diff --git a/dashboard/css/simulator.css b/dashboard/css/simulator.css index 37738cf..9a69ec0 100644 --- a/dashboard/css/simulator.css +++ b/dashboard/css/simulator.css @@ -429,7 +429,7 @@ display: flex; align-items: flex-start; gap: var(--space-2); - padding: 10px; + paddingvar(--space-250); background: var(--bg-hover); border-radius: var(--radius-control); border-left: 3px solid var(--slate-7); @@ -459,7 +459,7 @@ .sim-shopping-list { display: flex; flex-direction: column; - gap: 6px; + gapvar(--space-150); } .sim-shopping-item { diff --git a/dashboard/css/sleep.css b/dashboard/css/sleep.css index d207b90..bb5bbb3 100644 --- a/dashboard/css/sleep.css +++ b/dashboard/css/sleep.css @@ -4,8 +4,8 @@ .sleep-summary-card { position: fixed; - top: 16px; - right: 16px; + topvar(--space-4); + rightvar(--space-4); z-index: 1000; background: var(--bg-card, var(--bg-card)); border: 1px solid var(--border-color, var(--slate-5)); @@ -54,7 +54,7 @@ color: var(--text-muted, var(--text-muted)); font-size: var(--text-xl); cursor: pointer; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); line-height: 1; border-radius: var(--radius-control); } @@ -166,7 +166,7 @@ color: var(--text-muted, var(--text-muted)); font-size: var(--text-lg); cursor: pointer; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-control); } diff --git a/dashboard/css/timeline.css b/dashboard/css/timeline.css index 864c20c..005bbc0 100644 --- a/dashboard/css/timeline.css +++ b/dashboard/css/timeline.css @@ -51,7 +51,7 @@ background: transparent; border: 1px solid var(--border-color, var(--bg-hover)); border-radius: var(--radius-control); - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); cursor: pointer; color: var(--text-secondary, var(--text-secondary)); display: flex; @@ -115,8 +115,8 @@ .timeline-category-checkbox { display: flex; align-items: center; - gap: 6px; - padding: 6px 10px; + gapvar(--space-150); + paddingvar(--space-150) var(--space-250); background: var(--bg-primary, var(--bg-page)); border: 1px solid var(--border-color, var(--bg-hover)); border-radius: var(--radius-control); @@ -176,7 +176,7 @@ background: var(--bg-primary, var(--bg-page)); border: 1px solid var(--border-color, var(--bg-hover)); color: var(--text-primary, var(--slate-11)); - padding: 6px 10px; + paddingvar(--space-150) var(--space-250); border-radius: var(--radius-control); font-size: var(--text-sm); min-width: 100px; @@ -297,7 +297,7 @@ .timeline-event-header { display: flex; align-items: flex-start; - gap: 10px; + gapvar(--space-250); margin-bottom: 6px; } @@ -327,7 +327,7 @@ font-size: var(--text-sm); font-weight: 500; color: var(--text-primary, var(--slate-11)); - margin: 2px; + marginvar(--space-half); line-height: 1.3; } @@ -629,7 +629,7 @@ color: var(--text-on-accent); font-size: var(--text-2xs); font-weight: 600; - padding: 2px 6px; + paddingvar(--space-half) var(--space-150); border-radius: var(--radius-card); margin-left: var(--space-2); } @@ -654,7 +654,7 @@ .timeline-live-indicator { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); font-size: var(--text-xs); color: var(--color-success, var(--ok)); padding: var(--space-1) var(--space-2); @@ -706,8 +706,8 @@ #timeline-now-replaying { display: none; align-items: center; - gap: 6px; - padding: 2px 10px; + gapvar(--space-150); + paddingvar(--space-half) var(--space-250); margin-left: var(--space-2); background: color-mix(in srgb, var(--blue-9) 20%, transparent); color: var(--blue-11); @@ -783,7 +783,7 @@ } .timeline-event { - padding: 10px; + paddingvar(--space-250); } .timeline-event-actions { @@ -900,7 +900,7 @@ background: transparent; border: none; color: var(--text-secondary, var(--text-secondary)); - padding: 6px; + paddingvar(--space-150); border-radius: var(--radius-control); cursor: pointer; display: flex; @@ -943,7 +943,7 @@ padding: var(--space-2); display: flex; flex-direction: column; - gap: 6px; + gapvar(--space-150); } /* Compact Event Card for Sidebar */ @@ -1040,7 +1040,7 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: 6px; + gapvar(--space-150); font-size: var(--text-2xs); color: var(--text-muted, var(--text-muted)); } @@ -1060,7 +1060,7 @@ /* Event Actions */ .sidebar-timeline-event-actions { display: flex; - gap: 2px; + gapvar(--space-half); flex-shrink: 0; } @@ -1239,8 +1239,8 @@ .now-replaying-chip { display: inline-flex; align-items: center; - gap: 6px; - padding: 2px 10px; + gapvar(--space-150); + paddingvar(--space-half) var(--space-250); margin-left: var(--space-2); background: color-mix(in srgb, var(--blue-9) 20%, transparent); color: var(--blue-11); diff --git a/dashboard/css/troubleshoot.css b/dashboard/css/troubleshoot.css index 3da7115..6986c08 100644 --- a/dashboard/css/troubleshoot.css +++ b/dashboard/css/troubleshoot.css @@ -14,7 +14,7 @@ background: var(--warn-bg); border: 1px solid var(--warn-border); border-radius: var(--radius-control); - padding: 10px; + paddingvar(--space-250); margin-bottom: var(--space-2); font-size: var(--text-xs); animation: troubleshoot-fade-in 0.3s ease-out; @@ -28,7 +28,7 @@ .troubleshoot-card-header { display: flex; align-items: center; - gap: 6px; + gapvar(--space-150); margin-bottom: var(--space-2); color: var(--warn); font-weight: 600; @@ -59,7 +59,7 @@ .troubleshoot-timeline { display: flex; flex-direction: column; - gap: 6px; + gapvar(--space-150); } .troubleshoot-step { @@ -285,7 +285,7 @@ /* Dismiss all button */ .spaxel-dismiss-all { position: fixed; - bottom: 20px; + bottomvar(--space-5); left: 50%; transform: translateX(-50%); background: var(--bg-hover); @@ -347,7 +347,7 @@ .post-cal-actions { display: flex; - gap: 10px; + gapvar(--space-250); justify-content: center; flex-wrap: wrap; } diff --git a/dashboard/js/replay.test.js b/dashboard/js/replay.test.js new file mode 100644 index 0000000..4e920e7 --- /dev/null +++ b/dashboard/js/replay.test.js @@ -0,0 +1,444 @@ +/** + * Tests for SpaxelReplay time-travel module + * + * Covers: + * - jumpToTime creates replay session and fetches blobs + * - exitReplayMode cleans up state and notifies Viz3D + * - pauseLiveMode enters replay with correct time window + * - Timeline loop polls for session state during playback + * - Scrubber updates position based on session state + */ + +'use strict'; + +// Mock DOM elements +function setupDOM() { + document.body.innerHTML = ` +
+
+ `; +} + +// Mock Viz3D on both global and window +var viz3dMock = { + enterReplayMode: jest.fn(), + exitReplayMode: jest.fn(), + updateReplayBlobs: jest.fn() +}; +global.Viz3D = viz3dMock; + +// Mock SpaxelApp on both global and window +var appMock = { + showToast: jest.fn() +}; +global.SpaxelApp = appMock; + +// Mock SpaxelSidebarTimeline on both global and window +var sidebarMock = { + clearSelection: jest.fn(), + hideNowReplayingChip: jest.fn() +}; +global.SpaxelSidebarTimeline = sidebarMock; + +// Mock SpaxelRouter on both global and window +var routerMock = { + navigate: jest.fn() +}; +global.SpaxelRouter = routerMock; + +describe('SpaxelReplay', function() { + let Replay; + let fetchMock; + + beforeEach(function() { + jest.clearAllMocks(); + setupDOM(); + + // Default fetch mock returns store info + fetchMock = jest.fn(function(url, options) { + if (url === '/api/replay/sessions') { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + has_data: true, + oldest_timestamp_ms: Date.now() - 3600000, + newest_timestamp_ms: Date.now(), + sessions: [] + }); + } + }); + } + + if (url === '/api/replay/jump-to-time') { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + session_id: 'jump-session-1', + timestamp_ms: 1710519800000, + from_ms: 1710519795000, + to_ms: 1710519805000, + state: 'paused' + }); + } + }); + } + + // Session state endpoint (with blobs) + var sessionMatch = url.match(/\/api\/replay\/session\/(.+)$/); + if (sessionMatch) { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + session_id: sessionMatch[1], + current_ms: 1710519800000, + from_ms: 1710519795000, + to_ms: 1710519805000, + state: 'paused', + speed: 1, + blobs: [ + { + id: 1, + x: 2.5, + y: 1.3, + z: 0.8, + vx: 0.1, + vy: 0.0, + vz: 0.0, + weight: 0.85, + posture: 'standing' + } + ], + timestamp_ms: 1710519800000 + }); + } + }); + } + + // Stop session + if (url === '/api/replay/stop') { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ status: 'stopped' }); + } + }); + } + + // Start session + if (url === '/api/replay/start') { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + session_id: 'start-session-1', + from_ms: 1710519740000, + to_ms: 1710519800000, + speed: 1, + state: 'paused' + }); + } + }); + } + + return Promise.resolve({ + ok: false, + statusText: 'Not Found' + }); + }); + + global.fetch = fetchMock; + + // Load the module + jest.isolateModules(function() { + require('./replay.js'); + Replay = window.SpaxelReplay; + }); + }); + + afterEach(function() { + document.body.innerHTML = ''; + }); + + // ============================================ + // Initialization Tests + // ============================================ + describe('Initialization', function() { + test('initializes and fetches store info', function() { + expect(Replay).toBeTruthy(); + expect(Replay.isReplayMode()).toBe(false); + expect(Replay.isPaused()).toBe(false); + // Should have called fetch for store info + expect(fetchMock).toHaveBeenCalledWith('/api/replay/sessions'); + }); + + test('getSession returns initial empty state', function() { + var session = Replay.getSession(); + expect(session.id).toBeNull(); + expect(session.fromMs).toBeNull(); + expect(session.state).toBe('stopped'); + }); + }); + + // ============================================ + // Jump-to-Time Tests + // ============================================ + describe('jumpToTime', function() { + test('creates replay session centered on timestamp', function() { + return Replay.jumpToTime(1710519800000).then(function(data) { + expect(fetchMock).toHaveBeenCalledWith('/api/replay/jump-to-time', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }) + })); + + // Check the request body + var call = fetchMock.mock.calls.find(function(c) { return c[0] === '/api/replay/jump-to-time'; }); + var body = JSON.parse(call[1].body); + expect(body.timestamp_ms).toBe(1710519800000); + expect(body.window_ms).toBe(5000); + }); + }); + + test('uses custom window_ms when provided', function() { + fetchMock.mockImplementation(function(url) { + if (url === '/api/replay/jump-to-time') { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + session_id: 'custom-window', + timestamp_ms: 1710519800000, + from_ms: 1710519790000, + to_ms: 1710519810000, + state: 'paused' + }); + } + }); + } + if (url.match(/\/api\/replay\/session\//)) { + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + session_id: 'custom-window', + current_ms: 1710519800000, + from_ms: 1710519790000, + to_ms: 1710519810000, + state: 'paused', + blobs: [], + timestamp_ms: 1710519800000 + }); + } + }); + } + if (url === '/api/replay/sessions') { + return Promise.resolve({ + ok: true, + json: function() { return Promise.resolve({ has_data: true, sessions: [] }); } + }); + } + return Promise.resolve({ ok: false, statusText: 'Not Found' }); + }); + + // Need to re-init with new fetch mock + jest.isolateModules(function() { + require('./replay.js'); + var R = window.SpaxelReplay; + R.jumpToTime(1710519800000, 10000).then(function() { + var call = fetchMock.mock.calls.find(function(c) { return c[0] === '/api/replay/jump-to-time'; }); + var body = JSON.parse(call[1].body); + expect(body.window_ms).toBe(10000); + }); + }); + + return Promise.resolve(); + }); + + test('enters replay mode after successful jump', function() { + return Replay.jumpToTime(1710519800000).then(function() { + expect(Replay.isReplayMode()).toBe(true); + }); + }); + + test('shows replay control bar after jump', function() { + return Replay.jumpToTime(1710519800000).then(function() { + var bar = document.getElementById('replay-control-bar'); + expect(bar).toBeTruthy(); + expect(bar.style.display).toBe('block'); + }); + }); + + test('calls Viz3D.enterReplayMode', function() { + return Replay.jumpToTime(1710519800000).then(function() { + expect(global.Viz3D.enterReplayMode).toHaveBeenCalled(); + }); + }); + + test('fetches session blobs and feeds them to Viz3D', function() { + return Replay.jumpToTime(1710519800000).then(function() { + // Should have fetched session state for blobs + var sessionCall = fetchMock.mock.calls.find(function(c) { + return c[0] && c[0].match(/\/api\/replay\/session\//); + }); + expect(sessionCall).toBeTruthy(); + + // Should have fed blobs to Viz3D + expect(global.Viz3D.updateReplayBlobs).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 1, + x: 2.5, + y: 1.3, + z: 0.8 + }) + ]), + 1710519800000 + ); + }); + }); + + test('updates session state from jump response', function() { + return Replay.jumpToTime(1710519800000).then(function() { + var session = Replay.getSession(); + expect(session.id).toBe('jump-session-1'); + expect(session.currentMs).toBe(1710519800000); + expect(session.state).toBe('paused'); + }); + }); + + test('handles API error gracefully', function() { + fetchMock.mockImplementation(function(url) { + if (url === '/api/replay/sessions') { + return Promise.resolve({ + ok: true, + json: function() { return Promise.resolve({ has_data: false, sessions: [] }); } + }); + } + if (url === '/api/replay/jump-to-time') { + return Promise.resolve({ + ok: false, + statusText: 'Internal Server Error' + }); + } + return Promise.resolve({ ok: false, statusText: 'Not Found' }); + }); + + jest.isolateModules(function() { + require('./replay.js'); + return window.SpaxelReplay.jumpToTime(1710519800000).catch(function(err) { + expect(err.message).toContain('Failed to jump to time'); + }); + }); + }); + }); + + // ============================================ + // Exit Replay Mode Tests + // ============================================ + describe('exitReplayMode', function() { + test('exits replay mode and clears state', function() { + return Replay.jumpToTime(1710519800000).then(function() { + expect(Replay.isReplayMode()).toBe(true); + + return Replay.exitReplay(); + }).then(function() { + expect(Replay.isReplayMode()).toBe(false); + expect(Replay.isPaused()).toBe(false); + }); + }); + + test('hides replay control bar', function() { + return Replay.jumpToTime(1710519800000).then(function() { + return Replay.exitReplay(); + }).then(function() { + var bar = document.getElementById('replay-control-bar'); + expect(bar.style.display).toBe('none'); + }); + }); + + test('calls Viz3D.exitReplayMode', function() { + return Replay.jumpToTime(1710519800000).then(function() { + return Replay.exitReplay(); + }).then(function() { + expect(global.Viz3D.exitReplayMode).toHaveBeenCalled(); + }); + }); + + test('clears sidebar timeline selection and chip', function() { + return Replay.jumpToTime(1710519800000).then(function() { + return Replay.exitReplay(); + }).then(function() { + expect(global.SpaxelSidebarTimeline.clearSelection).toHaveBeenCalled(); + expect(global.SpaxelSidebarTimeline.hideNowReplayingChip).toHaveBeenCalled(); + }); + }); + + test('navigates back to live mode', function() { + return Replay.jumpToTime(1710519800000).then(function() { + return Replay.exitReplay(); + }).then(function() { + expect(global.SpaxelRouter.navigate).toHaveBeenCalledWith('live'); + }); + }); + + test('stops the replay session via API', function() { + return Replay.jumpToTime(1710519800000).then(function() { + return Replay.exitReplay(); + }).then(function() { + var stopCall = fetchMock.mock.calls.find(function(c) { return c[0] === '/api/replay/stop'; }); + expect(stopCall).toBeTruthy(); + var body = JSON.parse(stopCall[1].body); + expect(body.session_id).toBe('jump-session-1'); + }); + }); + }); + + // ============================================ + // Pause Live Mode Tests + // ============================================ + describe('pauseLiveMode', function() { + test('creates replay window 60 seconds before now', function() { + var beforeMs = Date.now(); + + Replay.pauseLive(); + + var startCall = fetchMock.mock.calls.find(function(c) { return c[0] === '/api/replay/start'; }); + expect(startCall).toBeTruthy(); + var body = JSON.parse(startCall[1].body); + var fromMs = new Date(body.from_iso8601).getTime(); + var toMs = new Date(body.to_iso8601).getTime(); + + // Should be approximately 60s window + var windowMs = toMs - fromMs; + expect(windowMs).toBeGreaterThanOrEqual(59000); + expect(windowMs).toBeLessThanOrEqual(61000); + expect(fromMs).toBeGreaterThanOrEqual(beforeMs - 61000); + }); + }); + + // ============================================ + // Replay Control Bar UI Tests + // ============================================ + describe('Replay Control Bar', function() { + test('control bar is created in the DOM', function() { + var bar = document.getElementById('replay-control-bar'); + expect(bar).toBeTruthy(); + }); + + test('control bar has all required buttons', function() { + expect(document.getElementById('replay-back-btn')).toBeTruthy(); + expect(document.getElementById('replay-play-btn')).toBeTruthy(); + expect(document.getElementById('replay-speed')).toBeTruthy(); + expect(document.getElementById('replay-scrubber')).toBeTruthy(); + expect(document.getElementById('replay-close-btn')).toBeTruthy(); + expect(document.getElementById('replay-tune-btn')).toBeTruthy(); + }); + + test('control bar is hidden by default', function() { + var bar = document.getElementById('replay-control-bar'); + expect(bar.style.display).toBe('none'); + }); + }); +});