workspace.js

import { SplitPopup } from "./components/split_popup.js"
import { SegmentContextMenu } from "./components/segment_context_menu.js"
import { StaleContextMenu } from "./components/stale_context_menu.js"
import { ConfirmDialog } from "./components/confirm_dialog.js"
import { EmbedDialog } from "./components/embed_dialog.js"
import { LiveQuotesDialog } from "./components/live_quotes_dialog.js"
import { StartPage } from "./start_page.js"
import { SpeakersPanel } from "./workspace_panels/speakers_panel.js"
import { TranscriptPanel } from "./workspace_panels/transcript_panel.js"
import { WaveformPanel } from "./workspace_panels/waveform_panel.js"
import { attachTooltip } from "./components/tooltip.js"
import { LOCAL_MODE } from "./utilities/constants.js"
import { HistoryManager } from "./utilities/history_manager.js"
import { VersionManager } from "./utilities/version_manager.js"

/**
 * Returns true if the viewport matches the mobile breakpoint.
 * @returns {boolean}
 */
function isMobile() {
    return window.matchMedia('(max-width: 768px)').matches;
}

/**
 * Central workspace controller. Owns the three editing panels (speakers,
 * transcript, waveform) and mediates all cross-panel interactions through
 * callbacks. Manages the active project, selection state, context menus,
 * and the split popup.
 */
export class Workspace {
    speakersPanel;
    transcriptPanel;
    waveformPanel;
    transcriptionManager = null;  // set by App after construction

    /**
     * @param {object} callbacks - callback functions for workspace actions
     * @param {function} callbacks.onNewProject - called when the user requests a new project
     * @param {function} callbacks.onOpenProject - called when the user requests to open a local project
     * @param {function} callbacks.onUpload - called with the active project when the upload button is clicked
     * @param {function} callbacks.onProjectModified - called with the project when it becomes dirty and is a server project
     * @param {function} callbacks.isServerConnected - returns true if the server is currently connected
     * @param {function} [callbacks.onPresentation] - called when the user opens a project's presentation
     * @param {function} [callbacks.onShare] - called when the user opens the share dialog for a project
     * @param {function} [callbacks.onVersionHistory] - called when the user opens the version history dialog
     * @param {function} [callbacks.getToken] - async function returning the current auth token string, or null
     * @param {function} [callbacks.onRenderSidebar] - called when the sidebar should be re-rendered
     */
    constructor({ onNewProject, onOpenProject, onUpload, onProjectModified, isServerConnected, onPresentation, onVersionHistory, onShare, getToken, onRenderSidebar }) {
        this._onNewProject = onNewProject ?? (() => {});
        this._onOpenProject = onOpenProject ?? (() => {});
        this._onUpload = onUpload ?? (() => {});
        this._onProjectModified = onProjectModified ?? null;
        this._isServerConnected = isServerConnected ?? (() => false);
        this.getToken = getToken ?? (() => Promise.resolve(null));
        this._onRenderSidebar = onRenderSidebar ?? null;
        this._onVersionHistory = onVersionHistory ?? null;
        this._onShare = onShare ?? null;
        this._onPresentation = onPresentation ?? ((project) => {
            // Default: just open the presentation in a new tab if it's a server project
            if (project?.projectId) window.open(`/presentation/${project.projectId}`, '_blank');
        });
        // Get all the document elements as members
        this.#getElements();

        // set the workspace empty, as nothing has been loaded
        this.workspaceEmpty.style.display = "flex";
        this.workspaceApp.style.display = "none";

        // Initialize any primitive members
        this.#initializeMembers();
        // Initialize the workspace panels and their callback
        this.initializeWorkspacePanels();
        // Setup all event listeners
        this.#setupListeners();
        // Initialize mobile layout handling
        this.#initMobile();
    }

    /** Constructs the three workspace panels and wires their cross-panel callbacks. */
    initializeWorkspacePanels() {
        // Initialize speakers panel with callbacks
        this.speakersPanel = new SpeakersPanel(this, {
            onSpeakerHover: (speakerId) => {
                this.hoveredSpeakerId = speakerId
                this.waveformPanel.drawRegions();
            },
            onSpeakerModified: (speaker) => {
                this.waveformPanel.drawRegions();
//                this.transcriptPanel.renderTranscript();
            },
        });

        // Initialize transcript panel with callbacks
        this.transcriptPanel = new TranscriptPanel(this, {
            onSegmentHover: (segmentIdx) => {
                // Only update if its changed for efficiency
                if (this.hoveredSegmentIdx !== segmentIdx) {
                    this.hoveredSegmentIdx = segmentIdx;
                    this.waveformPanel.setHoveredRegion(this.hoveredSegmentIdx);
                }
            },
            onSegmentSelect: (segmentIdx) => {
                // Only update if its changed for efficiency
                if (this.selectedSegmentIdx !== segmentIdx) {
                    this.selectedSegmentIdx = segmentIdx;
                    this.waveformPanel.setSelectedRegion(this.selectedSegmentIdx);
                    if (this.splitPopup) this.closeSplitPopup();
                }
            },
            onSegmentZoom: (segmentIdx) => {
                this.waveformPanel.zoomToRegion(segmentIdx);
            },
            onSearchChanged: (matchSet) => {
                this.searchMatchSet = matchSet;
                this.waveformPanel.drawRegions();
            },
            onParagraphHover: (paragraph) => {
                this.hoveredParagraphIdx = paragraph
                    ? this.activeProject.transcript().paragraphs.indexOf(paragraph)
                    : -1;
                this.waveformPanel.drawRegions();
            },
            onParagraphZoom: (paragraph) => {
                const idx = this.activeProject.transcript().paragraphs.indexOf(paragraph);
                if (idx >= 0) this.waveformPanel.zoomToParagraph(idx);
            },
        });

        // Initialize waveform panel with callbacks
        this.waveformPanel = new WaveformPanel(this, {
            onRegionHover: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.hoveredSegmentIdx !== regionIdx) {
                    this.hoveredSegmentIdx = regionIdx;
                    this.transcriptPanel.setHoveredSegment(this.hoveredSegmentIdx);
                }
            },
            onRegionSelect: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.selectedSegmentIdx !== regionIdx) {
                    this.selectedSegmentIdx = regionIdx;
                    this.transcriptPanel.setSelectedSegment(this.selectedSegmentIdx)
                    if (this.splitPopup) this.closeSplitPopup();
                }
            },
            onRegionActivate: (regionIdx) => {
                // Only update if its changed for efficiency
                if (this.activeSegmentIdx !== regionIdx) {
                    this.activeSegmentIdx = regionIdx;
                    this.transcriptPanel.setActiveSegment(this.activeSegmentIdx)
                }
            },
            onWordActivate: (time) => {
                this.transcriptPanel.setActiveWord(time);
            },
        });
    }

    /** Initializes all primitive instance variables to their default values. */
    #initializeMembers() {
        this.activeProject = null; // the currently loaded project
        this.versionManager = null;
        this.history = new HistoryManager({ maxSize: parseInt(localStorage.getItem('undo-queue-size') || '100', 10) });

        this.isUploading = false;

        this.activeCtxMenu = null;
        this.ctxSpeakerListOpen = false;
        this.ctxMenuSegIdx = -1;
        // Handles splitting control flow, while the splitting popup itself is in its own class
        this.splitPopup = null;

        this.selectedSegmentIdx = -1; // index into cachedSegments; -1 = none
        this.hoveredSegmentIdx = -1; // index under mouse cursor in region lane or transcript
        this.activeSegmentIdx = -1; // index of the segment currently under the playhead
        this.hoveredSpeakerId = null;
        this.hoveredParagraphIdx = -1; // index into transcript().paragraphs whose handle is hovered

        this.searchMatchSet = null; // Set of matching segment indices when search is active, null otherwise

        // Resize handle variables
        this.vHandleDragging = false;
        this.vHandleStartX = 0;
        this.vHandleStartW = 0;

        this.hHandleDragging = false
        this.hHandleStartY = 0
        this.hHandleStartH = 0;

        this.panelState = { waveform: null, speakers: null, transcript: null }
        this._leftColForcedWide = false
        this._waveformHeightBeforeCollapse = null
        this.focusedPanel = localStorage.getItem('panel-focused') || null
        this.preFocusState = JSON.parse(localStorage.getItem('panel-pre-focus-state') || 'null')
        // Migrate pre-focus state if it's in old boolean format
        if (this.preFocusState && typeof Object.values(this.preFocusState)[0] === 'boolean') {
            this.preFocusState = null
            this.focusedPanel = null
            localStorage.removeItem('panel-focused')
            localStorage.removeItem('panel-pre-focus-state')
        }
    }

    /** Binds DOM elements to instance properties. */
    #getElements() {
        this.root = document.getElementById('workspace');

        // build the start page
        this.startPage = new StartPage({ onNewProject: this._onNewProject, onOpenProject: this._onOpenProject });

        this.workspaceEmpty = this.root.querySelector("#workspaceEmpty");
        this.workspaceApp = this.root.querySelector("#workspaceApp");
        this.workspaceLoading = this.root.querySelector("#workspaceLoading");

        this.projectTitle = this.root.querySelector('#projectTitle');
        this.projectNameInput = this.root.querySelector('#projectNameInput');

        this.projectServerStatus = this.root.querySelector('#projectServerStatus');

        this.undoBtn = this.root.querySelector("#undoBtn");
        this.redoBtn = this.root.querySelector("#redoBtn");
        this.revertBtn = this.root.querySelector("#revertBtn");
        this.saveLocalBtn = this.root.querySelector("#saveLocalBtn");
        this.uploadServerBtn = this.root.querySelector("#uploadServerBtn");
        this.liveQuotesBtn  = this.root.querySelector("#liveQuotesBtn");
        this.presentationBtn = this.root.querySelector("#presentationBtn");
        this.versionHistoryBtn = this.root.querySelector("#versionHistoryBtn");
        this.headerShareGroup   = this.root.querySelector('#headerShareGroup');
        this.headerShareBtn     = this.root.querySelector('#headerShareBtn');
        this.headerShareAvatars = this.root.querySelector('#headerShareAvatars');

        // Workspace resize handles
        this.verticalHandle = this.root.querySelector('#handleV');
        this.horizontalHandle = this.root.querySelector('#handleH');
        this.leftCol = this.root.querySelector('#leftCol');
        this.kabobBtn = this.root.querySelector('#kabobBtn');
        this.headerActions = this.root.querySelector('.project-header-actions');
        this.mainLayout = this.root.querySelector('#mainLayout');

        // Panel elements and collapse buttons
        this.waveformPanelEl = this.root.querySelector('#waveformPanel');
        this.speakersPanelEl = this.root.querySelector('#speakersPanel');
        this.transcriptPanelEl = this.root.querySelector('#transcriptPanel');
        this.waveformCollapseBtn = this.root.querySelector('#waveformCollapseBtn');
        this.speakersCollapseBtn = this.root.querySelector('#speakersCollapseBtn');
        this.transcriptCollapseBtn = this.root.querySelector('#transcriptCollapseBtn');
        this.waveformFocusBtn = this.root.querySelector('#waveformFocusBtn');
        this.speakersFocusBtn = this.root.querySelector('#speakersFocusBtn');
        this.transcriptFocusBtn = this.root.querySelector('#transcriptFocusBtn');

        for (const btn of [
            this.waveformCollapseBtn, this.speakersCollapseBtn, this.transcriptCollapseBtn,
            this.waveformFocusBtn, this.speakersFocusBtn, this.transcriptFocusBtn,
        ]) { if (btn) attachTooltip(btn); }
    }

    /** Sets up resize handle drag logic, keyboard shortcuts, and project title editing. */
    #setupListeners() {
        window.addEventListener('mousemove', (e) => {
          if (!this.vHandleDragging) return;
          const maxW = this.leftCol.parentElement.clientWidth - 374;
          const w = Math.max(500, Math.min(maxW, this.vHandleStartW + e.clientX - this.vHandleStartX));
          this.leftCol.style.flex = `0 0 ${w}px`;
          if (!this.vResizeRafPending) {
            this.vResizeRafPending = true;
            requestAnimationFrame(() => {
              this.vResizeRafPending = false;
              const scrollEl = this.waveformPanel.getScrollEl();
              const anchorFrac = scrollEl && scrollEl.scrollWidth > 0
                ? scrollEl.scrollLeft / scrollEl.scrollWidth
                : null;
              this.waveformPanel.resizeMinimap();
              this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel, anchorFrac);
            });
          }
        });
        window.addEventListener('mouseup', () => {
          if (!this.vHandleDragging) return;
          this.vHandleDragging = false;
          this.verticalHandle.classList.remove('dragging');
          document.body.style.cursor = ''; document.body.style.userSelect = '';
          const match = this.leftCol.style.flex.match(/(\d+(?:\.\d+)?)px$/);
          if (match) localStorage.setItem('left-col-width', match[1]);
        });

        window.addEventListener('mousemove', (e) => {
          if (!this.hHandleDragging) return;
          this.pendingHeight = this.hHandleStartH + e.clientY - this.hHandleStartY;
          if (!this.hResizeRafPending) {
            this.hResizeRafPending = true;
            requestAnimationFrame(() => {
              this.hResizeRafPending = false;
              this.waveformPanel.setWaveformHeight(this.pendingHeight);
            });
          }
        });
        window.addEventListener('mouseup', () => {
          if (!this.hHandleDragging) return;
          this.hHandleDragging = false;
          this.horizontalHandle.classList.remove('dragging');
          document.body.style.cursor = ''; document.body.style.userSelect = '';
          localStorage.setItem('waveform-height', this.waveformPanel.waveformHeightPx);
        });

        // Keyboard shortcuts
        document.addEventListener('keydown', (e) => {
            // If the target is an input or selection dialog, don't handle the key press
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
            // If the target is an editable dialog, don't handle the keypress
            if (e.target.isContentEditable) return;

            // Undo / Redo
            if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
                e.preventDefault();
                this.history.undo().then(cmd => this._applyHistoryResult(cmd));
                return;
            }
            if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
                e.preventDefault();
                this.history.redo().then(cmd => this._applyHistoryResult(cmd));
                return;
            }

            // Tab / Shift+Tab — navigate segments and enter edit mode
            if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) {
                const segs = this.activeProject?.transcript()?.segments;
                if (segs?.length) {
                    e.preventDefault();
                    const cur = this.selectedSegmentIdx;
                    let next;
                    if (cur < 0) {
                        next = e.shiftKey ? segs.length - 1 : 0;
                    } else {
                        next = e.shiftKey ? cur - 1 : cur + 1;
                    }
                    if (next >= 0 && next < segs.length) {
                        this.setSelectedSeg(next);
                        if (!this.isReadOnly()) {
                            this.transcriptPanel.makeSegmentEditable(next);
                        }
                    }
                }
                return;
            }

            // If escape is pressed, close any open dialogs and clear selection
            if (e.code === 'Escape') {
                this.closeCtxMenu();
                this.closeSplitPopup();
                this.setSelectedSeg(-1);
            }

            // Segment shortcuts — only when a segment is selected and no menu/popup open
            const hasSel = this.selectedSegmentIdx >= 0;
            // S — split (allowed even when a split popup is open; closes the existing one first)
            if (hasSel && !this.activeCtxMenu && e.code === 'KeyS' && !e.shiftKey && !this.isReadOnly()) {
              e.preventDefault();
              const seg = this.activeProject.transcript().segments[this.selectedSegmentIdx];
              if (seg && seg.text.trim().split(/\s+/).length > 1) {
                const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
                this.enterSplitMode(this.selectedSegmentIdx, anchor);
              }
            }
            if (hasSel && !this.activeCtxMenu && !this.splitPopup && !this.isReadOnly()) {
              // E — edit text
              if (e.code === 'KeyE') {
                e.preventDefault();
                this.transcriptPanel.makeSegmentEditable(this.selectedSegmentIdx);
              }
              // C — change speaker (open ctx menu at segment position)
              if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) {
                e.preventDefault();
                const anchor = document.querySelector(`.t-seg[data-idx="${this.selectedSegmentIdx}"]`);
                if (anchor) {
                  const rect = anchor.getBoundingClientRect();
                  this.openSegmentCtxMenu(rect.left, rect.bottom + 4, this.selectedSegmentIdx);
                  // Auto-expand speaker list
                  requestAnimationFrame(() => {
                    const item = this.activeCtxMenu?.root?.querySelector('.ctx-item');
                    if (item) item.click();
                  });
                }
              }
              // Shift+A — merge with previous
              if (e.code === 'KeyA' && e.shiftKey) {
                e.preventDefault();
                const segs = this.activeProject.transcript().segments;
                const idx = this.selectedSegmentIdx;
                if (idx > 0 && segs[idx - 1].speaker === segs[idx].speaker) {
                  this._mergeWithHistory(idx - 1, idx);
                  this.setSelectedSeg(idx - 1);
                }
              }
              // Shift+D — merge with next
              if (e.code === 'KeyD' && e.shiftKey) {
                e.preventDefault();
                const segs = this.activeProject.transcript().segments;
                const idx = this.selectedSegmentIdx;
                if (idx < segs.length - 1 && segs[idx].speaker === segs[idx + 1].speaker) {
                  this._mergeWithHistory(idx, idx + 1);
                  this.setSelectedSeg(idx);
                }
              }
            }
        });

        this.projectTitle.addEventListener('click', () => {
            if (this.isReadOnly()) return;
            this.projectTitle.style.display = 'none';
            this.projectNameInput.style.display = '';
            this.projectNameInput.focus();
            this.projectNameInput.select();
        });
        this.projectNameInput.addEventListener('blur', () => this.commitProjectName());
        this.projectNameInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter')  { 
                e.preventDefault(); 
                this.projectNameInput.blur();
            }
            if (e.key === 'Escape') {
                this.projectNameInput.value = this.activeProject.projectName || 'Untitled Project';
                this.projectNameInput.blur();
            }
            e.stopPropagation();
        });

        this.undoBtn.addEventListener('click', () => {
            this.history.undo().then(cmd => this._applyHistoryResult(cmd));
        });
        this.redoBtn.addEventListener('click', () => {
            this.history.redo().then(cmd => this._applyHistoryResult(cmd));
        });

        this.revertBtn.addEventListener('click', () => {
            if (!this.activeProject?.isDirty()) return;
            new ConfirmDialog('Discard all changes since the last save?', {
                onConfirm: () => {
                    this.activeProject.revertToLastSave();
                    this.history.clear();
                    this._updateUndoRedoButtons();
                }
            });
        });
        this.saveLocalBtn.addEventListener('click', async () => { await this.saveLocalCopy(this.activeProject); });
        this.uploadServerBtn.addEventListener('click', () => {
            if (!this.uploadServerBtn.classList.contains('uploading')) {
                this._onUpload(this.activeProject);
            }
        });
        this.liveQuotesBtn.addEventListener('click', () => {
            new LiveQuotesDialog(this.activeProject.projectId, this.getToken);
        });
        this.presentationBtn.addEventListener('click', () => {
            this._onPresentation(this.activeProject);
        });
        this.headerShareBtn?.addEventListener('click', () => {
            if (this.activeProject && this._onShare) this._onShare(this.activeProject);
        });
        this.versionHistoryBtn.addEventListener('click', () => {
            if (this.activeProject && this._onVersionHistory) {
                this._onVersionHistory(this.activeProject, {
                    onRevertCurrent: (version) => this.applyVersionRevert(version),
                    onSaveVersion: (label) => this.versionManager?.saveNamedVersion(label),
                });
            }
        });

        // Kabob menu (mobile only)
        this.kabobBtn?.addEventListener('click', (e) => {
            e.stopPropagation();
            const isOpen = this.headerActions.classList.toggle('mobile-open');
            this.kabobBtn.classList.toggle('active', isOpen);
        });
        document.addEventListener('click', (e) => {
            if (!this.headerActions?.classList.contains('mobile-open')) return;
            if (!this.headerActions.contains(e.target) && e.target !== this.kabobBtn) {
                this.headerActions.classList.remove('mobile-open');
                this.kabobBtn?.classList.remove('active');
            }
        });

        // Workspace resize handles
        this.verticalHandle.addEventListener('mousedown', (e) => {
            if (isMobile()) return;
            this.vHandleDragging = true;
            this.vHandleStartX = e.clientX;
            this.vHandleStartW = this.leftCol.getBoundingClientRect().width;
            this.verticalHandle.classList.add('dragging');
            document.body.style.cssText += ';cursor:col-resize;user-select:none';
        });
        
        this.horizontalHandle.addEventListener('mousedown', (e) => {
            if (isMobile()) return;
            this.hHandleDragging = true;
            this.hHandleStartY = e.clientY;
            this.hHandleStartH = this.waveformPanel.waveformHeightPx;
            this.horizontalHandle.classList.add('dragging');
            document.body.style.cssText += ';cursor:row-resize;user-select:none';
        });

        this.#initPanelCollapse();
    }

    /** Sets up panel collapse buttons, click handlers, and double-click focus behaviour. */
    #initPanelCollapse() {
        const panels = [
            { id: 'waveform', el: this.waveformPanelEl, btn: this.waveformCollapseBtn, focusBtn: this.waveformFocusBtn, header: this.waveformPanelEl.querySelector('.player-card-header') },
            { id: 'speakers', el: this.speakersPanelEl, btn: this.speakersCollapseBtn, focusBtn: this.speakersFocusBtn, header: this.speakersPanelEl.querySelector('.speakers-panel-header') },
            { id: 'transcript', el: this.transcriptPanelEl, btn: this.transcriptCollapseBtn, focusBtn: this.transcriptFocusBtn, header: this.transcriptPanelEl.querySelector('.transcript-header') },
        ]
        this._focusBtnMap = { waveform: this.waveformFocusBtn, speakers: this.speakersFocusBtn, transcript: this.transcriptFocusBtn }

        // Resolve initial states (migrate old boolean keys if needed)
        const getInitialState = (id) => {
            const saved = localStorage.getItem('panel-state-' + id)
            if (saved) return saved
            const oldCollapsed = localStorage.getItem('panel-collapsed-' + id) === 'true'
            if (!oldCollapsed) return 'expanded'
            return id === 'transcript' ? 'collapsed_vertical' : 'collapsed'
        }
        for (const { id } of panels) {
            this.#setPanelState(id, getInitialState(id))
        }
        // Promote to vertical if both left-col were saved as collapsed
        if (this.panelState.waveform !== 'expanded' && this.panelState.speakers !== 'expanded') {
            this.#setPanelState('waveform', 'collapsed_vertical')
            this.#setPanelState('speakers', 'collapsed_vertical')
        }
        this.#applyLeftColLayout()
        this.#updateFocusBtnIcons()

        for (const { id, el, btn, focusBtn, header } of panels) {
            btn.addEventListener('click', (e) => {
                e.stopPropagation()
                this.#togglePanel(id)
            })
            focusBtn.addEventListener('click', (e) => {
                e.stopPropagation()
                if (this.focusedPanel === id) { this.#unfocusPanel() } else { this.#focusPanel(id) }
            })
            el.addEventListener('click', () => {
                if (this.panelState[id] !== 'expanded') this.#togglePanel(id)
            })
            header.addEventListener('dblclick', (e) => {
                if (e.target.closest('button') || e.target.closest('info-widget')) return
                if (this.focusedPanel === id) { this.#unfocusPanel() } else { this.#focusPanel(id) }
            })
        }
    }

    /** Syncs focus-button icons and titles to match the current focusedPanel state. */
    #updateFocusBtnIcons() {
        for (const id of ['waveform', 'speakers', 'transcript']) {
            const btn = this._focusBtnMap[id]
            if (!btn) continue
            const focused = this.focusedPanel === id
            const iconSpan = btn.querySelector('.icon')
            iconSpan.classList.toggle('icon-expand', !focused)
            iconSpan.classList.toggle('icon-compress', focused)
            btn.title = focused ? 'Unfocus panel' : 'Focus panel'
        }
    }

    /**
     * Exits any active focus, then toggles the given panel between expanded and collapsed.
     * @param {string} panelId - 'waveform' | 'speakers' | 'transcript'
     */
    #togglePanel(panelId) {
        if (this.focusedPanel) {
            this.focusedPanel = null
            this.preFocusState = null
            this._leftColForcedWide = false
            localStorage.removeItem('panel-focused')
            localStorage.removeItem('panel-pre-focus-state')
        }
        if (this.panelState[panelId] === 'expanded') {
            this.#collapsePanel(panelId)
        } else {
            this.#expandPanel(panelId)
        }
    }

    /**
     * Updates panelState, DOM dataset, collapse-button icon, and localStorage for one panel.
     * @param {string} panelId - 'waveform' | 'speakers' | 'transcript'
     * @param {string} state   - 'expanded' | 'collapsed' | 'collapsed_vertical'
     */
    #setPanelState(panelId, state) {
        if (this.panelState[panelId] === state) return
        this.panelState[panelId] = state

        const elMap = { waveform: this.waveformPanelEl, speakers: this.speakersPanelEl, transcript: this.transcriptPanelEl }
        const btnMap = { waveform: this.waveformCollapseBtn, speakers: this.speakersCollapseBtn, transcript: this.transcriptCollapseBtn }

        elMap[panelId].dataset.panelState = state

        const expandIcon = { waveform: 'icon-arrow-down', speakers: 'icon-arrow-up', transcript: 'icon-arrow-right' }
        const collapseIcon = { waveform: 'icon-arrow-up', speakers: 'icon-arrow-down', transcript: 'icon-arrow-right' }
        const iconSpan = btnMap[panelId].querySelector('.icon')
        iconSpan.classList.remove('icon-arrow-up', 'icon-arrow-down', 'icon-arrow-left', 'icon-arrow-right')
        iconSpan.classList.add(state === 'expanded' ? collapseIcon[panelId] : expandIcon[panelId])
        btnMap[panelId].title = state === 'expanded' ? 'Collapse panel' : 'Expand panel'

        localStorage.setItem('panel-state-' + panelId, state)
    }

    /**
     * Collapses a panel, promoting left-col panels to vertical when both are collapsed.
     * @param {string} panelId - 'waveform' | 'speakers' | 'transcript'
     */
    #collapsePanel(panelId) {
        if (panelId === 'transcript') {
            this.#setPanelState('transcript', 'collapsed_vertical')
        } else {
            if (panelId === 'speakers') {
                this._waveformHeightBeforeCollapse = this.waveformPanel.waveformHeightPx
            }
            this.#setPanelState(panelId, 'collapsed')
            // If both left-col panels are now non-expanded, promote both to vertical
            if (['waveform', 'speakers'].every(id => this.panelState[id] !== 'expanded')) {
                this.#setPanelState('waveform', 'collapsed_vertical')
                this.#setPanelState('speakers', 'collapsed_vertical')
            }
        }
        this.#applyLeftColLayout()
    }

    /**
     * Expands a panel, demoting any vertically-collapsed sibling back to horizontal.
     * @param {string} panelId - 'waveform' | 'speakers' | 'transcript'
     */
    #expandPanel(panelId) {
        if (panelId === 'transcript') {
            this.#setPanelState('transcript', 'expanded')
        } else {
            this.#setPanelState(panelId, 'expanded')
            // If the other left-col panel was vertical, demote it back to horizontal collapsed
            const other = panelId === 'waveform' ? 'speakers' : 'waveform'
            if (this.panelState[other] === 'collapsed_vertical') {
                this.#setPanelState(other, 'collapsed')
            }
        }
        this.#applyLeftColLayout()
    }

    /** Recomputes and applies left-column width and waveform height based on current panel states. */
    #applyLeftColLayout() {
        if (isMobile()) return;
        const ws = this.panelState.waveform
        const ss = this.panelState.speakers
        const ts = this.panelState.transcript
        const allLeftCollapsed = ws !== 'expanded' && ss !== 'expanded'

        // Left-col width
        let w
        if (ts === 'collapsed_vertical') {
            const gap = parseFloat(getComputedStyle(this.mainLayout).columnGap) || 24
            w = Math.max(500, this.mainLayout.clientWidth - 44 - gap)
        } else if (allLeftCollapsed && !this._leftColForcedWide) {
            w = 44
        } else {
            const parentW = this.leftCol.parentElement.clientWidth
            const saved = parseInt(localStorage.getItem('left-col-width'), 10)
            w = saved ? Math.max(500, Math.min(parentW - 374, saved)) : Math.max(500, Math.min(parentW - 374, parentW / 2))
        }
        this.leftCol.style.flex = `0 0 ${w}px`

        // Waveform height (only when waveform is expanded)
        if (ws === 'expanded') {
            if (ss === 'collapsed') {
                const gap = parseFloat(getComputedStyle(this.leftCol).rowGap) || 24
                const speakersH = this.speakersPanelEl.getBoundingClientRect().height
                const newH = this.leftCol.clientHeight - speakersH - gap
                this.waveformPanel.setWaveformHeight(newH)
            } else if (ss === 'expanded') {
                const savedH = this._waveformHeightBeforeCollapse ?? parseInt(localStorage.getItem('waveform-height'), 10)
                if (savedH) this.waveformPanel.setWaveformHeight(savedH)
                this._waveformHeightBeforeCollapse = null
            }
        }

        requestAnimationFrame(() => this.#resizeWaveformAfterWidthChange())
    }

    /** Refreshes minimap and re-applies zoom after the left column changes width. */
    #resizeWaveformAfterWidthChange() {
        const scrollEl = this.waveformPanel.getScrollEl()
        const anchorFrac = scrollEl && scrollEl.scrollWidth > 0 ? scrollEl.scrollLeft / scrollEl.scrollWidth : null
        this.waveformPanel.resizeMinimap()
        this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel, anchorFrac)
    }

    /**
     * Collapses all other panels and focuses the given one, saving pre-focus state for restore.
     * @param {string} panelId - 'waveform' | 'speakers' | 'transcript'
     */
    #focusPanel(panelId) {
        if (isMobile()) return;
        this.preFocusState = { ...this.panelState }
        this.focusedPanel = panelId

        if (panelId === 'transcript') {
            const allAlreadyCollapsed = ['waveform', 'speakers'].every(id => this.panelState[id] !== 'expanded')
            if (allAlreadyCollapsed) {
                // Both were already collapsed — focus expands left-col so transcript gets full space
                this._leftColForcedWide = true
                this.#applyLeftColLayout()
            } else {
                this._leftColForcedWide = false
                if (this.panelState.waveform === 'expanded') this.#collapsePanel('waveform')
                if (this.panelState.speakers === 'expanded') this.#collapsePanel('speakers')
            }
        } else {
            // Focusing a left-col panel: collapse the others, expand this one
            this._leftColForcedWide = false
            if (this.panelState.transcript === 'expanded') this.#collapsePanel('transcript')
            const other = panelId === 'waveform' ? 'speakers' : 'waveform'
            if (this.panelState[other] === 'expanded') this.#collapsePanel(other)
            if (this.panelState[panelId] !== 'expanded') this.#expandPanel(panelId)
        }

        localStorage.setItem('panel-focused', panelId)
        localStorage.setItem('panel-pre-focus-state', JSON.stringify(this.preFocusState))
        this.#updateFocusBtnIcons()
    }

    /** Restores pre-focus panel states and clears the focused-panel selection. */
    #unfocusPanel() {
        const state = this.preFocusState || { waveform: 'expanded', speakers: 'expanded', transcript: 'expanded' }
        this.focusedPanel = null
        this.preFocusState = null
        this._leftColForcedWide = false

        for (const id of ['waveform', 'speakers', 'transcript']) {
            this.#setPanelState(id, state[id])
        }
        this.#applyLeftColLayout()

        localStorage.removeItem('panel-focused')
        localStorage.removeItem('panel-pre-focus-state')
        this.#updateFocusBtnIcons()
    }

    /** Initializes responsive mobile layout handling via matchMedia. */
    #initMobile() {
        this._mobileTabBar = null;
        this._mobileActiveTab = 'transcript';
        this._mobileMediaQuery = window.matchMedia('(max-width: 768px)');
        const onModeChange = () => {
            if (this._mobileMediaQuery.matches) this.#enterMobileMode();
            else this.#exitMobileMode();
        };
        onModeChange();
        this._mobileMediaQuery.addEventListener('change', onModeChange);
    }

    /** Builds and inserts the mobile tab bar and sets the initial active tab. */
    #enterMobileMode() {
        if (this._mobileTabBar) return;

        const tabBar = document.createElement('div');
        tabBar.className = 'mobile-tab-bar';
        tabBar.id = 'mobileTabBar';
        tabBar.innerHTML = `
            <button class="mobile-tab-btn active" data-tab="transcript">
                <span class="icon icon-transcript" style="width:18px;height:18px;"></span>
                Transcript
            </button>
            <button class="mobile-tab-btn" data-tab="speakers">
                <span class="icon icon-speakers" style="width:18px;height:18px;"></span>
                Speakers
            </button>
        `;
        this.workspaceApp.appendChild(tabBar);
        this._mobileTabBar = tabBar;

        tabBar.querySelectorAll('.mobile-tab-btn').forEach(btn =>
            btn.addEventListener('click', () => this.#setMobileTab(btn.dataset.tab))
        );

        // Clear inline left-col flex so CSS 100%-width rule wins
        this.leftCol.style.flex = '';

        this.#setMobileTab(this._mobileActiveTab, true);

        requestAnimationFrame(() => {
            this.waveformPanel.resizeMinimap();
            this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel);
        });
    }

    /**
     * Switches the active mobile tab and toggles panel visibility.
     * @param {string} tab - The tab name to activate ('transcript', 'waveform', etc.).
     * @param {boolean} [initial=false] - If true, suppresses scroll/focus side-effects.
     */
    #setMobileTab(tab, initial = false) {
        this._mobileActiveTab = tab;
        this._mobileTabBar?.querySelectorAll('.mobile-tab-btn').forEach(btn =>
            btn.classList.toggle('active', btn.dataset.tab === tab)
        );
        this.transcriptPanelEl.classList.toggle('mobile-panel--hidden', tab !== 'transcript');
        this.speakersPanelEl.classList.toggle('mobile-panel--hidden', tab !== 'speakers');
        if (!initial) {
            requestAnimationFrame(() => {
                this.waveformPanel.resizeMinimap();
                this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel);
            });
        }
    }

    /** Removes the mobile tab bar and restores desktop layout. */
    #exitMobileMode() {
        if (!this._mobileTabBar) return;
        this._mobileTabBar.remove();
        this._mobileTabBar = null;
        this.transcriptPanelEl.classList.remove('mobile-panel--hidden');
        this.speakersPanelEl.classList.remove('mobile-panel--hidden');
        this.#applyLeftColLayout();
        requestAnimationFrame(() => {
            this.waveformPanel.resizeMinimap();
            this.waveformPanel.applyZoom(this.waveformPanel.currentZoomLevel);
        });
    }

    /**
    * Loads project data from the server uplink and populates the workspace
    * @param {object} project - the project to be loaded
    */
    async loadProject(project) {
        this.activeProject = project;

        // Show loading overlay while pulling data; keep workspace panels hidden
        this.workspaceEmpty.style.display = "none";
        this.workspaceLoading.style.display = "flex";
        this.workspaceApp.style.display = "none";

        try {
            // If its a not a local only server, pull it from the server
            if (!this.activeProject.localOnly) {
                // Reset any stale callbacks before pulling — server project objects are reused
                // across sessions, so old callbacks may still be registered from a prior load.
                // The real callbacks are re-registered below after the pull completes.
                project.registerModifyCallbacks({});
                await project.pullFromServer();
            }
        } catch(e) {
            // On failure, go back to the empty state and re-throw
            this.workspaceLoading.style.display = "none";
            this.workspaceEmpty.style.display = "flex";
            this.activeProject = null;
            throw e;
        }

        // Hide the loading overlay and reveal the workspace
        this.workspaceLoading.style.display = "none";
        this.workspaceApp.style.display = "flex";

        // Restore vertical divider position (or default to center)
        // Skip if both left-col panels are collapsed vertical — layout already set to 44px by #applyLeftColLayout
        requestAnimationFrame(() => {
            if (this.panelState.waveform !== 'expanded' && this.panelState.speakers !== 'expanded' && !this._leftColForcedWide) return;
            const parentW = this.leftCol.parentElement.clientWidth;
            const saved = parseInt(localStorage.getItem('left-col-width'), 10);
            const w = saved ? Math.max(500, Math.min(parentW - 374, saved)) : Math.max(500, Math.min(parentW - 374, parentW / 2));
            this.leftCol.style.flex = `0 0 ${w}px`;
        });

        // Set project name label
        this.projectNameInput.value = this.activeProject.projectName;
        this.projectTitle.textContent = this.activeProject.projectName;
        this.projectTitle.title = this.isReadOnly() ? '' : 'Click to rename';

        // Load the project into the three workspace panels
        this.speakersPanel.loadFromProject(project);
        this.waveformPanel.loadFromProject(project);
        this.transcriptPanel.loadFromProject(project);

        const savedH = parseInt(localStorage.getItem('waveform-height'), 10);
        if (savedH) this.waveformPanel.setWaveformHeight(savedH);

        // Mark the project to all clean
        this.activeProject.markClean();

        // Clear undo/redo history when a new project is loaded
        this.history.clear();
        this.versionManager?.destroy();
        if (!this.activeProject.localOnly && !LOCAL_MODE) {
            const autoVersionLimit = this.activeProject.activeServer?.backendUser?.preferences?.auto_version_limit ?? 50;
            this.versionManager = new VersionManager(
                this.activeProject.projectId,
                this.activeProject.activeServer,
                this.activeProject,
                { autoVersionLimit },
            );
        } else {
            this.versionManager = null;
        }
        this._updateUndoRedoButtons();

        this.updateProjectServerStatus();

        this.activeProject.registerModifyCallbacks({
            onSpeakersModified: () => {
                if(this.activeProject.hasTranscript && this.activeProject.transcript()) {
                    this.transcriptPanel.renderTranscript();
                }
                if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
                if(this.activeProject.hasSpeakers) { this.speakersPanel.renderSpeakersPanel(); }

                this.updateProjectServerStatus();
            },
            onTranscriptModified: () => {
                if(this.activeProject.hasTranscript) { this.transcriptPanel.renderTranscript(); }
                if(this.activeProject.hasWaveform) { this.waveformPanel.drawRegions(); }
                this.updateProjectServerStatus();
            },
            onWaveformModified: () => {
                this.waveformPanel.loadWaveform()
                if(this.activeProject.hasTranscript) { this.waveformPanel.drawRegions(); }
                this.updateProjectServerStatus();
            },
            onSpeakersSaved: () => {
                this.updateProjectServerStatus();
            },
            onTranscriptSaved: () => {
                this.updateProjectServerStatus();
            },
            onWaveformSaved: () => {
                this.updateProjectServerStatus();
            },
        });
    }

    /**
    * Unloads the currently active project, alerting user to unsaved changes
    */
    unloadActiveProject() {
        this.updateProjectServerStatus();
    }

    /**
     * Prompts the user for a save location and packages the project as a .wfs archive.
     * @param {object} project - the project to save
     * @returns {Promise<FileSystemFileHandle|undefined>} the file handle on success, or undefined if cancelled
     */
    async saveLocalCopy(project) {
        const suggestedName = `${project.projectName.replace(/[^a-z0-9_\-]/gi, '_')}.wfs`;
        try {
            const fileHandle = await window.showSaveFilePicker({
                suggestedName,
                types: [{
                    description: 'Workflow Save File',
                    accept: { 'application/octet-stream': ['.wfs'] },
                }],
            });
            await project.packageProject(fileHandle);
            project.markClean();
            this.updateProjectServerStatus();
            return fileHandle;

        } catch (err) {
            if (err.name !== 'AbortError') {
                console.error('Save failed:', err);
            }
        }
    }

    // ── Project name editing ───────────────────────────────────────────────────

    /**
    * Commits the project name from the inline input back to the title span,
    * updates the active project's name in state, and refreshes the sidebar.
    * Called on blur and Enter keydown of the title input.
    */
    commitProjectName() {
        const newName = this.projectNameInput.value.trim() || 'Untitled Project';
        this.projectNameInput.value = newName;
        this.projectTitle.textContent = newName;
        this.projectTitle.style.display = '';
        this.projectNameInput.style.display = 'none';
        if (!this.activeProject.projectId) {
            return;
        }
        const oldName = this.activeProject.projectName;
        this.activeProject.setName(newName);
        if (oldName !== newName) {
            this.history.push({
                label: 'Rename project', dirtyFlags: ['projectName'],
                undo: () => { this.activeProject.setName(oldName); this.updateProjectServerStatus(); },
                redo: () => { this.activeProject.setName(newName); this.updateProjectServerStatus(); },
            });
            this._updateUndoRedoButtons();
            this.updateProjectServerStatus();
        }
    }


    // ── Access mode ───────────────────────────────────────────────────────────

    /**
     * Returns true if the active project is in read-only mode.
     * @returns {boolean}
     */
    isReadOnly() {
        return this.activeProject?.readOnly ?? false;
    }

    // ── Selection ─────────────────────────────────────────────────────────────

    /**
    * Sets the selected segment, syncing workspace state, transcript panel, and waveform panel.
    * @param {number} idx - segment index, or -1 to clear selection
    */
    setSelectedSeg(idx) {
        this.selectedSegmentIdx = idx;
        this.transcriptPanel.setSelectedSegment(idx);
        this.waveformPanel.setSelectedRegion(idx);
    }

    // ── Split segment popup ───────────────────────────────────────────────────

    /**
     * Returns true if a split popup is currently active.
     * @returns {boolean}
     */
    isSplitting() { return this.splitPopup !== null; }

    /**
    * Close the active split popup if there is one
    */
    closeSplitPopup() {
        if (this.splitPopup) {
            this.splitPopup.destroy();
            this.splitPopup = null;
            this.waveformPanel.drawRegions();
        }
    }

    /**
    * Opens the split popup for the given segment, closing any existing one first.
    * @param {number} segIdx - index of the segment to split
    * @param {HTMLElement} anchorEl - element to anchor the popup near
    */
    enterSplitMode(segIdx, anchorEl) {
        // If there is already a split popup, close the current one
        if (this.splitPopup) {
            this.closeSplitPopup();
        }
        // Open a new SplitPopup
        this.splitPopup = new SplitPopup(segIdx, anchorEl, this.activeProject, this, {
            onSplit: (newSegmentA, newSegmentB) => {
                const project = this.activeProject;
                const transcript = project.transcript();
                const originalSeg = { ...transcript.segments[segIdx] };
                const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
                project.splitSegment(segIdx, newSegmentA, newSegmentB);
                const segA = { ...transcript.segments[segIdx] };
                const segB = { ...transcript.segments[segIdx + 1] };
                this.history.push({
                    label: 'Split segment', dirtyFlags: ['transcript'],
                    undo: () => {
                        transcript.segments.splice(segIdx, 2, originalSeg);
                        transcript.buildTranscript();
                        project.annotations = savedAnnotations;
                    },
                    redo: () => {
                        transcript.segments.splice(segIdx, 1, { ...segA }, { ...segB });
                        transcript.buildTranscript();
                    },
                });
                this._updateUndoRedoButtons();
                this.splitPopup = null;
            },

            onCancel: () => {
                this.splitPopup = null;
                this.waveformPanel.drawRegions();
            }
        });
    }


    // ── Segment context menu ──────────────────────────────────────────────────────────

    /**
     * Returns true if there is a segment before segIdx.
     * @param {number} segIdx - the segment index to check
     * @returns {boolean}
     */
    hasPrev(segIdx) { return segIdx > 0; }
    /**
     * Returns true if there is a segment after segIdx.
     * @param {number} segIdx - the segment index to check
     * @returns {boolean}
     */
    hasNext(segIdx) { return segIdx < this.activeProject.transcript().segments.length - 1; }

    /**
     * Returns true when the app is running in local (desktop) mode.
     * @returns {boolean}
     */
    isLocalMode() {
        return LOCAL_MODE;
    }

    /**
     * Opens the EmbedDialog for the given text selection target.
     * @param {object} selTarget - selection target from TranscriptPanel#getNativeSelection()
     */
    openEmbedDialog(selTarget) {
        new EmbedDialog(selTarget, this.activeProject, { getToken: this.getToken, wavesurfer: this.wavesurferInstance() });
    }

    /**
    * Closes the active context menu
    */
    closeCtxMenu() {
        if (this.activeCtxMenu) {
            this.activeCtxMenu.close();
            this.activeCtxMenu = null;
        }
    }

    /**
    * Opens a SegmentContextMenu for the given segment index.
    * @param {number} x - left position in viewport pixels
    * @param {number} y - top position in viewport pixels
    * @param {number} segmentIndex - index into the active project's transcript segments
    * @param {Element|{left:number,top:number,bottom:number}|null} splitAnchor - anchor for the split popup; defaults to the transcript segment element
    * @param {string} [title] - title shown at the top of the menu; defaults to 'Segment'
    * @param {ContextMenu|null} [primaryMenu] - existing primary menu to stack beneath; null opens a standalone menu
    */
    openSegmentCtxMenu(x, y, segmentIndex, splitAnchor = null, title = 'Segment', primaryMenu = null) {
        // If there is already a context menu open, close it
        this.closeCtxMenu();
        this.ctxMenuSegIdx = segmentIndex;

        const transcript = this.activeProject.transcript();
        const segments = transcript.segments;
        const segment = segments[segmentIndex];
        const hasPrev = segmentIndex > 0 && segments[segmentIndex - 1].speaker === segment.speaker;
        const hasNext = segmentIndex < segments.length - 1 && segments[segmentIndex + 1].speaker === segment.speaker;

        const wordCount = segment?.text.trim().split(/\s+/).length; // number of words in the segment text

        // Paragraph split/merge availability
        const paraIdx = transcript.paragraphs.findIndex(p => p.segments.includes(segment));
        const para = transcript.paragraphs[paraIdx];
        const prevPara = paraIdx > 0 ? transcript.paragraphs[paraIdx - 1] : null;
        const nextPara = paraIdx < transcript.paragraphs.length - 1 ? transcript.paragraphs[paraIdx + 1] : null;
        const isFirstInPara = para && para.segments[0] === segment;
        const canSplitParagraph = segmentIndex > 0 && !isFirstInPara;
        const canMergeParagraphPrev = !!(prevPara && prevPara.speaker === para?.speaker);

        // Section break availability — any segment in the paragraph can create/remove a break before or after it
        const paraFirstStart = para?.segments[0].start;
        const nextParaFirstStart = nextPara?.segments[0].start;
        const hasSectionBreakBefore = transcript.sectionBreaks.some(b => b.beforeSegStart === paraFirstStart);
        const hasSectionBreakAfter  = !!nextPara && transcript.sectionBreaks.some(b => b.beforeSegStart === nextParaFirstStart);
        const canCreateSectionBreak      = !this.isReadOnly() && paraIdx > 0 && !hasSectionBreakBefore;
        const canRemoveSectionBreak      = !this.isReadOnly() && hasSectionBreakBefore;
        const canCreateSectionBreakAfter = !this.isReadOnly() && !!nextPara && !hasSectionBreakAfter;
        const canRemoveSectionBreakAfter = !this.isReadOnly() && hasSectionBreakAfter;

        // Create a new context menu with its callbacks
        const segMenu = new SegmentContextMenu(x, y, segmentIndex, this.activeProject, {
            title,
            readOnly: this.isReadOnly(),
            // these callbacks are never null
            onChangeSpeaker: (speakerId) => {
                const segs = this.activeProject.transcript().segments;
                const oldSpeakerId = segs[segmentIndex].speaker;
                this.activeProject.changeSpeaker(segmentIndex, speakerId);
                this.history.push({
                    label: 'Change segment speaker', dirtyFlags: ['transcript'],
                    undo: () => {
                        this.activeProject.transcript().changeSpeaker(segmentIndex, oldSpeakerId);
                    },
                    redo: () => {
                        this.activeProject.transcript().changeSpeaker(segmentIndex, speakerId);
                    },
                });
                this._updateUndoRedoButtons();
                this.closeCtxMenu();
            },
            onEdit: () => {
                this.closeCtxMenu();
                this.transcriptPanel.makeSegmentEditable(segmentIndex);
            },
            onDismiss: () => { this.closeCtxMenu(); },
            onAddLink: this.isReadOnly() ? null : () => {
                this.closeCtxMenu();
                this.transcriptPanel.openAddLinkDialog(segmentIndex);
            },
            onAddNote: this.isReadOnly() ? null : () => {
                this.closeCtxMenu();
                this.transcriptPanel.insertNoteAtTimecode(segment.start);
            },

            // if wordCount is less than one, function is nullified
            onSplit: wordCount > 1 ? () => {
              const anchor = splitAnchor ?? document.querySelector(`.t-seg[data-idx="${segmentIndex}"]`);
              this.closeCtxMenu();
              this.enterSplitMode(segmentIndex, anchor);
            } : null,
            // if segment has no previous, function is nullified
            onMergePrev: hasPrev ? () => {
                this.closeCtxMenu();
                this._mergeWithHistory(segmentIndex - 1, segmentIndex);
            } : null,
            // if segment has no next, function is nullified
            onMergeNext: hasNext ? () => {
                this.closeCtxMenu();
                this._mergeWithHistory(segmentIndex, segmentIndex + 1);
            } : null,
            onSplitParagraph: canSplitParagraph ? () => {
                this.closeCtxMenu();
                this._splitParagraphWithHistory(segmentIndex);
            } : null,
            onMergeParagraphPrev: canMergeParagraphPrev ? () => {
                this.closeCtxMenu();
                this._mergeParagraphWithHistory(segmentIndex);
            } : null,
            onZoom: () => {
                this.closeCtxMenu();
                this.waveformPanel.zoomToRegion(segmentIndex);
                this.waveformPanel.setSelectedRegion(segmentIndex);
            },
            onRetranscribe: segment.wordsStale ? null : () => {
                this.closeCtxMenu();
                this.transcriptPanel.retranscribeSegments([segmentIndex]);
            },
            onCreateSectionBreak: canCreateSectionBreak ? () => {
                this.closeCtxMenu();
                this._addSectionBreakWithHistory(paraFirstStart);
            } : null,
            onRemoveSectionBreak: canRemoveSectionBreak ? () => {
                this.closeCtxMenu();
                this._removeSectionBreakWithHistory(paraFirstStart);
            } : null,
            onCreateSectionBreakAfter: canCreateSectionBreakAfter ? () => {
                this.closeCtxMenu();
                this._addSectionBreakWithHistory(nextParaFirstStart);
            } : null,
            onRemoveSectionBreakAfter: canRemoveSectionBreakAfter ? () => {
                this.closeCtxMenu();
                this._removeSectionBreakWithHistory(nextParaFirstStart);
            } : null,
        });

        if (segment.wordsStale) {
            const staleMenu = new StaleContextMenu(x, y, {
                onRetranscribe:          () => { this.closeCtxMenu(); this.transcriptPanel.retranscribeSegments([segmentIndex]); },
                onRevertAndRetranscribe: () => { this.closeCtxMenu(); this.transcriptPanel.revertAndRetranscribeSegments([segmentIndex]); },
                onDismiss: () => this.closeCtxMenu(),
            });
            staleMenu.stack(segMenu);
            this.activeCtxMenu = primaryMenu ? primaryMenu.stack(staleMenu) : staleMenu;
        } else if (primaryMenu) {
            primaryMenu.stack(segMenu);
            this.activeCtxMenu = primaryMenu;
        } else {
            this.activeCtxMenu = segMenu;
        }
    }

    /**
     * Adds a section break before the segment with the given start time and pushes undo/redo.
     * @param {number} beforeSegStart - start time of the first segment in the new section
     */
    _addSectionBreakWithHistory(beforeSegStart) {
        const transcript = this.activeProject.transcript();
        const prevBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        transcript.addSectionBreak(beforeSegStart, '');
        const nextBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        this.activeProject.markTranscriptDirty();
        this.transcriptPanel.renderTranscript();
        this.waveformPanel.renderSectionBreakLines();
        this.waveformPanel.drawMinimap();
        this.history.push({
            label: 'Create section break', dirtyFlags: ['transcript'],
            undo: () => {
                this.activeProject.transcript().sectionBreaks = prevBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
            redo: () => {
                this.activeProject.transcript().sectionBreaks = nextBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
        });
        this._updateUndoRedoButtons();
    }

    /**
     * Removes the section break at the given start time and pushes undo/redo.
     * @param {number} beforeSegStart - start time of the section break to remove
     */
    _removeSectionBreakWithHistory(beforeSegStart) {
        const transcript = this.activeProject.transcript();
        const prevBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        transcript.removeSectionBreak(beforeSegStart);
        const nextBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        this.activeProject.markTranscriptDirty();
        this.transcriptPanel.renderTranscript();
        this.waveformPanel.renderSectionBreakLines();
        this.waveformPanel.drawMinimap();
        this.history.push({
            label: 'Remove section break', dirtyFlags: ['transcript'],
            undo: () => {
                this.activeProject.transcript().sectionBreaks = prevBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
            redo: () => {
                this.activeProject.transcript().sectionBreaks = nextBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
        });
        this._updateUndoRedoButtons();
    }

    /**
     * Moves a section break from oldStart to newStart, preserving its name,
     * as a single undoable action.
     * @param {number} oldStart - beforeSegStart of the section break to move
     * @param {number} newStart - beforeSegStart of the target position
     */
    _moveSectionBreakWithHistory(oldStart, newStart) {
        if (oldStart === newStart) return;
        const transcript = this.activeProject.transcript();
        const prevBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        const name = transcript.sectionBreaks.find(b => b.beforeSegStart === oldStart)?.name ?? '';
        transcript.removeSectionBreak(oldStart);
        transcript.addSectionBreak(newStart, name);
        const nextBreaks = transcript.sectionBreaks.map(b => ({ ...b }));
        this.activeProject.markTranscriptDirty();
        this.transcriptPanel.renderTranscript();
        this.waveformPanel.renderSectionBreakLines();
        this.waveformPanel.drawMinimap();
        this.history.push({
            label: 'Move section break', dirtyFlags: ['transcript'],
            undo: () => {
                this.activeProject.transcript().sectionBreaks = prevBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
            redo: () => {
                this.activeProject.transcript().sectionBreaks = nextBreaks.map(b => ({ ...b }));
                this.transcriptPanel.renderTranscript();
                this.waveformPanel.renderSectionBreakLines();
                this.waveformPanel.drawMinimap();
            },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Shows or hides the upload spinner on the workspace header upload button.
    * @param {boolean} busy - true to show spinner, false to restore the icon
    */
    setUploadBusy(busy) {
        this.isUploading = busy;
        this.uploadServerBtn.classList.toggle('uploading', busy);
        this.uploadServerBtn.disabled = busy;
        this.updateProjectServerStatus();
    }

    /**
    * Updates the badge and optional push button in the project title bar
    * to reflect whether the active project is local-only, synced, or has
    * unsynced changes.
    */
    updateProjectServerStatus() {
        const el = this.projectServerStatus;

        if (!el) {
            return;
        }

        el.innerHTML = '';

        // If there is no active project, don't display the badges
        if (!this.activeProject) {
            this.revertBtn.style.display = 'none';
            this.saveLocalBtn.classList.remove('active');
            this.uploadServerBtn.classList.remove('active');
            this.liveQuotesBtn.style.display  = 'none';
            this.presentationBtn.style.display = 'none';
            this.versionHistoryBtn.style.display = 'none';
            if (this.headerShareGroup) this.headerShareGroup.style.display = 'none';
            return;
        }

//        const connected = AppState.server.isConnected;

        const localOnlyBadge = document.createElement('span');
        localOnlyBadge.className = 'project-cloud-badge';

        const projectDirtyBadge = document.createElement('span');
        projectDirtyBadge.className = 'project-cloud-badge';

        const connected = this._isServerConnected();

        // set the local only / server badge
        if (this.activeProject.localOnly) {
            localOnlyBadge.className += ' modified';
            localOnlyBadge.textContent = 'offline';
            localOnlyBadge.title = 'Project not pushed to a server';
        } else if (!connected) {
            localOnlyBadge.className += ' server';
            localOnlyBadge.textContent = 'on server ';
            localOnlyBadge.title = 'Project has been pushed to a server';
        }
        // When connected and on server, omit the "on server" badge — it's implied

        // set the save status badge
        if (this.isUploading) {
            projectDirtyBadge.className += ' uploading';
            projectDirtyBadge.textContent = 'uploading';
            projectDirtyBadge.title = 'Uploading to server…';
        } else if (this.activeProject.localOnly) {
            if (this.activeProject.isDirty()) {
                projectDirtyBadge.className += ' modified';
                projectDirtyBadge.textContent = 'unsaved';
                projectDirtyBadge.title = 'Project has unsaved changes';
            } else {
                projectDirtyBadge.className += ' server';
                projectDirtyBadge.textContent = 'saved  ';
                projectDirtyBadge.title = 'Project has been saved locally';
            }
        } else {
            if (this.activeProject.isDirty()) {
                projectDirtyBadge.className += ' modified';
                projectDirtyBadge.textContent = 'unsaved';
                projectDirtyBadge.title = 'Project has unsaved changes';
            } else {
                projectDirtyBadge.className += ' server';
                projectDirtyBadge.textContent = 'saved  ';
                projectDirtyBadge.title = 'Project has been saved to the server';
            }
        }

        // Trigger auto-save when a server project becomes dirty (skip while an upload is already running)
        if (this.activeProject.isDirty() && !this.activeProject.localOnly && !this.isUploading) {
            this._onProjectModified?.(this.activeProject);
        }

        el.appendChild(projectDirtyBadge);
        if (!LOCAL_MODE && (this.activeProject.localOnly || !connected)) {
            el.appendChild(localOnlyBadge);
        }

        // Access mode badge
        const modeBadge = document.createElement('span');
        modeBadge.className = 'project-cloud-badge';
        const accessLevel = this.activeProject.accessLevel;
        if (accessLevel === 'viewer') {
            modeBadge.textContent = 'Read Only';
            modeBadge.title = 'You have read-only access to this project';
            modeBadge.classList.add('mode-readonly');
        } else if (accessLevel === 'editor') {
            modeBadge.textContent = 'Edit';
            modeBadge.title = 'You have edit access to this project';
            modeBadge.classList.add('mode-editor');
        } else {
            modeBadge.textContent = 'Owner';
            modeBadge.title = 'You own this project';
            modeBadge.classList.add('mode-owner');
        }
        el.appendChild(modeBadge);

        if (this.activeProject.adminPublicAccess) {
            const adminBadge = document.createElement('span');
            adminBadge.className = 'project-cloud-badge mode-admin-edit';
            adminBadge.textContent = 'Admin Edit';
            adminBadge.title = 'You can edit this public project because you are a server admin';
            el.appendChild(adminBadge);
        }

        // Highlight download button when local project has unsaved changes
        const localDirty = this.activeProject.localOnly && this.activeProject.isDirty();
        this.saveLocalBtn.classList.toggle('active', localDirty);

        // Highlight upload button when server project has unsynced changes
        const serverDirty = !this.activeProject.localOnly && this.activeProject.isDirty();
        this.uploadServerBtn.classList.toggle('active', serverDirty);

        // Show revert button when there are unsaved changes but not while uploading
        this.revertBtn.style.display = (this.activeProject.isDirty() && !this.isUploading) ? '' : 'none';

        // Show live quotes, presentation, and version history buttons for server projects only
        const showServerBtns = !this.activeProject.localOnly && !LOCAL_MODE;
        this.liveQuotesBtn.style.display  = showServerBtns ? '' : 'none';
        this.presentationBtn.style.display = showServerBtns ? '' : 'none';
        this.versionHistoryBtn.style.display = showServerBtns ? '' : 'none';
        if (this.headerShareGroup) {
            this.headerShareGroup.style.display = showServerBtns ? '' : 'none';
        }
    }

    // ── Undo / Redo ───────────────────────────────────────────────────────────

    /**
     * Fires re-render callbacks based on the command's dirtyFlags after undo/redo.
     * @param {object|null} command - the command returned by history.undo() or history.redo()
     */
    _applyHistoryResult(command) {
        if (!command) return;
        if (command.dirtyFlags.includes('transcript'))
            this.activeProject.markTranscriptDirty();
        if (command.dirtyFlags.includes('speakers'))
            this.activeProject.markSpeakersDirty();
        if (command.dirtyFlags.includes('annotations'))
            this.transcriptPanel.renderTranscript();
        if (command.dirtyFlags.includes('projectName')) {
            this.projectTitle.textContent = this.activeProject.projectName;
            this.projectNameInput.value = this.activeProject.projectName;
            this._onRenderSidebar?.();
        }
        if (command.dirtyFlags.includes('projectFolder'))
            this._onRenderSidebar?.();
        this._updateUndoRedoButtons();
    }

    /** Updates the enabled/disabled state of the undo and redo buttons. */
    _updateUndoRedoButtons() {
        this.undoBtn.disabled = !this.history.canUndo;
        this.redoBtn.disabled = !this.history.canRedo;
        this.versionManager?.onHistoryPush(this.history._undoStack.length);
    }

    /**
     * Reverts the active project to a saved version snapshot, pushing an undoable command.
     * Replaces transcript, speakers, and effects with those from the version.
     * @param {object} version - Full version object from the server.
     */
    applyVersionRevert(version) {
        const project = this.activeProject;
        if (!project) return;

        const prevTranscript = project.transcript().compileJSON();
        const prevSpeakers   = project.metadata().speakers;
        const prevEffects    = JSON.parse(JSON.stringify(project.effects ?? { ranges: [] }));
        const prevName       = project.projectName;

        const applyVersion = (v) => {
            project.local.speakers = {};
            for (const [id, spk] of Object.entries(v.speakers || {})) {
                project.addSpeaker(id, spk.name, spk.hue, {}, true);
            }
            project.loadTranscriptJSON(v.transcript);
            project.effects = JSON.parse(JSON.stringify(v.effects ?? { ranges: [] }));
            if (project.projectName !== (v.project_name ?? project.projectName)) {
                project.setName(v.project_name);
            }
            this.speakersPanel.loadFromProject(project);
            this.transcriptPanel.loadFromProject(project);
            this.updateProjectServerStatus();
        };

        const revertSpeakers = (spk) => {
            project.local.speakers = {};
            for (const [id, s] of Object.entries(spk)) {
                project.addSpeaker(id, s.name, s.hue, {}, true);
            }
        };

        applyVersion(version);

        this.history.push({
            label: `Revert to "${version.label ?? new Date(version.created_at).toLocaleString()}"`,
            dirtyFlags: ['transcript', 'speakers', 'projectName'],
            undo: () => {
                revertSpeakers(prevSpeakers);
                project.loadTranscriptJSON(prevTranscript);
                project.effects = JSON.parse(JSON.stringify(prevEffects));
                if (project.projectName !== prevName) project.setName(prevName);
                this.speakersPanel.loadFromProject(project);
                this.transcriptPanel.loadFromProject(project);
                this.updateProjectServerStatus();
            },
            redo: () => { applyVersion(version); },
        });
        this._updateUndoRedoButtons();
    }

    /**
     * Merges two adjacent segments and pushes an undo/redo command to history.
     * Use this instead of calling activeProject.mergeSegments() directly.
     * @param {number} idxA - index of the earlier segment
     * @param {number} idxB - index of the later segment (must equal idxA + 1)
     */
    _mergeWithHistory(idxA, idxB) {
        const project = this.activeProject;
        const transcript = project.transcript();
        const segs = transcript.segments;
        const segA = { ...segs[idxA] };
        const segB = { ...segs[idxB] };
        const savedAnnotations = JSON.parse(JSON.stringify(project.annotations ?? {}));
        project.mergeSegments(idxA, idxB);
        this.history.push({
            label: 'Merge segments', dirtyFlags: ['transcript'],
            undo: () => {
                transcript.segments.splice(idxA, 1, { ...segA }, { ...segB });
                transcript.buildTranscript();
                project.annotations = savedAnnotations;
            },
            redo: () => { project.mergeSegments(idxA, idxB); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Inserts a paragraph break before the segment at segIdx and pushes an undo/redo command.
    * @param {number} segIdx - index of the segment that will start the new paragraph
    */
    _splitParagraphWithHistory(segIdx) {
        const transcript = this.activeProject.transcript();
        const prevFlag = transcript.splitParagraphAt(segIdx);
        if (prevFlag === null) return;
        this.activeProject.markTranscriptDirty();
        this.history.push({
            label: 'Split paragraph', dirtyFlags: ['transcript'],
            undo: () => { transcript.restoreParaBreak(segIdx, prevFlag); },
            redo: () => { transcript.splitParagraphAt(segIdx); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Merges the paragraph containing segIdx with the previous paragraph and pushes undo/redo.
    * @param {number} segIdx - index of any segment in the paragraph to merge upward
    */
    _mergeParagraphWithHistory(segIdx) {
        const transcript = this.activeProject.transcript();
        const result = transcript.mergeParagraphBefore(segIdx);
        if (!result) return;
        this.activeProject.markTranscriptDirty();
        const { segIdx: firstSegIdx, prevFlag } = result;
        this.history.push({
            label: 'Merge paragraph', dirtyFlags: ['transcript'],
            undo: () => { transcript.restoreParaBreak(firstSegIdx, prevFlag); },
            redo: () => { transcript.mergeParagraphBefore(firstSegIdx); },
        });
        this._updateUndoRedoButtons();
    }

    /**
    * Tears down the current WaveSurfer instance and clears all audio, transcript,
    * speaker, and UI state back to their initial values. Called when switching
    * projects or when a project is deleted/closed.
    */
    clearWorkspace() {
        this.activeProject?.registerModifyCallbacks({});
        this.waveformPanel.clearWaveformPanel();
        this.transcriptPanel.clearTranscriptPanel();
        this.speakersPanel.clearSpeakersPanel();
        this.leftCol.style.flex = '';

        this.selectedSegmentIdx = -1;
        this.hoveredSegmentIdx  = -1;
        this.activeSegmentIdx   = -1;
        this.activeProject = null;
        this.searchMatchSet = null;
        this.hoveredParagraphIdx = -1;

        this.history.clear();
        this.versionManager?.destroy();
        this.versionManager = null;
        this._updateUndoRedoButtons();

        this.workspaceLoading.style.display = "none";
        this.workspaceApp.style.display = "none";
        this.workspaceEmpty.style.display = "flex";

        this.updateProjectServerStatus();
    }

    /**
     * Returns the WaveSurfer instance from the waveform panel.
     * @returns {object} the WaveSurfer instance
     */
    wavesurferInstance() {
        return this.waveformPanel.wavesurferInstance;
    }

}