+ `;
+
+ // Click to expand data
+ tr.addEventListener('click', () => {
+ const dataDiv = tr.querySelector('.event-data');
+ dataDiv.classList.toggle('expanded');
+ if (dataDiv.classList.contains('expanded')) {
+ dataDiv.innerHTML = `
${JSON.stringify(event.data, null, 2)}
`;
+ } else {
+ dataDiv.innerHTML = formatEventData(event.data);
+ }
+ });
+ }
if (prepend) {
tbody.insertBefore(tr, tbody.firstChild);
diff --git a/gently/ui/web/static/js/gallery.js b/gently/ui/web/static/js/gallery.js
index 51ee4207..42159d18 100644
--- a/gently/ui/web/static/js/gallery.js
+++ b/gently/ui/web/static/js/gallery.js
@@ -422,13 +422,19 @@ const CalibrationProfileView = {
/** Compact SPIM live indicator used inside the metrics strip.
* Carries the same IDs as the old big preview so SpimLivePreview's
- * apply-on-render logic continues to work unchanged. */
+ * apply-on-render logic continues to work unchanged. The thumb is a
+ * button — click to open the floating popout for a larger view. */
_renderSpimIndicator() {
return `
SPIM
-
+
—
@@ -1329,21 +1335,33 @@ const SpimLivePreview = {
const placeholder = document.getElementById('cal-spim-placeholder');
const metaEl = document.getElementById('cal-spim-meta');
const led = document.getElementById('cal-spim-led');
- if (!img) return; // not in profile view
const latest = embryoId ? this._latestByEmbryo[embryoId] : null;
- if (latest) {
- img.src = `data:image/png;base64,${latest.base64_png}`;
- img.classList.add('has-frame');
- if (placeholder) placeholder.hidden = true;
- if (metaEl) metaEl.textContent = this._formatMeta(latest);
- if (led) led.classList.remove('idle');
- } else {
- img.removeAttribute('src');
- img.classList.remove('has-frame');
- if (placeholder) placeholder.hidden = false;
- if (metaEl) metaEl.textContent = '—';
- if (led) led.classList.add('idle');
+
+ if (img) {
+ if (latest) {
+ img.src = `data:image/png;base64,${latest.base64_png}`;
+ img.classList.add('has-frame');
+ if (placeholder) placeholder.hidden = true;
+ if (metaEl) metaEl.textContent = this._formatMeta(latest);
+ if (led) led.classList.remove('idle');
+ } else {
+ img.removeAttribute('src');
+ img.classList.remove('has-frame');
+ if (placeholder) placeholder.hidden = false;
+ if (metaEl) metaEl.textContent = '—';
+ if (led) led.classList.add('idle');
+ }
+ }
+
+ // Mirror into popout if it's open — the popout lives outside the
+ // calibration panel's innerHTML reset, so we paint it independently.
+ if (typeof SpimPopout !== 'undefined') {
+ SpimPopout.paint(latest ? {
+ base64_png: latest.base64_png,
+ meta: this._formatMeta(latest),
+ embryoId,
+ } : null);
}
},
@@ -1375,6 +1393,227 @@ const SpimLivePreview = {
document.addEventListener('DOMContentLoaded', () => SpimLivePreview.init());
+// ==========================================
+// SPIM live popout (floating draggable window)
+// ==========================================
+// Lazy-built floating window that mirrors SpimLivePreview at a larger
+// size. Draggable via the header bar, resizable from the bottom-right
+// corner. Position and size persist in localStorage so the window
+// re-opens where the operator last left it. Closes on Escape.
+const SpimPopout = {
+ _STORAGE_KEY: 'gently.spimPopout.v1',
+ _root: null,
+ _isOpen: false,
+
+ _ensureBuilt() {
+ if (this._root) return this._root;
+
+ const el = document.createElement('div');
+ el.className = 'cal-spim-popout';
+ el.id = 'cal-spim-popout';
+ el.hidden = true;
+ el.innerHTML = `
+
+
+ SPIM Live
+
+
+
+
+
+
+
+ Awaiting SPIM frame…
+
+
+
+ `;
+ document.body.appendChild(el);
+ this._root = el;
+
+ // Restore persisted geometry
+ const saved = this._loadGeometry();
+ if (saved) {
+ el.style.left = `${saved.left}px`;
+ el.style.top = `${saved.top}px`;
+ el.style.width = `${saved.width}px`;
+ el.style.height = `${saved.height}px`;
+ }
+
+ el.querySelector('#cal-spim-popout-close').addEventListener('click', () => this.close());
+ this._wireDrag(el);
+ this._wireResizeObserver(el);
+
+ return el;
+ },
+
+ open() {
+ const el = this._ensureBuilt();
+ if (this._isOpen) return;
+ el.hidden = false;
+ this._isOpen = true;
+
+ // Clamp into viewport in case window was resized while popout was hidden
+ this._clampIntoViewport(el);
+
+ // Paint current frame for the selected embryo
+ const selected = (typeof CalibrationManager !== 'undefined')
+ ? CalibrationManager.selectedEmbryoId : null;
+ if (selected && typeof SpimLivePreview !== 'undefined') {
+ const latest = SpimLivePreview._latestByEmbryo[selected];
+ this.paint(latest ? {
+ base64_png: latest.base64_png,
+ meta: SpimLivePreview._formatMeta(latest),
+ embryoId: selected,
+ } : null);
+ } else {
+ this.paint(null);
+ }
+
+ document.addEventListener('keydown', this._onKey);
+ },
+
+ close() {
+ if (!this._root || !this._isOpen) return;
+ this._root.hidden = true;
+ this._isOpen = false;
+ document.removeEventListener('keydown', this._onKey);
+ },
+
+ toggle() {
+ this._isOpen ? this.close() : this.open();
+ },
+
+ /** Called by SpimLivePreview whenever the current embryo's latest
+ * frame changes. Frame is {base64_png, meta, embryoId} or null. */
+ paint(frame) {
+ if (!this._root || !this._isOpen) return;
+ const img = this._root.querySelector('#cal-spim-popout-img');
+ const placeholder = this._root.querySelector('#cal-spim-popout-placeholder');
+ const meta = this._root.querySelector('#cal-spim-popout-meta');
+ const embryoEl = this._root.querySelector('#cal-spim-popout-embryo');
+ const led = this._root.querySelector('#cal-spim-popout-led');
+
+ if (frame) {
+ img.src = `data:image/png;base64,${frame.base64_png}`;
+ img.classList.add('has-frame');
+ placeholder.hidden = true;
+ meta.textContent = frame.meta || '—';
+ embryoEl.textContent = frame.embryoId || '';
+ led.classList.remove('idle');
+ } else {
+ img.removeAttribute('src');
+ img.classList.remove('has-frame');
+ placeholder.hidden = false;
+ meta.textContent = '—';
+ embryoEl.textContent = '';
+ led.classList.add('idle');
+ }
+ },
+
+ _onKey: (e) => {
+ if (e.key === 'Escape') SpimPopout.close();
+ },
+
+ _wireDrag(el) {
+ const header = el.querySelector('#cal-spim-popout-header');
+ let dragging = false;
+ let startX = 0, startY = 0, startLeft = 0, startTop = 0;
+
+ header.addEventListener('pointerdown', (e) => {
+ // Don't start drag on the close button
+ if (e.target.closest('.cal-spim-popout-close')) return;
+ dragging = true;
+ const rect = el.getBoundingClientRect();
+ startX = e.clientX;
+ startY = e.clientY;
+ startLeft = rect.left;
+ startTop = rect.top;
+ // Switch to absolute positioning if currently default
+ el.style.left = `${startLeft}px`;
+ el.style.top = `${startTop}px`;
+ el.style.right = 'auto';
+ el.style.bottom = 'auto';
+ header.setPointerCapture(e.pointerId);
+ el.classList.add('dragging');
+ });
+
+ header.addEventListener('pointermove', (e) => {
+ if (!dragging) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ let nextLeft = startLeft + dx;
+ let nextTop = startTop + dy;
+ // Keep at least 40px of header on-screen
+ const w = el.offsetWidth;
+ const h = el.offsetHeight;
+ nextLeft = Math.max(-(w - 80), Math.min(window.innerWidth - 80, nextLeft));
+ nextTop = Math.max(0, Math.min(window.innerHeight - 40, nextTop));
+ el.style.left = `${nextLeft}px`;
+ el.style.top = `${nextTop}px`;
+ });
+
+ const endDrag = (e) => {
+ if (!dragging) return;
+ dragging = false;
+ el.classList.remove('dragging');
+ try { header.releasePointerCapture(e.pointerId); } catch (_) {}
+ this._saveGeometry(el);
+ };
+ header.addEventListener('pointerup', endDrag);
+ header.addEventListener('pointercancel', endDrag);
+ },
+
+ _wireResizeObserver(el) {
+ if (typeof ResizeObserver === 'undefined') return;
+ let saveTimer = null;
+ const ro = new ResizeObserver(() => {
+ if (!this._isOpen) return;
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => this._saveGeometry(el), 250);
+ });
+ ro.observe(el);
+ },
+
+ _clampIntoViewport(el) {
+ const rect = el.getBoundingClientRect();
+ if (rect.left + 80 > window.innerWidth || rect.top + 40 > window.innerHeight
+ || rect.left < -(rect.width - 80) || rect.top < 0) {
+ // Recenter
+ const w = Math.min(rect.width || 520, window.innerWidth - 40);
+ const h = Math.min(rect.height || 440, window.innerHeight - 40);
+ el.style.width = `${w}px`;
+ el.style.height = `${h}px`;
+ el.style.left = `${Math.max(20, (window.innerWidth - w) / 2)}px`;
+ el.style.top = `${Math.max(20, (window.innerHeight - h) / 2)}px`;
+ }
+ },
+
+ _saveGeometry(el) {
+ const rect = el.getBoundingClientRect();
+ const data = {
+ left: Math.round(rect.left),
+ top: Math.round(rect.top),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ };
+ try { localStorage.setItem(this._STORAGE_KEY, JSON.stringify(data)); } catch (_) {}
+ },
+
+ _loadGeometry() {
+ try {
+ const raw = localStorage.getItem(this._STORAGE_KEY);
+ if (!raw) return null;
+ const data = JSON.parse(raw);
+ if (typeof data.left !== 'number') return null;
+ return data;
+ } catch (_) { return null; }
+ },
+};
+
// Legacy wrappers kept for backward compatibility
function renderCalibrationGallery() { CalibrationManager.render(); }
diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js
new file mode 100644
index 00000000..089d7de3
--- /dev/null
+++ b/gently/ui/web/static/js/home.js
@@ -0,0 +1,177 @@
+/**
+ * HomeApp — the landing tab.
+ *
+ * A light at-a-glance landing surface: recent sessions, recent plans, recent
+ * images, a thin status line, and a "Start / continue an experiment" button
+ * that launches the setup flow (the wizard, which no longer auto-pops in chat).
+ *
+ * Read-only fetches against existing endpoints (/api/sessions, /api/campaigns,
+ * /api/home/recent-images); mirrors the ReviewApp/CampaignsApp module pattern.
+ */
+const HomeApp = (() => {
+ let _inited = false;
+ const SESSIONS_N = 5;
+ const CAMPAIGNS_N = 5;
+ const IMAGES_N = 8;
+ // Recent images are stable (latest projection per embryo). refresh() runs on
+ // every Home-tab entry, so guard against redundant disk-walking fetches:
+ // skip if one is in flight or the strip was loaded within IMAGES_TTL_MS.
+ const IMAGES_TTL_MS = 15000;
+ let _imgState = { at: 0, inflight: false };
+
+ function relTime(iso) {
+ if (!iso) return '';
+ const t = Date.parse(iso);
+ if (isNaN(t)) return '';
+ const s = Math.max(0, (Date.now() - t) / 1000);
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ const d = Math.floor(s / 86400);
+ return d < 30 ? `${d}d ago` : new Date(t).toLocaleDateString();
+ }
+
+ function empty(el, msg) {
+ el.innerHTML = `